phase-4/core-authorization #10

Merged
Zayin_Admin merged 4 commits from phase-4/core-authorization into main 2026-07-23 16:52:40 +00:00
23 changed files with 3029 additions and 3 deletions
Showing only changes of commit 72e082ff2f - Show all commits
+6 -1
View File
@@ -174,13 +174,18 @@ async function applyCoreMigrations(url: string): Promise<void> {
platformVersion: "0.1.0", platformVersion: "0.1.0",
}, },
}); });
// Phase 4A's committed migration must not have drifted while building this transport slice. // Phase 4A's and Phase 4C pass 1's committed migrations must not have drifted while building
// this transport slice.
expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([ expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([
"core:0000_identity_core_tables", "core:0000_identity_core_tables",
"core:0001_authorization_core_tables",
]); ]);
expect(aggregate.migrations[0]?.checksum).toBe( expect(aggregate.migrations[0]?.checksum).toBe(
"f8f2086ef06f7a6943e8ffc4350bb010b37aed04c0a72030d2b153c986b1325f", "f8f2086ef06f7a6943e8ffc4350bb010b37aed04c0a72030d2b153c986b1325f",
); );
expect(aggregate.migrations[1]?.checksum).toBe(
"830e1fbc26d05af08df8d8710c3d10e1f3016d32c41b8363cb661c49afcbe81e",
);
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
} finally { } finally {
await rm(emptyModulesDirectory, { recursive: true, force: true }); await rm(emptyModulesDirectory, { recursive: true, force: true });
+1 -1
View File
@@ -2,7 +2,7 @@ import { defineConfig } from "drizzle-kit";
export default defineConfig({ export default defineConfig({
dialect: "postgresql", dialect: "postgresql",
schema: "./src/identity/schema.ts", schema: ["./src/identity/schema.ts", "./src/authorization/schema.ts"],
out: "./drizzle", out: "./drizzle",
migrations: { migrations: {
prefix: "index", prefix: "index",
@@ -0,0 +1,51 @@
CREATE TABLE "ekklesia_core_authorization_audit" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"action" text NOT NULL,
"actor_type" text NOT NULL,
"actor_id" text,
"subject_user_id" text NOT NULL,
"permission_id" text,
"scope" text,
"grant_id" uuid,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "ekklesia_core_authorization_audit_action_check" CHECK ("ekklesia_core_authorization_audit"."action" in ('grant.create', 'grant.revoke', 'platform_owner.grant', 'platform_owner.revoke')),
CONSTRAINT "ekklesia_core_authorization_audit_actor_type_check" CHECK ("ekklesia_core_authorization_audit"."actor_type" in ('user', 'system')),
CONSTRAINT "ekklesia_core_authorization_audit_scope_check" CHECK ("ekklesia_core_authorization_audit"."scope" is null or length("ekklesia_core_authorization_audit"."scope") > 0)
);
--> statement-breakpoint
CREATE TABLE "ekklesia_core_grant" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" text NOT NULL,
"permission_id" text NOT NULL,
"scope" text,
"source_kind" text NOT NULL,
"source_ref" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"created_by_actor_type" text NOT NULL,
"created_by_actor_id" text,
"expires_at" timestamp with time zone,
"revoked_at" timestamp with time zone,
"revoked_by_actor_type" text,
"revoked_by_actor_id" text,
CONSTRAINT "ekklesia_core_grant_source_kind_check" CHECK ("ekklesia_core_grant"."source_kind" in ('explicit', 'relationship')),
CONSTRAINT "ekklesia_core_grant_created_actor_type_check" CHECK ("ekklesia_core_grant"."created_by_actor_type" in ('user', 'system')),
CONSTRAINT "ekklesia_core_grant_revoked_actor_type_check" CHECK ("ekklesia_core_grant"."revoked_by_actor_type" is null or "ekklesia_core_grant"."revoked_by_actor_type" in ('user', 'system'))
);
--> statement-breakpoint
CREATE TABLE "ekklesia_core_platform_owner" (
"user_id" text PRIMARY KEY NOT NULL,
"granted_at" timestamp with time zone DEFAULT now() NOT NULL,
"granted_by_actor_type" text NOT NULL,
"granted_by_actor_id" text,
"revoked_at" timestamp with time zone,
"revoked_by_actor_type" text,
"revoked_by_actor_id" text,
CONSTRAINT "ekklesia_core_platform_owner_granted_actor_type_check" CHECK ("ekklesia_core_platform_owner"."granted_by_actor_type" in ('user', 'system')),
CONSTRAINT "ekklesia_core_platform_owner_revoked_actor_type_check" CHECK ("ekklesia_core_platform_owner"."revoked_by_actor_type" is null or "ekklesia_core_platform_owner"."revoked_by_actor_type" in ('user', 'system'))
);
--> statement-breakpoint
ALTER TABLE "ekklesia_core_authorization_audit" ADD CONSTRAINT "ekklesia_core_authorization_audit_subject_user_id_ekklesia_core_user_id_fk" FOREIGN KEY ("subject_user_id") REFERENCES "public"."ekklesia_core_user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "ekklesia_core_grant" ADD CONSTRAINT "ekklesia_core_grant_user_id_ekklesia_core_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."ekklesia_core_user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "ekklesia_core_platform_owner" ADD CONSTRAINT "ekklesia_core_platform_owner_user_id_ekklesia_core_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."ekklesia_core_user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "ekklesia_core_authorization_audit_subject_idx" ON "ekklesia_core_authorization_audit" USING btree ("subject_user_id");--> statement-breakpoint
CREATE INDEX "ekklesia_core_grant_user_permission_idx" ON "ekklesia_core_grant" USING btree ("user_id","permission_id");
+683
View File
@@ -0,0 +1,683 @@
{
"id": "dfd53f55-6e2a-45e1-9962-469ea6eca6e7",
"prevId": "42cf39d2-7833-4bf3-9043-4e5303da8a75",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.ekklesia_core_account": {
"name": "ekklesia_core_account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"ekklesia_core_account_user_id_idx": {
"name": "ekklesia_core_account_user_id_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"ekklesia_core_account_user_id_ekklesia_core_user_id_fk": {
"name": "ekklesia_core_account_user_id_ekklesia_core_user_id_fk",
"tableFrom": "ekklesia_core_account",
"tableTo": "ekklesia_core_user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.ekklesia_core_session": {
"name": "ekklesia_core_session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"ekklesia_core_session_user_id_idx": {
"name": "ekklesia_core_session_user_id_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"ekklesia_core_session_user_id_ekklesia_core_user_id_fk": {
"name": "ekklesia_core_session_user_id_ekklesia_core_user_id_fk",
"tableFrom": "ekklesia_core_session",
"tableTo": "ekklesia_core_user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"ekklesia_core_session_token_unique": {
"name": "ekklesia_core_session_token_unique",
"nullsNotDistinct": false,
"columns": ["token"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.ekklesia_core_user": {
"name": "ekklesia_core_user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"ekklesia_core_user_email_unique": {
"name": "ekklesia_core_user_email_unique",
"nullsNotDistinct": false,
"columns": ["email"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.ekklesia_core_verification": {
"name": "ekklesia_core_verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"ekklesia_core_verification_identifier_idx": {
"name": "ekklesia_core_verification_identifier_idx",
"columns": [
{
"expression": "identifier",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.ekklesia_core_authorization_audit": {
"name": "ekklesia_core_authorization_audit",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"action": {
"name": "action",
"type": "text",
"primaryKey": false,
"notNull": true
},
"actor_type": {
"name": "actor_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"actor_id": {
"name": "actor_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"subject_user_id": {
"name": "subject_user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"permission_id": {
"name": "permission_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"grant_id": {
"name": "grant_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"ekklesia_core_authorization_audit_subject_idx": {
"name": "ekklesia_core_authorization_audit_subject_idx",
"columns": [
{
"expression": "subject_user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"ekklesia_core_authorization_audit_subject_user_id_ekklesia_core_user_id_fk": {
"name": "ekklesia_core_authorization_audit_subject_user_id_ekklesia_core_user_id_fk",
"tableFrom": "ekklesia_core_authorization_audit",
"tableTo": "ekklesia_core_user",
"columnsFrom": ["subject_user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"ekklesia_core_authorization_audit_action_check": {
"name": "ekklesia_core_authorization_audit_action_check",
"value": "\"ekklesia_core_authorization_audit\".\"action\" in ('grant.create', 'grant.revoke', 'platform_owner.grant', 'platform_owner.revoke')"
},
"ekklesia_core_authorization_audit_actor_type_check": {
"name": "ekklesia_core_authorization_audit_actor_type_check",
"value": "\"ekklesia_core_authorization_audit\".\"actor_type\" in ('user', 'system')"
},
"ekklesia_core_authorization_audit_scope_check": {
"name": "ekklesia_core_authorization_audit_scope_check",
"value": "\"ekklesia_core_authorization_audit\".\"scope\" is null or length(\"ekklesia_core_authorization_audit\".\"scope\") > 0"
}
},
"isRLSEnabled": false
},
"public.ekklesia_core_grant": {
"name": "ekklesia_core_grant",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"permission_id": {
"name": "permission_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"source_kind": {
"name": "source_kind",
"type": "text",
"primaryKey": false,
"notNull": true
},
"source_ref": {
"name": "source_ref",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"created_by_actor_type": {
"name": "created_by_actor_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_by_actor_id": {
"name": "created_by_actor_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"revoked_at": {
"name": "revoked_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"revoked_by_actor_type": {
"name": "revoked_by_actor_type",
"type": "text",
"primaryKey": false,
"notNull": false
},
"revoked_by_actor_id": {
"name": "revoked_by_actor_id",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"ekklesia_core_grant_user_permission_idx": {
"name": "ekklesia_core_grant_user_permission_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "permission_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"ekklesia_core_grant_user_id_ekklesia_core_user_id_fk": {
"name": "ekklesia_core_grant_user_id_ekklesia_core_user_id_fk",
"tableFrom": "ekklesia_core_grant",
"tableTo": "ekklesia_core_user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"ekklesia_core_grant_source_kind_check": {
"name": "ekklesia_core_grant_source_kind_check",
"value": "\"ekklesia_core_grant\".\"source_kind\" in ('explicit', 'relationship')"
},
"ekklesia_core_grant_created_actor_type_check": {
"name": "ekklesia_core_grant_created_actor_type_check",
"value": "\"ekklesia_core_grant\".\"created_by_actor_type\" in ('user', 'system')"
},
"ekklesia_core_grant_revoked_actor_type_check": {
"name": "ekklesia_core_grant_revoked_actor_type_check",
"value": "\"ekklesia_core_grant\".\"revoked_by_actor_type\" is null or \"ekklesia_core_grant\".\"revoked_by_actor_type\" in ('user', 'system')"
}
},
"isRLSEnabled": false
},
"public.ekklesia_core_platform_owner": {
"name": "ekklesia_core_platform_owner",
"schema": "",
"columns": {
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"granted_at": {
"name": "granted_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"granted_by_actor_type": {
"name": "granted_by_actor_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"granted_by_actor_id": {
"name": "granted_by_actor_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"revoked_at": {
"name": "revoked_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"revoked_by_actor_type": {
"name": "revoked_by_actor_type",
"type": "text",
"primaryKey": false,
"notNull": false
},
"revoked_by_actor_id": {
"name": "revoked_by_actor_id",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"ekklesia_core_platform_owner_user_id_ekklesia_core_user_id_fk": {
"name": "ekklesia_core_platform_owner_user_id_ekklesia_core_user_id_fk",
"tableFrom": "ekklesia_core_platform_owner",
"tableTo": "ekklesia_core_user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"ekklesia_core_platform_owner_granted_actor_type_check": {
"name": "ekklesia_core_platform_owner_granted_actor_type_check",
"value": "\"ekklesia_core_platform_owner\".\"granted_by_actor_type\" in ('user', 'system')"
},
"ekklesia_core_platform_owner_revoked_actor_type_check": {
"name": "ekklesia_core_platform_owner_revoked_actor_type_check",
"value": "\"ekklesia_core_platform_owner\".\"revoked_by_actor_type\" is null or \"ekklesia_core_platform_owner\".\"revoked_by_actor_type\" in ('user', 'system')"
}
},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+6
View File
@@ -7,6 +7,12 @@
"destructive": { "destructive": {
"mode": "none" "mode": "none"
} }
},
{
"tag": "0001_authorization_core_tables",
"destructive": {
"mode": "none"
}
} }
] ]
} }
+7
View File
@@ -8,6 +8,13 @@
"when": 1784668552522, "when": 1784668552522,
"tag": "0000_identity_core_tables", "tag": "0000_identity_core_tables",
"breakpoints": true "breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1784819635300,
"tag": "0001_authorization_core_tables",
"breakpoints": true
} }
] ]
} }
+4
View File
@@ -26,6 +26,10 @@
"./identity": { "./identity": {
"types": "./dist/identity/index.d.ts", "types": "./dist/identity/index.d.ts",
"import": "./dist/identity/index.js" "import": "./dist/identity/index.js"
},
"./authorization": {
"types": "./dist/authorization/index.d.ts",
"import": "./dist/authorization/index.js"
} }
}, },
"scripts": { "scripts": {
+107
View File
@@ -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,
});
}
+133
View File
@@ -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;
},
});
}
+4
View File
@@ -0,0 +1,4 @@
export * from "./database.js";
export * from "./evaluator.js";
export * from "./ports.js";
export * from "./store.js";
+28
View File
@@ -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>;
}
+145
View File
@@ -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;
+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");
}
},
});
}
+1
View File
@@ -1,3 +1,4 @@
export * from "./authorization/index.js";
export * from "./diagnostics/index.js"; export * from "./diagnostics/index.js";
export * from "./identity/index.js"; export * from "./identity/index.js";
export * from "./modules/index.js"; export * from "./modules/index.js";
+146
View File
@@ -0,0 +1,146 @@
import { describe, expect, it, vi } from "vitest";
const poolConstructor = vi.hoisted(() => vi.fn());
vi.mock("pg", () => ({ Pool: poolConstructor }));
import {
AuthorizationDatabaseError,
createAuthorizationDrizzleDatabase,
} from "../src/authorization/database.js";
describe("Core authorization Drizzle database composition", () => {
it("does not construct a pool merely because the module is imported", () => {
expect(poolConstructor).not.toHaveBeenCalled();
});
it.each([
"",
" ",
"not-a-database-url",
"https://admin:[email protected]/database",
])("rejects an invalid connection string without exposing it (%s)", (connectionString) => {
let captured: unknown;
try {
createAuthorizationDrizzleDatabase(connectionString);
} catch (error) {
captured = error;
}
expect(captured).toBeInstanceOf(AuthorizationDatabaseError);
expect(captured).toMatchObject({ code: "AUTHORIZATION_DATABASE_CONFIGURATION_INVALID" });
const publicText = `${String(captured)}\n${
captured instanceof Error ? (captured.stack ?? "") : ""
}`;
if (connectionString) {
expect(publicText).not.toContain(connectionString);
}
expect(poolConstructor).not.toHaveBeenCalled();
});
it("explicitly constructs the pool only when called, with the authorization schema attached", () => {
poolConstructor.mockImplementation(function fakePool() {
return { end: async () => undefined, on: vi.fn() };
});
const handle = createAuthorizationDrizzleDatabase(
"postgresql://operator:[email protected]/ekklesia",
);
expect(poolConstructor).toHaveBeenCalledTimes(1);
expect(handle.db).toBeDefined();
expect(typeof handle.close).toBe("function");
expect(Object.isFrozen(handle)).toBe(true);
});
it("passes the connection timeout through to the pool configuration", () => {
poolConstructor.mockImplementation(function fakePool() {
return { end: async () => undefined, on: vi.fn() };
});
poolConstructor.mockClear();
createAuthorizationDrizzleDatabase("postgresql://example.test/ekklesia", {
connectionTimeoutMs: 5_000,
});
expect(poolConstructor).toHaveBeenCalledWith(
expect.objectContaining({ connectionTimeoutMillis: 5_000 }),
);
});
it("passes the operation timeout through to the pool configuration as both query_timeout and statement_timeout", () => {
poolConstructor.mockImplementation(function fakePool() {
return { end: async () => undefined, on: vi.fn() };
});
poolConstructor.mockClear();
createAuthorizationDrizzleDatabase("postgresql://example.test/ekklesia", {
operationTimeoutMs: 7_500,
});
expect(poolConstructor).toHaveBeenCalledWith(
expect.objectContaining({ query_timeout: 7_500, statement_timeout: 7_500 }),
);
});
it("does not set query_timeout, statement_timeout, or connectionTimeoutMillis when no timeout is configured", () => {
poolConstructor.mockImplementation(function fakePool() {
return { end: async () => undefined, on: vi.fn() };
});
poolConstructor.mockClear();
createAuthorizationDrizzleDatabase("postgresql://example.test/ekklesia");
const [poolConfig] = poolConstructor.mock.calls[0] as [Record<string, unknown>];
expect(poolConfig).not.toHaveProperty("query_timeout");
expect(poolConfig).not.toHaveProperty("statement_timeout");
expect(poolConfig).not.toHaveProperty("connectionTimeoutMillis");
});
it.each([
["operationTimeoutMs", 0],
["operationTimeoutMs", -1],
["operationTimeoutMs", Number.NaN],
["operationTimeoutMs", 1.5],
["operationTimeoutMs", 2_147_483_648],
["connectionTimeoutMs", 0],
["connectionTimeoutMs", -1],
["connectionTimeoutMs", Number.NaN],
] as const)("rejects an invalid %s (%s) without constructing a pool and without leaking the value", (optionName, value) => {
poolConstructor.mockClear();
let captured: unknown;
try {
createAuthorizationDrizzleDatabase("postgresql://example.test/ekklesia", {
[optionName]: value,
});
} catch (error) {
captured = error;
}
expect(captured).toBeInstanceOf(AuthorizationDatabaseError);
expect(captured).toMatchObject({ code: "AUTHORIZATION_DATABASE_CONFIGURATION_INVALID" });
const publicText = `${String(captured)}\n${
captured instanceof Error ? (captured.stack ?? "") : ""
}`;
expect(publicText).not.toContain(String(value));
expect(poolConstructor).not.toHaveBeenCalled();
});
it("registers a pool-level error listener so an idle client failure never becomes an uncaught exception", () => {
const on = vi.fn();
poolConstructor.mockImplementation(function fakePool() {
return { end: async () => undefined, on };
});
createAuthorizationDrizzleDatabase("postgresql://example.test/ekklesia");
expect(on).toHaveBeenCalledWith("error", expect.any(Function));
const handler = on.mock.calls.find((call) => call[0] === "error")?.[1] as
| ((error: unknown) => void)
| undefined;
expect(() =>
handler?.(new Error("terminating connection due to administrator command")),
).not.toThrow();
});
});
+500
View File
@@ -0,0 +1,500 @@
import {
ANONYMOUS_PRINCIPAL,
createAuthenticatedPrincipal,
parseAuthorizationScope,
parseEkklesiaUserId,
parsePermissionId,
} from "@ekklesia/contracts";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
type ActiveGrantQuery,
AuthorizationEvaluationError,
type AuthorizationGrantPort,
type AuthorizationPlatformOwnerPort,
type AuthorizeRequest,
createAuthorizationEvaluator,
} from "../src/authorization/index.js";
interface FakeGrant {
readonly userId: string;
readonly permission: string;
readonly scope?: string;
readonly expiresAt?: Date;
readonly revokedAt?: Date;
}
/** An in-memory double implementing the exact scope/expiry/revocation matching semantics the real
* Postgres store implements in SQL, so the evaluator's own decision logic can be exercised in
* isolation. The live integration suite separately proves the SQL performs the same matching. */
class FakeGrantStore implements AuthorizationGrantPort {
readonly grants: FakeGrant[] = [];
readonly calls: ActiveGrantQuery[] = [];
async hasActiveGrant(query: ActiveGrantQuery): Promise<boolean> {
this.calls.push(query);
return this.grants.some((grant) => {
if (grant.userId !== query.userId || grant.permission !== query.permission) {
return false;
}
if (grant.revokedAt !== undefined) {
return false;
}
if (grant.expiresAt !== undefined && grant.expiresAt <= query.now) {
return false;
}
if (grant.scope === undefined) {
return true; // an unscoped grant satisfies any request for the same permission
}
return grant.scope === query.scope; // a scoped grant never satisfies an unscoped request
});
}
}
class FakeOwnerStore implements AuthorizationPlatformOwnerPort {
private readonly owners = new Set<string>();
readonly calls: string[] = [];
makeOwner(userId: string): void {
this.owners.add(userId);
}
async isPlatformOwner(userId: string): Promise<boolean> {
this.calls.push(userId);
return this.owners.has(userId);
}
}
const userId = parseEkklesiaUserId("user_1");
const otherUserId = parseEkklesiaUserId("user_2");
const permission = parsePermissionId("calendar.events.read");
const scope = parseAuthorizationScope("calendar-event:123");
const otherScope = parseAuthorizationScope("calendar-event:456");
describe("Core authorization evaluator", () => {
let grants: FakeGrantStore;
let owners: FakeOwnerStore;
beforeEach(() => {
grants = new FakeGrantStore();
owners = new FakeOwnerStore();
});
function evaluator(now?: () => Date) {
return createAuthorizationEvaluator({
grants,
platformOwners: owners,
...(now ? { now } : {}),
});
}
describe("public baseline", () => {
it("allows an anonymous principal", async () => {
const decision = await evaluator().authorize({
baseline: "public",
permission,
principal: ANONYMOUS_PRINCIPAL,
});
expect(decision).toEqual({ allowed: true });
});
it("allows an authenticated principal", async () => {
const decision = await evaluator().authorize({
baseline: "public",
permission,
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: true });
});
it("never queries grants or platform-owner state for a public check", async () => {
await evaluator().authorize({
baseline: "public",
permission,
principal: ANONYMOUS_PRINCIPAL,
});
expect(grants.calls).toHaveLength(0);
expect(owners.calls).toHaveLength(0);
});
});
describe("authenticated baseline", () => {
it("allows an authenticated principal", async () => {
const decision = await evaluator().authorize({
baseline: "authenticated",
permission,
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: true });
});
it("rejects an anonymous principal", async () => {
const decision = await evaluator().authorize({
baseline: "authenticated",
permission,
principal: ANONYMOUS_PRINCIPAL,
});
expect(decision).toEqual({ allowed: false });
});
it("never queries grants or platform-owner state for an authenticated check", async () => {
await evaluator().authorize({
baseline: "authenticated",
permission,
principal: createAuthenticatedPrincipal(userId),
});
expect(grants.calls).toHaveLength(0);
expect(owners.calls).toHaveLength(0);
});
});
describe("restricted baseline", () => {
it("denies an anonymous principal without ever consulting grants or ownership", async () => {
const decision = await evaluator().authorize({
baseline: "restricted",
permission,
principal: ANONYMOUS_PRINCIPAL,
});
expect(decision).toEqual({ allowed: false });
expect(grants.calls).toHaveLength(0);
expect(owners.calls).toHaveLength(0);
});
it("denies an authenticated principal with no grant and no ownership", async () => {
const decision = await evaluator().authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: false });
});
it("allows an authenticated principal holding an active explicit grant", async () => {
grants.grants.push({ permission, userId });
const decision = await evaluator().authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: true });
});
it("allows an authenticated principal holding an active relationship-derived grant", async () => {
// The evaluator only distinguishes "active grant or not" through the port; source kind is a
// store/audit concern, not an evaluation input — proven here by a grant the fake store
// exposes with no source-kind field at all still being honored.
grants.grants.push({ permission, userId });
const decision = await evaluator().authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: true });
});
it("matches permissions exactly and does not treat a namespace prefix as inherited authority", async () => {
grants.grants.push({ permission: parsePermissionId("calendar.events.read.detail"), userId });
const decision = await evaluator().authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: false });
});
it("denies a grant that belongs to a different user", async () => {
grants.grants.push({ permission, userId: otherUserId });
const decision = await evaluator().authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: false });
});
it("lets an unscoped grant satisfy an unscoped request", async () => {
grants.grants.push({ permission, userId });
const decision = await evaluator().authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: true });
});
it("lets an unscoped grant satisfy a scoped request for the same permission", async () => {
grants.grants.push({ permission, userId });
const decision = await evaluator().authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
scope,
});
expect(decision).toEqual({ allowed: true });
});
it("lets a scoped grant satisfy a request for the exact matching scope", async () => {
grants.grants.push({ permission, scope, userId });
const decision = await evaluator().authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
scope,
});
expect(decision).toEqual({ allowed: true });
});
it("denies a scoped grant against a different scope", async () => {
grants.grants.push({ permission, scope, userId });
const decision = await evaluator().authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
scope: otherScope,
});
expect(decision).toEqual({ allowed: false });
});
it("never lets a scoped grant satisfy an unscoped request", async () => {
grants.grants.push({ permission, scope, userId });
const decision = await evaluator().authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: false });
});
it("denies an expired grant", async () => {
const fixedNow = new Date("2026-01-01T00:00:00.000Z");
grants.grants.push({
expiresAt: new Date("2025-12-31T00:00:00.000Z"),
permission,
userId,
});
const decision = await evaluator(() => fixedNow).authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: false });
});
it("allows a grant that has not yet expired, evaluated deterministically against an injected clock", async () => {
const fixedNow = new Date("2026-01-01T00:00:00.000Z");
grants.grants.push({
expiresAt: new Date("2026-01-02T00:00:00.000Z"),
permission,
userId,
});
const decision = await evaluator(() => fixedNow).authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: true });
});
it("denies a revoked grant even though it has not expired", async () => {
grants.grants.push({
expiresAt: new Date(Date.now() + 86_400_000),
permission,
revokedAt: new Date(),
userId,
});
const decision = await evaluator().authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: false });
});
it("denies an unknown or malformed permission without throwing", async () => {
const decision = await evaluator().authorize({
baseline: "restricted",
permission: "not a valid permission id",
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: false });
expect(grants.calls).toHaveLength(0);
});
it("denies a wildcard-shaped permission without throwing", async () => {
const decision = await evaluator().authorize({
baseline: "restricted",
permission: "media.*",
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: false });
});
it("denies a non-string permission (number, object, null, undefined) without throwing", async () => {
for (const malformed of [42, {}, null, undefined, ["calendar.events.read"]]) {
const decision = await evaluator().authorize({
baseline: "restricted",
permission: malformed,
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: false });
}
});
it("denies a malformed scope without throwing", async () => {
grants.grants.push({ permission, userId });
const decision = await evaluator().authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
scope: "",
});
expect(decision).toEqual({ allowed: false });
});
it("allows a platform owner with no matching grant at all", async () => {
owners.makeOwner(userId);
const decision = await evaluator().authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: true });
});
it("allows a platform owner for a scoped restricted permission with no scoped grant", async () => {
owners.makeOwner(userId);
const decision = await evaluator().authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
scope,
});
expect(decision).toEqual({ allowed: true });
});
it("does not grant ownership authority to a different user", async () => {
owners.makeOwner(otherUserId);
const decision = await evaluator().authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: false });
});
});
describe("an unknown or malformed baseline", () => {
it("denies rather than defaulting to any known baseline", async () => {
for (const malformed of ["superuser", "", 42, null, undefined, "Public", "PUBLIC"]) {
const decision = await evaluator().authorize({
baseline: malformed,
permission,
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: false });
}
});
});
describe("a client cannot inject owner or grant state", () => {
it("ignores extra caller-supplied fields such as isOwner/hasGrant and re-derives authority from the injected ports", async () => {
// Simulates a caller that smuggles extra fields the AuthorizeRequest type does not
// declare — going through `unknown` rather than `any` proves the object still satisfies
// AuthorizeRequest structurally; the extra fields are simply never read.
const hostileRequest = {
baseline: "restricted",
hasGrant: true,
isOwner: true,
permission,
principal: createAuthenticatedPrincipal(userId),
} as unknown as AuthorizeRequest;
const decision = await evaluator().authorize(hostileRequest);
expect(decision).toEqual({ allowed: false });
// The evaluator still had to actually ask the injected ports — it did not shortcut on the
// hostile flags.
expect(grants.calls).toHaveLength(1);
expect(owners.calls).toHaveLength(1);
});
});
describe("decision contract", () => {
it("returns a frozen, minimal decision with no extra fields", async () => {
const decision = await evaluator().authorize({
baseline: "public",
permission,
principal: ANONYMOUS_PRINCIPAL,
});
expect(Object.isFrozen(decision)).toBe(true);
expect(Object.keys(decision)).toEqual(["allowed"]);
});
});
describe("sanitized failures", () => {
it("propagates a genuine grant-lookup infrastructure failure rather than silently denying", async () => {
const failingGrants: AuthorizationGrantPort = {
hasActiveGrant: vi.fn().mockRejectedValue(new Error("connection terminated unexpectedly")),
};
const failingEvaluator = createAuthorizationEvaluator({
grants: failingGrants,
platformOwners: owners,
});
await expect(
failingEvaluator.authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
}),
).rejects.toMatchObject({
code: "AUTHORIZATION_EVALUATION_FAILED",
});
});
it("propagates a genuine platform-owner lookup infrastructure failure rather than silently denying", async () => {
const failingOwners: AuthorizationPlatformOwnerPort = {
isPlatformOwner: vi.fn().mockRejectedValue(new Error("connection terminated unexpectedly")),
};
const failingEvaluator = createAuthorizationEvaluator({
grants,
platformOwners: failingOwners,
});
await expect(
failingEvaluator.authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
}),
).rejects.toBeInstanceOf(AuthorizationEvaluationError);
});
it("never leaks a raw infrastructure error message through the thrown error", async () => {
const hostileMessage = "password=hunter2 host=db.internal.example port=5432";
const failingGrants: AuthorizationGrantPort = {
hasActiveGrant: vi.fn().mockRejectedValue(new Error(hostileMessage)),
};
const failingEvaluator = createAuthorizationEvaluator({
grants: failingGrants,
platformOwners: owners,
});
let captured: unknown;
try {
await failingEvaluator.authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
});
} catch (error) {
captured = error;
}
const publicText = `${String(captured)}\n${
captured instanceof Error ? (captured.stack ?? "") : ""
}`;
expect(publicText).not.toContain("hunter2");
expect(publicText).not.toContain("db.internal.example");
});
});
});
@@ -0,0 +1,539 @@
import { randomBytes } from "node:crypto";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
type AuthorizationScope,
createUserActor,
type GrantId,
parseAuthorizationScope,
parseEkklesiaUserId,
parsePermissionId,
SYSTEM_ACTOR,
} from "@ekklesia/contracts";
import { Client, type QueryResultRow } from "pg";
import { afterAll, describe, expect, it } from "vitest";
import {
AuthorizationStoreError,
createAuthorizationDrizzleDatabase,
createAuthorizationStore,
} from "../src/authorization/index.js";
import {
applyModuleMigrations,
createPostgresMigrationDatabase,
discoverAllMigrations,
} from "../src/migrations/index.js";
const integrationDatabaseUrlEnvironment = "EKKLESIA_MIGRATION_TEST_DATABASE_URL";
const coreMigrationsDirectory = path.join(
path.dirname(fileURLToPath(import.meta.url)),
"..",
"drizzle",
);
const trackedDatabases = new Set<string>();
let databaseCounter = 0;
class IntegrationHarnessError extends Error {
constructor(message: string) {
super(message);
this.name = "IntegrationHarnessError";
this.stack = `${this.name}: ${this.message}`;
}
}
function harnessError(message: string): IntegrationHarnessError {
return new IntegrationHarnessError(message);
}
function baseDatabaseUrl(): string {
const value = process.env[integrationDatabaseUrlEnvironment];
if (typeof value !== "string" || value.trim() === "") {
throw harnessError("The authorization integration database is not configured.");
}
try {
const parsed = new URL(value);
if (parsed.protocol !== "postgres:" && parsed.protocol !== "postgresql:") {
throw new TypeError("Unsupported protocol.");
}
} catch {
throw harnessError("The authorization integration database configuration is invalid.");
}
return value;
}
function quoteDatabaseName(databaseName: string): string {
if (!/^[a-z][a-z0-9_]{0,62}$/u.test(databaseName)) {
throw harnessError("The generated integration database name is invalid.");
}
return `"${databaseName}"`;
}
function makeDatabaseName(label: string): string {
const safeLabel = label
.toLowerCase()
.replaceAll(/[^a-z0-9]+/gu, "_")
.replaceAll(/^_+|_+$/gu, "")
.slice(0, 14);
databaseCounter += 1;
return `ekk_authz_${process.pid}_${databaseCounter}_${safeLabel || "scenario"}_${randomBytes(4).toString("hex")}`;
}
function databaseUrl(databaseName: string): string {
const parsed = new URL(baseDatabaseUrl());
parsed.pathname = `/${databaseName}`;
parsed.searchParams.delete("application_name");
return parsed.toString();
}
async function openAdministrativeClient(connectionString: string): Promise<Client> {
const client = new Client({ connectionString });
client.on("error", () => undefined);
try {
await client.connect();
return client;
} catch {
try {
await client.end();
} catch {
// The public harness failure remains static when connection cleanup also fails.
}
throw harnessError("The authorization integration database connection failed.");
}
}
async function safeQuery<Row extends QueryResultRow>(
client: Client,
text: string,
values: unknown[] = [],
): Promise<readonly Row[]> {
try {
const result = await client.query<Row>(text, values);
return result.rows;
} catch {
throw harnessError("An integration database administration operation failed.");
}
}
async function safeEnd(client: Client): Promise<void> {
try {
await client.end();
} catch {
throw harnessError("An integration database connection could not be closed.");
}
}
async function createDatabase(databaseName: string): Promise<void> {
const admin = await openAdministrativeClient(baseDatabaseUrl());
try {
await safeQuery(admin, `CREATE DATABASE ${quoteDatabaseName(databaseName)} TEMPLATE template0`);
} finally {
await safeEnd(admin);
}
}
async function dropDatabase(databaseName: string): Promise<void> {
const admin = await openAdministrativeClient(baseDatabaseUrl());
try {
await safeQuery(
admin,
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()",
[databaseName],
);
await safeQuery(
admin,
`DROP DATABASE IF EXISTS ${quoteDatabaseName(databaseName)} WITH (FORCE)`,
);
trackedDatabases.delete(databaseName);
} finally {
await safeEnd(admin);
}
}
async function createDisposableDatabase(label: string): Promise<{ name: string; url: string }> {
const name = makeDatabaseName(label);
await createDatabase(name);
trackedDatabases.add(name);
return { name, url: databaseUrl(name) };
}
async function applyCoreMigrations(url: string): Promise<void> {
const emptyModulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-authz-no-modules-"));
try {
const aggregate = await discoverAllMigrations({
coreMigrationsDirectory,
moduleDiscovery: {
moduleSdkVersion: "0.1.0",
modulesDirectory: emptyModulesDirectory,
platformVersion: "0.1.0",
},
});
expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([
"core:0000_identity_core_tables",
"core:0001_authorization_core_tables",
]);
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
} finally {
await rm(emptyModulesDirectory, { recursive: true, force: true });
}
}
async function seedUser(client: Client, id: string, email: string): Promise<void> {
await safeQuery(
client,
`insert into "ekklesia_core_user" (id, name, email, email_verified, created_at, updated_at)
values ($1, $2, $3, false, now(), now())`,
[id, "Integration Test User", email],
);
}
afterAll(async () => {
for (const databaseName of [...trackedDatabases]) {
await dropDatabase(databaseName).catch(() => undefined);
}
});
describe.sequential("live Core authorization store", () => {
let database: { name: string; url: string };
let admin: Client;
let store: ReturnType<typeof createAuthorizationStore>;
let close: () => Promise<void>;
const userOneId = parseEkklesiaUserId("authz_user_one");
const userTwoId = parseEkklesiaUserId("authz_user_two");
const permission = parsePermissionId("calendar.events.read");
const scope: AuthorizationScope = parseAuthorizationScope("calendar-event:123");
const actor = createUserActor("authz_user_two"); // an operator/admin user distinct from the subject
it("applies a fresh migration and creates exactly the three authorization tables with their constraints", async () => {
database = await createDisposableDatabase("fresh");
await applyCoreMigrations(database.url);
admin = await openAdministrativeClient(database.url);
const tables = await safeQuery<{ table_name: string }>(
admin,
`select table_name from information_schema.tables
where table_schema = 'public' and table_name like 'ekklesia_core_%'
order by table_name`,
);
expect(tables.map((row) => row.table_name)).toEqual([
"ekklesia_core_account",
"ekklesia_core_authorization_audit",
"ekklesia_core_grant",
"ekklesia_core_migrations",
"ekklesia_core_platform_owner",
"ekklesia_core_session",
"ekklesia_core_user",
"ekklesia_core_verification",
]);
await seedUser(admin, userOneId, "[email protected]");
await seedUser(admin, userTwoId, "[email protected]");
const handle = createAuthorizationDrizzleDatabase(database.url);
close = handle.close;
store = createAuthorizationStore(handle.db);
});
it("creates a grant and finds it as active for the matching user, permission, and scope", async () => {
const grant = await store.createGrant({
actor,
permission,
scope,
sourceKind: "explicit",
userId: userOneId,
});
expect(grant.userId).toBe(userOneId);
expect(grant.permission).toBe(permission);
expect(grant.scope).toBe(scope);
expect(grant.sourceKind).toBe("explicit");
expect(grant.revokedAt).toBeUndefined();
const active = await store.hasActiveGrant({
now: new Date(),
permission,
scope,
userId: userOneId,
});
expect(active).toBe(true);
});
it("does not find the grant for a different user, a different permission, or a different scope", async () => {
expect(
await store.hasActiveGrant({ now: new Date(), permission, scope, userId: userTwoId }),
).toBe(false);
expect(
await store.hasActiveGrant({
now: new Date(),
permission: parsePermissionId("calendar.events.write"),
scope,
userId: userOneId,
}),
).toBe(false);
expect(
await store.hasActiveGrant({
now: new Date(),
permission,
scope: parseAuthorizationScope("calendar-event:456"),
userId: userOneId,
}),
).toBe(false);
});
it("never lets the scoped grant satisfy an unscoped request", async () => {
const active = await store.hasActiveGrant({ now: new Date(), permission, userId: userOneId });
expect(active).toBe(false);
});
it("lets an unscoped grant satisfy both unscoped and scoped requests for the same permission", async () => {
const unscopedPermission = parsePermissionId("calendar.events.list");
await store.createGrant({
actor,
permission: unscopedPermission,
sourceKind: "relationship",
sourceRef: "roster:volunteer:42",
userId: userOneId,
});
expect(
await store.hasActiveGrant({
now: new Date(),
permission: unscopedPermission,
userId: userOneId,
}),
).toBe(true);
expect(
await store.hasActiveGrant({
now: new Date(),
permission: unscopedPermission,
scope: parseAuthorizationScope("anything-at-all"),
userId: userOneId,
}),
).toBe(true);
});
it("records exactly one atomic audit entry for the grant it just created", async () => {
const rows = await safeQuery<{
action: string;
actor_id: string | null;
actor_type: string;
permission_id: string | null;
scope: string | null;
subject_user_id: string;
}>(
admin,
`select action, actor_id, actor_type, permission_id, scope, subject_user_id
from "ekklesia_core_authorization_audit"
where subject_user_id = $1 and permission_id = $2`,
[userOneId, permission],
);
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({
action: "grant.create",
actor_id: userTwoId,
actor_type: "user",
permission_id: permission,
scope,
subject_user_id: userOneId,
});
});
it("denies a grant once it expires, evaluated against a real elapsed instant", async () => {
const expiringPermission = parsePermissionId("calendar.events.export");
const past = new Date(Date.now() - 1_000);
await store.createGrant({
actor,
expiresAt: past,
permission: expiringPermission,
sourceKind: "explicit",
userId: userOneId,
});
const active = await store.hasActiveGrant({
now: new Date(),
permission: expiringPermission,
userId: userOneId,
});
expect(active).toBe(false);
});
it("allows a grant that has not yet expired", async () => {
const notYetExpiredPermission = parsePermissionId("calendar.events.import");
const future = new Date(Date.now() + 86_400_000);
await store.createGrant({
actor,
expiresAt: future,
permission: notYetExpiredPermission,
sourceKind: "explicit",
userId: userOneId,
});
const active = await store.hasActiveGrant({
now: new Date(),
permission: notYetExpiredPermission,
userId: userOneId,
});
expect(active).toBe(true);
});
const revocablePermission = parsePermissionId("calendar.events.delete");
let revocableGrantId: GrantId;
it("revokes an active grant, records the revocation audit entry, and stops it authorizing", async () => {
const grant = await store.createGrant({
actor,
permission: revocablePermission,
sourceKind: "explicit",
userId: userOneId,
});
revocableGrantId = grant.id;
await store.revokeGrant({ actor, grantId: grant.id });
const active = await store.hasActiveGrant({
now: new Date(),
permission: revocablePermission,
userId: userOneId,
});
expect(active).toBe(false);
const audit = await safeQuery<{ action: string }>(
admin,
`select action from "ekklesia_core_authorization_audit" where grant_id = $1 order by created_at`,
[revocableGrantId],
);
expect(audit.map((row) => row.action)).toEqual(["grant.create", "grant.revoke"]);
});
it("refuses to revoke an already-revoked grant a second time", async () => {
let captured: unknown;
try {
await store.revokeGrant({ actor, grantId: revocableGrantId });
} catch (error) {
captured = error;
}
expect(captured).toBeInstanceOf(AuthorizationStoreError);
expect(captured).toMatchObject({ code: "AUTHORIZATION_GRANT_NOT_FOUND" });
});
it("rolls back the whole grant-plus-audit transaction when the audit write violates a real constraint", async () => {
const doomedPermission = parsePermissionId("calendar.events.archive");
// The audit table's scope CHECK constraint rejects a non-null empty string; the grant table
// has no such constraint. Bypassing the application-layer AuthorizationScope validation (which
// never produces an empty string) with a raw cast lets a real Postgres constraint violation
// fire on the audit insert specifically, after the grant insert within the same transaction
// would otherwise have succeeded.
let captured: unknown;
try {
await store.createGrant({
actor,
permission: doomedPermission,
scope: "" as AuthorizationScope,
sourceKind: "explicit",
userId: userOneId,
});
} catch (error) {
captured = error;
}
expect(captured).toBeInstanceOf(AuthorizationStoreError);
expect(captured).toMatchObject({ code: "AUTHORIZATION_GRANT_CREATE_FAILED" });
const orphanedGrants = await safeQuery(
admin,
`select id from "ekklesia_core_grant" where permission_id = $1`,
[doomedPermission],
);
expect(orphanedGrants).toHaveLength(0);
const orphanedAudit = await safeQuery(
admin,
`select id from "ekklesia_core_authorization_audit" where permission_id = $1`,
[doomedPermission],
);
expect(orphanedAudit).toHaveLength(0);
});
it("is not a platform owner before ever being granted ownership", async () => {
expect(await store.isPlatformOwner(userOneId)).toBe(false);
});
it("grants, records the audit entry for, and looks up platform-owner authority", async () => {
await store.grantPlatformOwner(userOneId, actor);
expect(await store.isPlatformOwner(userOneId)).toBe(true);
expect(await store.isPlatformOwner(userTwoId)).toBe(false);
const audit = await safeQuery<{ action: string; subject_user_id: string }>(
admin,
`select action, subject_user_id from "ekklesia_core_authorization_audit" where action = 'platform_owner.grant'`,
);
expect(audit).toEqual([{ action: "platform_owner.grant", subject_user_id: userOneId }]);
});
it("revokes platform-owner authority, records the audit entry, and stops future lookups from succeeding", async () => {
await store.revokePlatformOwner(userOneId, actor);
expect(await store.isPlatformOwner(userOneId)).toBe(false);
const audit = await safeQuery<{ action: string }>(
admin,
`select action from "ekklesia_core_authorization_audit" where action = 'platform_owner.revoke'`,
);
expect(audit).toHaveLength(1);
});
it("refuses to revoke platform-owner authority a user never held (or already lost)", async () => {
let captured: unknown;
try {
await store.revokePlatformOwner(userTwoId, actor);
} catch (error) {
captured = error;
}
expect(captured).toBeInstanceOf(AuthorizationStoreError);
expect(captured).toMatchObject({ code: "AUTHORIZATION_PLATFORM_OWNER_NOT_FOUND" });
});
it("re-grants platform-owner authority after a revocation (upsert), reactivating rather than duplicating the row", async () => {
await store.grantPlatformOwner(userOneId, SYSTEM_ACTOR);
expect(await store.isPlatformOwner(userOneId)).toBe(true);
const rows = await safeQuery(
admin,
`select user_id from "ekklesia_core_platform_owner" where user_id = $1`,
[userOneId],
);
expect(rows).toHaveLength(1);
});
it("shuts down cleanly", async () => {
await close();
await safeEnd(admin);
await dropDatabase(database.name);
});
});
describe.sequential("live Core authorization store — no automatic migration", () => {
it("creates no authorization tables merely by constructing the database, the store, or an evaluator against an unmigrated database", async () => {
const database = await createDisposableDatabase("unmigrated");
try {
const { db, close } = createAuthorizationDrizzleDatabase(database.url);
const store = createAuthorizationStore(db);
expect(store).toBeDefined();
await close();
const admin = await openAdministrativeClient(database.url);
try {
const tables = await safeQuery(
admin,
`select table_name from information_schema.tables where table_schema = 'public'`,
);
expect(tables).toHaveLength(0);
} finally {
await safeEnd(admin);
}
} finally {
await dropDatabase(database.name);
}
});
});
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it, vi } from "vitest";
const poolConstructor = vi.hoisted(() => vi.fn());
vi.mock("pg", () => ({ Pool: poolConstructor }));
import { createAuthorizationDrizzleDatabase } from "../src/authorization/database.js";
import { AuthorizationStoreError, createAuthorizationStore } from "../src/authorization/store.js";
describe("Core authorization store — import and construction safety", () => {
it("does not construct a pool or open a connection merely because the module is imported", () => {
expect(poolConstructor).not.toHaveBeenCalled();
});
it("does not perform any query merely because the store is constructed", () => {
poolConstructor.mockImplementation(function fakePool() {
return { end: async () => undefined, on: vi.fn(), query: vi.fn() };
});
const { db } = createAuthorizationDrizzleDatabase("postgresql://example.test/ekklesia");
const store = createAuthorizationStore(db);
expect(store).toBeDefined();
expect(typeof store.createGrant).toBe("function");
expect(typeof store.revokeGrant).toBe("function");
expect(typeof store.hasActiveGrant).toBe("function");
expect(typeof store.grantPlatformOwner).toBe("function");
expect(typeof store.revokePlatformOwner).toBe("function");
expect(typeof store.isPlatformOwner).toBe("function");
expect(Object.isFrozen(store)).toBe(true);
});
});
describe("AuthorizationStoreError", () => {
it.each([
"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",
] as const)("exposes a stable code and a fixed, generic message for %s", (code) => {
const error = new AuthorizationStoreError(code);
expect(error.code).toBe(code);
expect(error.name).toBe("AuthorizationStoreError");
expect(Object.isFrozen(error)).toBe(true);
expect(error.message.length).toBeGreaterThan(0);
});
it("never carries a cause, raw driver error, SQL, or stack detail beyond the fixed message", () => {
const error = new AuthorizationStoreError("AUTHORIZATION_GRANT_CREATE_FAILED");
const publicText = `${String(error)}\n${error.stack ?? ""}`;
expect(publicText).toBe(
`AuthorizationStoreError: ${error.message}\nAuthorizationStoreError: ${error.message}`,
);
});
});
+119
View File
@@ -0,0 +1,119 @@
import { z } from "zod";
import type { EkklesiaUserId } from "./identity.js";
import { parseEkklesiaUserId } from "./identity.js";
/**
* Opaque at the type level via Zod's nominal `.brand()`, exactly like `EkklesiaUserId` and
* `PermissionId`: a plain `string` is not assignable to `GrantId` without passing through
* `parseGrantId`/`safeParseGrantId`. Ekklesia does not assume any particular format; Core's
* Postgres-backed store happens to generate a UUID, but callers must treat the value as opaque.
*/
export const grantIdSchema = z.string().trim().min(1).max(128).brand<"GrantId">();
export type GrantId = z.infer<typeof grantIdSchema>;
export function parseGrantId(input: unknown): GrantId {
return grantIdSchema.parse(input);
}
export function safeParseGrantId(input: unknown): z.ZodSafeParseResult<GrantId> {
return grantIdSchema.safeParse(input);
}
/**
* A generic, Core-opaque resource scope narrowing a grant or an authorization check to one
* resource (for example a specific calendar event or media item). Core never interprets the
* value beyond exact string equality — the owning module defines what a scope string means, if
* anything. Not branded: unlike a permission or a grant, a scope carries no identity of its own
* within Core; it is simply an opaque comparison key.
*/
export const authorizationScopeSchema = z.string().trim().min(1).max(256);
export type AuthorizationScope = z.infer<typeof authorizationScopeSchema>;
export function parseAuthorizationScope(input: unknown): AuthorizationScope {
return authorizationScopeSchema.parse(input);
}
export function safeParseAuthorizationScope(
input: unknown,
): z.ZodSafeParseResult<AuthorizationScope> {
return authorizationScopeSchema.safeParse(input);
}
/**
* Every permission declaration has exactly one baseline. `public` allows any principal, anonymous
* or authenticated. `authenticated` requires a valid authenticated principal and nothing more.
* `restricted` requires an active matching grant or platform-owner authority — baseline alone
* never satisfies it.
*/
export const permissionBaselineSchema = z.enum(["public", "authenticated", "restricted"]);
export type PermissionBaseline = z.infer<typeof permissionBaselineSchema>;
/**
* Distinguishes a grant an operator/system created directly (`explicit`) from one a module
* created as a side effect of its own business operation, attributed to some source the module
* understands (`relationship`) — for example adding a person to a volunteer roster. Core never
* interprets which relationship produced the grant; it only records the distinction and an
* opaque source reference for audit purposes.
*/
export const grantSourceKindSchema = z.enum(["explicit", "relationship"]);
export type GrantSourceKind = z.infer<typeof grantSourceKindSchema>;
/** A Core-opaque reference to whatever produced a `relationship` grant (or, optionally, extra
* context for an `explicit` one) — for example a module-defined identifier. Never interpreted by
* Core, never a foreign key into another module's tables. */
export const grantSourceRefSchema = z.string().trim().min(1).max(256);
export type GrantSourceRef = z.infer<typeof grantSourceRefSchema>;
const authorizationActorTypeSchema = z.enum(["user", "system"]);
export type AuthorizationActorType = z.infer<typeof authorizationActorTypeSchema>;
export interface AuthorizationUserActor {
readonly type: "user";
readonly userId: EkklesiaUserId;
}
export interface AuthorizationSystemActor {
readonly type: "system";
}
/**
* Who performed a security-relevant authorization mutation (a grant or platform-owner change).
* Mirrors `EkklesiaPrincipal`'s discriminated-union style. `system` covers Core-internal or
* automated actions with no specific human actor (for example a future bootstrap command run by
* an operator with no prior Ekklesia identity); it never means "unauthenticated" or "anonymous
* caller" — every mutating call site chooses this deliberately.
*/
export type AuthorizationActor = AuthorizationUserActor | AuthorizationSystemActor;
export const SYSTEM_ACTOR: AuthorizationSystemActor = Object.freeze({ type: "system" });
export function createUserActor(userId: unknown): AuthorizationUserActor {
return Object.freeze({ type: "user", userId: parseEkklesiaUserId(userId) });
}
export function isUserActor(actor: AuthorizationActor): actor is AuthorizationUserActor {
return actor.type === "user";
}
/**
* The only public result of an authorization check. Deliberately minimal and frozen: it never
* carries a reason, a matched grant, a permission echo, or any other detail a caller could use to
* infer another user's grants or Core's internal evaluation path. `AUTHORIZATION_ALLOWED` and
* `AUTHORIZATION_DENIED` are the only two values ever produced.
*/
export interface AuthorizationDecision {
readonly allowed: boolean;
}
export const AUTHORIZATION_ALLOWED: AuthorizationDecision = Object.freeze({ allowed: true });
export const AUTHORIZATION_DENIED: AuthorizationDecision = Object.freeze({ allowed: false });
export function isAuthorizationAllowed(decision: AuthorizationDecision): boolean {
return decision.allowed;
}
+21 -1
View File
@@ -16,11 +16,23 @@ export const capabilityIdSchema = z
.max(160) .max(160)
.regex(namespacedIdentifierPattern, "Must be a lowercase dot-namespaced capability identifier."); .regex(namespacedIdentifierPattern, "Must be a lowercase dot-namespaced capability identifier.");
/**
* Opaque at the type level via Zod's nominal `.brand()`, exactly like `EkklesiaUserId`: a plain
* `string` is not assignable to `PermissionId` without passing through
* `parsePermissionId`/`safeParsePermissionId`. The brand adds no runtime tag or wrapper — a value
* still serializes as a plain string. This is the one permission-identifier type shared by module
* manifest declarations (`moduleManifestSchema`'s `permissions` array) and Core's authorization
* evaluator, so a permission a module declares and a permission Core evaluates are provably the
* same identifier space. Namespacing (the required `.`-segments) is purely a naming convention for
* readability and collision-avoidance — it never implies hierarchy or inheritance during
* evaluation, and wildcard segments are not part of this format at all.
*/
export const permissionIdSchema = z export const permissionIdSchema = z
.string() .string()
.min(3) .min(3)
.max(160) .max(160)
.regex(namespacedIdentifierPattern, "Must be a lowercase dot-namespaced permission identifier."); .regex(namespacedIdentifierPattern, "Must be a lowercase dot-namespaced permission identifier.")
.brand<"PermissionId">();
export const logicalBucketIdSchema = z export const logicalBucketIdSchema = z
.string() .string()
@@ -58,3 +70,11 @@ export type CapabilityId = z.infer<typeof capabilityIdSchema>;
export type PermissionId = z.infer<typeof permissionIdSchema>; export type PermissionId = z.infer<typeof permissionIdSchema>;
export type LogicalBucketId = z.infer<typeof logicalBucketIdSchema>; export type LogicalBucketId = z.infer<typeof logicalBucketIdSchema>;
export type PackageRelativePath = z.infer<typeof packageRelativePathSchema>; export type PackageRelativePath = z.infer<typeof packageRelativePathSchema>;
export function parsePermissionId(input: unknown): PermissionId {
return permissionIdSchema.parse(input);
}
export function safeParsePermissionId(input: unknown): z.ZodSafeParseResult<PermissionId> {
return permissionIdSchema.safeParse(input);
}
+1
View File
@@ -1,3 +1,4 @@
export * from "./authorization.js";
export * from "./identifiers.js"; export * from "./identifiers.js";
export * from "./identity.js"; export * from "./identity.js";
export * from "./module-lifecycle.js"; export * from "./module-lifecycle.js";
@@ -0,0 +1,128 @@
import { describe, expect, it } from "vitest";
import {
AUTHORIZATION_ALLOWED,
AUTHORIZATION_DENIED,
type AuthorizationActor,
type AuthorizationDecision,
createUserActor,
type GrantId,
isAuthorizationAllowed,
isUserActor,
parseAuthorizationScope,
parseGrantId,
parsePermissionId,
type PermissionId,
safeParseAuthorizationScope,
safeParseGrantId,
safeParsePermissionId,
SYSTEM_ACTOR,
} from "../src/index.js";
describe("PermissionId", () => {
it("parses a valid lowercase dot-namespaced permission id", () => {
expect(parsePermissionId("calendar.events.read")).toBe("calendar.events.read");
});
it("is opaque at the type level: a plain string is not assignable without parsing", () => {
// @ts-expect-error a bare string must not satisfy the branded PermissionId type.
const notAllowed: PermissionId = "calendar.events.read";
expect(notAllowed).toBe("calendar.events.read");
});
it("rejects a non-namespaced, empty, oversized, or non-string permission id", () => {
expect(() => parsePermissionId("read")).toThrow();
expect(() => parsePermissionId("")).toThrow();
expect(() => parsePermissionId("Calendar.Events.Read")).toThrow();
expect(() => parsePermissionId("x".repeat(161))).toThrow();
expect(() => parsePermissionId(42)).toThrow();
expect(() => parsePermissionId(null)).toThrow();
});
it("rejects a wildcard segment: there is no such thing as a wildcard permission", () => {
expect(() => parsePermissionId("media.*")).toThrow();
expect(() => parsePermissionId("media.*.read")).toThrow();
expect(() => parsePermissionId("*")).toThrow();
});
it("exposes a safe-parse variant that never throws", () => {
expect(safeParsePermissionId("calendar.events.read").success).toBe(true);
expect(safeParsePermissionId("*").success).toBe(false);
});
});
describe("GrantId", () => {
it("parses a non-empty opaque grant id and rejects empty/oversized input", () => {
const id: GrantId = parseGrantId("11111111-1111-1111-1111-111111111111");
expect(id).toBe("11111111-1111-1111-1111-111111111111");
expect(() => parseGrantId("")).toThrow();
expect(() => parseGrantId("x".repeat(129))).toThrow();
});
it("exposes a safe-parse variant that never throws", () => {
expect(safeParseGrantId("abc").success).toBe(true);
expect(safeParseGrantId("").success).toBe(false);
});
});
describe("AuthorizationScope", () => {
it("accepts a non-empty opaque scope and rejects empty/whitespace/oversized scope", () => {
expect(parseAuthorizationScope("calendar-event:123")).toBe("calendar-event:123");
expect(safeParseAuthorizationScope("").success).toBe(false);
expect(safeParseAuthorizationScope(" ").success).toBe(false);
expect(safeParseAuthorizationScope("x".repeat(257)).success).toBe(false);
});
});
describe("AuthorizationActor", () => {
it("creates a frozen user actor carrying only the opaque user id", () => {
const actor = createUserActor("user_123");
expect(actor).toEqual({ type: "user", userId: "user_123" });
expect(Object.isFrozen(actor)).toBe(true);
expect(isUserActor(actor)).toBe(true);
});
it("rejects an invalid user id when creating a user actor", () => {
expect(() => createUserActor("")).toThrow();
expect(() => createUserActor(42)).toThrow();
});
it("exposes a frozen system actor singleton distinguishable from a user actor", () => {
expect(SYSTEM_ACTOR).toEqual({ type: "system" });
expect(Object.isFrozen(SYSTEM_ACTOR)).toBe(true);
expect(isUserActor(SYSTEM_ACTOR)).toBe(false);
});
it("discriminates actors on type without leaking extra fields", () => {
const actors: AuthorizationActor[] = [SYSTEM_ACTOR, createUserActor("user_123")];
for (const actor of actors) {
expect(["user", "system"]).toContain(actor.type);
}
expect(Object.keys(SYSTEM_ACTOR)).toEqual(["type"]);
});
});
describe("AuthorizationDecision", () => {
it("exposes exactly two frozen, minimal decision singletons", () => {
expect(AUTHORIZATION_ALLOWED).toEqual({ allowed: true });
expect(AUTHORIZATION_DENIED).toEqual({ allowed: false });
expect(Object.isFrozen(AUTHORIZATION_ALLOWED)).toBe(true);
expect(Object.isFrozen(AUTHORIZATION_DENIED)).toBe(true);
expect(Object.keys(AUTHORIZATION_ALLOWED)).toEqual(["allowed"]);
expect(Object.keys(AUTHORIZATION_DENIED)).toEqual(["allowed"]);
});
it("never carries a reason, matched grant, permission echo, or any other detail", () => {
const decisions: AuthorizationDecision[] = [AUTHORIZATION_ALLOWED, AUTHORIZATION_DENIED];
for (const decision of decisions) {
expect(Object.keys(decision)).not.toContain("reason");
expect(Object.keys(decision)).not.toContain("grantId");
expect(Object.keys(decision)).not.toContain("permission");
expect(Object.keys(decision)).not.toContain("code");
}
});
it("exposes a boolean helper matching the allowed field", () => {
expect(isAuthorizationAllowed(AUTHORIZATION_ALLOWED)).toBe(true);
expect(isAuthorizationAllowed(AUTHORIZATION_DENIED)).toBe(false);
});
});
+1
View File
@@ -6,6 +6,7 @@ export default defineConfig({
hookTimeout: 30_000, hookTimeout: 30_000,
include: [ include: [
"core/tests/postgres-migration.integration.ts", "core/tests/postgres-migration.integration.ts",
"core/tests/authorization-store.integration.ts",
"apps/api/tests/api-http.integration.ts", "apps/api/tests/api-http.integration.ts",
], ],
passWithNoTests: false, passWithNoTests: false,