From 72e082ff2f20748852ef891809fb894b2731b6ad Mon Sep 17 00:00:00 2001 From: ekklesia Date: Thu, 23 Jul 2026 10:29:01 -0500 Subject: [PATCH 1/4] feat: add core authorization foundation --- apps/api/tests/api-http.integration.ts | 7 +- core/drizzle.config.ts | 2 +- .../0001_authorization_core_tables.sql | 51 ++ core/drizzle/meta/0001_snapshot.json | 683 ++++++++++++++++++ core/drizzle/meta/_ekklesia.json | 6 + core/drizzle/meta/_journal.json | 7 + core/package.json | 4 + core/src/authorization/database.ts | 107 +++ core/src/authorization/evaluator.ts | 133 ++++ core/src/authorization/index.ts | 4 + core/src/authorization/ports.ts | 28 + core/src/authorization/schema.ts | 145 ++++ core/src/authorization/store.ts | 340 +++++++++ core/src/index.ts | 1 + core/tests/authorization-database.test.ts | 146 ++++ core/tests/authorization-evaluator.test.ts | 500 +++++++++++++ core/tests/authorization-store.integration.ts | 539 ++++++++++++++ core/tests/authorization-store.test.ts | 58 ++ packages/contracts/src/authorization.ts | 119 +++ packages/contracts/src/identifiers.ts | 22 +- packages/contracts/src/index.ts | 1 + .../contracts/tests/authorization.test.ts | 128 ++++ vitest.migrations.integration.config.ts | 1 + 23 files changed, 3029 insertions(+), 3 deletions(-) create mode 100644 core/drizzle/0001_authorization_core_tables.sql create mode 100644 core/drizzle/meta/0001_snapshot.json create mode 100644 core/src/authorization/database.ts create mode 100644 core/src/authorization/evaluator.ts create mode 100644 core/src/authorization/index.ts create mode 100644 core/src/authorization/ports.ts create mode 100644 core/src/authorization/schema.ts create mode 100644 core/src/authorization/store.ts create mode 100644 core/tests/authorization-database.test.ts create mode 100644 core/tests/authorization-evaluator.test.ts create mode 100644 core/tests/authorization-store.integration.ts create mode 100644 core/tests/authorization-store.test.ts create mode 100644 packages/contracts/src/authorization.ts create mode 100644 packages/contracts/tests/authorization.test.ts diff --git a/apps/api/tests/api-http.integration.ts b/apps/api/tests/api-http.integration.ts index 06b4d70..cb27822 100644 --- a/apps/api/tests/api-http.integration.ts +++ b/apps/api/tests/api-http.integration.ts @@ -174,13 +174,18 @@ async function applyCoreMigrations(url: string): Promise { 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([ "core:0000_identity_core_tables", + "core:0001_authorization_core_tables", ]); expect(aggregate.migrations[0]?.checksum).toBe( "f8f2086ef06f7a6943e8ffc4350bb010b37aed04c0a72030d2b153c986b1325f", ); + expect(aggregate.migrations[1]?.checksum).toBe( + "830e1fbc26d05af08df8d8710c3d10e1f3016d32c41b8363cb661c49afcbe81e", + ); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); } finally { await rm(emptyModulesDirectory, { recursive: true, force: true }); diff --git a/core/drizzle.config.ts b/core/drizzle.config.ts index c7436db..77a35de 100644 --- a/core/drizzle.config.ts +++ b/core/drizzle.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from "drizzle-kit"; export default defineConfig({ dialect: "postgresql", - schema: "./src/identity/schema.ts", + schema: ["./src/identity/schema.ts", "./src/authorization/schema.ts"], out: "./drizzle", migrations: { prefix: "index", diff --git a/core/drizzle/0001_authorization_core_tables.sql b/core/drizzle/0001_authorization_core_tables.sql new file mode 100644 index 0000000..4964877 --- /dev/null +++ b/core/drizzle/0001_authorization_core_tables.sql @@ -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"); \ No newline at end of file diff --git a/core/drizzle/meta/0001_snapshot.json b/core/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..80f55ee --- /dev/null +++ b/core/drizzle/meta/0001_snapshot.json @@ -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": {} + } +} diff --git a/core/drizzle/meta/_ekklesia.json b/core/drizzle/meta/_ekklesia.json index 07be324..856273c 100644 --- a/core/drizzle/meta/_ekklesia.json +++ b/core/drizzle/meta/_ekklesia.json @@ -7,6 +7,12 @@ "destructive": { "mode": "none" } + }, + { + "tag": "0001_authorization_core_tables", + "destructive": { + "mode": "none" + } } ] } diff --git a/core/drizzle/meta/_journal.json b/core/drizzle/meta/_journal.json index c6f7b2b..3d8715f 100644 --- a/core/drizzle/meta/_journal.json +++ b/core/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1784668552522, "tag": "0000_identity_core_tables", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1784819635300, + "tag": "0001_authorization_core_tables", + "breakpoints": true } ] } diff --git a/core/package.json b/core/package.json index a25d3b7..2bb96bb 100644 --- a/core/package.json +++ b/core/package.json @@ -26,6 +26,10 @@ "./identity": { "types": "./dist/identity/index.d.ts", "import": "./dist/identity/index.js" + }, + "./authorization": { + "types": "./dist/authorization/index.d.ts", + "import": "./dist/authorization/index.js" } }, "scripts": { diff --git a/core/src/authorization/database.ts b/core/src/authorization/database.ts new file mode 100644 index 0000000..db18ec8 --- /dev/null +++ b/core/src/authorization/database.ts @@ -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; + +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; + 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, + }); +} diff --git a/core/src/authorization/evaluator.ts b/core/src/authorization/evaluator.ts new file mode 100644 index 0000000..ed8a95e --- /dev/null +++ b/core/src/authorization/evaluator.ts @@ -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; +} + +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 => { + 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; + }, + }); +} diff --git a/core/src/authorization/index.ts b/core/src/authorization/index.ts new file mode 100644 index 0000000..c4f6a05 --- /dev/null +++ b/core/src/authorization/index.ts @@ -0,0 +1,4 @@ +export * from "./database.js"; +export * from "./evaluator.js"; +export * from "./ports.js"; +export * from "./store.js"; diff --git a/core/src/authorization/ports.ts b/core/src/authorization/ports.ts new file mode 100644 index 0000000..37c06f4 --- /dev/null +++ b/core/src/authorization/ports.ts @@ -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; +} + +/** 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; +} diff --git a/core/src/authorization/schema.ts b/core/src/authorization/schema.ts new file mode 100644 index 0000000..87c05ed --- /dev/null +++ b/core/src/authorization/schema.ts @@ -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; diff --git a/core/src/authorization/store.ts b/core/src/authorization/store.ts new file mode 100644 index 0000000..2dd2f8e --- /dev/null +++ b/core/src/authorization/store.ts @@ -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> = 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; + revokeGrant(input: RevokeGrantInput): Promise; + grantPlatformOwner(userId: EkklesiaUserId, actor: AuthorizationActor): Promise; + revokePlatformOwner(userId: EkklesiaUserId, actor: AuthorizationActor): Promise; +} + +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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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"); + } + }, + }); +} diff --git a/core/src/index.ts b/core/src/index.ts index 8cc005f..a5c889e 100644 --- a/core/src/index.ts +++ b/core/src/index.ts @@ -1,3 +1,4 @@ +export * from "./authorization/index.js"; export * from "./diagnostics/index.js"; export * from "./identity/index.js"; export * from "./modules/index.js"; diff --git a/core/tests/authorization-database.test.ts b/core/tests/authorization-database.test.ts new file mode 100644 index 0000000..cb8f067 --- /dev/null +++ b/core/tests/authorization-database.test.ts @@ -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:do-not-log@db.example.test/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:do-not-log@example.test/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]; + 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(); + }); +}); diff --git a/core/tests/authorization-evaluator.test.ts b/core/tests/authorization-evaluator.test.ts new file mode 100644 index 0000000..8c9ba86 --- /dev/null +++ b/core/tests/authorization-evaluator.test.ts @@ -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 { + 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(); + readonly calls: string[] = []; + + makeOwner(userId: string): void { + this.owners.add(userId); + } + + async isPlatformOwner(userId: string): Promise { + 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"); + }); + }); +}); diff --git a/core/tests/authorization-store.integration.ts b/core/tests/authorization-store.integration.ts new file mode 100644 index 0000000..5e8ced7 --- /dev/null +++ b/core/tests/authorization-store.integration.ts @@ -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(); +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 { + 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( + client: Client, + text: string, + values: unknown[] = [], +): Promise { + try { + const result = await client.query(text, values); + return result.rows; + } catch { + throw harnessError("An integration database administration operation failed."); + } +} + +async function safeEnd(client: Client): Promise { + try { + await client.end(); + } catch { + throw harnessError("An integration database connection could not be closed."); + } +} + +async function createDatabase(databaseName: string): Promise { + 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 { + 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 { + 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 { + 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; + let close: () => Promise; + + 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, "authz-one@example.test"); + await seedUser(admin, userTwoId, "authz-two@example.test"); + + 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); + } + }); +}); diff --git a/core/tests/authorization-store.test.ts b/core/tests/authorization-store.test.ts new file mode 100644 index 0000000..865067d --- /dev/null +++ b/core/tests/authorization-store.test.ts @@ -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}`, + ); + }); +}); diff --git a/packages/contracts/src/authorization.ts b/packages/contracts/src/authorization.ts new file mode 100644 index 0000000..85aa1e3 --- /dev/null +++ b/packages/contracts/src/authorization.ts @@ -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; + +export function parseGrantId(input: unknown): GrantId { + return grantIdSchema.parse(input); +} + +export function safeParseGrantId(input: unknown): z.ZodSafeParseResult { + 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; + +export function parseAuthorizationScope(input: unknown): AuthorizationScope { + return authorizationScopeSchema.parse(input); +} + +export function safeParseAuthorizationScope( + input: unknown, +): z.ZodSafeParseResult { + 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; + +/** + * 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; + +/** 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; + +const authorizationActorTypeSchema = z.enum(["user", "system"]); + +export type AuthorizationActorType = z.infer; + +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; +} diff --git a/packages/contracts/src/identifiers.ts b/packages/contracts/src/identifiers.ts index 322faee..0809809 100644 --- a/packages/contracts/src/identifiers.ts +++ b/packages/contracts/src/identifiers.ts @@ -16,11 +16,23 @@ export const capabilityIdSchema = z .max(160) .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 .string() .min(3) .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 .string() @@ -58,3 +70,11 @@ export type CapabilityId = z.infer; export type PermissionId = z.infer; export type LogicalBucketId = z.infer; export type PackageRelativePath = z.infer; + +export function parsePermissionId(input: unknown): PermissionId { + return permissionIdSchema.parse(input); +} + +export function safeParsePermissionId(input: unknown): z.ZodSafeParseResult { + return permissionIdSchema.safeParse(input); +} diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 5cf678a..3fd62c0 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -1,3 +1,4 @@ +export * from "./authorization.js"; export * from "./identifiers.js"; export * from "./identity.js"; export * from "./module-lifecycle.js"; diff --git a/packages/contracts/tests/authorization.test.ts b/packages/contracts/tests/authorization.test.ts new file mode 100644 index 0000000..cd605e8 --- /dev/null +++ b/packages/contracts/tests/authorization.test.ts @@ -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); + }); +}); diff --git a/vitest.migrations.integration.config.ts b/vitest.migrations.integration.config.ts index b97bc8d..61a3a88 100644 --- a/vitest.migrations.integration.config.ts +++ b/vitest.migrations.integration.config.ts @@ -6,6 +6,7 @@ export default defineConfig({ hookTimeout: 30_000, include: [ "core/tests/postgres-migration.integration.ts", + "core/tests/authorization-store.integration.ts", "apps/api/tests/api-http.integration.ts", ], passWithNoTests: false, From 13636085524d6b0ca9bba0c2f2f20caefb0ddb77 Mon Sep 17 00:00:00 2001 From: ekklesia Date: Thu, 23 Jul 2026 11:16:32 -0500 Subject: [PATCH 2/4] feat: add authorization bootstrap and HTTP enforcement Wires Core's identity and authorization domains into apps/api over one shared, validated PostgreSQL pool; adds an explicit, single-use, concurrency-safe platform-owner bootstrap CLI; adds a smallest-possible immutable permission registry with one Core-owned restricted permission (core.authorization.grants.manage); and adds reusable Hono authorization enforcement plus a narrowly scoped /api/v1/grants HTTP API for managing explicit grants only, gated by an exact trusted Origin on every mutation. --- README.md | 23 +- apps/api/src/app.ts | 29 +- apps/api/src/authorization-middleware.ts | 109 ++++++ apps/api/src/database.ts | 36 ++ apps/api/src/grants-routes.ts | 212 ++++++++++ apps/api/src/server.ts | 41 +- apps/api/tests/api-http.integration.ts | 281 ++++++++++++++ apps/api/tests/app.test.ts | 141 ++++++- .../tests/authorization-middleware.test.ts | 204 ++++++++++ apps/api/tests/grants-routes.test.ts | 350 +++++++++++++++++ apps/api/tests/lifecycle.test.ts | 22 ++ core/package.json | 4 + core/src/authorization/bootstrap.ts | 115 ++++++ core/src/authorization/database.ts | 11 + core/src/authorization/index.ts | 2 + core/src/authorization/permissions.ts | 109 ++++++ core/src/authorization/store.ts | 53 ++- core/src/cli/bootstrap-platform-owner.ts | 106 +++++ core/src/database/index.ts | 1 + core/src/database/pool.ts | 110 ++++++ core/src/identity/database.ts | 11 + core/src/index.ts | 1 + .../authorization-bootstrap.integration.ts | 364 ++++++++++++++++++ core/tests/authorization-bootstrap.test.ts | 61 +++ core/tests/authorization-database.test.ts | 18 + core/tests/authorization-permissions.test.ts | 94 +++++ .../cli-bootstrap-platform-owner.test.ts | 22 ++ core/tests/database-pool.test.ts | 122 ++++++ core/tests/identity-database.test.ts | 23 +- docs/development/api.md | 49 ++- docs/development/authorization.md | 299 ++++++++++++++ package.json | 1 + vitest.migrations.integration.config.ts | 1 + 33 files changed, 2962 insertions(+), 63 deletions(-) create mode 100644 apps/api/src/authorization-middleware.ts create mode 100644 apps/api/src/database.ts create mode 100644 apps/api/src/grants-routes.ts create mode 100644 apps/api/tests/authorization-middleware.test.ts create mode 100644 apps/api/tests/grants-routes.test.ts create mode 100644 core/src/authorization/bootstrap.ts create mode 100644 core/src/authorization/permissions.ts create mode 100644 core/src/cli/bootstrap-platform-owner.ts create mode 100644 core/src/database/index.ts create mode 100644 core/src/database/pool.ts create mode 100644 core/tests/authorization-bootstrap.integration.ts create mode 100644 core/tests/authorization-bootstrap.test.ts create mode 100644 core/tests/authorization-permissions.test.ts create mode 100644 core/tests/cli-bootstrap-platform-owner.test.ts create mode 100644 core/tests/database-pool.test.ts create mode 100644 docs/development/authorization.md diff --git a/README.md b/README.md index eeb42cf..2c6e33b 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,8 @@ surfaces. ## Current status -Phase 3 is complete, and Phase 4A (Core identity foundation) and Phase 4B (identity HTTP -transport) have landed. The repository includes +Phase 3 is complete, Phase 4A (Core identity foundation) and Phase 4B (identity HTTP transport) +have landed, and Phase 4C (Core authorization) is in progress. The repository includes public module contracts, independently versioned capabilities, static module manifests, deterministic automatic discovery, compiled server loading, a staged capability registry, dependency-ordered lifecycle with rollback, module-owned Drizzle table contracts, deterministic @@ -35,10 +35,20 @@ still cannot reach any of this. Public email/password registration is intentiona receives identical lowest-privilege baseline status and signup order never grants elevation. See [API](docs/development/api.md) and [ADR 0011](docs/architecture/decisions/0011-identity-http-transport-and-public-enrollment.md). -**No email verification, password recovery, Dashboard, or role/permission model exists yet** — -that, along with production approval-provider composition, jobs, physical storage provisioning, -surfaces, and feature modules, remains later vertical slices. Public registration is not yet ready -for a real public launch. +**No email verification, password recovery, or Dashboard exists yet** — that, along with +production approval-provider composition, jobs, physical storage provisioning, surfaces, and +feature modules, remains later vertical slices. Public registration is not yet ready for a real +public launch. + +Phase 4C (in progress) adds Core's authorization foundation on top of identity: a schema, +evaluator, and store for platform owners, explicit grants, and relationship-derived grants; a +smallest-possible immutable permission registry; an explicit, single-use, concurrency-safe +platform-owner bootstrap command (`pnpm authorization:bootstrap-owner`, never HTTP-exposed, never +inferred from signup order); and, in `apps/api`, reusable Hono authorization enforcement plus a +narrowly scoped `/api/v1/grants` HTTP API for managing *explicit* grants only, requiring an exact +trusted `Origin` on every mutation. Roles/groups/teams/tenants, an owner-management HTTP endpoint, +service accounts/API tokens, and module-permission registration remain later work. See +[Authorization](docs/development/authorization.md). ## Quick start @@ -146,6 +156,7 @@ environment. - [Testing](docs/development/testing.md) - [Identity](docs/development/identity.md) - [API](docs/development/api.md) +- [Authorization](docs/development/authorization.md) - [Module development](docs/module-development/README.md) - [Architecture decisions](docs/architecture/decisions/README.md) diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 166b01f..8acd5fa 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -1,13 +1,27 @@ import { isAuthenticatedPrincipal } from "@ekklesia/contracts"; +import { + type AuthorizationEvaluator, + type AuthorizationStore, + CORE_PERMISSION_REGISTRY, +} from "@ekklesia/core/authorization"; import { type EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/core/identity"; import { Hono } from "hono"; import type { Context } from "hono"; import { cors } from "hono/cors"; +import { createGrantsRoutes } from "./grants-routes.js"; + +export interface CreateApiAppAuthorizationOptions { + readonly evaluator: AuthorizationEvaluator; + readonly store: AuthorizationStore; +} export interface CreateApiAppOptions { + readonly authorization: CreateApiAppAuthorizationOptions; readonly runtime: EkklesiaIdentityRuntime; /** Exact allowed browser origins. Never a wildcard; combined with `credentials: true`, only an - * exact, reflected match is ever returned in `Access-Control-Allow-Origin`. */ + * exact, reflected match is ever returned in `Access-Control-Allow-Origin`. Also the trusted set + * `requireTrustedOrigin` checks for grant-mutation routes — there is no second, competing origin + * configuration. */ readonly trustedOrigins: readonly string[]; } @@ -20,7 +34,7 @@ function sanitizedErrorResponse(c: Context, error: unknown, status: 500 | 503) { /** * Pure Hono application factory: no I/O, no environment reads, no process side effects. Suitable * for unit and integration tests via `app.fetch` directly, with a fully injected identity runtime - * standing in for real Better Auth/PostgreSQL where appropriate. + * and authorization evaluator/store standing in for real Better Auth/PostgreSQL where appropriate. */ export function createApiApp(options: CreateApiAppOptions): Hono { const { runtime } = options; @@ -62,6 +76,17 @@ export function createApiApp(options: CreateApiAppOptions): Hono { } }); + app.route( + "/", + createGrantsRoutes({ + evaluator: options.authorization.evaluator, + registry: CORE_PERMISSION_REGISTRY, + runtime, + store: options.authorization.store, + trustedOrigins: allowedOrigins, + }), + ); + app.notFound((c) => { c.header("Cache-Control", "no-store"); return c.json({ code: "NOT_FOUND" }, 404); diff --git a/apps/api/src/authorization-middleware.ts b/apps/api/src/authorization-middleware.ts new file mode 100644 index 0000000..813d184 --- /dev/null +++ b/apps/api/src/authorization-middleware.ts @@ -0,0 +1,109 @@ +import type { EkklesiaPrincipal, PermissionId } from "@ekklesia/contracts"; +import type { AuthorizationEvaluator, PermissionRegistry } from "@ekklesia/core/authorization"; +import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity"; +import type { Context, MiddlewareHandler, Next } from "hono"; + +const PRINCIPAL_CONTEXT_KEY = "ekklesiaPrincipal"; + +export interface AuthorizationMiddlewareOptions { + readonly evaluator: AuthorizationEvaluator; + readonly registry: PermissionRegistry; + readonly runtime: EkklesiaIdentityRuntime; +} + +function sanitizedDenial(c: Context, status: 403 | 503, code: string): Response { + c.header("Cache-Control", "no-store"); + return c.json({ code }, status); +} + +/** + * Builds a Hono middleware that enforces `permissionId`, resolved exclusively from Core's own + * trusted, server-side permission registry — a route never supplies its own baseline, and no + * request input can influence which baseline is checked. The principal is resolved exclusively + * through the injected identity runtime (`resolvePrincipal`), the same one `/api/v1/principal` + * already uses; nothing here ever trusts a client-supplied claim about identity, ownership, or + * grants. A permission id that is not registered fails closed (403) exactly like a denied + * decision — in normal operation this route only ever hard-codes an already-registered constant, + * but the check stays live per-request as defense in depth rather than being trusted once at + * composition time. + * + * On success, the resolved `EkklesiaPrincipal` — Core's own narrow `{kind, userId}` contract, not + * Better Auth's richer session/user object — is stored on the Hono context so the route handler + * can build an actor without resolving the principal a second time. This is deliberately narrower + * than the "no generalized session/user object in Hono context" concern ADR 0011 raised about + * Better Auth's own documented example: only Core's already-public-safe principal shape is ever + * set, never a session token, raw user row, or Better Auth type. + * + * A genuine infrastructure failure — principal resolution, evaluation, or the underlying + * database — is never folded into a 403; it returns a sanitized 503, mirroring + * `/api/v1/principal`'s existing behavior for the identical reason: a caller must never see "no + * permission" when the true answer is "could not be determined." + */ +export function requirePermission( + options: AuthorizationMiddlewareOptions, + permissionId: PermissionId, +): MiddlewareHandler { + return async (c: Context, next: Next) => { + const declaration = options.registry.get(permissionId); + if (!declaration) { + return sanitizedDenial(c, 403, "AUTHORIZATION_DENIED"); + } + + let principal: EkklesiaPrincipal; + try { + principal = await options.runtime.resolvePrincipal(c.req.raw.headers); + } catch { + return sanitizedDenial(c, 503, "PRINCIPAL_RESOLUTION_FAILED"); + } + + let decision: { allowed: boolean }; + try { + decision = await options.evaluator.authorize({ + baseline: declaration.baseline, + permission: declaration.id, + principal, + }); + } catch { + return sanitizedDenial(c, 503, "AUTHORIZATION_EVALUATION_FAILED"); + } + + if (!decision.allowed) { + return sanitizedDenial(c, 403, "AUTHORIZATION_DENIED"); + } + + c.set(PRINCIPAL_CONTEXT_KEY, principal); + await next(); + return; + }; +} + +/** Reads the principal a preceding `requirePermission` middleware already resolved and verified. + * Throws (a route-wiring bug, not a runtime condition) if called on a route this middleware never + * protected. */ +export function getAuthorizedPrincipal(c: Context): EkklesiaPrincipal { + const principal = c.get(PRINCIPAL_CONTEXT_KEY) as EkklesiaPrincipal | undefined; + if (!principal) { + throw new Error("getAuthorizedPrincipal called outside a requirePermission-protected route."); + } + return principal; +} + +/** + * Requires an exact trusted `Origin` header for a cookie-authenticated, state-changing route. + * CORS response headers alone are not CSRF protection — they only stop a browser from *reading* a + * cross-origin response, not from *sending* the state-changing request in the first place — so + * mutation routes need this explicit, server-side check in addition to (not instead of) the + * existing CORS policy. A missing or untrusted origin is rejected before the route (or the + * permission check) ever runs. Non-browser, non-cookie automation (service accounts/API tokens) + * is deliberately out of scope for this slice and has no way to satisfy this check yet. + */ +export function requireTrustedOrigin(trustedOrigins: ReadonlySet): MiddlewareHandler { + return async (c: Context, next: Next) => { + const origin = c.req.header("origin"); + if (!origin || !trustedOrigins.has(origin)) { + return sanitizedDenial(c, 403, "UNTRUSTED_ORIGIN"); + } + await next(); + return; + }; +} diff --git a/apps/api/src/database.ts b/apps/api/src/database.ts new file mode 100644 index 0000000..b8b6b10 --- /dev/null +++ b/apps/api/src/database.ts @@ -0,0 +1,36 @@ +import { + type AuthorizationDrizzleDatabase, + createAuthorizationDrizzleView, +} from "@ekklesia/core/authorization"; +import { createValidatedPostgresPool, type PostgresPoolOptions } from "@ekklesia/core/database"; +import { createIdentityDrizzleView, type IdentityDrizzleDatabase } from "@ekklesia/core/identity"; + +export interface ApiDatabaseHandle { + readonly authorization: AuthorizationDrizzleDatabase; + readonly identity: IdentityDrizzleDatabase; + /** Closes the one shared pool exactly once, regardless of how many typed views were created + * over it. Safe to call from both a startup-failure cleanup path and normal shutdown. */ + readonly close: () => Promise; +} + +/** + * Composes the one physical PostgreSQL pool this process uses for both Core's identity and + * authorization schemas: one validated, timeout-bounded `pg.Pool` (`createValidatedPostgresPool`, + * `@ekklesia/core/database`), wrapped by two independent typed Drizzle views + * (`createIdentityDrizzleView`, `createAuthorizationDrizzleView`). Neither Core domain owns or + * closes the pool in this composition — `close()` here is the only thing that ever calls + * `pool.end()`. `createIdentityDrizzleDatabase`/`createAuthorizationDrizzleDatabase` remain + * available, unaffected, and independently testable for callers that only need one domain. + */ +export function createApiDatabase( + databaseUrl: string, + options: PostgresPoolOptions = {}, +): ApiDatabaseHandle { + const { close, pool } = createValidatedPostgresPool(databaseUrl, options); + + return Object.freeze({ + authorization: createAuthorizationDrizzleView(pool), + close, + identity: createIdentityDrizzleView(pool), + }); +} diff --git a/apps/api/src/grants-routes.ts b/apps/api/src/grants-routes.ts new file mode 100644 index 0000000..78fdd4d --- /dev/null +++ b/apps/api/src/grants-routes.ts @@ -0,0 +1,212 @@ +import type { AuthorizationScope, EkklesiaUserId, PermissionId } from "@ekklesia/contracts"; +import { + createUserActor, + isAuthenticatedPrincipal, + safeParseAuthorizationScope, + safeParseEkklesiaUserId, + safeParseGrantId, + safeParsePermissionId, +} from "@ekklesia/contracts"; +import { + type AuthorizationStore, + AuthorizationStoreError, + CORE_AUTHORIZATION_GRANTS_MANAGE, + type GrantRecord, +} from "@ekklesia/core/authorization"; +import { Hono } from "hono"; +import type { Context } from "hono"; +import { + type AuthorizationMiddlewareOptions, + getAuthorizedPrincipal, + requirePermission, + requireTrustedOrigin, +} from "./authorization-middleware.js"; + +export interface CreateGrantsRoutesOptions extends AuthorizationMiddlewareOptions { + readonly store: AuthorizationStore; + readonly trustedOrigins: ReadonlySet; +} + +function sanitizedStoreErrorResponse(c: Context, error: unknown): Response { + c.header("Cache-Control", "no-store"); + if (error instanceof AuthorizationStoreError) { + const status = error.code === "AUTHORIZATION_GRANT_NOT_FOUND" ? 404 : 503; + return c.json({ code: error.code }, status); + } + return c.json({ code: "INTERNAL_ERROR" }, 503); +} + +interface ParsedCreateGrantBody { + readonly expiresAt?: Date; + readonly permission: PermissionId; + readonly scope?: AuthorizationScope; + readonly subjectUserId: EkklesiaUserId; +} + +const CREATE_GRANT_ALLOWED_KEYS = new Set(["subjectUserId", "permission", "scope", "expiresAt"]); + +/** + * Accepts only `subjectUserId`, `permission`, an optional `scope`, and an optional `expiresAt`. + * Any other key present — `actor`, `sourceKind`, `sourceRef`, any audit field, `createdAt`, + * `revokedAt`, a grant id, or anything claiming owner/effective-permission state — makes the + * whole body invalid rather than being silently dropped, so a caller cannot smuggle + * server-derived fields through a request that otherwise looks well-formed. + */ +function parseCreateGrantBody(raw: unknown): ParsedCreateGrantBody | undefined { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { + return undefined; + } + const body = raw as Record; + if (Object.keys(body).some((key) => !CREATE_GRANT_ALLOWED_KEYS.has(key))) { + return undefined; + } + + const subjectUserId = safeParseEkklesiaUserId(body.subjectUserId); + if (!subjectUserId.success) { + return undefined; + } + const permission = safeParsePermissionId(body.permission); + if (!permission.success) { + return undefined; + } + + let scope: AuthorizationScope | undefined; + if (body.scope !== undefined) { + const parsedScope = safeParseAuthorizationScope(body.scope); + if (!parsedScope.success) { + return undefined; + } + scope = parsedScope.data; + } + + let expiresAt: Date | undefined; + if (body.expiresAt !== undefined) { + if (typeof body.expiresAt !== "string") { + return undefined; + } + const parsedDate = new Date(body.expiresAt); + if (Number.isNaN(parsedDate.getTime())) { + return undefined; + } + expiresAt = parsedDate; + } + + return Object.freeze({ + ...(expiresAt === undefined ? {} : { expiresAt }), + permission: permission.data, + ...(scope === undefined ? {} : { scope }), + subjectUserId: subjectUserId.data, + }); +} + +function serializeGrant(grant: GrantRecord) { + return { + createdAt: grant.createdAt.toISOString(), + expiresAt: grant.expiresAt ? grant.expiresAt.toISOString() : null, + id: grant.id, + permission: grant.permission, + revokedAt: grant.revokedAt ? grant.revokedAt.toISOString() : null, + scope: grant.scope ?? null, + sourceKind: grant.sourceKind, + userId: grant.userId, + }; +} + +/** + * The v1 explicit-grant management API: list, create, and revoke *explicit* grants only. Every + * route requires `core.authorization.grants.manage`; a platform owner passes through the + * evaluator's existing owner bypass without ever needing an explicit grant of this permission + * themselves — that bypass is exactly how a platform owner deliberately delegates grant + * administration to another account by granting them this one permission. + * + * Relationship-derived grants are never reachable through this API in either direction: creation + * always hard-codes `sourceKind: "explicit"` server-side (the request body cannot supply a + * `sourceKind` at all), and revocation is constrained to `sourceKind: "explicit"`, so attempting + * to revoke a relationship-derived grant by id behaves exactly like the grant does not exist — + * those grants remain owned by the module operation that established the relationship. + */ +export function createGrantsRoutes(options: CreateGrantsRoutesOptions): Hono { + const routes = new Hono(); + const requireManagePermission = requirePermission(options, CORE_AUTHORIZATION_GRANTS_MANAGE); + const requireOrigin = requireTrustedOrigin(options.trustedOrigins); + + routes.get("/api/v1/grants", requireManagePermission, async (c) => { + c.header("Cache-Control", "no-store"); + const subjectUserId = safeParseEkklesiaUserId(c.req.query("userId")); + if (!subjectUserId.success) { + return c.json({ code: "INVALID_REQUEST" }, 400); + } + try { + const grants = await options.store.listGrantsForUser(subjectUserId.data, { + sourceKind: "explicit", + }); + return c.json({ grants: grants.map(serializeGrant) }, 200); + } catch (error) { + return sanitizedStoreErrorResponse(c, error); + } + }); + + routes.post("/api/v1/grants", requireOrigin, requireManagePermission, async (c) => { + c.header("Cache-Control", "no-store"); + const principal = getAuthorizedPrincipal(c); + if (!isAuthenticatedPrincipal(principal)) { + return c.json({ code: "AUTHORIZATION_DENIED" }, 403); + } + + let rawBody: unknown; + try { + rawBody = await c.req.json(); + } catch { + return c.json({ code: "INVALID_REQUEST" }, 400); + } + const parsed = parseCreateGrantBody(rawBody); + if (!parsed) { + return c.json({ code: "INVALID_REQUEST" }, 400); + } + + try { + const grant = await options.store.createGrant({ + actor: createUserActor(principal.userId), + ...(parsed.expiresAt === undefined ? {} : { expiresAt: parsed.expiresAt }), + permission: parsed.permission, + ...(parsed.scope === undefined ? {} : { scope: parsed.scope }), + sourceKind: "explicit", + userId: parsed.subjectUserId, + }); + return c.json({ grant: serializeGrant(grant) }, 201); + } catch (error) { + return sanitizedStoreErrorResponse(c, error); + } + }); + + routes.post( + "/api/v1/grants/:grantId/revoke", + requireOrigin, + requireManagePermission, + async (c) => { + c.header("Cache-Control", "no-store"); + const principal = getAuthorizedPrincipal(c); + if (!isAuthenticatedPrincipal(principal)) { + return c.json({ code: "AUTHORIZATION_DENIED" }, 403); + } + + const grantId = safeParseGrantId(c.req.param("grantId")); + if (!grantId.success) { + return c.json({ code: "INVALID_REQUEST" }, 400); + } + + try { + await options.store.revokeGrant({ + actor: createUserActor(principal.userId), + grantId: grantId.data, + sourceKind: "explicit", + }); + return c.json({ success: true }, 200); + } catch (error) { + return sanitizedStoreErrorResponse(c, error); + } + }, + ); + + return routes; +} diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index e492ab4..c4b2760 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -1,15 +1,15 @@ import path from "node:path"; import { pathToFileURL } from "node:url"; import { - createIdentityDrizzleDatabase, - createIdentityRuntime, - type IdentityDatabaseHandle, - type IdentityRateLimitOverride, -} from "@ekklesia/core/identity"; + createAuthorizationEvaluator, + createAuthorizationStore, +} from "@ekklesia/core/authorization"; +import { createIdentityRuntime, type IdentityRateLimitOverride } from "@ekklesia/core/identity"; import { serve, type ServerType } from "@hono/node-server"; import type { Hono } from "hono"; import { createApiApp } from "./app.js"; import { type ApiRuntimeConfig, createApiRuntimeConfig } from "./config.js"; +import { type ApiDatabaseHandle, createApiDatabase } from "./database.js"; const connectionTimeoutMs = 10_000; const operationTimeoutMs = 10_000; @@ -26,7 +26,7 @@ export interface StartApiOptions { readonly rateLimitOverride?: IdentityRateLimitOverride; } -async function safeCloseDatabase(handle: IdentityDatabaseHandle): Promise { +async function safeCloseDatabase(handle: ApiDatabaseHandle): Promise { try { await handle.close(); } catch { @@ -54,34 +54,47 @@ function bindServer(app: Hono, host: string, port: number): Promise } /** - * Explicit runtime composition: constructs the PostgreSQL pool and Drizzle database, the Core - * identity runtime, and the Hono application, then starts the Node HTTP server. Returns a handle - * with an idempotent `shutdown()` so both the executable entry point and tests can stop the - * server and close the database pool deterministically. Never applies migrations or generates - * schema — the database must already be migrated through `pnpm migrations:apply`. + * Explicit runtime composition: constructs the one shared PostgreSQL pool (identity and + * authorization each get a typed Drizzle view over it — see `./database.js`), the Core identity + * runtime, the Core authorization store and evaluator, and the Hono application, then starts the + * Node HTTP server. Returns a handle with an idempotent `shutdown()` so both the executable entry + * point and tests can stop the server and close the shared pool deterministically. Never applies + * migrations or generates schema — the database must already be migrated through + * `pnpm migrations:apply`. */ export async function startApi( config: ApiRuntimeConfig, options: StartApiOptions = {}, ): Promise { - const databaseHandle = createIdentityDrizzleDatabase(config.databaseUrl, { + const databaseHandle = createApiDatabase(config.databaseUrl, { connectionTimeoutMs, operationTimeoutMs, }); let runtime: ReturnType; + let authorizationStore: ReturnType; try { runtime = createIdentityRuntime({ config: config.identity, - db: databaseHandle.db, + db: databaseHandle.identity, ...(options.rateLimitOverride ? { rateLimitOverride: options.rateLimitOverride } : {}), }); + authorizationStore = createAuthorizationStore(databaseHandle.authorization); } catch (error) { await safeCloseDatabase(databaseHandle); throw error; } - const app = createApiApp({ runtime, trustedOrigins: config.identity.trustedOrigins }); + const evaluator = createAuthorizationEvaluator({ + grants: authorizationStore, + platformOwners: authorizationStore, + }); + + const app = createApiApp({ + authorization: { evaluator, store: authorizationStore }, + runtime, + trustedOrigins: config.identity.trustedOrigins, + }); let server: ServerType; try { diff --git a/apps/api/tests/api-http.integration.ts b/apps/api/tests/api-http.integration.ts index cb27822..652e291 100644 --- a/apps/api/tests/api-http.integration.ts +++ b/apps/api/tests/api-http.integration.ts @@ -2,6 +2,12 @@ import { randomBytes } from "node:crypto"; import { request as httpRequest } from "node:http"; import { fileURLToPath } from "node:url"; import path from "node:path"; +import { parseEkklesiaUserId, parsePermissionId, SYSTEM_ACTOR } from "@ekklesia/contracts"; +import { + bootstrapPlatformOwner, + createAuthorizationDrizzleDatabase, + createAuthorizationStore, +} from "@ekklesia/core/authorization"; import { applyModuleMigrations, createPostgresMigrationDatabase, @@ -812,3 +818,278 @@ describe.sequential("live API — Better Auth logging never leaks operational de } }); }); + +describe.sequential("live API — platform owner and delegated grant management", () => { + let database: { name: string; url: string }; + let running: RunningApi; + let origin: string; + let ownerUserId: string; + let ownerCookie: string; + let managerUserId: string; + let managerCookie: string; + let createdGrantId: string; + + const ownerEmail = "authz-owner@example.test"; + const managerEmail = "authz-manager@example.test"; + const password = "correct horse battery staple"; + const testPermission = "calendar.events.read"; + + it("starts a freshly migrated API and signs up the eventual owner and eventual delegated manager", async () => { + database = await createDisposableDatabase("authz_http"); + await applyCoreMigrations(database.url); + running = await startMigratedApi(database.url); + origin = apiOrigin(running); + + const ownerSignUp = await signUp(origin, ownerEmail, password); + ownerUserId = ownerSignUp.body.user.id; + const managerSignUp = await signUp(origin, managerEmail, password); + managerUserId = managerSignUp.body.user.id; + + const ownerSignIn = await signIn(origin, ownerEmail, password); + expect(ownerSignIn.cookiePair).toBeDefined(); + ownerCookie = ownerSignIn.cookiePair as string; + }); + + it("denies grants access to an anonymous caller", async () => { + const response = await fetch(`${origin}/api/v1/grants?userId=${ownerUserId}`); + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" }); + }); + + it("denies grants access to an authenticated caller with no owner authority and no grant", async () => { + const managerSignInBefore = await signIn(origin, managerEmail, password); + const response = await fetch(`${origin}/api/v1/grants?userId=${ownerUserId}`, { + headers: { cookie: managerSignInBefore.cookiePair as string }, + }); + expect(response.status).toBe(403); + }); + + it("bootstraps the owner via Core's real bootstrap operation, then confirms owner authority over the HTTP grants API", async () => { + // Exactly what `pnpm authorization:bootstrap-owner` would do, driven directly against the + // same disposable database the running API already uses — no second process needed to prove + // the HTTP layer honors real platform-owner state. + const { close, db } = createAuthorizationDrizzleDatabase(database.url); + try { + await bootstrapPlatformOwner(db, { userId: parseEkklesiaUserId(ownerUserId) }); + } finally { + await close(); + } + + const response = await fetch(`${origin}/api/v1/grants?userId=${ownerUserId}`, { + headers: { cookie: ownerCookie }, + }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ grants: [] }); + }); + + it("lets the owner delegate grants.manage to another account through the explicit-grant HTTP API", async () => { + const response = await fetch(`${origin}/api/v1/grants`, { + body: JSON.stringify({ + permission: "core.authorization.grants.manage", + subjectUserId: managerUserId, + }), + headers: { + "content-type": "application/json", + cookie: ownerCookie, + origin: dashboardOrigin, + }, + method: "POST", + }); + const body = (await response.json()) as { grant: { sourceKind: string; userId: string } }; + + expect(response.status).toBe(201); + expect(body.grant.sourceKind).toBe("explicit"); + expect(body.grant.userId).toBe(managerUserId); + }); + + it("lets the delegated manager — who is not a platform owner — manage another user's grants", async () => { + const managerSignIn = await signIn(origin, managerEmail, password); + expect(managerSignIn.cookiePair).toBeDefined(); + managerCookie = managerSignIn.cookiePair as string; + + const response = await fetch(`${origin}/api/v1/grants?userId=${ownerUserId}`, { + headers: { cookie: managerCookie }, + }); + expect(response.status).toBe(200); + }); + + it("creates an explicit grant via HTTP, deriving sourceKind and actor server-side, and records an atomic audit entry", async () => { + const response = await fetch(`${origin}/api/v1/grants`, { + body: JSON.stringify({ + permission: testPermission, + scope: "calendar-event:1", + subjectUserId: managerUserId, + }), + headers: { + "content-type": "application/json", + cookie: managerCookie, + origin: dashboardOrigin, + }, + method: "POST", + }); + const body = (await response.json()) as { + grant: { id: string; sourceKind: string; userId: string }; + }; + + expect(response.status).toBe(201); + expect(body.grant.sourceKind).toBe("explicit"); + expect(body.grant.userId).toBe(managerUserId); + createdGrantId = body.grant.id; + + const admin = await openAdministrativeClient(database.url); + try { + const rows = await safeQuery<{ action: string; actor_id: string | null }>( + admin, + `select action, actor_id from "ekklesia_core_authorization_audit" where grant_id = $1`, + [createdGrantId], + ); + expect(rows).toEqual([{ action: "grant.create", actor_id: managerUserId }]); + } finally { + await safeEnd(admin); + } + }); + + it("lists the explicit grant via HTTP", async () => { + const response = await fetch(`${origin}/api/v1/grants?userId=${managerUserId}`, { + headers: { cookie: ownerCookie }, + }); + const body = (await response.json()) as { grants: Array<{ id: string }> }; + + expect(response.status).toBe(200); + expect(body.grants.some((grant) => grant.id === createdGrantId)).toBe(true); + }); + + it("rejects a create-grant request carrying disallowed fields such as actor or sourceKind, even alongside otherwise-valid fields", async () => { + const response = await fetch(`${origin}/api/v1/grants`, { + body: JSON.stringify({ + actor: { type: "system" }, + permission: testPermission, + sourceKind: "relationship", + subjectUserId: managerUserId, + }), + headers: { + "content-type": "application/json", + cookie: ownerCookie, + origin: dashboardOrigin, + }, + method: "POST", + }); + expect(response.status).toBe(400); + }); + + it("rejects a mutation request with no trusted Origin header, and never returns a permissive CORS header", async () => { + const response = await fetch(`${origin}/api/v1/grants`, { + body: JSON.stringify({ permission: testPermission, subjectUserId: managerUserId }), + headers: { "content-type": "application/json", cookie: ownerCookie }, + method: "POST", + }); + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" }); + expect(response.headers.get("access-control-allow-origin")).toBeNull(); + }); + + it("rejects a mutation request from an untrusted Origin the same way", async () => { + const response = await fetch(`${origin}/api/v1/grants`, { + body: JSON.stringify({ permission: testPermission, subjectUserId: managerUserId }), + headers: { + "content-type": "application/json", + cookie: ownerCookie, + origin: untrustedOrigin, + }, + method: "POST", + }); + expect(response.status).toBe(403); + expect(response.headers.get("access-control-allow-origin")).toBeNull(); + }); + + it("revokes the explicit grant via HTTP and records the revocation audit entry", async () => { + const response = await fetch(`${origin}/api/v1/grants/${createdGrantId}/revoke`, { + headers: { cookie: ownerCookie, origin: dashboardOrigin }, + method: "POST", + }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ success: true }); + + const admin = await openAdministrativeClient(database.url); + try { + const rows = await safeQuery<{ action: string }>( + admin, + `select action from "ekklesia_core_authorization_audit" where grant_id = $1 order by created_at`, + [createdGrantId], + ); + expect(rows.map((row) => row.action)).toEqual(["grant.create", "grant.revoke"]); + } finally { + await safeEnd(admin); + } + }); + + it("never exposes a relationship-derived grant through the generic list, create, or revoke HTTP API", async () => { + // Seeded directly through the store, simulating a module's own business operation — never + // through HTTP, which has no way to express sourceKind at all. + const { close, db } = createAuthorizationDrizzleDatabase(database.url); + let relationshipGrantId: string; + try { + const store = createAuthorizationStore(db); + const relationshipGrant = await store.createGrant({ + actor: SYSTEM_ACTOR, + permission: parsePermissionId("roster.volunteer.read"), + sourceKind: "relationship", + sourceRef: "roster:volunteer:99", + userId: parseEkklesiaUserId(managerUserId), + }); + relationshipGrantId = relationshipGrant.id; + } finally { + await close(); + } + + const listResponse = await fetch(`${origin}/api/v1/grants?userId=${managerUserId}`, { + headers: { cookie: ownerCookie }, + }); + const listBody = (await listResponse.json()) as { grants: Array<{ id: string }> }; + expect(listBody.grants.some((grant) => grant.id === relationshipGrantId)).toBe(false); + + const revokeResponse = await fetch(`${origin}/api/v1/grants/${relationshipGrantId}/revoke`, { + headers: { cookie: ownerCookie, origin: dashboardOrigin }, + method: "POST", + }); + expect(revokeResponse.status).toBe(404); + expect(await revokeResponse.json()).toEqual({ code: "AUTHORIZATION_GRANT_NOT_FOUND" }); + }); + + it("shuts down cleanly, closing the one shared pool", async () => { + await running.shutdown(); + const remainingConnections = await activeConnectionCount(database.name); + expect(remainingConnections).toBe(0); + await dropDatabase(database.name); + }); +}); + +describe.sequential("live API — authorization infrastructure failure is never downgraded to a false denial", () => { + it("returns a sanitized 503, not a 403, when the database becomes unreachable mid-session", async () => { + const database = await createDisposableDatabase("authz_infra_failure"); + await applyCoreMigrations(database.url); + const running = await startMigratedApi(database.url); + const origin = apiOrigin(running); + + await signUp(origin, "authz-infra@example.test", "correct horse battery staple"); + const { cookiePair } = await signIn( + origin, + "authz-infra@example.test", + "correct horse battery staple", + ); + expect(cookiePair).toBeDefined(); + + // Force the database itself unreachable out from under the running API pool — the same + // technique the identity infra-failure test above uses, applied to a grants route this time. + await dropDatabase(database.name); + + const response = await fetch(`${origin}/api/v1/grants?userId=does-not-matter`, { + headers: { cookie: cookiePair as string }, + }); + expect(response.status).toBe(503); + const body = (await response.json()) as { code: string }; + expect(["PRINCIPAL_RESOLUTION_FAILED", "AUTHORIZATION_EVALUATION_FAILED"]).toContain(body.code); + + await running.shutdown().catch(() => undefined); + }); +}); diff --git a/apps/api/tests/app.test.ts b/apps/api/tests/app.test.ts index a9e9821..d2d590c 100644 --- a/apps/api/tests/app.test.ts +++ b/apps/api/tests/app.test.ts @@ -1,8 +1,9 @@ import { ANONYMOUS_PRINCIPAL, createAuthenticatedPrincipal } from "@ekklesia/contracts"; +import type { AuthorizationEvaluator, AuthorizationStore } from "@ekklesia/core/authorization"; import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity"; import { IdentityRuntimeError } from "@ekklesia/core/identity"; import { describe, expect, it, vi } from "vitest"; -import { createApiApp } from "../src/app.js"; +import { createApiApp, type CreateApiAppAuthorizationOptions } from "../src/app.js"; const trustedOrigin = "https://dashboard.example.test"; const untrustedOrigin = "https://evil.example.test"; @@ -15,10 +16,37 @@ function fakeRuntime(overrides: Partial = {}): Ekklesia }; } +/** A fully stubbed authorization dependency, denying everything by default — tests that need a + * specific decision or store behavior override the relevant method explicitly. */ +function fakeAuthorization(overrides: { + readonly evaluator?: Partial; + readonly store?: Partial; +}): CreateApiAppAuthorizationOptions { + const evaluator: AuthorizationEvaluator = { + authorize: vi.fn().mockResolvedValue({ allowed: false }), + ...overrides.evaluator, + }; + const store: AuthorizationStore = { + createGrant: vi.fn(), + grantPlatformOwner: vi.fn(), + hasActiveGrant: vi.fn().mockResolvedValue(false), + isPlatformOwner: vi.fn().mockResolvedValue(false), + listGrantsForUser: vi.fn().mockResolvedValue([]), + revokeGrant: vi.fn(), + revokePlatformOwner: vi.fn(), + ...overrides.store, + }; + return { evaluator, store }; +} + describe("Ekklesia API Hono application", () => { it("mounts Better Auth exactly under /api/auth/*", async () => { const runtime = fakeRuntime(); - const app = createApiApp({ runtime, trustedOrigins: [trustedOrigin] }); + const app = createApiApp({ + authorization: fakeAuthorization({}), + runtime, + trustedOrigins: [trustedOrigin], + }); const response = await app.request("/api/auth/sign-in/email", { method: "POST" }); @@ -29,7 +57,11 @@ describe("Ekklesia API Hono application", () => { it("does not route requests outside /api/auth/* to Better Auth", async () => { const runtime = fakeRuntime(); - const app = createApiApp({ runtime, trustedOrigins: [trustedOrigin] }); + const app = createApiApp({ + authorization: fakeAuthorization({}), + runtime, + trustedOrigins: [trustedOrigin], + }); const response = await app.request("/sign-in/email", { method: "POST" }); @@ -41,7 +73,11 @@ describe("Ekklesia API Hono application", () => { const runtime = fakeRuntime({ resolvePrincipal: vi.fn().mockResolvedValue(ANONYMOUS_PRINCIPAL), }); - const app = createApiApp({ runtime, trustedOrigins: [trustedOrigin] }); + const app = createApiApp({ + authorization: fakeAuthorization({}), + runtime, + trustedOrigins: [trustedOrigin], + }); const response = await app.request("/api/v1/principal"); @@ -55,7 +91,11 @@ describe("Ekklesia API Hono application", () => { const runtime = fakeRuntime({ resolvePrincipal: vi.fn().mockResolvedValue(createAuthenticatedPrincipal("user_123")), }); - const app = createApiApp({ runtime, trustedOrigins: [trustedOrigin] }); + const app = createApiApp({ + authorization: fakeAuthorization({}), + runtime, + trustedOrigins: [trustedOrigin], + }); const response = await app.request("/api/v1/principal"); const body = (await response.json()) as Record; @@ -71,7 +111,11 @@ describe("Ekklesia API Hono application", () => { .fn() .mockRejectedValue(new IdentityRuntimeError("PRINCIPAL_RESOLUTION_FAILED")), }); - const app = createApiApp({ runtime, trustedOrigins: [trustedOrigin] }); + const app = createApiApp({ + authorization: fakeAuthorization({}), + runtime, + trustedOrigins: [trustedOrigin], + }); const response = await app.request("/api/v1/principal"); @@ -84,7 +128,11 @@ describe("Ekklesia API Hono application", () => { const runtime = fakeRuntime({ handleAuthRequest: vi.fn().mockRejectedValue(new Error("postgresql://admin:secret@db/x")), }); - const app = createApiApp({ runtime, trustedOrigins: [trustedOrigin] }); + const app = createApiApp({ + authorization: fakeAuthorization({}), + runtime, + trustedOrigins: [trustedOrigin], + }); const response = await app.request("/api/auth/sign-in/email", { method: "POST" }); const body = await response.json(); @@ -96,7 +144,11 @@ describe("Ekklesia API Hono application", () => { }); it("has deterministic 404 behavior for an unknown route", async () => { - const app = createApiApp({ runtime: fakeRuntime(), trustedOrigins: [trustedOrigin] }); + const app = createApiApp({ + authorization: fakeAuthorization({}), + runtime: fakeRuntime(), + trustedOrigins: [trustedOrigin], + }); const response = await app.request("/does-not-exist"); @@ -105,7 +157,11 @@ describe("Ekklesia API Hono application", () => { }); it("reflects an allowed origin in Access-Control-Allow-Origin with credentials support", async () => { - const app = createApiApp({ runtime: fakeRuntime(), trustedOrigins: [trustedOrigin] }); + const app = createApiApp({ + authorization: fakeAuthorization({}), + runtime: fakeRuntime(), + trustedOrigins: [trustedOrigin], + }); const response = await app.request("/api/v1/principal", { headers: { origin: trustedOrigin }, @@ -116,7 +172,11 @@ describe("Ekklesia API Hono application", () => { }); it("never returns Access-Control-Allow-Origin for a disallowed origin", async () => { - const app = createApiApp({ runtime: fakeRuntime(), trustedOrigins: [trustedOrigin] }); + const app = createApiApp({ + authorization: fakeAuthorization({}), + runtime: fakeRuntime(), + trustedOrigins: [trustedOrigin], + }); const response = await app.request("/api/v1/principal", { headers: { origin: untrustedOrigin }, @@ -126,7 +186,11 @@ describe("Ekklesia API Hono application", () => { }); it("supports requests with no Origin header at all (native/server clients)", async () => { - const app = createApiApp({ runtime: fakeRuntime(), trustedOrigins: [trustedOrigin] }); + const app = createApiApp({ + authorization: fakeAuthorization({}), + runtime: fakeRuntime(), + trustedOrigins: [trustedOrigin], + }); const response = await app.request("/api/v1/principal"); @@ -134,7 +198,11 @@ describe("Ekklesia API Hono application", () => { }); it("handles preflight OPTIONS requests deterministically for an allowed origin", async () => { - const app = createApiApp({ runtime: fakeRuntime(), trustedOrigins: [trustedOrigin] }); + const app = createApiApp({ + authorization: fakeAuthorization({}), + runtime: fakeRuntime(), + trustedOrigins: [trustedOrigin], + }); const response = await app.request("/api/v1/principal", { headers: { @@ -149,7 +217,11 @@ describe("Ekklesia API Hono application", () => { }); it("never wildcards Access-Control-Allow-Origin", async () => { - const app = createApiApp({ runtime: fakeRuntime(), trustedOrigins: [trustedOrigin] }); + const app = createApiApp({ + authorization: fakeAuthorization({}), + runtime: fakeRuntime(), + trustedOrigins: [trustedOrigin], + }); const response = await app.request("/api/v1/principal", { headers: { origin: trustedOrigin }, @@ -158,10 +230,41 @@ describe("Ekklesia API Hono application", () => { expect(response.headers.get("access-control-allow-origin")).not.toBe("*"); }); - // "No generalized session/user object stored in Hono context" is verified by construction - // rather than by a black-box context probe: unlike Better Auth's own documented Hono example - // (which hydrates `c.set("user", session.user)` on every request), createApiApp never calls - // `c.set` at all — the only per-request identity data that ever exists is the narrow - // EkklesiaPrincipal returned directly from the /api/v1/principal handler above, whose exact - // shape (kind + userId only) is already asserted by the principal-shape tests in this file. + // Unlike Better Auth's own documented Hono example (which hydrates `c.set("user", session.user)` + // on every request), createApiApp only ever calls `c.set` once, for the narrow EkklesiaPrincipal + // (kind + userId only), and only on routes `requirePermission` actually protects — proven by the + // grants-routes tests, not by a black-box context probe here. `/api/v1/principal` itself still + // never touches `c.set`; its response shape is asserted directly above. + + describe("grants routes mounting (see grants-routes.test.ts for full coverage)", () => { + it("mounts the v1 grants routes under /api/v1/grants", async () => { + const evaluatorAuthorize = vi.fn().mockResolvedValue({ allowed: false }); + const app = createApiApp({ + authorization: fakeAuthorization({ evaluator: { authorize: evaluatorAuthorize } }), + runtime: fakeRuntime(), + trustedOrigins: [trustedOrigin], + }); + + const response = await app.request("/api/v1/grants?userId=user_123"); + + // Reaching the permission check (and being denied by it) proves the route is mounted and + // wired to the injected evaluator, rather than falling through to the 404 handler. + expect(response.status).toBe(403); + expect(evaluatorAuthorize).toHaveBeenCalledTimes(1); + }); + + it("applies the same CORS policy to /api/v1/grants as the rest of /api/v1/*", async () => { + const app = createApiApp({ + authorization: fakeAuthorization({}), + runtime: fakeRuntime(), + trustedOrigins: [trustedOrigin], + }); + + const response = await app.request("/api/v1/grants?userId=user_123", { + headers: { origin: untrustedOrigin }, + }); + + expect(response.headers.get("access-control-allow-origin")).toBeNull(); + }); + }); }); diff --git a/apps/api/tests/authorization-middleware.test.ts b/apps/api/tests/authorization-middleware.test.ts new file mode 100644 index 0000000..9c42136 --- /dev/null +++ b/apps/api/tests/authorization-middleware.test.ts @@ -0,0 +1,204 @@ +import { + ANONYMOUS_PRINCIPAL, + createAuthenticatedPrincipal, + parsePermissionId, +} from "@ekklesia/contracts"; +import { + type AuthorizationEvaluator, + createPermissionRegistry, +} from "@ekklesia/core/authorization"; +import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity"; +import { Hono } from "hono"; +import { describe, expect, it, vi } from "vitest"; +import { + type AuthorizationMiddlewareOptions, + getAuthorizedPrincipal, + requirePermission, + requireTrustedOrigin, +} from "../src/authorization-middleware.js"; + +const testPermission = parsePermissionId("core.test.restricted-thing"); +const unregisteredPermission = parsePermissionId("core.test.unregistered-thing"); +const registry = createPermissionRegistry([ + { baseline: "restricted", description: "A restricted test permission.", id: testPermission }, +]); + +function fakeRuntime(overrides: Partial = {}): EkklesiaIdentityRuntime { + return { + handleAuthRequest: vi.fn(), + resolvePrincipal: vi.fn().mockResolvedValue(ANONYMOUS_PRINCIPAL), + ...overrides, + }; +} + +function fakeEvaluator(overrides: Partial = {}): AuthorizationEvaluator { + return { + authorize: vi.fn().mockResolvedValue({ allowed: true }), + ...overrides, + }; +} + +function buildTestApp(options: Partial = {}) { + const runtime = options.runtime ?? fakeRuntime(); + const evaluator = options.evaluator ?? fakeEvaluator(); + const middlewareOptions: AuthorizationMiddlewareOptions = { evaluator, registry, runtime }; + + const app = new Hono(); + app.get("/protected", requirePermission(middlewareOptions, testPermission), (c) => { + const principal = getAuthorizedPrincipal(c); + return c.json({ principalKind: principal.kind }); + }); + app.get("/unregistered", requirePermission(middlewareOptions, unregisteredPermission), (c) => + c.json({ ok: true }), + ); + app.get("/unprotected", (c) => { + let threw = false; + try { + getAuthorizedPrincipal(c); + } catch { + threw = true; + } + return c.json({ threw }); + }); + + return { app, evaluator, runtime }; +} + +describe("requirePermission", () => { + it("allows the request through and stores the resolved principal on context when the decision is allowed", async () => { + const runtime = fakeRuntime({ + resolvePrincipal: vi.fn().mockResolvedValue(createAuthenticatedPrincipal("user_1")), + }); + const { app } = buildTestApp({ runtime }); + + const response = await app.request("/protected"); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ principalKind: "authenticated" }); + }); + + it("returns a sanitized 403 when the evaluator denies", async () => { + const evaluator = fakeEvaluator({ authorize: vi.fn().mockResolvedValue({ allowed: false }) }); + const { app } = buildTestApp({ evaluator }); + + const response = await app.request("/protected"); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" }); + expect(response.headers.get("cache-control")).toBe("no-store"); + }); + + it("returns a sanitized 403 for an unregistered permission without ever resolving the principal", async () => { + const runtime = fakeRuntime(); + const { app } = buildTestApp({ runtime }); + + const response = await app.request("/unregistered"); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" }); + expect(runtime.resolvePrincipal).not.toHaveBeenCalled(); + }); + + it("returns a sanitized 503, not a 403, when principal resolution fails", async () => { + const runtime = fakeRuntime({ + resolvePrincipal: vi.fn().mockRejectedValue(new Error("connection terminated")), + }); + const { app } = buildTestApp({ runtime }); + + const response = await app.request("/protected"); + + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ code: "PRINCIPAL_RESOLUTION_FAILED" }); + }); + + it("returns a sanitized 503, not a 403, when evaluation itself fails", async () => { + const evaluator = fakeEvaluator({ + authorize: vi.fn().mockRejectedValue(new Error("connection terminated")), + }); + const { app } = buildTestApp({ evaluator }); + + const response = await app.request("/protected"); + + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ code: "AUTHORIZATION_EVALUATION_FAILED" }); + }); + + it("never leaks a raw infrastructure error message through either sanitized 503", async () => { + const hostileMessage = "password=hunter2 host=db.internal.example port=5432"; + const evaluator = fakeEvaluator({ + authorize: vi.fn().mockRejectedValue(new Error(hostileMessage)), + }); + const { app } = buildTestApp({ evaluator }); + + const response = await app.request("/protected"); + const text = await response.text(); + + expect(text).not.toContain("hunter2"); + expect(text).not.toContain("db.internal.example"); + }); + + it("passes the registry's own baseline and permission id to the evaluator, never a client-supplied value", async () => { + const evaluator = fakeEvaluator(); + const { app } = buildTestApp({ evaluator }); + + // A hostile client cannot even express "use this baseline instead" — there is no request + // field the middleware reads for it — but sending an unrelated header proves it is ignored. + await app.request("/protected", { headers: { "x-baseline": "public" } }); + + expect(evaluator.authorize).toHaveBeenCalledWith( + expect.objectContaining({ baseline: "restricted", permission: testPermission }), + ); + }); +}); + +describe("getAuthorizedPrincipal", () => { + it("throws when called on a route requirePermission never protected", async () => { + const { app } = buildTestApp(); + + const response = await app.request("/unprotected"); + + expect(await response.json()).toEqual({ threw: true }); + }); +}); + +describe("requireTrustedOrigin", () => { + const trustedOrigins = new Set(["https://dashboard.example.test"]); + + function buildOriginApp() { + const app = new Hono(); + app.post("/mutate", requireTrustedOrigin(trustedOrigins), (c) => c.json({ ok: true })); + return app; + } + + it("allows a request from a trusted origin", async () => { + const app = buildOriginApp(); + + const response = await app.request("/mutate", { + headers: { origin: "https://dashboard.example.test" }, + method: "POST", + }); + + expect(response.status).toBe(200); + }); + + it("rejects a request with no Origin header at all", async () => { + const app = buildOriginApp(); + + const response = await app.request("/mutate", { method: "POST" }); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" }); + }); + + it("rejects a request from an untrusted origin", async () => { + const app = buildOriginApp(); + + const response = await app.request("/mutate", { + headers: { origin: "https://evil.example.test" }, + method: "POST", + }); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" }); + }); +}); diff --git a/apps/api/tests/grants-routes.test.ts b/apps/api/tests/grants-routes.test.ts new file mode 100644 index 0000000..0cd368f --- /dev/null +++ b/apps/api/tests/grants-routes.test.ts @@ -0,0 +1,350 @@ +import { + createAuthenticatedPrincipal, + parseAuthorizationScope, + parseEkklesiaUserId, + parseGrantId, + parsePermissionId, +} from "@ekklesia/contracts"; +import { + type AuthorizationEvaluator, + type AuthorizationStore, + AuthorizationStoreError, + CORE_PERMISSION_REGISTRY, +} from "@ekklesia/core/authorization"; +import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity"; +import { Hono } from "hono"; +import { describe, expect, it, vi } from "vitest"; +import { createGrantsRoutes } from "../src/grants-routes.js"; + +const trustedOrigin = "https://dashboard.example.test"; +const untrustedOrigin = "https://evil.example.test"; +const manager = createAuthenticatedPrincipal("manager_1"); +const subjectUserId = parseEkklesiaUserId("subject_1"); +const permission = parsePermissionId("calendar.events.read"); +const grantId = parseGrantId("11111111-1111-1111-1111-111111111111"); + +function fakeRuntime(overrides: Partial = {}): EkklesiaIdentityRuntime { + return { + handleAuthRequest: vi.fn(), + resolvePrincipal: vi.fn().mockResolvedValue(manager), + ...overrides, + }; +} + +function fakeStore(overrides: Partial = {}): AuthorizationStore { + return { + createGrant: vi.fn(), + grantPlatformOwner: vi.fn(), + hasActiveGrant: vi.fn().mockResolvedValue(false), + isPlatformOwner: vi.fn().mockResolvedValue(false), + listGrantsForUser: vi.fn().mockResolvedValue([]), + revokeGrant: vi.fn(), + revokePlatformOwner: vi.fn(), + ...overrides, + }; +} + +function buildApp(options: { + readonly allowed?: boolean; + readonly runtime?: EkklesiaIdentityRuntime; + readonly store?: AuthorizationStore; +}) { + const evaluator: AuthorizationEvaluator = { + authorize: vi.fn().mockResolvedValue({ allowed: options.allowed ?? true }), + }; + const runtime = options.runtime ?? fakeRuntime(); + const store = options.store ?? fakeStore(); + const app = new Hono(); + app.route( + "/", + createGrantsRoutes({ + evaluator, + registry: CORE_PERMISSION_REGISTRY, + runtime, + store, + trustedOrigins: new Set([trustedOrigin]), + }), + ); + return { app, evaluator, runtime, store }; +} + +describe("GET /api/v1/grants", () => { + it("denies an anonymous or non-manager authenticated caller (evaluator says no)", async () => { + const { app, evaluator } = buildApp({ allowed: false }); + + const response = await app.request(`/api/v1/grants?userId=${subjectUserId}`); + + expect(response.status).toBe(403); + expect(evaluator.authorize).toHaveBeenCalledWith( + expect.objectContaining({ baseline: "restricted" }), + ); + }); + + it("allows a caller the evaluator authorizes — whether by platform ownership or a delegated grants.manage grant, the route cannot tell and does not need to (see core/tests/authorization-evaluator.test.ts for that distinction)", async () => { + const grants = [ + { + createdAt: new Date("2026-01-01T00:00:00.000Z"), + id: grantId, + permission, + sourceKind: "explicit" as const, + userId: subjectUserId, + }, + ]; + const store = fakeStore({ listGrantsForUser: vi.fn().mockResolvedValue(grants) }); + const { app } = buildApp({ store }); + + const response = await app.request(`/api/v1/grants?userId=${subjectUserId}`); + const body = (await response.json()) as { grants: unknown[] }; + + expect(response.status).toBe(200); + expect(body.grants).toHaveLength(1); + expect(store.listGrantsForUser).toHaveBeenCalledWith(subjectUserId, { sourceKind: "explicit" }); + }); + + it("rejects a missing or malformed userId query parameter", async () => { + const { app, store } = buildApp({}); + + const response = await app.request("/api/v1/grants?userId="); + + expect(response.status).toBe(400); + expect(store.listGrantsForUser).not.toHaveBeenCalled(); + }); + + it("maps a store infrastructure failure to a sanitized 503", async () => { + const store = fakeStore({ + listGrantsForUser: vi + .fn() + .mockRejectedValue(new AuthorizationStoreError("AUTHORIZATION_GRANT_LIST_FAILED")), + }); + const { app } = buildApp({ store }); + + const response = await app.request(`/api/v1/grants?userId=${subjectUserId}`); + + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ code: "AUTHORIZATION_GRANT_LIST_FAILED" }); + }); +}); + +describe("POST /api/v1/grants", () => { + it("rejects a request with no trusted Origin header before ever checking permission", async () => { + const { app, evaluator } = buildApp({}); + + const response = await app.request("/api/v1/grants", { + body: JSON.stringify({ permission, subjectUserId }), + headers: { "content-type": "application/json" }, + method: "POST", + }); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" }); + expect(evaluator.authorize).not.toHaveBeenCalled(); + }); + + it("rejects an untrusted Origin header the same way", async () => { + const { app } = buildApp({}); + + const response = await app.request("/api/v1/grants", { + body: JSON.stringify({ permission, subjectUserId }), + headers: { "content-type": "application/json", origin: untrustedOrigin }, + method: "POST", + }); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" }); + }); + + it("denies a caller the evaluator does not authorize", async () => { + const { app, store } = buildApp({ allowed: false }); + + const response = await app.request("/api/v1/grants", { + body: JSON.stringify({ permission, subjectUserId }), + headers: { "content-type": "application/json", origin: trustedOrigin }, + method: "POST", + }); + + expect(response.status).toBe(403); + expect(store.createGrant).not.toHaveBeenCalled(); + }); + + it("creates an explicit grant, deriving sourceKind and actor server-side from the authenticated principal, never from the request body", async () => { + const createdGrant = { + createdAt: new Date("2026-01-01T00:00:00.000Z"), + id: grantId, + permission, + scope: parseAuthorizationScope("calendar-event:123"), + sourceKind: "explicit" as const, + userId: subjectUserId, + }; + const store = fakeStore({ createGrant: vi.fn().mockResolvedValue(createdGrant) }); + const { app } = buildApp({ store }); + + const response = await app.request("/api/v1/grants", { + body: JSON.stringify({ permission, scope: "calendar-event:123", subjectUserId }), + headers: { "content-type": "application/json", origin: trustedOrigin }, + method: "POST", + }); + const body = (await response.json()) as { grant: { id: string } }; + + expect(response.status).toBe(201); + expect(body.grant.id).toBe(grantId); + expect(store.createGrant).toHaveBeenCalledWith( + expect.objectContaining({ + actor: { type: "user", userId: manager.userId }, + permission, + scope: "calendar-event:123", + sourceKind: "explicit", + userId: subjectUserId, + }), + ); + }); + + it.each([ + "actor", + "owner", + "isOwner", + "sourceKind", + "sourceRef", + "id", + "grantId", + "createdAt", + "revokedAt", + "createdByActorId", + "effectivePermissions", + ])("rejects a request body carrying a disallowed field (%s), even alongside otherwise-valid fields", async (field) => { + const { app, store } = buildApp({}); + + const response = await app.request("/api/v1/grants", { + body: JSON.stringify({ [field]: "hostile-value", permission, subjectUserId }), + headers: { "content-type": "application/json", origin: trustedOrigin }, + method: "POST", + }); + + expect(response.status).toBe(400); + expect(store.createGrant).not.toHaveBeenCalled(); + }); + + it("rejects a malformed permission, subject id, scope, or expiresAt", async () => { + const { app, store } = buildApp({}); + + const responses = await Promise.all( + [ + { permission: "not-namespaced", subjectUserId }, + { permission, subjectUserId: "" }, + { permission, scope: "", subjectUserId }, + { expiresAt: "not-a-date", permission, subjectUserId }, + ].map((body) => + app.request("/api/v1/grants", { + body: JSON.stringify(body), + headers: { "content-type": "application/json", origin: trustedOrigin }, + method: "POST", + }), + ), + ); + + for (const response of responses) { + expect(response.status).toBe(400); + } + expect(store.createGrant).not.toHaveBeenCalled(); + }); + + it("maps a store infrastructure failure to a sanitized 503", async () => { + const store = fakeStore({ + createGrant: vi + .fn() + .mockRejectedValue(new AuthorizationStoreError("AUTHORIZATION_GRANT_CREATE_FAILED")), + }); + const { app } = buildApp({ store }); + + const response = await app.request("/api/v1/grants", { + body: JSON.stringify({ permission, subjectUserId }), + headers: { "content-type": "application/json", origin: trustedOrigin }, + method: "POST", + }); + + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ code: "AUTHORIZATION_GRANT_CREATE_FAILED" }); + }); +}); + +describe("POST /api/v1/grants/:grantId/revoke", () => { + it("rejects a request with no trusted Origin header before ever checking permission", async () => { + const { app, evaluator } = buildApp({}); + + const response = await app.request(`/api/v1/grants/${grantId}/revoke`, { method: "POST" }); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" }); + expect(evaluator.authorize).not.toHaveBeenCalled(); + }); + + it("revokes an explicit grant, deriving the actor server-side from the authenticated principal", async () => { + const store = fakeStore({ revokeGrant: vi.fn().mockResolvedValue(undefined) }); + const { app } = buildApp({ store }); + + const response = await app.request(`/api/v1/grants/${grantId}/revoke`, { + headers: { origin: trustedOrigin }, + method: "POST", + }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ success: true }); + expect(store.revokeGrant).toHaveBeenCalledWith({ + actor: { type: "user", userId: manager.userId }, + grantId, + sourceKind: "explicit", + }); + }); + + it("always constrains revocation to sourceKind explicit, so a relationship-derived grant can never be targeted by id", async () => { + // The store itself enforces this by treating a sourceKind mismatch exactly like a missing + // grant; this test proves the route always asks for that constraint, and correctly surfaces + // the resulting "not found" as 404 rather than leaking that the grant exists but is the wrong + // kind. + const store = fakeStore({ + revokeGrant: vi + .fn() + .mockRejectedValue(new AuthorizationStoreError("AUTHORIZATION_GRANT_NOT_FOUND")), + }); + const { app } = buildApp({ store }); + + const response = await app.request(`/api/v1/grants/${grantId}/revoke`, { + headers: { origin: trustedOrigin }, + method: "POST", + }); + + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ code: "AUTHORIZATION_GRANT_NOT_FOUND" }); + expect(store.revokeGrant).toHaveBeenCalledWith( + expect.objectContaining({ sourceKind: "explicit" }), + ); + }); + + it("rejects a malformed grant id", async () => { + const { app, store } = buildApp({}); + + const response = await app.request("/api/v1/grants/%20/revoke", { + headers: { origin: trustedOrigin }, + method: "POST", + }); + + expect(response.status).toBe(400); + expect(store.revokeGrant).not.toHaveBeenCalled(); + }); + + it("maps a store infrastructure failure to a sanitized 503", async () => { + const store = fakeStore({ + revokeGrant: vi + .fn() + .mockRejectedValue(new AuthorizationStoreError("AUTHORIZATION_GRANT_REVOKE_FAILED")), + }); + const { app } = buildApp({ store }); + + const response = await app.request(`/api/v1/grants/${grantId}/revoke`, { + headers: { origin: trustedOrigin }, + method: "POST", + }); + + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ code: "AUTHORIZATION_GRANT_REVOKE_FAILED" }); + }); +}); diff --git a/apps/api/tests/lifecycle.test.ts b/apps/api/tests/lifecycle.test.ts index e52d31d..6e5a3dd 100644 --- a/apps/api/tests/lifecycle.test.ts +++ b/apps/api/tests/lifecycle.test.ts @@ -176,6 +176,26 @@ describe("Ekklesia API shutdown lifecycle", () => { expect(poolEnd).toHaveBeenCalledTimes(1); }); + it("constructs exactly one physical PostgreSQL pool for both the identity and authorization views, and closes only that one pool on shutdown", async () => { + const poolEnd = vi.fn().mockResolvedValue(undefined); + poolConstructor.mockImplementation(function fakePool() { + return { end: poolEnd, on: vi.fn() }; + }); + betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() }); + mockSuccessfulServe(); + + const running = await startApi(validConfig()); + + // Identity (via Better Auth's Drizzle adapter) and authorization both read from the same + // running API, yet only one `new Pool(...)` call is ever made — proving the shared-pool + // composition in apps/api/src/database.ts never falls back to a second, independent pool. + expect(poolConstructor).toHaveBeenCalledTimes(1); + + await running.shutdown(); + + expect(poolEnd).toHaveBeenCalledTimes(1); + }); + it("keeps shutdown idempotent for calls made after the first has already resolved", async () => { const poolEnd = vi.fn().mockResolvedValue(undefined); poolConstructor.mockImplementation(function fakePool() { @@ -205,6 +225,7 @@ describe("Ekklesia API shutdown lifecycle", () => { code: "IDENTITY_CONSTRUCTION_FAILED", }); + expect(poolConstructor).toHaveBeenCalledTimes(1); expect(poolEnd).toHaveBeenCalledTimes(1); expect(serveMock).not.toHaveBeenCalled(); }); @@ -219,6 +240,7 @@ describe("Ekklesia API shutdown lifecycle", () => { await expect(startApi(validConfig())).rejects.toThrow(); + expect(poolConstructor).toHaveBeenCalledTimes(1); expect(poolEnd).toHaveBeenCalledTimes(1); }); diff --git a/core/package.json b/core/package.json index 2bb96bb..1f46033 100644 --- a/core/package.json +++ b/core/package.json @@ -30,6 +30,10 @@ "./authorization": { "types": "./dist/authorization/index.d.ts", "import": "./dist/authorization/index.js" + }, + "./database": { + "types": "./dist/database/index.d.ts", + "import": "./dist/database/index.js" } }, "scripts": { diff --git a/core/src/authorization/bootstrap.ts b/core/src/authorization/bootstrap.ts new file mode 100644 index 0000000..ab18850 --- /dev/null +++ b/core/src/authorization/bootstrap.ts @@ -0,0 +1,115 @@ +import type { EkklesiaUserId } from "@ekklesia/contracts"; +import { eq, sql } from "drizzle-orm"; +import { ekklesiaCoreUser } from "../identity/schema.js"; +import type { AuthorizationDrizzleDatabase } from "./database.js"; +import { ekklesiaCoreAuthorizationAudit, ekklesiaCorePlatformOwner } from "./schema.js"; + +export type PlatformOwnerBootstrapErrorCode = + | "PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED" + | "PLATFORM_OWNER_BOOTSTRAP_FAILED" + | "PLATFORM_OWNER_BOOTSTRAP_USER_NOT_FOUND"; + +const bootstrapErrorMessages: Readonly> = + Object.freeze({ + PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED: + "Platform-owner bootstrap has already been used and cannot be used again.", + PLATFORM_OWNER_BOOTSTRAP_FAILED: "Platform-owner bootstrap could not be completed.", + PLATFORM_OWNER_BOOTSTRAP_USER_NOT_FOUND: "The target user does not exist.", + }); + +/** Never carries the underlying database error, SQL text, host, port, path, or stack. */ +export class PlatformOwnerBootstrapError extends Error { + readonly code: PlatformOwnerBootstrapErrorCode; + + constructor(code: PlatformOwnerBootstrapErrorCode) { + super(bootstrapErrorMessages[code]); + this.name = "PlatformOwnerBootstrapError"; + this.code = code; + this.stack = `${this.name}: ${this.message}`; + Object.freeze(this); + } +} + +function isPlatformOwnerBootstrapError(error: unknown): error is PlatformOwnerBootstrapError { + return error instanceof PlatformOwnerBootstrapError; +} + +/** A fixed, sanitized, non-user actor identity for the audit trail — distinguishable from both an + * ordinary "system" action with no specific actor and from any real `EkklesiaUserId`, so an + * auditor can tell this owner grant came specifically from the bootstrap command. Never a real + * user id and never derived from caller input. */ +const BOOTSTRAP_ACTOR_TYPE = "system"; +const BOOTSTRAP_ACTOR_ID = "authorization-bootstrap-cli"; + +// A dedicated advisory-lock key pair, distinct from `core/src/migrations/postgres-database.ts`'s +// migration lock, so bootstrap serialization can never collide with or be blocked by unrelated +// migration locking. `pg_advisory_xact_lock` is transaction-scoped: it is released automatically +// on COMMIT or ROLLBACK, so no manual unlock is needed even if the transaction fails partway. +const bootstrapLockNamespace = 1_500_000_001; +const bootstrapLockKey = 1_500_000_002; + +export interface BootstrapPlatformOwnerInput { + readonly userId: EkklesiaUserId; +} + +/** + * Core's dedicated, Hono-independent platform-owner bootstrap operation. Deliberately not a + * composition of "check, then call the existing grant function": the single-use check, the target + * user's existence check, the owner-grant insert, and its audit entry are all one PostgreSQL + * transaction, serialized by a transaction-scoped advisory lock acquired as the very first + * statement — so two concurrent callers can never both observe "no owner yet" and both insert. + * The second caller blocks on the lock until the first's transaction commits or rolls back, then + * re-evaluates against the now-current state. + * + * "Already consumed" is global and permanent: it is true the moment + * `ekklesia_core_platform_owner` has ever held any row for any user, including one whose + * `revoked_at` is set — a later, separate owner-management flow (not part of this slice) is how a + * platform owner would ever be re-established after a revocation, not this command. + */ +export async function bootstrapPlatformOwner( + db: AuthorizationDrizzleDatabase, + input: BootstrapPlatformOwnerInput, +): Promise { + try { + await db.transaction(async (tx) => { + await tx.execute( + sql`select pg_advisory_xact_lock(${bootstrapLockNamespace}, ${bootstrapLockKey})`, + ); + + const existingOwnerRows = await tx + .select({ userId: ekklesiaCorePlatformOwner.userId }) + .from(ekklesiaCorePlatformOwner) + .limit(1); + if (existingOwnerRows.length > 0) { + throw new PlatformOwnerBootstrapError("PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED"); + } + + const targetUserRows = await tx + .select({ id: ekklesiaCoreUser.id }) + .from(ekklesiaCoreUser) + .where(eq(ekklesiaCoreUser.id, input.userId)) + .limit(1); + if (targetUserRows.length === 0) { + throw new PlatformOwnerBootstrapError("PLATFORM_OWNER_BOOTSTRAP_USER_NOT_FOUND"); + } + + await tx.insert(ekklesiaCorePlatformOwner).values({ + grantedByActorId: BOOTSTRAP_ACTOR_ID, + grantedByActorType: BOOTSTRAP_ACTOR_TYPE, + userId: input.userId, + }); + + await tx.insert(ekklesiaCoreAuthorizationAudit).values({ + action: "platform_owner.grant", + actorId: BOOTSTRAP_ACTOR_ID, + actorType: BOOTSTRAP_ACTOR_TYPE, + subjectUserId: input.userId, + }); + }); + } catch (error) { + if (isPlatformOwnerBootstrapError(error)) { + throw error; + } + throw new PlatformOwnerBootstrapError("PLATFORM_OWNER_BOOTSTRAP_FAILED"); + } +} diff --git a/core/src/authorization/database.ts b/core/src/authorization/database.ts index db18ec8..8050614 100644 --- a/core/src/authorization/database.ts +++ b/core/src/authorization/database.ts @@ -105,3 +105,14 @@ export function createAuthorizationDrizzleDatabase( db, }); } + +/** + * A typed Drizzle view over an externally owned pool — for example one shared with the identity + * schema via `createValidatedPostgresPool` (`core/src/database/pool.ts`). Unlike + * `createAuthorizationDrizzleDatabase`, this function never constructs, registers an error + * listener on, or closes a pool itself: the caller retains that responsibility exactly once, + * regardless of how many typed views it creates over the same pool. + */ +export function createAuthorizationDrizzleView(pool: Pool): AuthorizationDrizzleDatabase { + return drizzle(pool, { schema: authorizationSchema }); +} diff --git a/core/src/authorization/index.ts b/core/src/authorization/index.ts index c4f6a05..272b3ad 100644 --- a/core/src/authorization/index.ts +++ b/core/src/authorization/index.ts @@ -1,4 +1,6 @@ +export * from "./bootstrap.js"; export * from "./database.js"; export * from "./evaluator.js"; +export * from "./permissions.js"; export * from "./ports.js"; export * from "./store.js"; diff --git a/core/src/authorization/permissions.ts b/core/src/authorization/permissions.ts new file mode 100644 index 0000000..66e52ce --- /dev/null +++ b/core/src/authorization/permissions.ts @@ -0,0 +1,109 @@ +import { + type PermissionBaseline, + type PermissionId, + parsePermissionId, + safeParsePermissionId, +} from "@ekklesia/contracts"; + +/** + * A registered permission declaration: the trusted pairing of a permission identifier with its + * one baseline. This is what distinguishes a *registered* permission from a merely well-formed + * `PermissionId` string — the evaluator itself (`createAuthorizationEvaluator`) still takes an + * explicit, caller-supplied baseline per pass 1's design; the registry is what a trusted caller + * (Core's own HTTP enforcement, eventually a module registration mechanism) consults so that + * baseline is never client-supplied or guessed. + */ +export interface PermissionDeclaration { + readonly id: PermissionId; + readonly baseline: PermissionBaseline; + /** Human-readable, for operators and audit review only — never interpreted by the evaluator or + * by enforcement. */ + readonly description: string; +} + +export interface PermissionRegistry { + readonly declarations: readonly PermissionDeclaration[]; + /** Returns the declaration for a permission id, or `undefined` for anything malformed or not + * registered — the two are deliberately indistinguishable to callers, so "unknown permission" + * and "invalid permission" fail closed the same way. */ + get(permissionId: unknown): PermissionDeclaration | undefined; + has(permissionId: unknown): boolean; +} + +export type PermissionRegistryErrorCode = "PERMISSION_REGISTRY_DUPLICATE_DECLARATION"; + +/** Thrown only at composition time (registry construction), never per-request. Carries the + * offending permission id deliberately — this is a startup-time programmer/configuration error + * message, not a runtime response, and a namespaced permission id is not sensitive. */ +export class PermissionRegistryError extends Error { + readonly code: PermissionRegistryErrorCode; + readonly permissionId: string; + + constructor(permissionId: string) { + super(`Permission "${permissionId}" is already registered.`); + this.name = "PermissionRegistryError"; + this.code = "PERMISSION_REGISTRY_DUPLICATE_DECLARATION"; + this.permissionId = permissionId; + this.stack = `${this.name}: ${this.message}`; + Object.freeze(this); + } +} + +/** + * Builds an immutable permission registry from a fixed list of declarations, rejecting any + * duplicate permission id outright at composition time. Deliberately the smallest mechanism that + * distinguishes "registered" from "well-formed": no module discovery, no dynamic registration, no + * network or filesystem I/O — a future module registration mechanism can merge its own + * declarations into a combined list and still call this same constructor, inheriting duplicate + * detection for free instead of reimplementing it. + */ +export function createPermissionRegistry( + declarations: readonly PermissionDeclaration[], +): PermissionRegistry { + const byId = new Map(); + for (const declaration of declarations) { + if (byId.has(declaration.id)) { + throw new PermissionRegistryError(declaration.id); + } + byId.set(declaration.id, Object.freeze({ ...declaration })); + } + const frozenDeclarations = Object.freeze([...byId.values()]); + + return Object.freeze({ + declarations: frozenDeclarations, + get(permissionId: unknown): PermissionDeclaration | undefined { + const parsed = safeParsePermissionId(permissionId); + return parsed.success ? byId.get(parsed.data) : undefined; + }, + has(permissionId: unknown): boolean { + const parsed = safeParsePermissionId(permissionId); + return parsed.success && byId.has(parsed.data); + }, + }); +} + +/** Restricted: delegated grant administration is itself a privileged action, never public or + * merely authenticated. A platform owner always passes the evaluator's existing owner bypass and + * can delegate this permission to another account deliberately via the explicit-grant API. */ +export const CORE_AUTHORIZATION_GRANTS_MANAGE: PermissionId = parsePermissionId( + "core.authorization.grants.manage", +); + +const CORE_PERMISSION_DECLARATIONS: readonly PermissionDeclaration[] = Object.freeze([ + Object.freeze({ + baseline: "restricted" as const, + description: "Create, list, and revoke another user's explicit authorization grants.", + id: CORE_AUTHORIZATION_GRANTS_MANAGE, + }), +]); + +/** + * Core's own permission registry — the trusted source of truth `apps/api`'s authorization + * enforcement consults so an HTTP route can never supply its own baseline. Contains exactly the + * one Core-owned permission this slice needs; a future module registration mechanism is + * deliberately out of scope here and will compose its own declarations through + * `createPermissionRegistry` rather than through this constant. + */ +export const CORE_PERMISSION_REGISTRY: PermissionRegistry = createPermissionRegistry( + CORE_PERMISSION_DECLARATIONS, +); diff --git a/core/src/authorization/store.ts b/core/src/authorization/store.ts index 2dd2f8e..0946539 100644 --- a/core/src/authorization/store.ts +++ b/core/src/authorization/store.ts @@ -14,7 +14,7 @@ import { parseGrantId, parsePermissionId, } from "@ekklesia/contracts"; -import { and, eq, gt, isNull, or, sql } from "drizzle-orm"; +import { and, desc, eq, gt, isNull, or, sql } from "drizzle-orm"; import type { AuthorizationDrizzleDatabase } from "./database.js"; import type { ActiveGrantQuery, @@ -29,6 +29,7 @@ import { export type AuthorizationStoreErrorCode = | "AUTHORIZATION_GRANT_CREATE_FAILED" + | "AUTHORIZATION_GRANT_LIST_FAILED" | "AUTHORIZATION_GRANT_LOOKUP_FAILED" | "AUTHORIZATION_GRANT_NOT_FOUND" | "AUTHORIZATION_GRANT_REVOKE_FAILED" @@ -38,6 +39,7 @@ export type AuthorizationStoreErrorCode = const storeErrorMessages: Readonly> = Object.freeze({ AUTHORIZATION_GRANT_CREATE_FAILED: "The authorization grant could not be created.", + AUTHORIZATION_GRANT_LIST_FAILED: "The authorization grant list could not be retrieved.", 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.", @@ -90,6 +92,18 @@ export interface GrantRecord { export interface RevokeGrantInput { readonly grantId: GrantId; readonly actor: AuthorizationActor; + /** When provided, the revoke only matches a grant whose `sourceKind` equals this value — a + * grant of a different source kind is treated exactly like a nonexistent one + * (`AUTHORIZATION_GRANT_NOT_FOUND`). The generic explicit-grant HTTP API always passes + * `"explicit"` here, so it can never revoke a relationship-derived grant regardless of caller + * intent; internal Core callers that omit this may revoke any grant, matching pass 1's + * original, unconstrained behavior. */ + readonly sourceKind?: GrantSourceKind; +} + +export interface ListGrantsForUserOptions { + /** When provided, only grants with this exact `sourceKind` are returned. */ + readonly sourceKind?: GrantSourceKind; } /** @@ -103,6 +117,12 @@ export interface RevokeGrantInput { export interface AuthorizationStore extends AuthorizationGrantPort, AuthorizationPlatformOwnerPort { createGrant(input: CreateGrantInput): Promise; revokeGrant(input: RevokeGrantInput): Promise; + /** Every grant ever recorded for the user, active or revoked, newest first — the caller decides + * what to display. Never includes another user's grants. */ + listGrantsForUser( + userId: EkklesiaUserId, + options?: ListGrantsForUserOptions, + ): Promise; grantPlatformOwner(userId: EkklesiaUserId, actor: AuthorizationActor): Promise; revokePlatformOwner(userId: EkklesiaUserId, actor: AuthorizationActor): Promise; } @@ -193,7 +213,13 @@ export function createAuthorizationStore(db: AuthorizationDrizzleDatabase): Auth revokedByActorType: input.actor.type, }) .where( - and(eq(ekklesiaCoreGrant.id, input.grantId), isNull(ekklesiaCoreGrant.revokedAt)), + and( + eq(ekklesiaCoreGrant.id, input.grantId), + isNull(ekklesiaCoreGrant.revokedAt), + ...(input.sourceKind === undefined + ? [] + : [eq(ekklesiaCoreGrant.sourceKind, input.sourceKind)]), + ), ) .returning(); if (!row) { @@ -243,6 +269,29 @@ export function createAuthorizationStore(db: AuthorizationDrizzleDatabase): Auth } }, + listGrantsForUser: async ( + userId: EkklesiaUserId, + options: ListGrantsForUserOptions = {}, + ): Promise => { + try { + const rows = await db + .select() + .from(ekklesiaCoreGrant) + .where( + and( + eq(ekklesiaCoreGrant.userId, userId), + ...(options.sourceKind === undefined + ? [] + : [eq(ekklesiaCoreGrant.sourceKind, options.sourceKind)]), + ), + ) + .orderBy(desc(ekklesiaCoreGrant.createdAt)); + return Object.freeze(rows.map(toGrantRecord)); + } catch { + throw new AuthorizationStoreError("AUTHORIZATION_GRANT_LIST_FAILED"); + } + }, + grantPlatformOwner: async ( userId: EkklesiaUserId, actor: AuthorizationActor, diff --git a/core/src/cli/bootstrap-platform-owner.ts b/core/src/cli/bootstrap-platform-owner.ts new file mode 100644 index 0000000..4be74c7 --- /dev/null +++ b/core/src/cli/bootstrap-platform-owner.ts @@ -0,0 +1,106 @@ +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { safeParseEkklesiaUserId } from "@ekklesia/contracts"; +import { + bootstrapPlatformOwner, + createAuthorizationDrizzleDatabase, + PlatformOwnerBootstrapError, +} from "../authorization/index.js"; + +class BootstrapCliError extends Error { + constructor(message: string) { + super(message); + this.name = "BootstrapCliError"; + } +} + +const usage = + "Usage: pnpm authorization:bootstrap-owner -- --user-id --confirm-user-id "; + +interface ParsedArgs { + readonly confirmUserId: string; + readonly userId: string; +} + +/** A minimal, hand-rolled flag parser — this command takes exactly two required, named flags, so + * a general-purpose argument-parsing dependency is not warranted. */ +function parseArgs(argv: readonly string[]): ParsedArgs { + let userId: string | undefined; + let confirmUserId: string | undefined; + for (let index = 0; index < argv.length; index += 1) { + const flag = argv[index]; + if (flag === "--user-id") { + userId = argv[index + 1]; + index += 1; + } else if (flag === "--confirm-user-id") { + confirmUserId = argv[index + 1]; + index += 1; + } + } + if (userId === undefined || confirmUserId === undefined) { + throw new BootstrapCliError(usage); + } + return { confirmUserId, userId }; +} + +async function main(): Promise { + const { confirmUserId, userId: rawUserId } = parseArgs(process.argv.slice(2)); + + // Deliberately not "infer the first user" or "select by signup order" — the operator must name + // the exact, opaque Ekklesia user id twice, and both must match exactly, before anything else + // is even attempted. + if (rawUserId !== confirmUserId) { + throw new BootstrapCliError( + "--user-id and --confirm-user-id must match exactly. No changes were made.", + ); + } + + const parsedUserId = safeParseEkklesiaUserId(rawUserId); + if (!parsedUserId.success) { + throw new BootstrapCliError("The provided user id is not a valid Ekklesia user id."); + } + + // Read only when this command actually executes — never merely by importing this module (see + // isDirectExecution() below) or any module it imports. + const databaseUrl = process.env.DATABASE_URL; + if (typeof databaseUrl !== "string" || databaseUrl.trim().length === 0) { + throw new BootstrapCliError("DATABASE_URL is required."); + } + + const { close, db } = createAuthorizationDrizzleDatabase(databaseUrl, { + connectionTimeoutMs: 10_000, + operationTimeoutMs: 10_000, + }); + try { + await bootstrapPlatformOwner(db, { userId: parsedUserId.data }); + console.log(`Platform-owner bootstrap complete for user "${parsedUserId.data}".`); + } finally { + await close().catch(() => undefined); + } +} + +function reportBootstrapCliError(error: unknown): void { + if (error instanceof BootstrapCliError) { + console.error(error.message); + return; + } + if (error instanceof PlatformOwnerBootstrapError) { + console.error(`[${error.code}] ${error.message}`); + return; + } + console.error("Platform-owner bootstrap failed unexpectedly."); +} + +function isDirectExecution(): boolean { + const entrypoint = process.argv[1]; + return ( + entrypoint !== undefined && import.meta.url === pathToFileURL(path.resolve(entrypoint)).href + ); +} + +if (isDirectExecution()) { + main().catch((error: unknown) => { + reportBootstrapCliError(error); + process.exitCode = 1; + }); +} diff --git a/core/src/database/index.ts b/core/src/database/index.ts new file mode 100644 index 0000000..8636161 --- /dev/null +++ b/core/src/database/index.ts @@ -0,0 +1 @@ +export * from "./pool.js"; diff --git a/core/src/database/pool.ts b/core/src/database/pool.ts new file mode 100644 index 0000000..0273141 --- /dev/null +++ b/core/src/database/pool.ts @@ -0,0 +1,110 @@ +import { Pool, type PoolConfig } from "pg"; + +export type PostgresPoolErrorCode = "POSTGRES_POOL_CONFIGURATION_INVALID"; + +/** Never carries the offending connection string, host, or underlying driver error. */ +export class PostgresPoolError extends Error { + readonly code: PostgresPoolErrorCode; + + constructor(code: PostgresPoolErrorCode) { + super("PostgreSQL pool configuration is invalid."); + this.name = "PostgresPoolError"; + this.code = code; + this.stack = `${this.name}: ${this.message}`; + Object.freeze(this); + } +} + +const maximumPostgresTimeoutMs = 2_147_483_647; + +export interface PostgresPoolOptions { + readonly connectionTimeoutMs?: number; + /** Bounds both `query_timeout` and `statement_timeout`: `connectionTimeoutMs` alone only bounds + * establishing a connection, not a hung query already running on one. */ + readonly operationTimeoutMs?: number; +} + +export interface PostgresPoolHandle { + readonly close: () => Promise; + readonly pool: Pool; +} + +function validateConnectionString(connectionString: string): string { + if (connectionString.trim().length === 0) { + throw new PostgresPoolError("POSTGRES_POOL_CONFIGURATION_INVALID"); + } + let protocol: string; + try { + protocol = new URL(connectionString).protocol; + } catch { + throw new PostgresPoolError("POSTGRES_POOL_CONFIGURATION_INVALID"); + } + if (protocol !== "postgres:" && protocol !== "postgresql:") { + throw new PostgresPoolError("POSTGRES_POOL_CONFIGURATION_INVALID"); + } + return connectionString; +} + +/** Mirrors the validation already used by `core/src/identity/database.ts`, + * `core/src/authorization/database.ts`, and `core/src/migrations/postgres-database.ts`: 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 PostgresPoolError("POSTGRES_POOL_CONFIGURATION_INVALID"); + } + return value; +} + +/** + * Ekklesia's one shared, validated `pg.Pool` constructor for a single physical database + * connection meant to back more than one typed Drizzle view (for example Core's identity and + * authorization schemas sharing one pool in `apps/api`) — one pool, one mandatory error listener, + * one `close()`. Building the 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. + * + * This does not replace `createIdentityDrizzleDatabase` or `createAuthorizationDrizzleDatabase`: + * those remain independent, self-contained factories (each owns a private pool and its own + * `close()`) for callers — tests included — that only need one domain. Use this function, plus + * `createIdentityDrizzleView`/`createAuthorizationDrizzleView`, specifically when more than one + * domain must share one physical connection pool. + */ +export function createValidatedPostgresPool( + connectionString: string, + options: PostgresPoolOptions = {}, +): PostgresPoolHandle { + 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); + + return Object.freeze({ + close: () => pool.end(), + pool, + }); +} diff --git a/core/src/identity/database.ts b/core/src/identity/database.ts index 5f9b110..ff68722 100644 --- a/core/src/identity/database.ts +++ b/core/src/identity/database.ts @@ -108,3 +108,14 @@ export function createIdentityDrizzleDatabase( db, }); } + +/** + * A typed Drizzle view over an externally owned pool — for example one shared with the + * authorization schema via `createValidatedPostgresPool` (`core/src/database/pool.ts`). Unlike + * `createIdentityDrizzleDatabase`, this function never constructs, registers an error listener + * on, or closes a pool itself: the caller retains that responsibility exactly once, regardless of + * how many typed views it creates over the same pool. + */ +export function createIdentityDrizzleView(pool: Pool): IdentityDrizzleDatabase { + return drizzle(pool, { schema: identityAdapterSchema }); +} diff --git a/core/src/index.ts b/core/src/index.ts index a5c889e..1a9ca00 100644 --- a/core/src/index.ts +++ b/core/src/index.ts @@ -1,4 +1,5 @@ export * from "./authorization/index.js"; +export * from "./database/index.js"; export * from "./diagnostics/index.js"; export * from "./identity/index.js"; export * from "./modules/index.js"; diff --git a/core/tests/authorization-bootstrap.integration.ts b/core/tests/authorization-bootstrap.integration.ts new file mode 100644 index 0000000..246cb94 --- /dev/null +++ b/core/tests/authorization-bootstrap.integration.ts @@ -0,0 +1,364 @@ +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 { parseEkklesiaUserId } from "@ekklesia/contracts"; +import { Client, type QueryResultRow } from "pg"; +import { afterAll, describe, expect, it } from "vitest"; +import { + bootstrapPlatformOwner, + createAuthorizationDrizzleDatabase, + PlatformOwnerBootstrapError, +} 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(); +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 bootstrap 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 bootstrap 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_bootstrap_${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 { + 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 bootstrap integration database connection failed."); + } +} + +async function safeQuery( + client: Client, + text: string, + values: unknown[] = [], +): Promise { + try { + const result = await client.query(text, values); + return result.rows; + } catch { + throw harnessError("An integration database administration operation failed."); + } +} + +async function safeEnd(client: Client): Promise { + try { + await client.end(); + } catch { + throw harnessError("An integration database connection could not be closed."); + } +} + +async function createDatabase(databaseName: string): Promise { + 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 { + 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 { + const emptyModulesDirectory = await mkdtemp( + path.join(tmpdir(), "ekklesia-bootstrap-no-modules-"), + ); + try { + const aggregate = await discoverAllMigrations({ + coreMigrationsDirectory, + moduleDiscovery: { + moduleSdkVersion: "0.1.0", + modulesDirectory: emptyModulesDirectory, + platformVersion: "0.1.0", + }, + }); + await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); + } finally { + await rm(emptyModulesDirectory, { recursive: true, force: true }); + } +} + +async function seedUser(client: Client, id: string, email: string): Promise { + 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], + ); +} + +async function ownerRowCount(client: Client): Promise { + const rows = await safeQuery<{ count: string }>( + client, + `select count(*)::text as count from "ekklesia_core_platform_owner"`, + ); + return Number(rows[0]?.count ?? "0"); +} + +afterAll(async () => { + for (const databaseName of [...trackedDatabases]) { + await dropDatabase(databaseName).catch(() => undefined); + } +}); + +describe.sequential("live platform-owner bootstrap", () => { + let database: { name: string; url: string }; + let admin: Client; + let db: ReturnType["db"]; + let close: () => Promise; + + const firstUserId = parseEkklesiaUserId("bootstrap_user_one"); + const secondUserId = parseEkklesiaUserId("bootstrap_user_two"); + + it("bootstraps successfully for an existing user and records exactly one audit entry with the fixed bootstrap actor identity", async () => { + database = await createDisposableDatabase("success"); + await applyCoreMigrations(database.url); + + admin = await openAdministrativeClient(database.url); + await seedUser(admin, firstUserId, "bootstrap-one@example.test"); + await seedUser(admin, secondUserId, "bootstrap-two@example.test"); + + const handle = createAuthorizationDrizzleDatabase(database.url); + db = handle.db; + close = handle.close; + + await bootstrapPlatformOwner(db, { userId: firstUserId }); + + const owners = await safeQuery<{ user_id: string }>( + admin, + `select user_id from "ekklesia_core_platform_owner"`, + ); + expect(owners).toEqual([{ user_id: firstUserId }]); + + const audit = await safeQuery<{ + action: string; + actor_id: string | null; + actor_type: string; + subject_user_id: string; + }>( + admin, + `select action, actor_id, actor_type, subject_user_id from "ekklesia_core_authorization_audit"`, + ); + expect(audit).toEqual([ + { + action: "platform_owner.grant", + actor_id: "authorization-bootstrap-cli", + actor_type: "system", + subject_user_id: firstUserId, + }, + ]); + }); + + it("rejects a second bootstrap attempt outright, even for a different user, and leaves no new row or audit entry", async () => { + let captured: unknown; + try { + await bootstrapPlatformOwner(db, { userId: secondUserId }); + } catch (error) { + captured = error; + } + + expect(captured).toBeInstanceOf(PlatformOwnerBootstrapError); + expect(captured).toMatchObject({ code: "PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED" }); + + expect(await ownerRowCount(admin)).toBe(1); + const audit = await safeQuery(admin, `select id from "ekklesia_core_authorization_audit"`); + expect(audit).toHaveLength(1); + }); + + it("still refuses bootstrap after the existing owner is revoked — the historical row alone is enough to have consumed it", async () => { + await safeQuery( + admin, + `update "ekklesia_core_platform_owner" set revoked_at = now(), revoked_by_actor_type = 'system' where user_id = $1`, + [firstUserId], + ); + + let captured: unknown; + try { + await bootstrapPlatformOwner(db, { userId: secondUserId }); + } catch (error) { + captured = error; + } + + expect(captured).toBeInstanceOf(PlatformOwnerBootstrapError); + expect(captured).toMatchObject({ code: "PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED" }); + expect(await ownerRowCount(admin)).toBe(1); + }); + + it("shuts down cleanly", async () => { + await close(); + await safeEnd(admin); + await dropDatabase(database.name); + }); +}); + +describe.sequential("live platform-owner bootstrap — nonexistent user", () => { + it("rolls back entirely for a user that does not exist, leaving no owner row and no audit entry", async () => { + const database = await createDisposableDatabase("missing_user"); + await applyCoreMigrations(database.url); + const { close, db } = createAuthorizationDrizzleDatabase(database.url); + + try { + const missingUserId = parseEkklesiaUserId("bootstrap_user_never_seeded"); + let captured: unknown; + try { + await bootstrapPlatformOwner(db, { userId: missingUserId }); + } catch (error) { + captured = error; + } + + expect(captured).toBeInstanceOf(PlatformOwnerBootstrapError); + expect(captured).toMatchObject({ code: "PLATFORM_OWNER_BOOTSTRAP_USER_NOT_FOUND" }); + + const admin = await openAdministrativeClient(database.url); + try { + expect(await ownerRowCount(admin)).toBe(0); + const audit = await safeQuery(admin, `select id from "ekklesia_core_authorization_audit"`); + expect(audit).toHaveLength(0); + } finally { + await safeEnd(admin); + } + } finally { + await close(); + await dropDatabase(database.name); + } + }); +}); + +describe.sequential("live platform-owner bootstrap — concurrency", () => { + it("produces exactly one owner when two bootstrap attempts race for two different users", async () => { + const database = await createDisposableDatabase("race"); + await applyCoreMigrations(database.url); + + const admin = await openAdministrativeClient(database.url); + const raceUserA = parseEkklesiaUserId("bootstrap_race_user_a"); + const raceUserB = parseEkklesiaUserId("bootstrap_race_user_b"); + await seedUser(admin, raceUserA, "bootstrap-race-a@example.test"); + await seedUser(admin, raceUserB, "bootstrap-race-b@example.test"); + + const { close, db } = createAuthorizationDrizzleDatabase(database.url); + try { + const results = await Promise.allSettled([ + bootstrapPlatformOwner(db, { userId: raceUserA }), + bootstrapPlatformOwner(db, { userId: raceUserB }), + ]); + + const fulfilled = results.filter((result) => result.status === "fulfilled"); + const rejected = results.filter((result) => result.status === "rejected"); + expect(fulfilled).toHaveLength(1); + expect(rejected).toHaveLength(1); + expect((rejected[0] as PromiseRejectedResult).reason).toBeInstanceOf( + PlatformOwnerBootstrapError, + ); + expect((rejected[0] as PromiseRejectedResult).reason).toMatchObject({ + code: "PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED", + }); + + const owners = await safeQuery<{ user_id: string }>( + admin, + `select user_id from "ekklesia_core_platform_owner"`, + ); + expect(owners).toHaveLength(1); + expect([raceUserA, raceUserB]).toContain(owners[0]?.user_id); + + const audit = await safeQuery( + admin, + `select id from "ekklesia_core_authorization_audit" where action = 'platform_owner.grant'`, + ); + expect(audit).toHaveLength(1); + } finally { + await close(); + await safeEnd(admin); + await dropDatabase(database.name); + } + }); +}); diff --git a/core/tests/authorization-bootstrap.test.ts b/core/tests/authorization-bootstrap.test.ts new file mode 100644 index 0000000..e87c25b --- /dev/null +++ b/core/tests/authorization-bootstrap.test.ts @@ -0,0 +1,61 @@ +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 { + bootstrapPlatformOwner, + PlatformOwnerBootstrapError, +} from "../src/authorization/bootstrap.js"; + +describe("Core authorization bootstrap — 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("performs no database work merely by being imported: bootstrapPlatformOwner is only a function reference until called", () => { + expect(typeof bootstrapPlatformOwner).toBe("function"); + expect(poolConstructor).not.toHaveBeenCalled(); + }); +}); + +describe("PlatformOwnerBootstrapError", () => { + it.each([ + "PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED", + "PLATFORM_OWNER_BOOTSTRAP_FAILED", + "PLATFORM_OWNER_BOOTSTRAP_USER_NOT_FOUND", + ] as const)("exposes a stable code and a fixed, generic message for %s", (code) => { + const error = new PlatformOwnerBootstrapError(code); + expect(error.code).toBe(code); + expect(error.name).toBe("PlatformOwnerBootstrapError"); + 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 PlatformOwnerBootstrapError("PLATFORM_OWNER_BOOTSTRAP_FAILED"); + const publicText = `${String(error)}\n${error.stack ?? ""}`; + expect(publicText).toBe( + `PlatformOwnerBootstrapError: ${error.message}\nPlatformOwnerBootstrapError: ${error.message}`, + ); + }); +}); + +describe("bootstrapPlatformOwner — construction without a real database", () => { + it("does not construct a pool merely because a database handle was created; only calling bootstrapPlatformOwner would query", () => { + poolConstructor.mockClear(); + poolConstructor.mockImplementation(function fakePool() { + return { end: async () => undefined, on: vi.fn() }; + }); + + const { db } = createAuthorizationDrizzleDatabase("postgresql://example.test/ekklesia"); + + expect(poolConstructor).toHaveBeenCalledTimes(1); + expect(db).toBeDefined(); + // bootstrapPlatformOwner is intentionally not invoked here: exercising the real transactional + // and advisory-lock behavior requires a real PostgreSQL server and is covered by + // core/tests/authorization-bootstrap.integration.ts. + }); +}); diff --git a/core/tests/authorization-database.test.ts b/core/tests/authorization-database.test.ts index cb8f067..e5dba33 100644 --- a/core/tests/authorization-database.test.ts +++ b/core/tests/authorization-database.test.ts @@ -1,3 +1,4 @@ +import type { Pool } from "pg"; import { describe, expect, it, vi } from "vitest"; const poolConstructor = vi.hoisted(() => vi.fn()); @@ -7,6 +8,7 @@ vi.mock("pg", () => ({ Pool: poolConstructor })); import { AuthorizationDatabaseError, createAuthorizationDrizzleDatabase, + createAuthorizationDrizzleView, } from "../src/authorization/database.js"; describe("Core authorization Drizzle database composition", () => { @@ -144,3 +146,19 @@ describe("Core authorization Drizzle database composition", () => { ).not.toThrow(); }); }); + +describe("createAuthorizationDrizzleView", () => { + it("wraps an externally owned pool without constructing, registering an error listener on, or closing one itself", () => { + poolConstructor.mockClear(); + const fakePool = { end: vi.fn(), on: vi.fn(), query: vi.fn() }; + + // A minimal fake pg.Pool double is sufficient — this test proves + // createAuthorizationDrizzleView never touches the pool beyond wrapping it. + const db = createAuthorizationDrizzleView(fakePool as unknown as Pool); + + expect(db).toBeDefined(); + expect(poolConstructor).not.toHaveBeenCalled(); + expect(fakePool.on).not.toHaveBeenCalled(); + expect(fakePool.end).not.toHaveBeenCalled(); + }); +}); diff --git a/core/tests/authorization-permissions.test.ts b/core/tests/authorization-permissions.test.ts new file mode 100644 index 0000000..fb4574a --- /dev/null +++ b/core/tests/authorization-permissions.test.ts @@ -0,0 +1,94 @@ +import { parsePermissionId } from "@ekklesia/contracts"; +import { describe, expect, it } from "vitest"; +import { + CORE_AUTHORIZATION_GRANTS_MANAGE, + CORE_PERMISSION_REGISTRY, + createPermissionRegistry, + PermissionRegistryError, +} from "../src/authorization/index.js"; + +describe("createPermissionRegistry", () => { + it("builds an immutable registry from a fixed declaration list", () => { + const id = parsePermissionId("test.example.read"); + const registry = createPermissionRegistry([ + { baseline: "public", description: "An example permission.", id }, + ]); + + expect(registry.has(id)).toBe(true); + expect(registry.get(id)).toEqual({ + baseline: "public", + description: "An example permission.", + id, + }); + expect(Object.isFrozen(registry)).toBe(true); + expect(Object.isFrozen(registry.declarations)).toBe(true); + expect(Object.isFrozen(registry.get(id))).toBe(true); + }); + + it("rejects a duplicate permission id at composition time, not per request", () => { + const id = parsePermissionId("test.example.read"); + expect(() => + createPermissionRegistry([ + { baseline: "public", description: "First.", id }, + { baseline: "restricted", description: "Second.", id }, + ]), + ).toThrow(PermissionRegistryError); + }); + + it("carries the offending permission id on the duplicate-declaration error (a composition-time, developer-facing detail, not a runtime response)", () => { + const id = parsePermissionId("test.example.read"); + let captured: unknown; + try { + createPermissionRegistry([ + { baseline: "public", description: "First.", id }, + { baseline: "restricted", description: "Second.", id }, + ]); + } catch (error) { + captured = error; + } + expect(captured).toBeInstanceOf(PermissionRegistryError); + expect(captured).toMatchObject({ + code: "PERMISSION_REGISTRY_DUPLICATE_DECLARATION", + permissionId: id, + }); + }); + + it("treats an unregistered permission the same as a malformed one: get/has both fail closed", () => { + const registered = parsePermissionId("test.example.read"); + const unregistered = parsePermissionId("test.example.unregistered"); + const registry = createPermissionRegistry([ + { baseline: "public", description: "Registered.", id: registered }, + ]); + + expect(registry.has(unregistered)).toBe(false); + expect(registry.get(unregistered)).toBeUndefined(); + }); + + it("fails closed for a malformed permission id passed to get/has, without throwing", () => { + const registry = createPermissionRegistry([]); + + expect(registry.has("not a valid permission")).toBe(false); + expect(registry.get(null)).toBeUndefined(); + expect(registry.get(undefined)).toBeUndefined(); + expect(registry.get(42)).toBeUndefined(); + expect(registry.get("media.*")).toBeUndefined(); + }); + + it("builds an empty registry from an empty list without error", () => { + const registry = createPermissionRegistry([]); + expect(registry.declarations).toEqual([]); + }); +}); + +describe("CORE_PERMISSION_REGISTRY", () => { + it("registers exactly core.authorization.grants.manage as restricted", () => { + const declaration = CORE_PERMISSION_REGISTRY.get(CORE_AUTHORIZATION_GRANTS_MANAGE); + expect(declaration).toBeDefined(); + expect(declaration?.baseline).toBe("restricted"); + expect(declaration?.id).toBe("core.authorization.grants.manage"); + }); + + it("contains no other permission in this slice", () => { + expect(CORE_PERMISSION_REGISTRY.declarations).toHaveLength(1); + }); +}); diff --git a/core/tests/cli-bootstrap-platform-owner.test.ts b/core/tests/cli-bootstrap-platform-owner.test.ts new file mode 100644 index 0000000..eb069bd --- /dev/null +++ b/core/tests/cli-bootstrap-platform-owner.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it, vi } from "vitest"; + +const poolConstructor = vi.hoisted(() => vi.fn()); + +vi.mock("pg", () => ({ Pool: poolConstructor })); + +describe("bootstrap-platform-owner CLI import safety", () => { + const originalEnv = { ...process.env }; + + it("does not read DATABASE_URL, connect to PostgreSQL, or run bootstrap merely by importing the module", async () => { + delete process.env.DATABASE_URL; + + // Under Vitest, process.argv[1] is never this file's own path, so isDirectExecution() is + // false the same way it naturally is for apps/api/src/server.ts under its own test suite — + // no argv manipulation is needed to prove the guard holds. + await expect(import("../src/cli/bootstrap-platform-owner.js")).resolves.toBeDefined(); + + expect(poolConstructor).not.toHaveBeenCalled(); + + process.env = originalEnv; + }); +}); diff --git a/core/tests/database-pool.test.ts b/core/tests/database-pool.test.ts new file mode 100644 index 0000000..1dad7e1 --- /dev/null +++ b/core/tests/database-pool.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it, vi } from "vitest"; + +const poolConstructor = vi.hoisted(() => vi.fn()); + +vi.mock("pg", () => ({ Pool: poolConstructor })); + +import { createValidatedPostgresPool, PostgresPoolError } from "../src/database/pool.js"; + +describe("createValidatedPostgresPool", () => { + it("does not construct a pool merely because the module is imported", () => { + expect(poolConstructor).not.toHaveBeenCalled(); + }); + + it.each([ + "", + " ", + "not-a-database-url", + "https://admin:do-not-log@db.example.test/database", + ])("rejects an invalid connection string without exposing it (%s)", (connectionString) => { + let captured: unknown; + try { + createValidatedPostgresPool(connectionString); + } catch (error) { + captured = error; + } + + expect(captured).toBeInstanceOf(PostgresPoolError); + expect(captured).toMatchObject({ code: "POSTGRES_POOL_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", () => { + poolConstructor.mockImplementation(function fakePool() { + return { end: async () => undefined, on: vi.fn() }; + }); + + const handle = createValidatedPostgresPool( + "postgresql://operator:do-not-log@example.test/ekklesia", + ); + + expect(poolConstructor).toHaveBeenCalledTimes(1); + expect(handle.pool).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(); + + createValidatedPostgresPool("postgresql://example.test/ekklesia", { + connectionTimeoutMs: 5_000, + }); + + expect(poolConstructor).toHaveBeenCalledWith( + expect.objectContaining({ connectionTimeoutMillis: 5_000 }), + ); + }); + + it("passes the operation timeout through as both query_timeout and statement_timeout", () => { + poolConstructor.mockImplementation(function fakePool() { + return { end: async () => undefined, on: vi.fn() }; + }); + poolConstructor.mockClear(); + + createValidatedPostgresPool("postgresql://example.test/ekklesia", { + operationTimeoutMs: 7_500, + }); + + expect(poolConstructor).toHaveBeenCalledWith( + expect.objectContaining({ query_timeout: 7_500, statement_timeout: 7_500 }), + ); + }); + + it.each([ + ["operationTimeoutMs", 0], + ["operationTimeoutMs", -1], + ["operationTimeoutMs", Number.NaN], + ["connectionTimeoutMs", 0], + ["connectionTimeoutMs", -1], + ] as const)("rejects an invalid %s (%s) without constructing a pool and without leaking the value", (optionName, value) => { + poolConstructor.mockClear(); + + let captured: unknown; + try { + createValidatedPostgresPool("postgresql://example.test/ekklesia", { + [optionName]: value, + }); + } catch (error) { + captured = error; + } + + expect(captured).toBeInstanceOf(PostgresPoolError); + expect(captured).toMatchObject({ code: "POSTGRES_POOL_CONFIGURATION_INVALID" }); + 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 }; + }); + + createValidatedPostgresPool("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(); + }); +}); diff --git a/core/tests/identity-database.test.ts b/core/tests/identity-database.test.ts index 5e07508..a7440c8 100644 --- a/core/tests/identity-database.test.ts +++ b/core/tests/identity-database.test.ts @@ -1,10 +1,15 @@ +import type { Pool } from "pg"; import { describe, expect, it, vi } from "vitest"; const poolConstructor = vi.hoisted(() => vi.fn()); vi.mock("pg", () => ({ Pool: poolConstructor })); -import { createIdentityDrizzleDatabase, IdentityDatabaseError } from "../src/identity/database.js"; +import { + createIdentityDrizzleDatabase, + createIdentityDrizzleView, + IdentityDatabaseError, +} from "../src/identity/database.js"; describe("Core identity Drizzle database composition", () => { it("does not construct a pool merely because the module is imported", () => { @@ -141,3 +146,19 @@ describe("Core identity Drizzle database composition", () => { ).not.toThrow(); }); }); + +describe("createIdentityDrizzleView", () => { + it("wraps an externally owned pool without constructing, registering an error listener on, or closing one itself", () => { + poolConstructor.mockClear(); + const fakePool = { end: vi.fn(), on: vi.fn(), query: vi.fn() }; + + // A minimal fake pg.Pool double is sufficient — this test proves createIdentityDrizzleView + // never touches the pool beyond wrapping it. + const db = createIdentityDrizzleView(fakePool as unknown as Pool); + + expect(db).toBeDefined(); + expect(poolConstructor).not.toHaveBeenCalled(); + expect(fakePool.on).not.toHaveBeenCalled(); + expect(fakePool.end).not.toHaveBeenCalled(); + }); +}); diff --git a/docs/development/api.md b/docs/development/api.md index 9710b10..699d324 100644 --- a/docs/development/api.md +++ b/docs/development/api.md @@ -9,6 +9,8 @@ A follow-up corrective slice hardened this transport (sanitized Better Auth logg production-equivalent origin/CSRF protection under the test runner, bounded identity database timeouts, idempotent shutdown, and pinned password-length boundaries) without changing any of the above; see [ADR 0012](../architecture/decisions/0012-identity-origin-csrf-protection-under-test.md). +Phase 4C adds Core's authorization foundation and, on top of this transport, a platform-owner +bootstrap command plus an enforced explicit-grant HTTP API — see [Authorization](authorization.md). ## What this is, and is not @@ -54,6 +56,7 @@ No real values are shown here — configure per environment and never commit the | --- | --- | --- | | `POST\|GET /api/auth/*` | Better Auth (mounted directly) | Better Auth's own request/response shapes pass through unmodified — see [Better Auth's email/password docs](https://www.better-auth.com/docs/authentication/email-password) for the exact endpoints (`/sign-up/email`, `/sign-in/email`, `/sign-out`, `/get-session`, ...). | | `GET /api/v1/principal` | Ekklesia | Returns the minimal `EkklesiaPrincipal` contract as JSON. `Cache-Control: no-store`. | +| `GET\|POST /api/v1/grants*` | Ekklesia | Explicit authorization grant management, gated by `core.authorization.grants.manage`. See [Authorization](authorization.md#explicit-grant-http-api). | Better Auth's handler is unreachable outside `/api/auth/*`; every other unmatched path returns a deterministic `404 {"code":"NOT_FOUND"}`. @@ -101,11 +104,12 @@ exist or treats an empty database as special. Baseline authenticated status is w let a signed-in person use ownership-scoped personal features (watch history, continue watching, saved content, preferences) — none of those features are implemented in this PR. -Explicit **platform-owner bootstrap is deferred to Phase 4C**, which must introduce an explicit, -auditable operator command that grants an *existing* identity platform-owner authority — signup -order is never part of that decision. Future **relationship-derived access** (for example, a -person added to a volunteer roster automatically gaining narrow, reversible read access to that -roster) belongs to a later roster slice, not this one. +Explicit **platform-owner bootstrap** is now available as a local operator command — see +[Authorization](authorization.md#platform-owner-bootstrap) — and grants an *existing* identity +platform-owner authority; signup order is never part of that decision. Future +**relationship-derived access** (for example, a person added to a volunteer roster automatically +gaining narrow, reversible read access to that roster) belongs to a later roster slice, not this +one. `emailAndPassword.autoSignIn` is set to `false`. Signing up creates the account but does **not** automatically create a session — no `Set-Cookie` is sent, and the sign-up response's `token` field @@ -204,15 +208,19 @@ abuse-control design. ## Database connection limits -`apps/api/src/server.ts` configures the identity PostgreSQL pool (`createIdentityDrizzleDatabase`, -`core/src/identity/database.ts`) with both a connection timeout and an operation timeout (10 -seconds each), the same bounded, validated pattern `core/src/migrations/postgres-database.ts` -already uses. `connectionTimeoutMillis` alone only bounds *establishing* a connection; it does -nothing once a query is already running on one. The operation timeout additionally sets `pg`'s -`query_timeout` and `statement_timeout`, so a hung or unexpectedly slow query is aborted rather than -holding a pool connection (and its caller) indefinitely. An invalid timeout (non-integer, zero, -negative, or larger than PostgreSQL's own maximum) is rejected as configuration error before any -pool is constructed, sanitized the same way an invalid connection string already is. +`apps/api/src/server.ts` configures the one shared PostgreSQL pool (`createApiDatabase`, +`apps/api/src/database.ts`, built on `createValidatedPostgresPool` from +`core/src/database/pool.ts`) with both a connection timeout and an operation timeout (10 seconds +each), the same bounded, validated pattern `core/src/migrations/postgres-database.ts` already +uses. `connectionTimeoutMillis` alone only bounds *establishing* a connection; it does nothing once +a query is already running on one. The operation timeout additionally sets `pg`'s `query_timeout` +and `statement_timeout`, so a hung or unexpectedly slow query is aborted rather than holding a pool +connection (and its caller) indefinitely. An invalid timeout (non-integer, zero, negative, or +larger than PostgreSQL's own maximum) is rejected as configuration error before any pool is +constructed, sanitized the same way an invalid connection string already is. Both the identity and +authorization domains read from this one pool through independent typed Drizzle views — see +[Authorization](authorization.md#shared-database-composition) for why there is exactly one pool, +not two. ## Graceful shutdown @@ -221,7 +229,9 @@ executable entry point and tests) returns `{app, server, shutdown}`. `shutdown() shared promise the first time it is called: every caller, concurrent or sequential, awaits the exact same listener-drain-then-pool-close operation, and that operation runs exactly once no matter how many times `shutdown()` is called. It stops the HTTP listener from accepting new connections and -lets in-flight requests finish, then closes the PostgreSQL pool. The executable entry point's +lets in-flight requests finish, then closes the one shared PostgreSQL pool (identity and +authorization both read from it — see +[Authorization](authorization.md#shared-database-composition)). The executable entry point's `SIGINT`/`SIGTERM` wiring is extracted into `installShutdownSignalHandlers(running, exit)` — the same function production composition uses, but independently testable with a fake `running`/`exit` so a test can trigger "a signal arrived" without sending a real process signal or invoking the real @@ -268,7 +278,8 @@ Cookie: better-auth.session_token= ## Next phase -Phase 4C: Core authorization (public / authenticated-baseline / relationship-derived / grants / -platform-owner), an explicit, auditable platform-owner bootstrap command, and reversible -relationship-derived access. Not a Dashboard Surface, which still depends on an authorization model -that does not exist yet. +Phase 4C, pass 3: module discovery/Module SDK permission registration, roles/groups/teams/tenants, +an owner-management HTTP endpoint, service accounts/API tokens for non-browser automation, and +reversible relationship-derived access itself. See [Authorization](authorization.md) for what this +pass already added (permission registry, platform-owner bootstrap, explicit-grant HTTP API). Not a +Dashboard Surface, which still depends on an authorization model that is only now getting started. diff --git a/docs/development/authorization.md b/docs/development/authorization.md new file mode 100644 index 0000000..ad7d996 --- /dev/null +++ b/docs/development/authorization.md @@ -0,0 +1,299 @@ +# Authorization + +Phase 4C establishes Core's authorization foundation and, in a second slice, wires it into +`apps/api` over HTTP. This page covers both: the schema/evaluator/store/registry Core owns, the +platform-owner bootstrap operation, and the enforcement/endpoints `apps/api` exposes on top of it. +See [Identity](identity.md) for the identity foundation this builds on and [API](api.md) for the +HTTP transport it is mounted into. + +## What this is, and is not + +After this slice, an operator can bootstrap exactly one platform owner for a deployment, a +platform owner (or anyone they delegate to) can create, list, and revoke *explicit* authorization +grants over HTTP, and every enforced route fails closed on infrastructure trouble instead of +silently allowing or denying. It is **not** possible to manage roles, groups, teams, or tenants; +there is no owner-management HTTP endpoint (revoking or re-granting platform-owner status is not +exposed at all in this slice); there are no service accounts or API tokens, so only +cookie-authenticated browser callers can reach the mutation routes; and relationship-derived +grants (for example, automatic access from a roster assignment) cannot yet be created by anything +in this repository — the generic grants API structurally cannot create or revoke them either. + +## Core authorization schema and store + +Established as a prior checkpoint on this branch (`ekklesia_core_platform_owner`, +`ekklesia_core_grant`, `ekklesia_core_authorization_audit`; `AuthorizationEvaluator`, +`AuthorizationStore`; `@ekklesia/contracts`'s `PermissionId`/`GrantId`/`AuthorizationScope` +branded types). This page does not restate that design — read `core/src/authorization/schema.ts`, +`evaluator.ts`, and `store.ts` directly for it. This page covers what pass 2 added on top: the +permission registry, the bootstrap operation, and HTTP enforcement. + +## Permission registry + +`core/src/authorization/permissions.ts` defines the smallest possible registry: an immutable, +composition-time-built `PermissionRegistry` distinguishing a *registered declaration* +(`{id, baseline, description}`) from a merely well-formed `PermissionId` string. Two properties +matter: + +- **Duplicate declarations fail at composition time.** `createPermissionRegistry` throws + `PermissionRegistryError` (`PERMISSION_REGISTRY_DUPLICATE_DECLARATION`) if the same permission id + is declared twice — this can only happen while wiring the registry itself, not at request time. +- **Unknown permissions fail closed at lookup time.** `registry.get(permissionId)` and + `registry.has(permissionId)` both re-validate their input (`safeParsePermissionId` internally) + and return `undefined`/`false` for anything malformed or simply not registered — there is no + "trust this permission id because it looks right" path. + +`CORE_PERMISSION_REGISTRY` is Core's own registry, currently declaring exactly one permission: + +| Permission | Baseline | Description | +| --- | --- | --- | +| `core.authorization.grants.manage` | `restricted` | Create, list, and revoke another user's explicit authorization grants. | + +Module-owned permission declarations and a discovery/composition path for them are out of scope +for this slice (deferred to the Module SDK work). + +## Platform-owner bootstrap + +There is no "first user becomes owner" inference anywhere in this repository, and no HTTP +endpoint exposes bootstrap. The only way to establish a platform owner is the local CLI command: + +```bash +pnpm migrations:apply # the database must already be migrated +DATABASE_URL=postgresql://... pnpm authorization:bootstrap-owner -- \ + --user-id \ + --confirm-user-id +``` + +`DATABASE_URL` is read only when the command actually executes (never merely by importing the CLI +module — proved by `core/tests/cli-bootstrap-platform-owner.test.ts`), and it is never printed, +logged, or included in any error. `--user-id` and `--confirm-user-id` must match exactly, checked +before the id is even parsed as a valid `EkklesiaUserId` — this command never infers a target from +signup order or "the only user in the database"; the operator must name the exact, opaque id +twice. + +### Bootstrap is permanently single-use + +`core/src/authorization/bootstrap.ts`'s `bootstrapPlatformOwner(db, {userId})` is a dedicated, +Hono-independent Core operation — deliberately *not* a composition of "check for an owner, then +call the existing grant function." "Already consumed" means **any row has ever existed** in +`ekklesia_core_platform_owner`, for any user, including one whose `revoked_at` is set. There is no +owner-management flow in this slice that clears that state; a revoked owner staying revoked (and +bootstrap staying consumed) is intentional here. + +The single-use check, the target user's existence check, the owner-grant insert, and its audit +entry are all one PostgreSQL transaction. The very first statement inside that transaction is a +transaction-scoped advisory lock: + +```sql +select pg_advisory_xact_lock(1500000001, 1500000002) +``` + +This lock key pair is distinct from the migration runner's own advisory lock +(`core/src/migrations/postgres-database.ts`), so bootstrap serialization can never collide with or +be blocked by unrelated migration locking. `pg_advisory_xact_lock` releases automatically on +commit or rollback, so no manual unlock path is needed even when the transaction fails partway. +Two concurrent bootstrap attempts serialize on this lock: the second caller blocks until the +first's transaction commits or rolls back, then re-evaluates the "does any owner row exist" check +against now-current state — so exactly one of two concurrent attempts ever succeeds, proved live +against real PostgreSQL in `core/tests/authorization-bootstrap.integration.ts` +(`Promise.allSettled` on two simultaneous calls; asserts exactly one `fulfilled`, exactly one +`rejected` with `PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED`, exactly one owner row, exactly one +audit row). + +A failed target-user lookup, an audit-insert failure, or losing the concurrency race all roll back +the entire transaction — there is no partial state where an owner row exists without its audit +entry, or vice versa. + +The audit entry for a bootstrap grant always uses a fixed, sanitized, non-user actor identity — +never derived from any caller input — so an auditor can tell a given `platform_owner.grant` row +came specifically from this command rather than from a future owner-management flow: + +| Field | Value | +| --- | --- | +| `actor_type` | `system` | +| `actor_id` | `authorization-bootstrap-cli` | + +### Error codes + +`PlatformOwnerBootstrapError` never carries the underlying database error, SQL text, host, port, +path, or stack — only a stable `code` and a fixed message: + +| Code | Meaning | +| --- | --- | +| `PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED` | A platform-owner row already exists (any user, revoked or not). | +| `PLATFORM_OWNER_BOOTSTRAP_USER_NOT_FOUND` | `--user-id` does not match an existing identity user. | +| `PLATFORM_OWNER_BOOTSTRAP_FAILED` | Any other failure (database/infrastructure). | + +## HTTP authorization enforcement + +`apps/api/src/authorization-middleware.ts` exports `requirePermission(options, permissionId)`, a +Hono middleware factory reused by every protected route: + +1. Looks up `permissionId` in the trusted, server-side `PermissionRegistry` (never a + client-supplied baseline) — an unregistered permission id fails closed with `403` before the + principal is even resolved. In today's wiring only hard-coded registered constants are ever + passed by route code, but the check stays live per-request as defense in depth rather than + being trusted once at composition time. +2. Resolves the principal exclusively through the same server-side identity runtime + `/api/v1/principal` already uses (`EkklesiaIdentityRuntime.resolvePrincipal`) — nothing here + ever trusts a client-supplied claim about identity. +3. Calls the injected `AuthorizationEvaluator` with the registry's baseline and permission for + that principal (platform owners pass through the evaluator's owner bypass here — this is how + delegation to `core.authorization.grants.manage` recipients also works, without a second code + path). +4. On success, stores the resolved `EkklesiaPrincipal` (Core's narrow `{kind, userId}` — never a + Better Auth session/user object) on the Hono context via `c.set`, readable by the route through + `getAuthorizedPrincipal(c)`. This is a deliberately narrow exception to the "no generalized + session/user object in Hono context" concern [ADR + 0011](../architecture/decisions/0011-identity-http-transport-and-public-enrollment.md) raised — + only this already-public-safe principal shape is ever set. + +A denied decision and an unregistered permission id both return a sanitized `403 +{"code":"AUTHORIZATION_DENIED"}`. A genuine infrastructure failure — principal resolution, +evaluation, or the database underneath either — is never folded into that `403`; it returns a +sanitized `503` instead (`PRINCIPAL_RESOLUTION_FAILED` or `AUTHORIZATION_EVALUATION_FAILED`), +mirroring `/api/v1/principal`'s existing rule that "could not be determined" must never look like +"no permission." Neither response ever reveals whether a grant or owner row exists for the caller. + +### Origin enforcement for mutations + +`requireTrustedOrigin(trustedOrigins)` requires an exact match against the same +`EKKLESIA_IDENTITY_TRUSTED_ORIGINS` set the API already uses for CORS and Better Auth's own +checks (see [API](api.md#origin-cors-and-cookies)) — a missing or untrusted `Origin` header is +rejected with `403 {"code":"UNTRUSTED_ORIGIN"}` before the permission check or the route handler +ever runs. This exists because CORS response headers alone are not CSRF protection: they only +stop a browser from *reading* a cross-origin response, not from *sending* the state-changing +request in the first place. Every grants mutation route (`POST /api/v1/grants`, +`POST /api/v1/grants/:grantId/revoke`) requires this; the read-only list route does not. +Non-browser, non-cookie automation (service accounts/API tokens) is out of scope for this slice +and has no way to satisfy this check yet. + +## Explicit-grant HTTP API + +Mounted at the API's root by `apps/api/src/grants-routes.ts`. Every route requires +`core.authorization.grants.manage`. All three routes only ever act on *explicit* grants — the +generic HTTP API structurally cannot create or revoke a relationship-derived grant. + +| Route | Origin required | Notes | +| --- | --- | --- | +| `GET /api/v1/grants?userId=` | No | Lists the subject's explicit grants only (`sourceKind: "explicit"` filter applied server-side). | +| `POST /api/v1/grants` | Yes | Creates a new explicit grant. | +| `POST /api/v1/grants/:grantId/revoke` | Yes | Revokes an explicit grant without deleting it. | + +### `POST /api/v1/grants` request body + +Only four keys are accepted; any other key present (`actor`, `owner`, `isOwner`, `sourceKind`, +`sourceRef`, `id`, `createdAt`, `revokedAt`, `createdByActorId`, `effectivePermissions`, or +anything else) makes the **entire body** invalid rather than being silently dropped, so a caller +cannot smuggle a server-derived field through a request that otherwise looks well-formed: + +| Field | Required | Notes | +| --- | --- | --- | +| `subjectUserId` | Yes | Must parse as a valid `EkklesiaUserId`. | +| `permission` | Yes | Must parse as a valid `PermissionId`. | +| `scope` | No | Must parse as a valid, non-empty `AuthorizationScope` if present. | +| `expiresAt` | No | An ISO date string if present. | + +The server always derives, and the request body can never supply: the grant id, `sourceKind` +(always `"explicit"`), the actor (always the authenticated caller, via +`createUserActor(principal.userId)`), and all audit/timestamp fields. + +### Response shapes + +A grant, wherever returned: + +```json +{ + "id": "...", + "userId": "...", + "permission": "core.authorization.grants.manage", + "scope": null, + "sourceKind": "explicit", + "createdAt": "2026-07-23T00:00:00.000Z", + "expiresAt": null, + "revokedAt": null +} +``` + +`GET /api/v1/grants` → `200 {"grants": [...]}`. `POST /api/v1/grants` → `201 {"grant": {...}}`. +`POST /api/v1/grants/:grantId/revoke` → `200 {"success": true}`. + +### Status-code mapping + +| Status | When | +| --- | --- | +| `200` / `201` | Success. | +| `400 {"code":"INVALID_REQUEST"}` | Malformed query/body/param (including a disallowed extra field). | +| `403 {"code":"AUTHORIZATION_DENIED"}` | Caller lacks `core.authorization.grants.manage`, or the permission id was unregistered. | +| `403 {"code":"UNTRUSTED_ORIGIN"}` | Missing or untrusted `Origin` on a mutation route. | +| `404 {"code":"AUTHORIZATION_GRANT_NOT_FOUND"}` | The targeted grant id does not exist as an *explicit* grant — this is also what revoking a real relationship-derived grant's id looks like, indistinguishably. | +| `503` | Principal resolution, evaluation, or store failure (`PRINCIPAL_RESOLUTION_FAILED`, `AUTHORIZATION_EVALUATION_FAILED`, or an `AuthorizationStoreError` code). | + +Every response sets `Cache-Control: no-store`. + +## Shared database composition + +`apps/api/src/database.ts`'s `createApiDatabase(databaseUrl, options)` is the one place `apps/api` +touches PostgreSQL connection setup. It constructs exactly one validated, timeout-bounded +`pg.Pool` (`createValidatedPostgresPool`, `@ekklesia/core/database`) and wraps it with two +independent typed Drizzle views — `createIdentityDrizzleView(pool)` and +`createAuthorizationDrizzleView(pool)` — rather than two separate pools. `close()` on the returned +handle is the only thing that ever calls `pool.end()`; neither domain view owns or closes the pool +itself. `apps/api/src/server.ts` calls `createApiDatabase` once during `startApi()`, builds the +identity runtime from `.identity` and the `AuthorizationStore`/`AuthorizationEvaluator` from +`.authorization` off that same handle, and closes the handle exactly once — on graceful shutdown, +and on any startup failure after the pool was already constructed (mirroring the existing +identity-only failure-cleanup path from [API](api.md#graceful-shutdown)). + +The existing per-domain factories (`createIdentityDrizzleDatabase`, +`createAuthorizationDrizzleDatabase`), which each construct and own a private pool, are +unaffected and remain available for standalone/testable use — the CLI bootstrap command uses +`createAuthorizationDrizzleDatabase` directly for exactly that reason, since it only ever needs +the authorization schema and has no reason to share a pool with anything else. + +`apps/api/tests/lifecycle.test.ts` proves this composition explicitly: exactly one +`new Pool(...)` call for a full `startApi()` lifecycle (covering both identity and authorization +usage), and exactly one `pool.end()` call regardless of how many concurrent `shutdown()` callers +there are or whether the failure happened during startup. + +## Tests + +- `core/tests/authorization-permissions.test.ts` — registry construction, duplicate-declaration + rejection, fail-closed lookup, `CORE_PERMISSION_REGISTRY` contents. +- `core/tests/database-pool.test.ts` — shared pool validation/construction (mirrors the existing + per-domain database tests). +- `core/tests/authorization-bootstrap.test.ts` — import safety, `PlatformOwnerBootstrapError` + behavior. +- `core/tests/cli-bootstrap-platform-owner.test.ts` — the CLI never constructs a pool merely by + being imported. +- `core/tests/authorization-bootstrap.integration.ts` (live PostgreSQL) — successful bootstrap and + its audit row, nonexistent-user rollback, second-bootstrap rejection, a revoked historical owner + still blocking reuse, and the concurrency scenario described above. +- `apps/api/tests/authorization-middleware.test.ts` — allow/deny/unregistered/infrastructure- + failure paths for `requirePermission`, and `requireTrustedOrigin`'s allow/reject behavior. +- `apps/api/tests/grants-routes.test.ts` — all three routes' allow/deny paths, disallowed-field + and malformed-field rejection, exact server-derived-argument assertions, sourceKind-filtered + revoke returning 404 for a non-explicit grant, origin enforcement, and store-error mapping. +- `apps/api/tests/lifecycle.test.ts` — shared-pool-closes-exactly-once coverage described above. +- `apps/api/tests/api-http.integration.ts` (live PostgreSQL) — end-to-end: anonymous/non-owner + denial, real bootstrap against the running API's own database, owner authority over the HTTP + grants API, the owner delegating `core.authorization.grants.manage` to another account and that + account then managing grants as a non-owner, a create/list/revoke cycle with audit-trail + verification, disallowed-field rejection, missing/untrusted-origin rejection, a real + relationship-derived grant proven invisible to LIST and unreachable by REVOKE, and an + infrastructure-failure scenario (database dropped out from under a live session) asserting a + `503` rather than a false `403` or `200`. + +Run the focused suites: + +```bash +pnpm --filter @ekklesia/core test +pnpm --filter @ekklesia/api test +pnpm test:migrations:integration # requires Docker +``` + +## Next phase + +Phase 4C, pass 3: deferred from this slice — module discovery/Module SDK permission +registration, roles/groups/teams/tenants, an owner-management HTTP endpoint, service +accounts/API tokens for non-browser automation, and relationship-derived grant creation itself +(a roster or similar module operation, not the generic grants API). diff --git a/package.json b/package.json index 5a90f9a..fc3a493 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "packageManager": "pnpm@11.12.0", "scripts": { "api:start": "turbo run build && node --env-file-if-exists=.env apps/api/dist/server.js", + "authorization:bootstrap-owner": "turbo run build && node --env-file-if-exists=.env core/dist/cli/bootstrap-platform-owner.js", "boundaries": "node scripts/check-boundaries.ts", "build": "turbo run build", "ci:verify": "pnpm verify && pnpm infrastructure:config", diff --git a/vitest.migrations.integration.config.ts b/vitest.migrations.integration.config.ts index 61a3a88..49e7c36 100644 --- a/vitest.migrations.integration.config.ts +++ b/vitest.migrations.integration.config.ts @@ -7,6 +7,7 @@ export default defineConfig({ include: [ "core/tests/postgres-migration.integration.ts", "core/tests/authorization-store.integration.ts", + "core/tests/authorization-bootstrap.integration.ts", "apps/api/tests/api-http.integration.ts", ], passWithNoTests: false, From 967c275027b432e0f17faa59bf9ffaad7a2e7f53 Mon Sep 17 00:00:00 2001 From: ekklesia Date: Thu, 23 Jul 2026 11:26:47 -0500 Subject: [PATCH 3/4] docs: record core authorization architecture Adds ADR 0013 documenting the durable Phase 4C decisions (permission registry, evaluator baselines, grant/scope semantics, platform-owner bootstrap, shared database pool, Origin enforcement, and what remains deferred), cross-links it from the authorization/README docs, and refreshes the pnpm doctor no-DATABASE_URL example output in README.md and repository-workflow.md to include the authorization migration. --- README.md | 26 ++- ...-core-authorization-and-owner-bootstrap.md | 209 ++++++++++++++++++ docs/architecture/decisions/README.md | 1 + docs/development/authorization.md | 6 +- docs/development/repository-workflow.md | 3 +- 5 files changed, 230 insertions(+), 15 deletions(-) create mode 100644 docs/architecture/decisions/0013-core-authorization-and-owner-bootstrap.md diff --git a/README.md b/README.md index 2c6e33b..dfc62b1 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,8 @@ surfaces. ## Current status -Phase 3 is complete, Phase 4A (Core identity foundation) and Phase 4B (identity HTTP transport) -have landed, and Phase 4C (Core authorization) is in progress. The repository includes +Phase 3 is complete, and Phase 4A (Core identity foundation), Phase 4B (identity HTTP transport), +and Phase 4C (Core authorization) have landed. The repository includes public module contracts, independently versioned capabilities, static module manifests, deterministic automatic discovery, compiled server loading, a staged capability registry, dependency-ordered lifecycle with rollback, module-owned Drizzle table contracts, deterministic @@ -40,15 +40,16 @@ production approval-provider composition, jobs, physical storage provisioning, s feature modules, remains later vertical slices. Public registration is not yet ready for a real public launch. -Phase 4C (in progress) adds Core's authorization foundation on top of identity: a schema, -evaluator, and store for platform owners, explicit grants, and relationship-derived grants; a -smallest-possible immutable permission registry; an explicit, single-use, concurrency-safe -platform-owner bootstrap command (`pnpm authorization:bootstrap-owner`, never HTTP-exposed, never -inferred from signup order); and, in `apps/api`, reusable Hono authorization enforcement plus a -narrowly scoped `/api/v1/grants` HTTP API for managing *explicit* grants only, requiring an exact -trusted `Origin` on every mutation. Roles/groups/teams/tenants, an owner-management HTTP endpoint, -service accounts/API tokens, and module-permission registration remain later work. See -[Authorization](docs/development/authorization.md). +Phase 4C adds Core's authorization foundation on top of identity: a schema, evaluator, and store +for platform owners, explicit grants, and relationship-derived grants; a smallest-possible +immutable permission registry; an explicit, single-use, concurrency-safe platform-owner bootstrap +command (`pnpm authorization:bootstrap-owner`, never HTTP-exposed, never inferred from signup +order); and, in `apps/api`, reusable Hono authorization enforcement plus a narrowly scoped +`/api/v1/grants` HTTP API for managing *explicit* grants only, requiring an exact trusted `Origin` +on every mutation. Roles/groups/teams/tenants, an owner-management HTTP endpoint, service +accounts/API tokens, and module-permission registration remain later work. See +[Authorization](docs/development/authorization.md) and +[ADR 0013](docs/architecture/decisions/0013-core-authorization-and-owner-bootstrap.md). ## Quick start @@ -93,8 +94,9 @@ Data definitions ✓ Schemas validated 0 modules · 0 tables ✓ Migrations validated - 1 migration · checksums valid + 2 migrations · checksums valid core:0000_identity_core_tables · f8f2086ef06f + core:0001_authorization_core_tables · 830e1fbc26d0 PostgreSQL ○ Connection check skipped diff --git a/docs/architecture/decisions/0013-core-authorization-and-owner-bootstrap.md b/docs/architecture/decisions/0013-core-authorization-and-owner-bootstrap.md new file mode 100644 index 0000000..afcdad4 --- /dev/null +++ b/docs/architecture/decisions/0013-core-authorization-and-owner-bootstrap.md @@ -0,0 +1,209 @@ +# 0013: Core authorization, permission registry, and platform-owner bootstrap + +- Status: Accepted +- Date: 2026-07-23 + +## Context + +ADR 0010 and ADR 0011 established Core-owned identity and its HTTP transport but explicitly left +authorization out of scope: every account received identical baseline authenticated status, and +signup order never granted elevation. `docs/architecture/platform-architecture.md` names +permissions as a Core concern (ADR 0001), not a feature module or a surface concern, alongside +identity, database connectivity, and module lifecycle. + +Phase 4C adds that missing piece in two slices on this branch. The first slice +(`feat: add core authorization foundation`) established the schema, evaluator, store, and +contracts. The second (`feat: add authorization bootstrap and HTTP enforcement`) added an explicit +platform-owner bootstrap operation, a permission registry, and HTTP enforcement wired into +`apps/api`. This ADR records the durable architectural decisions both slices established, since +together they fix a shape future changes must not casually violate: what a permission is, who can +grant one, how a platform owner is established, and how any of this reaches HTTP. + +## Decision + +### Core owns authorization independently of Hono, Surfaces, and modules + +Authorization is a Core platform concern, exactly like identity. The schema +(`core/src/authorization/schema.ts`), evaluator (`core/src/authorization/evaluator.ts`), store +(`core/src/authorization/store.ts`), permission registry (`core/src/authorization/permissions.ts`), +and bootstrap operation (`core/src/authorization/bootstrap.ts`) import nothing from `hono`, +`apps/api`, or any module, and are independently testable without an HTTP transport. `apps/api` +consumes Core's authorization surface the same way it already consumes identity: through explicit, +injected composition (`apps/api/src/authorization-middleware.ts`, +`apps/api/src/grants-routes.ts`), never the reverse. Modules cannot import Core at all under +`scripts/check-boundaries.ts`, so this holds structurally, not just by convention. + +### Permission IDs are exact, registered, and deny by default + +A `PermissionId` (`@ekklesia/contracts`) is a validated, namespaced string +(`core.authorization.grants.manage`), but being well-formed is not the same as being *registered*. +`PermissionRegistry` (`core/src/authorization/permissions.ts`) is the trusted, immutable, +composition-time-built source of which permissions actually exist and what baseline each requires. +Matching is always exact string equality — there is no wildcard, prefix, or namespace-inheritance +matching anywhere in the evaluator or the store; a permission `core.authorization.grants.manage` +never implies or is implied by `core.authorization.grants.*` or `core.authorization.*`. An unknown +or malformed permission id is treated identically to a denied decision, never a distinct "this +would work if declared" response — deny-by-default holds even for permissions nobody has declared +yet. + +### Permissions declare one trusted baseline: public, authenticated, or restricted + +Every registered permission declares exactly one `PermissionBaseline` at registration time. The +evaluator (`core/src/authorization/evaluator.ts`) still accepts a baseline as part of its own +request shape — that is pass 1's evaluator contract, unchanged — but the only caller allowed to +supply it in this repository is Core's own trusted registry lookup +(`requirePermission` in `apps/api/src/authorization-middleware.ts`); no HTTP route reads a baseline +from a request header, query parameter, or body. `public` always allows; `authenticated` requires a +non-anonymous principal and nothing else; `restricted` requires an authenticated principal *and* +either platform-owner authority or an active matching grant — the baseline alone never satisfies a +restricted check. + +### Unknown permissions and client-supplied baselines fail closed + +Two independent fail-closed layers exist on purpose, not one relied on twice. The registry itself +(`PermissionRegistry.get`/`.has`) returns `undefined`/`false` for anything malformed or +unregistered. `requirePermission` treats a registry miss as an immediate sanitized `403`, before +principal resolution or evaluation ever runs. Separately, `createAuthorizationEvaluator` itself +denies (without throwing) any malformed or unrecognized `permission`/`baseline`/`scope` it +receives, so a future caller that reaches the evaluator directly — bypassing the registry, for +example a different transport Core does not yet have — inherits the same deny-by-default guarantee +rather than depending on the registry check having already run. + +### Explicit and relationship-derived grants share persistence but have distinct source kinds + +`ekklesia_core_grant` is one table for both explicit grants (an administrator naming a subject +user, permission, and optional scope directly) and relationship-derived grants (a future module +operation establishing access automatically from a relationship it owns, for example a roster +assignment). A `source_kind` column, constrained to exactly `'explicit'` or `'relationship'`, +distinguishes them at the row level; nothing else about how a grant is stored or evaluated differs +by source kind. This lets both kinds share one evaluator code path and one audit trail while still +letting HTTP enforcement treat them very differently (see below). + +### Unscoped grants satisfy scoped or unscoped checks; scoped grants satisfy only the exact matching scope + +`AuthorizationStore.hasActiveGrant` (`core/src/authorization/store.ts`) matches a grant whose +`scope` column is `NULL` against *any* check, scoped or unscoped, but matches a grant with a +non-null `scope` only against a check requesting that exact scope string. An unscoped grant is +strictly broader than any scoped grant of the same permission; a scoped grant never leaks into an +unscoped check for the same permission. There is no partial, prefix, or hierarchical scope +matching. + +### Revoked and expired grants never authorize + +`hasActiveGrant`'s query requires `revoked_at IS NULL` and either no `expires_at` or +`expires_at` in the future, evaluated against an injectable clock (`now`) rather than each caller's +own wall-clock read. Revocation is soft: `AuthorizationStore.revokeGrant` sets `revoked_at` and the +revoking actor rather than deleting the row, so the audit trail and the grant's own history remain +queryable after revocation, but a revoked row can never again satisfy an authorization check. + +### Platform-owner authority is represented separately from grants and is not a wildcard permission + +`ekklesia_core_platform_owner` is its own table, not a synthetic all-permissions grant row. +`createAuthorizationEvaluator` checks `platformOwners.isPlatformOwner(userId)` as an independent +`OR` alongside `hasActiveGrant`, so owner status bypasses the grant/scope machinery entirely rather +than being implemented as a grant with a special scope or permission value that other code could +accidentally match against. This keeps "is a platform owner" and "has this specific permission" +structurally distinct, so a future owner-management feature can reason about the one table without +touching grant semantics. + +### Platform-owner bootstrap is explicit, CLI-only, globally single-use, permanently consumed even after revocation, concurrency-safe, and audited atomically + +There is no signup-order or first-user inference anywhere in this repository. The only way to +establish a platform owner is `pnpm authorization:bootstrap-owner -- --user-id +--confirm-user-id ` (`core/src/cli/bootstrap-platform-owner.ts`, +`core/src/authorization/bootstrap.ts`), never an HTTP endpoint. "Bootstrap has already been +consumed" is defined as *any row ever created* in `ekklesia_core_platform_owner`, for any user, +including one whose `revoked_at` is set — there is no path in this slice that re-opens bootstrap +after a revocation; a later, separate owner-management feature (not part of this slice) is how a +platform owner would ever be re-established. The single-use check, the target user's existence +check, the owner-row insert, and its audit-row insert are one PostgreSQL transaction, serialized by +a transaction-scoped advisory lock (`pg_advisory_xact_lock`, a key pair distinct from the migration +runner's own lock) acquired as the transaction's first statement, so two concurrent bootstrap +attempts can never both observe "no owner yet" — exactly one succeeds, proven against real +PostgreSQL. Any failure path (nonexistent user, lost race, audit failure) rolls back the entire +transaction, leaving no partial owner or bootstrap state. + +### Identity and Authorization use typed Drizzle views over one physical PostgreSQL pool in apps/api + +`apps/api/src/database.ts`'s `createApiDatabase` constructs exactly one validated `pg.Pool` +(`core/src/database/pool.ts`) and wraps it with two independent typed Drizzle views +(`createIdentityDrizzleView`, `createAuthorizationDrizzleView`) rather than owning two pools. This +is additive: `createIdentityDrizzleDatabase` and `createAuthorizationDrizzleDatabase` (each owning +a private pool) remain available and unaffected for callers — the bootstrap CLI included — that +only need one domain and have no reason to share a connection. `apps/api` closes the one shared +pool exactly once, on graceful shutdown and on any startup failure after the pool was already +constructed. + +### Cookie-authenticated authorization mutations require an exact trusted Origin + +`requireTrustedOrigin` (`apps/api/src/authorization-middleware.ts`) requires an exact match against +the same trusted-origin set already used for CORS and Better Auth's own checks (ADR 0011, ADR +0012) on every grant-mutation route. This is deliberately a server-side check distinct from CORS: +CORS response headers only stop a browser from *reading* a cross-origin response, not from +*sending* the state-changing request in the first place. A missing or untrusted origin is rejected +before the permission check or any state change. Non-browser, non-cookie automation (service +accounts/API tokens) has no way to satisfy this check and is explicitly deferred, not silently +exempted. + +### Generic HTTP grant management handles only explicit grants + +`POST /api/v1/grants` always sets `source_kind` to `'explicit'` server-side — the request body has +no field that can influence it — and `POST /api/v1/grants/:grantId/revoke` always filters its +mutation to `source_kind = 'explicit'`. A relationship-derived grant's id, presented to the revoke +route, matches zero rows and returns the same `404` a nonexistent grant id would, indistinguishably +from the caller's perspective. The generic HTTP API also never accepts `actor`, `sourceKind`, +`sourceRef`, any audit field, a grant id, `createdAt`, or `revokedAt` in a mutation body: the +allowlist rejects the entire request if any other key is present, so a well-formed-looking request +cannot smuggle a server-derived field past validation. + +### Relationship-derived grants may be created or revoked only by the trusted module operation owning that relationship + +No code path in this repository creates a `source_kind = 'relationship'` grant yet — there is no +roster or similar module operation in this slice — but the architectural boundary is fixed here: +when that operation exists, it is the only trusted caller for that grant's lifecycle, exercised +through `AuthorizationStore` directly (as pass 2's own integration tests do to seed one), never +through the generic `/api/v1/grants` HTTP surface. Nothing about this ADR assumes what that future +module operation looks like — only that it does not go through the explicit-grant API. + +### Deferred + +Roles, groups, teams, and tenants; an owner-management HTTP endpoint (revoking or re-granting +platform-owner status over HTTP); service accounts and API tokens for non-browser automation; and +module permission discovery/registration (a mechanism for a module to contribute its own +declarations to a combined permission registry) are all deliberately out of scope for Phase 4C and +remain later work. + +## Alternatives + +- Representing platform-owner status as a special grant row (a synthetic `*` permission, or a + reserved scope value meaning "everything") was rejected: it would make "is this row an owner + grant or a real permission grant" a runtime distinction every future grant-handling code path + would need to remember to check, rather than a structural one enforced by table shape. +- Implementing bootstrap as "check for an existing owner, then call the same `grantPlatformOwner` + function grants already use" was rejected: composing two separately-transacted operations leaves + a window between the check and the grant where a concurrent caller could observe the same "no + owner yet" state, which is exactly the race a single-use bootstrap must not allow. A dedicated, + single-transaction operation closes that window structurally instead of relying on discipline at + every call site. +- Allowing the generic explicit-grant HTTP API to also manage relationship-derived grants (with an + optional `sourceKind` field in the request body) was rejected: it would let any caller holding + `core.authorization.grants.manage` silently unwind access a module relationship established for + reasons the generic API has no way to evaluate — for example, revoking a roster-derived grant + without the roster module ever knowing its own invariant was violated. +- A second physical PostgreSQL pool dedicated to authorization in `apps/api` (mirroring identity's + own private-pool factory) was rejected in favor of one shared pool: `apps/api` is one process + with one connection budget, and two independently-sized pools would double configured connection + limits for no isolation benefit within a single process. + +## Consequences + +Every HTTP route that needs authorization now goes through one reusable middleware +(`requirePermission`) backed by one trusted registry, rather than each route independently deciding +how to check a permission. Establishing a platform owner is a deliberate, auditable, one-time +operator action with no ambiguity about who holds it or how it was granted. Future work extending +authorization — module-declared permissions, relationship-derived grants, roles — has a fixed shape +to extend rather than a green field: new permissions register through the same +`createPermissionRegistry` constructor, new grant sources use the existing `source_kind` column +and audit table, and any new HTTP surface reuses `requirePermission`/`requireTrustedOrigin` rather +than reimplementing enforcement. No identity behavior, route, or response shape from ADR 0010, +0011, or 0012 changed. diff --git a/docs/architecture/decisions/README.md b/docs/architecture/decisions/README.md index e257026..49a90cd 100644 --- a/docs/architecture/decisions/README.md +++ b/docs/architecture/decisions/README.md @@ -21,3 +21,4 @@ ADRs capture durable choices that affect more than one package or phase. - [0010: Core identity architecture and Better Auth adoption](0010-core-identity-and-better-auth-adoption.md) — Accepted - [0011: Identity HTTP transport and public enrollment policy](0011-identity-http-transport-and-public-enrollment.md) — Accepted - [0012: Production-equivalent identity origin/CSRF protection under test](0012-identity-origin-csrf-protection-under-test.md) — Accepted +- [0013: Core authorization, permission registry, and platform-owner bootstrap](0013-core-authorization-and-owner-bootstrap.md) — Accepted diff --git a/docs/development/authorization.md b/docs/development/authorization.md index ad7d996..01102ec 100644 --- a/docs/development/authorization.md +++ b/docs/development/authorization.md @@ -3,8 +3,10 @@ Phase 4C establishes Core's authorization foundation and, in a second slice, wires it into `apps/api` over HTTP. This page covers both: the schema/evaluator/store/registry Core owns, the platform-owner bootstrap operation, and the enforcement/endpoints `apps/api` exposes on top of it. -See [Identity](identity.md) for the identity foundation this builds on and [API](api.md) for the -HTTP transport it is mounted into. +See [Identity](identity.md) for the identity foundation this builds on, [API](api.md) for the +HTTP transport it is mounted into, and [ADR +0013](../architecture/decisions/0013-core-authorization-and-owner-bootstrap.md) for the durable +architectural decisions this page describes in more implementation detail. ## What this is, and is not diff --git a/docs/development/repository-workflow.md b/docs/development/repository-workflow.md index 6438197..1d1e266 100644 --- a/docs/development/repository-workflow.md +++ b/docs/development/repository-workflow.md @@ -106,8 +106,9 @@ Data definitions ✓ Schemas validated 0 modules · 0 tables ✓ Migrations validated - 1 migration · checksums valid + 2 migrations · checksums valid core:0000_identity_core_tables · f8f2086ef06f + core:0001_authorization_core_tables · 830e1fbc26d0 PostgreSQL ○ Connection check skipped From df2af0a3e0347f4ddc20305e6c3cb46f105a3ab8 Mon Sep 17 00:00:00 2001 From: ekklesia Date: Thu, 23 Jul 2026 11:44:45 -0500 Subject: [PATCH 4/4] fix: enforce registered authorization permissions Two related fail-closed gaps let a well-formed but never-declared permission id slip through: - POST /api/v1/grants created explicit grants for any well-formed PermissionId without checking the injected permission registry, allowing dormant grants for invented identifiers that could become effective the moment those identifiers were registered later. - createAuthorizationEvaluator validated the permission id only inside the restricted-baseline branch, so a malformed permission combined with the public or authenticated baseline could still return allowed. Both now fail closed: the grants route checks the target permission against the trusted registry before calling store.createGrant (sanitized 403, no store or audit write), and the evaluator validates permission shape before any baseline is allowed to return an allowed decision, without adding a registry dependency to the evaluator itself. The live integration lifecycle test switched from an unregistered calendar.events.read to the registered core.authorization.grants.manage permission, since module permission registration does not exist yet. --- apps/api/src/grants-routes.ts | 10 ++++ apps/api/tests/api-http.integration.ts | 56 ++++++++++++++++++- apps/api/tests/grants-routes.test.ts | 33 +++++++++-- core/src/authorization/evaluator.ts | 24 +++++--- core/tests/authorization-evaluator.test.ts | 44 +++++++++++++++ ...-core-authorization-and-owner-bootstrap.md | 37 +++++++++--- docs/development/authorization.md | 49 +++++++++++++--- 7 files changed, 226 insertions(+), 27 deletions(-) diff --git a/apps/api/src/grants-routes.ts b/apps/api/src/grants-routes.ts index 78fdd4d..2f3d0c0 100644 --- a/apps/api/src/grants-routes.ts +++ b/apps/api/src/grants-routes.ts @@ -164,6 +164,16 @@ export function createGrantsRoutes(options: CreateGrantsRoutesOptions): Hono { return c.json({ code: "INVALID_REQUEST" }, 400); } + // A well-formed PermissionId is not necessarily a *registered* one. Only the trusted registry + // (never the request) decides what may be granted — the same registry `requireManagePermission` + // already consults for the caller's own `core.authorization.grants.manage` check — so a caller + // cannot pre-create a dormant grant for an invented identifier that later becomes effective the + // moment someone registers it. Sanitized identically to any other denial: this must never reveal + // whether the identifier was malformed (400, above) or simply unregistered (403, here). + if (!options.registry.has(parsed.permission)) { + return c.json({ code: "AUTHORIZATION_DENIED" }, 403); + } + try { const grant = await options.store.createGrant({ actor: createUserActor(principal.userId), diff --git a/apps/api/tests/api-http.integration.ts b/apps/api/tests/api-http.integration.ts index 652e291..c997385 100644 --- a/apps/api/tests/api-http.integration.ts +++ b/apps/api/tests/api-http.integration.ts @@ -832,7 +832,13 @@ describe.sequential("live API — platform owner and delegated grant management" const ownerEmail = "authz-owner@example.test"; const managerEmail = "authz-manager@example.test"; const password = "correct horse battery staple"; - const testPermission = "calendar.events.read"; + // Module permission registration does not exist yet, so `core.authorization.grants.manage` — + // the one permission `CORE_PERMISSION_REGISTRY` actually declares — stands in for "some + // registered permission" throughout this scenario's explicit-grant lifecycle. + const testPermission = "core.authorization.grants.manage"; + // Well-formed (passes `PermissionId` shape validation) but never registered — used to prove the + // live route rejects it even for a caller who otherwise has full grant-management authority. + const unregisteredPermission = "calendar.events.read"; it("starts a freshly migrated API and signs up the eventual owner and eventual delegated manager", async () => { database = await createDisposableDatabase("authz_http"); @@ -913,6 +919,54 @@ describe.sequential("live API — platform owner and delegated grant management" expect(response.status).toBe(200); }); + it("rejects a well-formed but unregistered permission even from a caller with full grant-management authority, and writes no grant or audit row", async () => { + const admin = await openAdministrativeClient(database.url); + try { + const grantCountBefore = await safeQuery<{ count: string }>( + admin, + `select count(*)::text as count from "ekklesia_core_grant" where permission_id = $1`, + [unregisteredPermission], + ); + const auditCountBefore = await safeQuery<{ count: string }>( + admin, + `select count(*)::text as count from "ekklesia_core_authorization_audit" where permission_id = $1`, + [unregisteredPermission], + ); + expect(grantCountBefore[0]?.count).toBe("0"); + expect(auditCountBefore[0]?.count).toBe("0"); + + const response = await fetch(`${origin}/api/v1/grants`, { + body: JSON.stringify({ + permission: unregisteredPermission, + subjectUserId: managerUserId, + }), + headers: { + "content-type": "application/json", + cookie: managerCookie, + origin: dashboardOrigin, + }, + method: "POST", + }); + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" }); + + const grantCountAfter = await safeQuery<{ count: string }>( + admin, + `select count(*)::text as count from "ekklesia_core_grant" where permission_id = $1`, + [unregisteredPermission], + ); + const auditCountAfter = await safeQuery<{ count: string }>( + admin, + `select count(*)::text as count from "ekklesia_core_authorization_audit" where permission_id = $1`, + [unregisteredPermission], + ); + expect(grantCountAfter[0]?.count).toBe("0"); + expect(auditCountAfter[0]?.count).toBe("0"); + } finally { + await safeEnd(admin); + } + }); + it("creates an explicit grant via HTTP, deriving sourceKind and actor server-side, and records an atomic audit entry", async () => { const response = await fetch(`${origin}/api/v1/grants`, { body: JSON.stringify({ diff --git a/apps/api/tests/grants-routes.test.ts b/apps/api/tests/grants-routes.test.ts index 0cd368f..0b1b37c 100644 --- a/apps/api/tests/grants-routes.test.ts +++ b/apps/api/tests/grants-routes.test.ts @@ -9,6 +9,7 @@ import { type AuthorizationEvaluator, type AuthorizationStore, AuthorizationStoreError, + CORE_AUTHORIZATION_GRANTS_MANAGE, CORE_PERMISSION_REGISTRY, } from "@ekklesia/core/authorization"; import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity"; @@ -20,7 +21,13 @@ const trustedOrigin = "https://dashboard.example.test"; const untrustedOrigin = "https://evil.example.test"; const manager = createAuthenticatedPrincipal("manager_1"); const subjectUserId = parseEkklesiaUserId("subject_1"); +// Well-formed but never declared in `CORE_PERMISSION_REGISTRY` — used to prove the routes reject +// (or, for listing, still return) an unregistered permission id rather than assuming registration. const permission = parsePermissionId("calendar.events.read"); +// A permission `CORE_PERMISSION_REGISTRY` actually declares, so tests that must reach +// `store.createGrant`/`store.revokeGrant` can do so without also depending on module permission +// registration, which does not exist yet. +const registeredPermission = CORE_AUTHORIZATION_GRANTS_MANAGE; const grantId = parseGrantId("11111111-1111-1111-1111-111111111111"); function fakeRuntime(overrides: Partial = {}): EkklesiaIdentityRuntime { @@ -170,7 +177,7 @@ describe("POST /api/v1/grants", () => { const createdGrant = { createdAt: new Date("2026-01-01T00:00:00.000Z"), id: grantId, - permission, + permission: registeredPermission, scope: parseAuthorizationScope("calendar-event:123"), sourceKind: "explicit" as const, userId: subjectUserId, @@ -179,7 +186,11 @@ describe("POST /api/v1/grants", () => { const { app } = buildApp({ store }); const response = await app.request("/api/v1/grants", { - body: JSON.stringify({ permission, scope: "calendar-event:123", subjectUserId }), + body: JSON.stringify({ + permission: registeredPermission, + scope: "calendar-event:123", + subjectUserId, + }), headers: { "content-type": "application/json", origin: trustedOrigin }, method: "POST", }); @@ -190,7 +201,7 @@ describe("POST /api/v1/grants", () => { expect(store.createGrant).toHaveBeenCalledWith( expect.objectContaining({ actor: { type: "user", userId: manager.userId }, - permission, + permission: registeredPermission, scope: "calendar-event:123", sourceKind: "explicit", userId: subjectUserId, @@ -198,6 +209,20 @@ describe("POST /api/v1/grants", () => { ); }); + it("rejects a well-formed but unregistered permission with the same sanitized denial as an unauthorized caller, and never calls the store", async () => { + const { app, store } = buildApp({}); + + const response = await app.request("/api/v1/grants", { + body: JSON.stringify({ permission, subjectUserId }), + headers: { "content-type": "application/json", origin: trustedOrigin }, + method: "POST", + }); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" }); + expect(store.createGrant).not.toHaveBeenCalled(); + }); + it.each([ "actor", "owner", @@ -256,7 +281,7 @@ describe("POST /api/v1/grants", () => { const { app } = buildApp({ store }); const response = await app.request("/api/v1/grants", { - body: JSON.stringify({ permission, subjectUserId }), + body: JSON.stringify({ permission: registeredPermission, subjectUserId }), headers: { "content-type": "application/json", origin: trustedOrigin }, method: "POST", }); diff --git a/core/src/authorization/evaluator.ts b/core/src/authorization/evaluator.ts index ed8a95e..8b8f43a 100644 --- a/core/src/authorization/evaluator.ts +++ b/core/src/authorization/evaluator.ts @@ -32,8 +32,13 @@ export class AuthorizationEvaluationError extends Error { 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." */ + /** Validated internally via the branded `PermissionId` schema *before any baseline can return an + * allowed decision* — a malformed or unrecognizable value denies rather than throwing, for + * `public` and `authenticated` baselines exactly as much as `restricted`. This is shape + * validation only: the evaluator has no registry and cannot tell a well-formed-but-unregistered + * permission from one that is actually registered. Only a trusted caller consulting a + * `PermissionRegistry` (`core/src/authorization/permissions.ts`) before calling `authorize` can + * make that distinction — see that module and `apps/api/src/authorization-middleware.ts`. */ readonly permission: unknown; /** Validated internally against the known `public`/`authenticated`/`restricted` set; anything * else denies rather than throwing, for the same fail-closed reason. */ @@ -80,6 +85,16 @@ export function createAuthorizationEvaluator( return AUTHORIZATION_DENIED; } + // Shape-validated before any baseline is allowed to return `allowed` — a malformed + // permission id must deny under `public` and `authenticated` exactly as much as under + // `restricted`, even though neither of the first two ever reads `parsedPermission.data`. + // This is a pure, synchronous check: it never touches the grant or platform-owner ports, so + // `public`/`authenticated` still resolve with no database lookup at all. + const parsedPermission = safeParsePermissionId(request.permission); + if (!parsedPermission.success) { + return AUTHORIZATION_DENIED; + } + if (parsedBaseline.data === "public") { return AUTHORIZATION_ALLOWED; } @@ -96,11 +111,6 @@ export function createAuthorizationEvaluator( 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); diff --git a/core/tests/authorization-evaluator.test.ts b/core/tests/authorization-evaluator.test.ts index 8c9ba86..bbc41ca 100644 --- a/core/tests/authorization-evaluator.test.ts +++ b/core/tests/authorization-evaluator.test.ts @@ -115,6 +115,28 @@ describe("Core authorization evaluator", () => { expect(grants.calls).toHaveLength(0); expect(owners.calls).toHaveLength(0); }); + + it("denies an anonymous principal when the permission id is malformed, without throwing or querying grants or ownership", async () => { + const decision = await evaluator().authorize({ + baseline: "public", + permission: "not a valid permission id", + principal: ANONYMOUS_PRINCIPAL, + }); + expect(decision).toEqual({ allowed: false }); + expect(grants.calls).toHaveLength(0); + expect(owners.calls).toHaveLength(0); + }); + + it("denies an authenticated principal when the permission id is malformed", async () => { + const decision = await evaluator().authorize({ + baseline: "public", + permission: "not a valid permission id", + principal: createAuthenticatedPrincipal(userId), + }); + expect(decision).toEqual({ allowed: false }); + expect(grants.calls).toHaveLength(0); + expect(owners.calls).toHaveLength(0); + }); }); describe("authenticated baseline", () => { @@ -145,6 +167,28 @@ describe("Core authorization evaluator", () => { expect(grants.calls).toHaveLength(0); expect(owners.calls).toHaveLength(0); }); + + it("denies an authenticated principal when the permission id is malformed, without throwing or querying grants or ownership", async () => { + const decision = await evaluator().authorize({ + baseline: "authenticated", + permission: "not a valid permission id", + principal: createAuthenticatedPrincipal(userId), + }); + expect(decision).toEqual({ allowed: false }); + expect(grants.calls).toHaveLength(0); + expect(owners.calls).toHaveLength(0); + }); + + it("still denies an anonymous principal when the permission id is malformed too", async () => { + const decision = await evaluator().authorize({ + baseline: "authenticated", + permission: "not a valid permission id", + principal: ANONYMOUS_PRINCIPAL, + }); + expect(decision).toEqual({ allowed: false }); + expect(grants.calls).toHaveLength(0); + expect(owners.calls).toHaveLength(0); + }); }); describe("restricted baseline", () => { diff --git a/docs/architecture/decisions/0013-core-authorization-and-owner-bootstrap.md b/docs/architecture/decisions/0013-core-authorization-and-owner-bootstrap.md index afcdad4..5f84daa 100644 --- a/docs/architecture/decisions/0013-core-authorization-and-owner-bootstrap.md +++ b/docs/architecture/decisions/0013-core-authorization-and-owner-bootstrap.md @@ -46,6 +46,18 @@ or malformed permission id is treated identically to a denied decision, never a would work if declared" response — deny-by-default holds even for permissions nobody has declared yet. +Registration is enforced everywhere a permission id can originate from outside Core's own trusted +route wiring, not only for the caller's own required permission. `requirePermission` +(`apps/api/src/authorization-middleware.ts`) checks the registry for the permission a *route* +requires; `POST /api/v1/grants` (`apps/api/src/grants-routes.ts`) separately checks the registry +for the *target* permission a caller is requesting be granted to someone else, since that value +comes from the request body rather than route wiring. Without the second check, a caller with +`core.authorization.grants.manage` could create a stored grant for a permission id that is merely +well-formed, not registered — a dormant grant that would silently become effective the moment +that identifier was registered later, with no further action or review at registration time. Both +checks reject with the identical sanitized `403 {"code":"AUTHORIZATION_DENIED"}` an ordinary denial +would return, and neither ever calls into the store on rejection. + ### Permissions declare one trusted baseline: public, authenticated, or restricted Every registered permission declares exactly one `PermissionBaseline` at registration time. The @@ -60,14 +72,23 @@ restricted check. ### Unknown permissions and client-supplied baselines fail closed -Two independent fail-closed layers exist on purpose, not one relied on twice. The registry itself -(`PermissionRegistry.get`/`.has`) returns `undefined`/`false` for anything malformed or -unregistered. `requirePermission` treats a registry miss as an immediate sanitized `403`, before -principal resolution or evaluation ever runs. Separately, `createAuthorizationEvaluator` itself -denies (without throwing) any malformed or unrecognized `permission`/`baseline`/`scope` it -receives, so a future caller that reaches the evaluator directly — bypassing the registry, for -example a different transport Core does not yet have — inherits the same deny-by-default guarantee -rather than depending on the registry check having already run. +Two independent fail-closed layers exist on purpose, not one relied on twice, and they check +different things. The registry itself (`PermissionRegistry.get`/`.has`) returns `undefined`/`false` +for anything malformed or unregistered — this is the only layer that knows what is *registered*. +`requirePermission` treats a registry miss as an immediate sanitized `403`, before principal +resolution or evaluation ever runs; `POST /api/v1/grants` performs the equivalent registry check +for the target permission named in its body (see above). Separately, `createAuthorizationEvaluator` +independently re-validates permission *shape* — it holds no registry and cannot recognize +registration at all — denying (without throwing) any malformed or unrecognized +`permission`/`baseline`/`scope` it receives. This shape check runs, and can deny, **before any +baseline is allowed to return an allowed decision** — `public` and `authenticated` are not +exempt, even though neither reads the permission value for any other reason — so a future caller +that reaches the evaluator directly, bypassing the registry entirely (for example a different +transport Core does not yet have), inherits the same fail-closed guarantee for malformed input +rather than depending on a registry check having already run upstream. The evaluator's shape check +and the registry's registration check are deliberately not merged into one: only the registry can +answer "is this permission registered," and the evaluator must remain usable — and safe against +malformed input — by a caller that has no registry to consult at all. ### Explicit and relationship-derived grants share persistence but have distinct source kinds diff --git a/docs/development/authorization.md b/docs/development/authorization.md index 01102ec..177eda3 100644 --- a/docs/development/authorization.md +++ b/docs/development/authorization.md @@ -53,6 +53,33 @@ matter: Module-owned permission declarations and a discovery/composition path for them are out of scope for this slice (deferred to the Module SDK work). +### Two layers, not one: shape versus registration + +`createAuthorizationEvaluator` (`core/src/authorization/evaluator.ts`) has no registry and is +never given one — it independently re-validates `permission`, `baseline`, and `scope` as a pure +shape check (`safeParsePermissionId`/`permissionBaselineSchema`/`safeParseAuthorizationScope`) and +denies a malformed value under **every** baseline, including `public` and `authenticated`, before +any baseline is allowed to return an allowed decision. This is defense in depth for a future +caller that reaches the evaluator directly, not a substitute for the registry: the evaluator has +no way to tell a well-formed-but-unregistered permission id (for example a plausible-looking +`calendar.events.read` that nothing has ever declared) from a genuinely registered one — it can +only reject what is not even shaped like a `PermissionId` at all. + +*Registration* — "does this permission actually exist" — is exclusively the registry's +responsibility, consulted by every trusted caller before the evaluator ever runs: +`requirePermission` (`apps/api/src/authorization-middleware.ts`) looks up the *caller's own* +required permission (for example `core.authorization.grants.manage`, always a hardcoded route +constant, never client-supplied) in the registry before resolving a principal or evaluating +anything; `POST /api/v1/grants` (`apps/api/src/grants-routes.ts`) separately looks up the *target* +permission being granted — the one named in the request body — in the same registry, after body +shape-validation and before ever calling `store.createGrant`, so a caller cannot create a dormant +grant for an invented permission id that might later become effective the moment someone actually +registers it. A malformed target permission (fails `PermissionId` shape validation) still resolves +to the body-level `400 {"code":"INVALID_REQUEST"}` documented below, unchanged; a well-formed but +unregistered target permission instead receives the identical sanitized +`403 {"code":"AUTHORIZATION_DENIED"}` a genuinely denied caller would, so that response never +reveals whether the caller was denied or the permission was simply never registered. + ## Platform-owner bootstrap There is no "first user becomes owner" inference anywhere in this repository, and no HTTP @@ -191,7 +218,7 @@ cannot smuggle a server-derived field through a request that otherwise looks wel | Field | Required | Notes | | --- | --- | --- | | `subjectUserId` | Yes | Must parse as a valid `EkklesiaUserId`. | -| `permission` | Yes | Must parse as a valid `PermissionId`. | +| `permission` | Yes | Must parse as a valid `PermissionId` **and** be registered in `CORE_PERMISSION_REGISTRY` — see [Two layers, not one](#two-layers-not-one-shape-versus-registration). A malformed value is a `400`; a well-formed but unregistered one is a `403`. | | `scope` | No | Must parse as a valid, non-empty `AuthorizationScope` if present. | | `expiresAt` | No | An ISO date string if present. | @@ -225,7 +252,7 @@ A grant, wherever returned: | --- | --- | | `200` / `201` | Success. | | `400 {"code":"INVALID_REQUEST"}` | Malformed query/body/param (including a disallowed extra field). | -| `403 {"code":"AUTHORIZATION_DENIED"}` | Caller lacks `core.authorization.grants.manage`, or the permission id was unregistered. | +| `403 {"code":"AUTHORIZATION_DENIED"}` | Caller lacks `core.authorization.grants.manage`; the caller's own required permission id was unregistered; or, for `POST /api/v1/grants`, the well-formed *target* permission named in the body is not registered. | | `403 {"code":"UNTRUSTED_ORIGIN"}` | Missing or untrusted `Origin` on a mutation route. | | `404 {"code":"AUTHORIZATION_GRANT_NOT_FOUND"}` | The targeted grant id does not exist as an *explicit* grant — this is also what revoking a real relationship-derived grant's id looks like, indistinguishably. | | `503` | Principal resolution, evaluation, or store failure (`PRINCIPAL_RESOLUTION_FAILED`, `AUTHORIZATION_EVALUATION_FAILED`, or an `AuthorizationStoreError` code). | @@ -273,15 +300,23 @@ there are or whether the failure happened during startup. - `apps/api/tests/authorization-middleware.test.ts` — allow/deny/unregistered/infrastructure- failure paths for `requirePermission`, and `requireTrustedOrigin`'s allow/reject behavior. - `apps/api/tests/grants-routes.test.ts` — all three routes' allow/deny paths, disallowed-field - and malformed-field rejection, exact server-derived-argument assertions, sourceKind-filtered - revoke returning 404 for a non-explicit grant, origin enforcement, and store-error mapping. + and malformed-field rejection, a well-formed but unregistered target permission rejected with + `403` and `store.createGrant` never called, exact server-derived-argument assertions, + sourceKind-filtered revoke returning 404 for a non-explicit grant, origin enforcement, and + store-error mapping. - `apps/api/tests/lifecycle.test.ts` — shared-pool-closes-exactly-once coverage described above. +- `core/tests/authorization-evaluator.test.ts` — includes a malformed permission id denying under + every baseline (`public`, `authenticated`, `restricted`), never just `restricted`, and never + consulting the grant or platform-owner ports for `public`/`authenticated`. - `apps/api/tests/api-http.integration.ts` (live PostgreSQL) — end-to-end: anonymous/non-owner denial, real bootstrap against the running API's own database, owner authority over the HTTP grants API, the owner delegating `core.authorization.grants.manage` to another account and that - account then managing grants as a non-owner, a create/list/revoke cycle with audit-trail - verification, disallowed-field rejection, missing/untrusted-origin rejection, a real - relationship-derived grant proven invisible to LIST and unreachable by REVOKE, and an + account then managing grants as a non-owner, a well-formed but unregistered target permission + rejected with `403` (with no grant or audit row written) even for a caller with full + grant-management authority, a create/list/revoke cycle with audit-trail verification (using + `core.authorization.grants.manage` itself as the granted permission, since module permission + registration does not exist yet), disallowed-field rejection, missing/untrusted-origin rejection, + a real relationship-derived grant proven invisible to LIST and unreachable by REVOKE, and an infrastructure-failure scenario (database dropped out from under a live session) asserting a `503` rather than a false `403` or `200`.