From 72e082ff2f20748852ef891809fb894b2731b6ad Mon Sep 17 00:00:00 2001 From: ekklesia Date: Thu, 23 Jul 2026 10:29:01 -0500 Subject: [PATCH] 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,