feat: add core authorization foundation
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
|
||||
import { Pool, type PoolConfig } from "pg";
|
||||
import { authorizationSchema } from "./schema.js";
|
||||
|
||||
export type AuthorizationDrizzleDatabase = NodePgDatabase<typeof authorizationSchema>;
|
||||
|
||||
export type AuthorizationDatabaseErrorCode = "AUTHORIZATION_DATABASE_CONFIGURATION_INVALID";
|
||||
|
||||
const maximumPostgresTimeoutMs = 2_147_483_647;
|
||||
|
||||
/** Never carries the offending connection string, host, or underlying driver error. */
|
||||
export class AuthorizationDatabaseError extends Error {
|
||||
readonly code: AuthorizationDatabaseErrorCode;
|
||||
|
||||
constructor(code: AuthorizationDatabaseErrorCode) {
|
||||
super("Authorization database configuration is invalid.");
|
||||
this.name = "AuthorizationDatabaseError";
|
||||
this.code = code;
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
Object.freeze(this);
|
||||
}
|
||||
}
|
||||
|
||||
export interface AuthorizationDatabaseOptions {
|
||||
readonly connectionTimeoutMs?: number;
|
||||
/** Bounds both `query_timeout` and `statement_timeout`, mirroring
|
||||
* `core/src/identity/database.ts`: `connectionTimeoutMs` alone only bounds establishing a
|
||||
* connection, not a hung query already running on one. */
|
||||
readonly operationTimeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface AuthorizationDatabaseHandle {
|
||||
readonly close: () => Promise<void>;
|
||||
readonly db: AuthorizationDrizzleDatabase;
|
||||
}
|
||||
|
||||
function validateConnectionString(connectionString: string): string {
|
||||
if (connectionString.trim().length === 0) {
|
||||
throw new AuthorizationDatabaseError("AUTHORIZATION_DATABASE_CONFIGURATION_INVALID");
|
||||
}
|
||||
let protocol: string;
|
||||
try {
|
||||
protocol = new URL(connectionString).protocol;
|
||||
} catch {
|
||||
throw new AuthorizationDatabaseError("AUTHORIZATION_DATABASE_CONFIGURATION_INVALID");
|
||||
}
|
||||
if (protocol !== "postgres:" && protocol !== "postgresql:") {
|
||||
throw new AuthorizationDatabaseError("AUTHORIZATION_DATABASE_CONFIGURATION_INVALID");
|
||||
}
|
||||
return connectionString;
|
||||
}
|
||||
|
||||
/** Mirrors `core/src/identity/database.ts`'s timeout validation exactly: an explicit timeout must
|
||||
* be a positive safe integer no larger than PostgreSQL's own maximum, or it is rejected as invalid
|
||||
* configuration rather than silently passed through to `pg`. */
|
||||
function validateTimeout(value: unknown): number | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
typeof value !== "number" ||
|
||||
!Number.isSafeInteger(value) ||
|
||||
value <= 0 ||
|
||||
value > maximumPostgresTimeoutMs
|
||||
) {
|
||||
throw new AuthorizationDatabaseError("AUTHORIZATION_DATABASE_CONFIGURATION_INVALID");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicitly constructs the Core-owned Drizzle database instance backing the authorization store.
|
||||
* Building the `pg.Pool` performs no network I/O until a query is issued, and this function is
|
||||
* only ever invoked by explicit caller code — never during module import.
|
||||
*/
|
||||
export function createAuthorizationDrizzleDatabase(
|
||||
connectionString: string,
|
||||
options: AuthorizationDatabaseOptions = {},
|
||||
): AuthorizationDatabaseHandle {
|
||||
const validatedConnectionString = validateConnectionString(connectionString);
|
||||
const validatedConnectionTimeoutMs = validateTimeout(options.connectionTimeoutMs);
|
||||
const validatedOperationTimeoutMs = validateTimeout(options.operationTimeoutMs);
|
||||
const poolConfig: PoolConfig = {
|
||||
connectionString: validatedConnectionString,
|
||||
...(validatedConnectionTimeoutMs === undefined
|
||||
? {}
|
||||
: { connectionTimeoutMillis: validatedConnectionTimeoutMs }),
|
||||
...(validatedOperationTimeoutMs === undefined
|
||||
? {}
|
||||
: {
|
||||
query_timeout: validatedOperationTimeoutMs,
|
||||
statement_timeout: validatedOperationTimeoutMs,
|
||||
}),
|
||||
};
|
||||
|
||||
const pool = new Pool(poolConfig);
|
||||
// Same rationale as core/src/identity/database.ts: pg's own documentation requires an error
|
||||
// listener on the pool, or a server-initiated termination of an idle client becomes an
|
||||
// unhandled Node.js error and can crash the process.
|
||||
pool.on("error", () => undefined);
|
||||
const db = drizzle(pool, { schema: authorizationSchema });
|
||||
|
||||
return Object.freeze({
|
||||
close: () => pool.end(),
|
||||
db,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import {
|
||||
AUTHORIZATION_ALLOWED,
|
||||
AUTHORIZATION_DENIED,
|
||||
type AuthorizationDecision,
|
||||
type AuthorizationScope,
|
||||
type EkklesiaPrincipal,
|
||||
isAuthenticatedPrincipal,
|
||||
permissionBaselineSchema,
|
||||
safeParseAuthorizationScope,
|
||||
safeParsePermissionId,
|
||||
} from "@ekklesia/contracts";
|
||||
import type { AuthorizationGrantPort, AuthorizationPlatformOwnerPort } from "./ports.js";
|
||||
|
||||
export type AuthorizationEvaluationErrorCode = "AUTHORIZATION_EVALUATION_FAILED";
|
||||
|
||||
/** Never carries the underlying port error, database error, cause, stack, or any other internal
|
||||
* detail — defense in depth on top of whatever sanitization the injected ports themselves already
|
||||
* perform (`core/src/authorization/store.ts` already sanitizes), exactly like
|
||||
* `core/src/identity/runtime.ts`'s `resolvePrincipal` re-sanitizes on top of Better Auth's own
|
||||
* error handling rather than trusting it exclusively. */
|
||||
export class AuthorizationEvaluationError extends Error {
|
||||
readonly code: AuthorizationEvaluationErrorCode;
|
||||
|
||||
constructor() {
|
||||
super("The authorization decision could not be evaluated.");
|
||||
this.name = "AuthorizationEvaluationError";
|
||||
this.code = "AUTHORIZATION_EVALUATION_FAILED";
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
Object.freeze(this);
|
||||
}
|
||||
}
|
||||
|
||||
export interface AuthorizeRequest {
|
||||
readonly principal: EkklesiaPrincipal;
|
||||
/** Validated internally via the branded `PermissionId` schema; a malformed or unrecognizable
|
||||
* value denies rather than throwing — "unknown or malformed permissions fail closed." */
|
||||
readonly permission: unknown;
|
||||
/** Validated internally against the known `public`/`authenticated`/`restricted` set; anything
|
||||
* else denies rather than throwing, for the same fail-closed reason. */
|
||||
readonly baseline: unknown;
|
||||
/** Optional; validated internally when present. Absent means an unscoped check. */
|
||||
readonly scope?: unknown;
|
||||
}
|
||||
|
||||
export interface AuthorizationEvaluator {
|
||||
authorize(request: AuthorizeRequest): Promise<AuthorizationDecision>;
|
||||
}
|
||||
|
||||
export interface CreateAuthorizationEvaluatorOptions {
|
||||
readonly grants: AuthorizationGrantPort;
|
||||
readonly platformOwners: AuthorizationPlatformOwnerPort;
|
||||
/** Injectable clock so expiration evaluation is deterministic and testable. Defaults to the
|
||||
* real wall clock; production composition must leave this unset. */
|
||||
readonly now?: () => Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Core's Hono-independent, deny-by-default authorization evaluator. It is the only place
|
||||
* Ekklesia decides whether a principal may exercise a permission, and it never trusts anything
|
||||
* the caller supplies beyond `principal`/`permission`/`baseline`/`scope` — there is no field for a
|
||||
* caller to assert "I am the owner" or "I already have this grant." Owner and grant state are
|
||||
* always re-derived from the injected ports for every call.
|
||||
*
|
||||
* A genuine infrastructure failure from either port (for example a database outage) is
|
||||
* deliberately NOT swallowed into a `denied` decision here: it propagates to the caller as a
|
||||
* thrown, sanitized `AuthorizationEvaluationError`, exactly like Core's identity
|
||||
* `resolvePrincipal` never silently downgrades an outage to "anonymous." Only a genuinely
|
||||
* malformed or unrecognized permission/baseline/scope — a deterministic, expected input problem,
|
||||
* not an infrastructure failure — resolves to `denied` without throwing.
|
||||
*/
|
||||
export function createAuthorizationEvaluator(
|
||||
options: CreateAuthorizationEvaluatorOptions,
|
||||
): AuthorizationEvaluator {
|
||||
const now = options.now ?? (() => new Date());
|
||||
|
||||
return Object.freeze({
|
||||
authorize: async (request: AuthorizeRequest): Promise<AuthorizationDecision> => {
|
||||
const parsedBaseline = permissionBaselineSchema.safeParse(request.baseline);
|
||||
if (!parsedBaseline.success) {
|
||||
return AUTHORIZATION_DENIED;
|
||||
}
|
||||
|
||||
if (parsedBaseline.data === "public") {
|
||||
return AUTHORIZATION_ALLOWED;
|
||||
}
|
||||
|
||||
if (parsedBaseline.data === "authenticated") {
|
||||
return isAuthenticatedPrincipal(request.principal)
|
||||
? AUTHORIZATION_ALLOWED
|
||||
: AUTHORIZATION_DENIED;
|
||||
}
|
||||
|
||||
// parsedBaseline.data === "restricted": requires an authenticated principal AND
|
||||
// (an active matching grant OR platform-owner authority). Baseline alone never satisfies it.
|
||||
if (!isAuthenticatedPrincipal(request.principal)) {
|
||||
return AUTHORIZATION_DENIED;
|
||||
}
|
||||
|
||||
const parsedPermission = safeParsePermissionId(request.permission);
|
||||
if (!parsedPermission.success) {
|
||||
return AUTHORIZATION_DENIED;
|
||||
}
|
||||
|
||||
let scope: AuthorizationScope | undefined;
|
||||
if (request.scope !== undefined) {
|
||||
const parsedScope = safeParseAuthorizationScope(request.scope);
|
||||
if (!parsedScope.success) {
|
||||
return AUTHORIZATION_DENIED;
|
||||
}
|
||||
scope = parsedScope.data;
|
||||
}
|
||||
|
||||
const userId = request.principal.userId;
|
||||
let isOwner: boolean;
|
||||
let hasGrant: boolean;
|
||||
try {
|
||||
[isOwner, hasGrant] = await Promise.all([
|
||||
options.platformOwners.isPlatformOwner(userId),
|
||||
options.grants.hasActiveGrant({
|
||||
now: now(),
|
||||
permission: parsedPermission.data,
|
||||
userId,
|
||||
...(scope === undefined ? {} : { scope }),
|
||||
}),
|
||||
]);
|
||||
} catch {
|
||||
throw new AuthorizationEvaluationError();
|
||||
}
|
||||
|
||||
return isOwner || hasGrant ? AUTHORIZATION_ALLOWED : AUTHORIZATION_DENIED;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./database.js";
|
||||
export * from "./evaluator.js";
|
||||
export * from "./ports.js";
|
||||
export * from "./store.js";
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { AuthorizationScope, EkklesiaUserId, PermissionId } from "@ekklesia/contracts";
|
||||
|
||||
export interface ActiveGrantQuery {
|
||||
readonly userId: EkklesiaUserId;
|
||||
readonly permission: PermissionId;
|
||||
/** Absent means an unscoped check. An unscoped grant satisfies either an unscoped or scoped
|
||||
* check for the same permission; a scoped grant satisfies only the exact matching scope and
|
||||
* never satisfies an unscoped check. */
|
||||
readonly scope?: AuthorizationScope;
|
||||
/** The instant to evaluate expiration against — injected so evaluation is deterministic and
|
||||
* testable rather than reading the wall clock inside the lookup itself. */
|
||||
readonly now: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* The only read dependency `createAuthorizationEvaluator` has for grant state. Narrow and
|
||||
* Postgres-independent by design: a test can supply a fake implementation, and the evaluator
|
||||
* itself never knows whether it is backed by `core/src/authorization/store.ts` or a test double.
|
||||
*/
|
||||
export interface AuthorizationGrantPort {
|
||||
hasActiveGrant(query: ActiveGrantQuery): Promise<boolean>;
|
||||
}
|
||||
|
||||
/** The only read dependency the evaluator has for platform-owner authority — deliberately a
|
||||
* separate, narrower port from grants, since owner authority is not represented as a grant. */
|
||||
export interface AuthorizationPlatformOwnerPort {
|
||||
isPlatformOwner(userId: EkklesiaUserId): Promise<boolean>;
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { relations, sql } from "drizzle-orm";
|
||||
import { check, index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
|
||||
import { ekklesiaCoreUser } from "../identity/schema.js";
|
||||
|
||||
/**
|
||||
* Core-owned authorization schema: grants (explicit or relationship-derived), platform-owner
|
||||
* status, and an append-only audit log of every security-relevant mutation. Table and index names
|
||||
* are explicit and namespaced so ownership is unambiguous inside the shared public PostgreSQL
|
||||
* schema, exactly like `core/src/identity/schema.ts`. Foreign keys to `ekklesia_core_user` are
|
||||
* safe here (unlike a module referencing another module's tables) because Core owns both sides.
|
||||
*/
|
||||
|
||||
export const ekklesiaCoreGrant = pgTable(
|
||||
"ekklesia_core_grant",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => ekklesiaCoreUser.id, { onDelete: "cascade" }),
|
||||
permissionId: text("permission_id").notNull(),
|
||||
scope: text("scope"),
|
||||
sourceKind: text("source_kind").notNull(),
|
||||
sourceRef: text("source_ref"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
createdByActorType: text("created_by_actor_type").notNull(),
|
||||
createdByActorId: text("created_by_actor_id"),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }),
|
||||
revokedAt: timestamp("revoked_at", { withTimezone: true }),
|
||||
revokedByActorType: text("revoked_by_actor_type"),
|
||||
revokedByActorId: text("revoked_by_actor_id"),
|
||||
},
|
||||
(table) => [
|
||||
index("ekklesia_core_grant_user_permission_idx").on(table.userId, table.permissionId),
|
||||
check(
|
||||
"ekklesia_core_grant_source_kind_check",
|
||||
sql`${table.sourceKind} in ('explicit', 'relationship')`,
|
||||
),
|
||||
check(
|
||||
"ekklesia_core_grant_created_actor_type_check",
|
||||
sql`${table.createdByActorType} in ('user', 'system')`,
|
||||
),
|
||||
check(
|
||||
"ekklesia_core_grant_revoked_actor_type_check",
|
||||
sql`${table.revokedByActorType} is null or ${table.revokedByActorType} in ('user', 'system')`,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
export const ekklesiaCorePlatformOwner = pgTable(
|
||||
"ekklesia_core_platform_owner",
|
||||
{
|
||||
userId: text("user_id")
|
||||
.primaryKey()
|
||||
.references(() => ekklesiaCoreUser.id, { onDelete: "cascade" }),
|
||||
grantedAt: timestamp("granted_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
grantedByActorType: text("granted_by_actor_type").notNull(),
|
||||
grantedByActorId: text("granted_by_actor_id"),
|
||||
revokedAt: timestamp("revoked_at", { withTimezone: true }),
|
||||
revokedByActorType: text("revoked_by_actor_type"),
|
||||
revokedByActorId: text("revoked_by_actor_id"),
|
||||
},
|
||||
(table) => [
|
||||
check(
|
||||
"ekklesia_core_platform_owner_granted_actor_type_check",
|
||||
sql`${table.grantedByActorType} in ('user', 'system')`,
|
||||
),
|
||||
check(
|
||||
"ekklesia_core_platform_owner_revoked_actor_type_check",
|
||||
sql`${table.revokedByActorType} is null or ${table.revokedByActorType} in ('user', 'system')`,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* Append-only: no update or delete path is ever exposed by Core's authorization store. `scope`
|
||||
* intentionally rejects an empty (but non-null) string — defense in depth matching the
|
||||
* application-layer `AuthorizationScope` contract, which never produces one — so a coding defect
|
||||
* that slipped an empty scope past that validation fails the whole grant-plus-audit transaction
|
||||
* atomically instead of recording a meaningless audit entry.
|
||||
*/
|
||||
export const ekklesiaCoreAuthorizationAudit = pgTable(
|
||||
"ekklesia_core_authorization_audit",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
action: text("action").notNull(),
|
||||
actorType: text("actor_type").notNull(),
|
||||
actorId: text("actor_id"),
|
||||
subjectUserId: text("subject_user_id")
|
||||
.notNull()
|
||||
.references(() => ekklesiaCoreUser.id, { onDelete: "cascade" }),
|
||||
permissionId: text("permission_id"),
|
||||
scope: text("scope"),
|
||||
grantId: uuid("grant_id"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index("ekklesia_core_authorization_audit_subject_idx").on(table.subjectUserId),
|
||||
check(
|
||||
"ekklesia_core_authorization_audit_action_check",
|
||||
sql`${table.action} in ('grant.create', 'grant.revoke', 'platform_owner.grant', 'platform_owner.revoke')`,
|
||||
),
|
||||
check(
|
||||
"ekklesia_core_authorization_audit_actor_type_check",
|
||||
sql`${table.actorType} in ('user', 'system')`,
|
||||
),
|
||||
check(
|
||||
"ekklesia_core_authorization_audit_scope_check",
|
||||
sql`${table.scope} is null or length(${table.scope}) > 0`,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
export const ekklesiaCoreGrantRelations = relations(ekklesiaCoreGrant, ({ one }) => ({
|
||||
user: one(ekklesiaCoreUser, {
|
||||
fields: [ekklesiaCoreGrant.userId],
|
||||
references: [ekklesiaCoreUser.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const ekklesiaCorePlatformOwnerRelations = relations(
|
||||
ekklesiaCorePlatformOwner,
|
||||
({ one }) => ({
|
||||
user: one(ekklesiaCoreUser, {
|
||||
fields: [ekklesiaCorePlatformOwner.userId],
|
||||
references: [ekklesiaCoreUser.id],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
export const ekklesiaCoreAuthorizationAuditRelations = relations(
|
||||
ekklesiaCoreAuthorizationAudit,
|
||||
({ one }) => ({
|
||||
subject: one(ekklesiaCoreUser, {
|
||||
fields: [ekklesiaCoreAuthorizationAudit.subjectUserId],
|
||||
references: [ekklesiaCoreUser.id],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
/** Explicit map passed to Drizzle, mirroring `identityAdapterSchema`'s explicit-mapping style. */
|
||||
export const authorizationSchema = {
|
||||
authorizationAudit: ekklesiaCoreAuthorizationAudit,
|
||||
grant: ekklesiaCoreGrant,
|
||||
platformOwner: ekklesiaCorePlatformOwner,
|
||||
} as const;
|
||||
@@ -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