feat: add core authorization foundation
This commit is contained in:
@@ -0,0 +1,340 @@
|
||||
import type {
|
||||
AuthorizationActor,
|
||||
AuthorizationScope,
|
||||
EkklesiaUserId,
|
||||
GrantId,
|
||||
GrantSourceKind,
|
||||
GrantSourceRef,
|
||||
PermissionId,
|
||||
} from "@ekklesia/contracts";
|
||||
import {
|
||||
isUserActor,
|
||||
parseAuthorizationScope,
|
||||
parseEkklesiaUserId,
|
||||
parseGrantId,
|
||||
parsePermissionId,
|
||||
} from "@ekklesia/contracts";
|
||||
import { and, eq, gt, isNull, or, sql } from "drizzle-orm";
|
||||
import type { AuthorizationDrizzleDatabase } from "./database.js";
|
||||
import type {
|
||||
ActiveGrantQuery,
|
||||
AuthorizationGrantPort,
|
||||
AuthorizationPlatformOwnerPort,
|
||||
} from "./ports.js";
|
||||
import {
|
||||
ekklesiaCoreAuthorizationAudit,
|
||||
ekklesiaCoreGrant,
|
||||
ekklesiaCorePlatformOwner,
|
||||
} from "./schema.js";
|
||||
|
||||
export type AuthorizationStoreErrorCode =
|
||||
| "AUTHORIZATION_GRANT_CREATE_FAILED"
|
||||
| "AUTHORIZATION_GRANT_LOOKUP_FAILED"
|
||||
| "AUTHORIZATION_GRANT_NOT_FOUND"
|
||||
| "AUTHORIZATION_GRANT_REVOKE_FAILED"
|
||||
| "AUTHORIZATION_PLATFORM_OWNER_LOOKUP_FAILED"
|
||||
| "AUTHORIZATION_PLATFORM_OWNER_MUTATION_FAILED"
|
||||
| "AUTHORIZATION_PLATFORM_OWNER_NOT_FOUND";
|
||||
|
||||
const storeErrorMessages: Readonly<Record<AuthorizationStoreErrorCode, string>> = Object.freeze({
|
||||
AUTHORIZATION_GRANT_CREATE_FAILED: "The authorization grant could not be created.",
|
||||
AUTHORIZATION_GRANT_LOOKUP_FAILED: "The authorization grant lookup could not be completed.",
|
||||
AUTHORIZATION_GRANT_NOT_FOUND: "The authorization grant was not found or is already revoked.",
|
||||
AUTHORIZATION_GRANT_REVOKE_FAILED: "The authorization grant could not be revoked.",
|
||||
AUTHORIZATION_PLATFORM_OWNER_LOOKUP_FAILED: "The platform-owner lookup could not be completed.",
|
||||
AUTHORIZATION_PLATFORM_OWNER_MUTATION_FAILED: "The platform-owner change could not be recorded.",
|
||||
AUTHORIZATION_PLATFORM_OWNER_NOT_FOUND:
|
||||
"The platform-owner record was not found or is already revoked.",
|
||||
});
|
||||
|
||||
/** Never carries the underlying database error, SQL text, host, port, path, or stack. */
|
||||
export class AuthorizationStoreError extends Error {
|
||||
readonly code: AuthorizationStoreErrorCode;
|
||||
|
||||
constructor(code: AuthorizationStoreErrorCode) {
|
||||
super(storeErrorMessages[code]);
|
||||
this.name = "AuthorizationStoreError";
|
||||
this.code = code;
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
Object.freeze(this);
|
||||
}
|
||||
}
|
||||
|
||||
function isAuthorizationStoreError(error: unknown): error is AuthorizationStoreError {
|
||||
return error instanceof AuthorizationStoreError;
|
||||
}
|
||||
|
||||
export interface CreateGrantInput {
|
||||
readonly userId: EkklesiaUserId;
|
||||
readonly permission: PermissionId;
|
||||
readonly scope?: AuthorizationScope;
|
||||
readonly sourceKind: GrantSourceKind;
|
||||
readonly sourceRef?: GrantSourceRef;
|
||||
readonly actor: AuthorizationActor;
|
||||
/** Absent means the grant never expires. */
|
||||
readonly expiresAt?: Date;
|
||||
}
|
||||
|
||||
export interface GrantRecord {
|
||||
readonly id: GrantId;
|
||||
readonly userId: EkklesiaUserId;
|
||||
readonly permission: PermissionId;
|
||||
readonly scope?: AuthorizationScope;
|
||||
readonly sourceKind: GrantSourceKind;
|
||||
readonly sourceRef?: GrantSourceRef;
|
||||
readonly createdAt: Date;
|
||||
readonly expiresAt?: Date;
|
||||
readonly revokedAt?: Date;
|
||||
}
|
||||
|
||||
export interface RevokeGrantInput {
|
||||
readonly grantId: GrantId;
|
||||
readonly actor: AuthorizationActor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Core's Postgres-backed authorization persistence: grants, platform-owner status, and the
|
||||
* append-only audit log, all behind one explicit factory — no environment reads, no connection
|
||||
* attempt, and no query merely from importing this module or calling this factory; every method
|
||||
* call is the only thing that ever touches PostgreSQL. Implements the narrow read ports
|
||||
* `createAuthorizationEvaluator` depends on directly, so the same store instance is both Core's
|
||||
* write API and the evaluator's read dependency.
|
||||
*/
|
||||
export interface AuthorizationStore extends AuthorizationGrantPort, AuthorizationPlatformOwnerPort {
|
||||
createGrant(input: CreateGrantInput): Promise<GrantRecord>;
|
||||
revokeGrant(input: RevokeGrantInput): Promise<void>;
|
||||
grantPlatformOwner(userId: EkklesiaUserId, actor: AuthorizationActor): Promise<void>;
|
||||
revokePlatformOwner(userId: EkklesiaUserId, actor: AuthorizationActor): Promise<void>;
|
||||
}
|
||||
|
||||
function actorId(actor: AuthorizationActor): string | null {
|
||||
return isUserActor(actor) ? actor.userId : null;
|
||||
}
|
||||
|
||||
interface GrantRow {
|
||||
readonly id: string;
|
||||
readonly userId: string;
|
||||
readonly permissionId: string;
|
||||
readonly scope: string | null;
|
||||
readonly sourceKind: string;
|
||||
readonly sourceRef: string | null;
|
||||
readonly createdAt: Date;
|
||||
readonly expiresAt: Date | null;
|
||||
readonly revokedAt: Date | null;
|
||||
}
|
||||
|
||||
/** Re-validates a row this same store just wrote, rather than trusting raw column values by
|
||||
* construction — defense in depth against a future schema/migration drift, matching the
|
||||
* paranoid-by-default posture the rest of Core's persistence code already follows. */
|
||||
function toGrantRecord(row: GrantRow): GrantRecord {
|
||||
return Object.freeze({
|
||||
createdAt: row.createdAt,
|
||||
id: parseGrantId(row.id),
|
||||
permission: parsePermissionId(row.permissionId),
|
||||
...(row.scope === null ? {} : { scope: parseAuthorizationScope(row.scope) }),
|
||||
...(row.expiresAt === null ? {} : { expiresAt: row.expiresAt }),
|
||||
...(row.revokedAt === null ? {} : { revokedAt: row.revokedAt }),
|
||||
sourceKind: row.sourceKind as GrantSourceKind,
|
||||
...(row.sourceRef === null ? {} : { sourceRef: row.sourceRef }),
|
||||
userId: parseEkklesiaUserId(row.userId),
|
||||
});
|
||||
}
|
||||
|
||||
export function createAuthorizationStore(db: AuthorizationDrizzleDatabase): AuthorizationStore {
|
||||
return Object.freeze({
|
||||
createGrant: async (input: CreateGrantInput): Promise<GrantRecord> => {
|
||||
try {
|
||||
return await db.transaction(async (tx) => {
|
||||
const [row] = await tx
|
||||
.insert(ekklesiaCoreGrant)
|
||||
.values({
|
||||
createdByActorId: actorId(input.actor),
|
||||
createdByActorType: input.actor.type,
|
||||
expiresAt: input.expiresAt ?? null,
|
||||
permissionId: input.permission,
|
||||
scope: input.scope ?? null,
|
||||
sourceKind: input.sourceKind,
|
||||
sourceRef: input.sourceRef ?? null,
|
||||
userId: input.userId,
|
||||
})
|
||||
.returning();
|
||||
if (!row) {
|
||||
throw new Error("grant insert returned no row");
|
||||
}
|
||||
|
||||
await tx.insert(ekklesiaCoreAuthorizationAudit).values({
|
||||
action: "grant.create",
|
||||
actorId: actorId(input.actor),
|
||||
actorType: input.actor.type,
|
||||
grantId: row.id,
|
||||
permissionId: input.permission,
|
||||
scope: input.scope ?? null,
|
||||
subjectUserId: input.userId,
|
||||
});
|
||||
|
||||
return toGrantRecord(row);
|
||||
});
|
||||
} catch (error) {
|
||||
if (isAuthorizationStoreError(error)) {
|
||||
throw error;
|
||||
}
|
||||
throw new AuthorizationStoreError("AUTHORIZATION_GRANT_CREATE_FAILED");
|
||||
}
|
||||
},
|
||||
|
||||
revokeGrant: async (input: RevokeGrantInput): Promise<void> => {
|
||||
try {
|
||||
await db.transaction(async (tx) => {
|
||||
const [row] = await tx
|
||||
.update(ekklesiaCoreGrant)
|
||||
.set({
|
||||
revokedAt: sql`now()`,
|
||||
revokedByActorId: actorId(input.actor),
|
||||
revokedByActorType: input.actor.type,
|
||||
})
|
||||
.where(
|
||||
and(eq(ekklesiaCoreGrant.id, input.grantId), isNull(ekklesiaCoreGrant.revokedAt)),
|
||||
)
|
||||
.returning();
|
||||
if (!row) {
|
||||
throw new AuthorizationStoreError("AUTHORIZATION_GRANT_NOT_FOUND");
|
||||
}
|
||||
|
||||
await tx.insert(ekklesiaCoreAuthorizationAudit).values({
|
||||
action: "grant.revoke",
|
||||
actorId: actorId(input.actor),
|
||||
actorType: input.actor.type,
|
||||
grantId: row.id,
|
||||
permissionId: row.permissionId,
|
||||
scope: row.scope,
|
||||
subjectUserId: row.userId,
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
if (isAuthorizationStoreError(error)) {
|
||||
throw error;
|
||||
}
|
||||
throw new AuthorizationStoreError("AUTHORIZATION_GRANT_REVOKE_FAILED");
|
||||
}
|
||||
},
|
||||
|
||||
hasActiveGrant: async (query: ActiveGrantQuery): Promise<boolean> => {
|
||||
try {
|
||||
const scopeCondition =
|
||||
query.scope === undefined
|
||||
? isNull(ekklesiaCoreGrant.scope)
|
||||
: or(isNull(ekklesiaCoreGrant.scope), eq(ekklesiaCoreGrant.scope, query.scope));
|
||||
const rows = await db
|
||||
.select({ id: ekklesiaCoreGrant.id })
|
||||
.from(ekklesiaCoreGrant)
|
||||
.where(
|
||||
and(
|
||||
eq(ekklesiaCoreGrant.userId, query.userId),
|
||||
eq(ekklesiaCoreGrant.permissionId, query.permission),
|
||||
scopeCondition,
|
||||
isNull(ekklesiaCoreGrant.revokedAt),
|
||||
or(isNull(ekklesiaCoreGrant.expiresAt), gt(ekklesiaCoreGrant.expiresAt, query.now)),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return rows.length > 0;
|
||||
} catch {
|
||||
throw new AuthorizationStoreError("AUTHORIZATION_GRANT_LOOKUP_FAILED");
|
||||
}
|
||||
},
|
||||
|
||||
grantPlatformOwner: async (
|
||||
userId: EkklesiaUserId,
|
||||
actor: AuthorizationActor,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.insert(ekklesiaCorePlatformOwner)
|
||||
.values({
|
||||
grantedByActorId: actorId(actor),
|
||||
grantedByActorType: actor.type,
|
||||
userId,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
set: {
|
||||
grantedAt: sql`now()`,
|
||||
grantedByActorId: actorId(actor),
|
||||
grantedByActorType: actor.type,
|
||||
revokedAt: null,
|
||||
revokedByActorId: null,
|
||||
revokedByActorType: null,
|
||||
},
|
||||
target: ekklesiaCorePlatformOwner.userId,
|
||||
});
|
||||
|
||||
await tx.insert(ekklesiaCoreAuthorizationAudit).values({
|
||||
action: "platform_owner.grant",
|
||||
actorId: actorId(actor),
|
||||
actorType: actor.type,
|
||||
subjectUserId: userId,
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
throw new AuthorizationStoreError("AUTHORIZATION_PLATFORM_OWNER_MUTATION_FAILED");
|
||||
}
|
||||
},
|
||||
|
||||
revokePlatformOwner: async (
|
||||
userId: EkklesiaUserId,
|
||||
actor: AuthorizationActor,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
await db.transaction(async (tx) => {
|
||||
const [row] = await tx
|
||||
.update(ekklesiaCorePlatformOwner)
|
||||
.set({
|
||||
revokedAt: sql`now()`,
|
||||
revokedByActorId: actorId(actor),
|
||||
revokedByActorType: actor.type,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(ekklesiaCorePlatformOwner.userId, userId),
|
||||
isNull(ekklesiaCorePlatformOwner.revokedAt),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
if (!row) {
|
||||
throw new AuthorizationStoreError("AUTHORIZATION_PLATFORM_OWNER_NOT_FOUND");
|
||||
}
|
||||
|
||||
await tx.insert(ekklesiaCoreAuthorizationAudit).values({
|
||||
action: "platform_owner.revoke",
|
||||
actorId: actorId(actor),
|
||||
actorType: actor.type,
|
||||
subjectUserId: userId,
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
if (isAuthorizationStoreError(error)) {
|
||||
throw error;
|
||||
}
|
||||
throw new AuthorizationStoreError("AUTHORIZATION_PLATFORM_OWNER_MUTATION_FAILED");
|
||||
}
|
||||
},
|
||||
|
||||
isPlatformOwner: async (userId: EkklesiaUserId): Promise<boolean> => {
|
||||
try {
|
||||
const rows = await db
|
||||
.select({ userId: ekklesiaCorePlatformOwner.userId })
|
||||
.from(ekklesiaCorePlatformOwner)
|
||||
.where(
|
||||
and(
|
||||
eq(ekklesiaCorePlatformOwner.userId, userId),
|
||||
isNull(ekklesiaCorePlatformOwner.revokedAt),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return rows.length > 0;
|
||||
} catch {
|
||||
throw new AuthorizationStoreError("AUTHORIZATION_PLATFORM_OWNER_LOOKUP_FAILED");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user