feat: add core authorization foundation

This commit is contained in:
ekklesia
2026-07-23 10:29:01 -05:00
parent 3fa1c4cdec
commit 72e082ff2f
23 changed files with 3029 additions and 3 deletions
+340
View File
@@ -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");
}
},
});
}