diff --git a/apps/api/tests/api-http.integration.ts b/apps/api/tests/api-http.integration.ts index 2161a6a..da3838f 100644 --- a/apps/api/tests/api-http.integration.ts +++ b/apps/api/tests/api-http.integration.ts @@ -188,6 +188,7 @@ async function applyCoreMigrations(url: string): Promise { expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([ "core:0000_identity_core_tables", "core:0001_authorization_core_tables", + "core:0002_authorization_integrity", ]); expect(aggregate.migrations[0]?.checksum).toBe( "f8f2086ef06f7a6943e8ffc4350bb010b37aed04c0a72030d2b153c986b1325f", @@ -195,6 +196,10 @@ async function applyCoreMigrations(url: string): Promise { expect(aggregate.migrations[1]?.checksum).toBe( "830e1fbc26d05af08df8d8710c3d10e1f3016d32c41b8363cb661c49afcbe81e", ); + // Pass 3's new Core migration, pinned the same way: 0000/0001 above must stay byte-identical. + expect(aggregate.migrations[2]?.checksum).toBe( + "63429473118065dfe3877a1baec61b8f61663b88127d9cb17707c859f27f6036", + ); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); } finally { await rm(emptyModulesDirectory, { recursive: true, force: true }); diff --git a/apps/api/tests/module-database.integration.ts b/apps/api/tests/module-database.integration.ts index 97ad02d..864b3c8 100644 --- a/apps/api/tests/module-database.integration.ts +++ b/apps/api/tests/module-database.integration.ts @@ -219,6 +219,7 @@ async function applyCoreMigrations(url: string): Promise { expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([ "core:0000_identity_core_tables", "core:0001_authorization_core_tables", + "core:0002_authorization_integrity", ]); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); } finally { @@ -239,9 +240,11 @@ async function applyMigrationsWithModule(url: string): Promise { platformVersion: "0.1.0", }, }); + // Core migrations aggregate first, exactly once each, and always before any module migration. expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([ "core:0000_identity_core_tables", "core:0001_authorization_core_tables", + "core:0002_authorization_integrity", "db-facade:0000_initial", ]); expect(aggregate.migrations[0]?.checksum).toBe( @@ -250,6 +253,9 @@ async function applyMigrationsWithModule(url: string): Promise { expect(aggregate.migrations[1]?.checksum).toBe( "830e1fbc26d05af08df8d8710c3d10e1f3016d32c41b8363cb661c49afcbe81e", ); + expect(aggregate.migrations[2]?.checksum).toBe( + "63429473118065dfe3877a1baec61b8f61663b88127d9cb17707c859f27f6036", + ); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); } @@ -430,6 +436,9 @@ describe.sequential("live API — module database explicit migration ownership", expect(await migrationHistoryCount(database.url, "core:0001_authorization_core_tables")).toBe( 1, ); + expect(await migrationHistoryCount(database.url, "core:0002_authorization_integrity")).toBe( + 1, + ); } finally { await dropDatabase(database.name); } diff --git a/core/drizzle/0002_authorization_integrity.sql b/core/drizzle/0002_authorization_integrity.sql new file mode 100644 index 0000000..7e0b6bf --- /dev/null +++ b/core/drizzle/0002_authorization_integrity.sql @@ -0,0 +1,13 @@ +CREATE TABLE "ekklesia_core_platform_owner_bootstrap" ( + "id" text PRIMARY KEY NOT NULL, + "consumed_at" timestamp with time zone DEFAULT now() NOT NULL, + "consumed_for_user_id" text, + CONSTRAINT "ekklesia_core_platform_owner_bootstrap_singleton_check" CHECK ("ekklesia_core_platform_owner_bootstrap"."id" = 'singleton') +); +--> statement-breakpoint +ALTER TABLE "ekklesia_core_authorization_audit" DROP CONSTRAINT "ekklesia_core_authorization_audit_subject_user_id_ekklesia_core_user_id_fk"; +--> statement-breakpoint +INSERT INTO "ekklesia_core_platform_owner_bootstrap" ("id") +SELECT 'singleton' +WHERE EXISTS (SELECT 1 FROM "ekklesia_core_platform_owner") +ON CONFLICT ("id") DO NOTHING; diff --git a/core/drizzle/meta/0002_snapshot.json b/core/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..877ad89 --- /dev/null +++ b/core/drizzle/meta/0002_snapshot.json @@ -0,0 +1,710 @@ +{ + "id": "fac36f3d-7551-42df-a967-aad65c2fe7f9", + "prevId": "dfd53f55-6e2a-45e1-9962-469ea6eca6e7", + "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": {}, + "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 + }, + "public.ekklesia_core_platform_owner_bootstrap": { + "name": "ekklesia_core_platform_owner_bootstrap", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "consumed_for_user_id": { + "name": "consumed_for_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ekklesia_core_platform_owner_bootstrap_singleton_check": { + "name": "ekklesia_core_platform_owner_bootstrap_singleton_check", + "value": "\"ekklesia_core_platform_owner_bootstrap\".\"id\" = 'singleton'" + } + }, + "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 856273c..4663ed3 100644 --- a/core/drizzle/meta/_ekklesia.json +++ b/core/drizzle/meta/_ekklesia.json @@ -13,6 +13,12 @@ "destructive": { "mode": "none" } + }, + { + "tag": "0002_authorization_integrity", + "destructive": { + "mode": "none" + } } ] } diff --git a/core/drizzle/meta/_journal.json b/core/drizzle/meta/_journal.json index 3d8715f..c5f1080 100644 --- a/core/drizzle/meta/_journal.json +++ b/core/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1784819635300, "tag": "0001_authorization_core_tables", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1785104670124, + "tag": "0002_authorization_integrity", + "breakpoints": true } ] } diff --git a/core/src/authorization/bootstrap.ts b/core/src/authorization/bootstrap.ts index ab18850..073a7e4 100644 --- a/core/src/authorization/bootstrap.ts +++ b/core/src/authorization/bootstrap.ts @@ -2,7 +2,12 @@ import type { EkklesiaUserId } from "@ekklesia/contracts"; import { eq, sql } from "drizzle-orm"; import { ekklesiaCoreUser } from "../identity/schema.js"; import type { AuthorizationDrizzleDatabase } from "./database.js"; -import { ekklesiaCoreAuthorizationAudit, ekklesiaCorePlatformOwner } from "./schema.js"; +import { + ekklesiaCoreAuthorizationAudit, + ekklesiaCorePlatformOwner, + ekklesiaCorePlatformOwnerBootstrap, + PLATFORM_OWNER_BOOTSTRAP_SINGLETON_ID, +} from "./schema.js"; export type PlatformOwnerBootstrapErrorCode = | "PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED" @@ -54,17 +59,25 @@ export interface BootstrapPlatformOwnerInput { /** * Core's dedicated, Hono-independent platform-owner bootstrap operation. Deliberately not a - * composition of "check, then call the existing grant function": the single-use check, the target + * composition of "check, then call the existing grant function": the single-use claim, the target * user's existence check, the owner-grant insert, and its audit entry are all one PostgreSQL * transaction, serialized by a transaction-scoped advisory lock acquired as the very first - * statement — so two concurrent callers can never both observe "no owner yet" and both insert. + * statement — so two concurrent callers can never both observe "not yet consumed" and both insert. * The second caller blocks on the lock until the first's transaction commits or rolls back, then * re-evaluates against the now-current state. * - * "Already consumed" is global and permanent: it is true the moment - * `ekklesia_core_platform_owner` has ever held any row for any user, including one whose - * `revoked_at` is set — a later, separate owner-management flow (not part of this slice) is how a - * platform owner would ever be re-established after a revocation, not this command. + * **Single-use authority lives in `ekklesia_core_platform_owner_bootstrap`, never in the continued + * existence of a platform-owner row.** The owner table's `user_id` cascades on user deletion, so + * treating "an owner row exists" as the consumed marker meant deleting the bootstrapped owner's + * user row silently reopened the one operation that establishes a platform's first administrator — + * along with the audit evidence that it had ever run. The dedicated singleton has no user foreign + * key, is never deleted by Core, and therefore survives owner revocation and user deletion alike. + * + * Consumption is deliberately tied to *success*: the sentinel is written inside the same + * transaction as the owner row and its audit entry, so a missing target user, a constraint + * violation, or any other failure rolls the sentinel back with everything else and leaves the one + * legitimate first use still available. There is no reset command, deletion path, HTTP endpoint, + * environment override, or force flag — by design, "consumed" is permanent. */ export async function bootstrapPlatformOwner( db: AuthorizationDrizzleDatabase, @@ -76,11 +89,11 @@ export async function bootstrapPlatformOwner( sql`select pg_advisory_xact_lock(${bootstrapLockNamespace}, ${bootstrapLockKey})`, ); - const existingOwnerRows = await tx - .select({ userId: ekklesiaCorePlatformOwner.userId }) - .from(ekklesiaCorePlatformOwner) + const consumedRows = await tx + .select({ id: ekklesiaCorePlatformOwnerBootstrap.id }) + .from(ekklesiaCorePlatformOwnerBootstrap) .limit(1); - if (existingOwnerRows.length > 0) { + if (consumedRows.length > 0) { throw new PlatformOwnerBootstrapError("PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED"); } @@ -93,6 +106,15 @@ export async function bootstrapPlatformOwner( throw new PlatformOwnerBootstrapError("PLATFORM_OWNER_BOOTSTRAP_USER_NOT_FOUND"); } + // The sentinel is claimed inside the same transaction as everything below it. The advisory + // lock already serializes concurrent callers; the singleton primary key is the independent, + // structural backstop that makes a second row impossible even if a future caller reached + // this insert without the lock. + await tx.insert(ekklesiaCorePlatformOwnerBootstrap).values({ + consumedForUserId: input.userId, + id: PLATFORM_OWNER_BOOTSTRAP_SINGLETON_ID, + }); + await tx.insert(ekklesiaCorePlatformOwner).values({ grantedByActorId: BOOTSTRAP_ACTOR_ID, grantedByActorType: BOOTSTRAP_ACTOR_TYPE, diff --git a/core/src/authorization/schema.ts b/core/src/authorization/schema.ts index 87c05ed..ba90b42 100644 --- a/core/src/authorization/schema.ts +++ b/core/src/authorization/schema.ts @@ -72,11 +72,20 @@ export const ekklesiaCorePlatformOwner = pgTable( ); /** - * 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. + * Append-only *by application convention*: Core's authorization store exposes no update or delete + * path for these rows. That is a property of this codebase, not a database guarantee — no + * PostgreSQL role, trigger, or rule prevents a database administrator, a future migration, or + * anything else holding the same credentials from modifying them. `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. + * + * `subject_user_id` is a **retained historical snapshot**, deliberately carrying no foreign key to + * the live Better Auth user row. It previously cascaded on user deletion, which meant deleting a + * user destroyed exactly the security history describing what that user had been granted and who + * granted it — the audit trail disappeared precisely when it mattered most. The value stays a + * plain, non-null identifier string; no name, email, or other profile data is ever copied here. */ export const ekklesiaCoreAuthorizationAudit = pgTable( "ekklesia_core_authorization_audit", @@ -85,9 +94,7 @@ export const ekklesiaCoreAuthorizationAudit = pgTable( action: text("action").notNull(), actorType: text("actor_type").notNull(), actorId: text("actor_id"), - subjectUserId: text("subject_user_id") - .notNull() - .references(() => ekklesiaCoreUser.id, { onDelete: "cascade" }), + subjectUserId: text("subject_user_id").notNull(), permissionId: text("permission_id"), scope: text("scope"), grantId: uuid("grant_id"), @@ -110,6 +117,40 @@ export const ekklesiaCoreAuthorizationAudit = pgTable( ], ); +/** + * The durable, single-row record meaning "platform-owner bootstrap was successfully consumed." + * + * Bootstrap's single-use authority previously came from the mere existence of a row in + * `ekklesia_core_platform_owner` — which cascades on user deletion, so deleting the bootstrapped + * owner's user row silently reopened the one-time operation that establishes the platform's first + * administrator. This table exists specifically so that authority survives owner revocation and + * user deletion: it has **no foreign key to any user**, and nothing in Core ever deletes from it. + * + * `id` is pinned by a check constraint to the single literal value `'singleton'`, so the primary + * key makes a second row structurally impossible rather than merely unwritten by convention — + * concurrent first attempts collide on the primary key inside their transactions. + * + * `consumed_for_user_id` is an optional historical snapshot of who bootstrap made an owner, in the + * same retained-identifier style as the audit subject and carrying no profile data. It is null for + * an installation whose consumed state was backfilled by migration, because a backfill cannot know + * which of several historical owner rows the original single use actually targeted, and guessing + * one would fabricate history. + */ +export const ekklesiaCorePlatformOwnerBootstrap = pgTable( + "ekklesia_core_platform_owner_bootstrap", + { + id: text("id").primaryKey(), + consumedAt: timestamp("consumed_at", { withTimezone: true }).defaultNow().notNull(), + consumedForUserId: text("consumed_for_user_id"), + }, + (table) => [ + check("ekklesia_core_platform_owner_bootstrap_singleton_check", sql`${table.id} = 'singleton'`), + ], +); + +/** The only value `ekklesia_core_platform_owner_bootstrap.id` may ever hold. */ +export const PLATFORM_OWNER_BOOTSTRAP_SINGLETON_ID = "singleton"; + export const ekklesiaCoreGrantRelations = relations(ekklesiaCoreGrant, ({ one }) => ({ user: one(ekklesiaCoreUser, { fields: [ekklesiaCoreGrant.userId], @@ -127,19 +168,14 @@ export const ekklesiaCorePlatformOwnerRelations = relations( }), ); -export const ekklesiaCoreAuthorizationAuditRelations = relations( - ekklesiaCoreAuthorizationAudit, - ({ one }) => ({ - subject: one(ekklesiaCoreUser, { - fields: [ekklesiaCoreAuthorizationAudit.subjectUserId], - references: [ekklesiaCoreUser.id], - }), - }), -); +// `ekklesiaCoreAuthorizationAudit` deliberately declares no `relations(...)` to the user table: +// its subject is a retained historical identifier that may name a user row that no longer exists, +// so modelling it as a live relation would misdescribe the data. /** Explicit map passed to Drizzle, mirroring `identityAdapterSchema`'s explicit-mapping style. */ export const authorizationSchema = { authorizationAudit: ekklesiaCoreAuthorizationAudit, grant: ekklesiaCoreGrant, platformOwner: ekklesiaCorePlatformOwner, + platformOwnerBootstrap: ekklesiaCorePlatformOwnerBootstrap, } as const; diff --git a/core/tests/authorization-bootstrap.integration.ts b/core/tests/authorization-bootstrap.integration.ts index 246cb94..745cb75 100644 --- a/core/tests/authorization-bootstrap.integration.ts +++ b/core/tests/authorization-bootstrap.integration.ts @@ -3,12 +3,13 @@ import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { parseEkklesiaUserId } from "@ekklesia/contracts"; +import { createUserActor, parseEkklesiaUserId, parsePermissionId } from "@ekklesia/contracts"; import { Client, type QueryResultRow } from "pg"; import { afterAll, describe, expect, it } from "vitest"; import { bootstrapPlatformOwner, createAuthorizationDrizzleDatabase, + createAuthorizationStore, PlatformOwnerBootstrapError, } from "../src/authorization/index.js"; import { @@ -255,7 +256,7 @@ describe.sequential("live platform-owner bootstrap", () => { expect(audit).toHaveLength(1); }); - it("still refuses bootstrap after the existing owner is revoked — the historical row alone is enough to have consumed it", async () => { + it("still refuses bootstrap after the existing owner is revoked — the durable consumed record, not the owner row, is the authority", async () => { await safeQuery( admin, `update "ekklesia_core_platform_owner" set revoked_at = now(), revoked_by_actor_type = 'system' where user_id = $1`, @@ -362,3 +363,328 @@ describe.sequential("live platform-owner bootstrap — concurrency", () => { } }); }); + +/** + * Pass 3: bootstrap's single-use authority and authorization security history must both survive + * user deletion, which is exactly what the pre-Pass-3 cascading foreign keys destroyed. + * + * The administrative client here is **test-only provisioning and inspection infrastructure**: it + * seeds users, performs the user deletion under test, and reads back the resulting state. It is + * never an API pool or a runtime connection, and every client and disposable database it opens is + * closed or dropped in `finally`. + */ +describe.sequential("live platform-owner bootstrap — durability across user deletion", () => { + async function consumedCount(client: Client): Promise { + const rows = await safeQuery<{ count: string }>( + client, + `select count(*)::text as count from "ekklesia_core_platform_owner_bootstrap"`, + ); + return Number(rows[0]?.count ?? "0"); + } + + it("records exactly one consumed row, one owner row, and one audit row in one transaction", async () => { + const database = await createDisposableDatabase("durable"); + await applyCoreMigrations(database.url); + const admin = await openAdministrativeClient(database.url); + const { close, db } = createAuthorizationDrizzleDatabase(database.url); + try { + const ownerId = parseEkklesiaUserId("durable_owner"); + const laterId = parseEkklesiaUserId("durable_later"); + await seedUser(admin, ownerId, "durable-owner@example.test"); + await seedUser(admin, laterId, "durable-later@example.test"); + + expect(await consumedCount(admin)).toBe(0); + await bootstrapPlatformOwner(db, { userId: ownerId }); + + const consumed = await safeQuery<{ consumed_for_user_id: string | null; id: string }>( + admin, + `select id, consumed_for_user_id from "ekklesia_core_platform_owner_bootstrap"`, + ); + expect(consumed).toHaveLength(1); + expect(consumed[0]?.id).toBe("singleton"); + expect(consumed[0]?.consumed_for_user_id).toBe(ownerId); + expect(await ownerRowCount(admin)).toBe(1); + expect( + await safeQuery( + admin, + `select id from "ekklesia_core_authorization_audit" where action = 'platform_owner.grant'`, + ), + ).toHaveLength(1); + + // Deleting the bootstrapped owner's user row removes the *live* owner row, exactly as + // before — but no longer the consumed sentinel or the security history describing it. + await safeQuery(admin, `delete from "ekklesia_core_user" where id = $1`, [ownerId]); + expect(await ownerRowCount(admin)).toBe(0); + expect(await consumedCount(admin)).toBe(1); + const survivingAudit = await safeQuery<{ action: string; subject_user_id: string }>( + admin, + `select action, subject_user_id from "ekklesia_core_authorization_audit"`, + ); + expect(survivingAudit).toHaveLength(1); + expect(survivingAudit[0]).toMatchObject({ + action: "platform_owner.grant", + subject_user_id: ownerId, + }); + + // And bootstrap is still consumed — the operation that establishes a platform's first + // administrator cannot be reopened by deleting a user. + let captured: unknown; + try { + await bootstrapPlatformOwner(db, { userId: laterId }); + } catch (error) { + captured = error; + } + expect(captured).toMatchObject({ code: "PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED" }); + expect(await ownerRowCount(admin)).toBe(0); + } finally { + await close(); + await safeEnd(admin); + await dropDatabase(database.name); + } + }); + + it("retains a grant subject's create and revoke audit rows after that user is deleted", async () => { + const database = await createDisposableDatabase("audit_survives"); + await applyCoreMigrations(database.url); + const admin = await openAdministrativeClient(database.url); + const { close, db } = createAuthorizationDrizzleDatabase(database.url); + try { + const subjectId = parseEkklesiaUserId("audit_subject"); + const managerId = parseEkklesiaUserId("audit_manager"); + await seedUser(admin, subjectId, "audit-subject@example.test"); + await seedUser(admin, managerId, "audit-manager@example.test"); + + const store = createAuthorizationStore(db); + const grant = await store.createGrant({ + actor: createUserActor(managerId), + permission: parsePermissionId("calendar.events.read"), + sourceKind: "explicit", + userId: subjectId, + }); + await store.revokeGrant({ actor: createUserActor(managerId), grantId: grant.id }); + + const before = await safeQuery<{ action: string }>( + admin, + `select action from "ekklesia_core_authorization_audit" where subject_user_id = $1 order by created_at`, + [subjectId], + ); + expect(before.map((row) => row.action)).toEqual(["grant.create", "grant.revoke"]); + + await safeQuery(admin, `delete from "ekklesia_core_user" where id = $1`, [subjectId]); + + // The live grant row still cascades with the user, as intended — the security *history* + // does not. + expect( + await safeQuery(admin, `select id from "ekklesia_core_grant" where user_id = $1`, [ + subjectId, + ]), + ).toHaveLength(0); + const after = await safeQuery<{ action: string; subject_user_id: string }>( + admin, + `select action, subject_user_id from "ekklesia_core_authorization_audit" where subject_user_id = $1 order by created_at`, + [subjectId], + ); + expect(after.map((row) => row.action)).toEqual(["grant.create", "grant.revoke"]); + // The retained subject is a plain identifier snapshot; no profile data was ever copied here. + expect(Object.keys(after[0] as object).sort()).toEqual(["action", "subject_user_id"]); + } finally { + await close(); + await safeEnd(admin); + await dropDatabase(database.name); + } + }); + + it("does not consume bootstrap when the transaction fails after the sentinel was claimed", async () => { + const database = await createDisposableDatabase("failed_claim"); + await applyCoreMigrations(database.url); + const admin = await openAdministrativeClient(database.url); + const { close, db } = createAuthorizationDrizzleDatabase(database.url); + try { + const ownerId = parseEkklesiaUserId("failed_claim_owner"); + await seedUser(admin, ownerId, "failed-claim@example.test"); + + // A temporary trigger makes the audit insert — the last statement of the bootstrap + // transaction, after the sentinel and owner row were already written — violate the audit + // action CHECK constraint. + await safeQuery( + admin, + `create function ekklesia_test_break_bootstrap() returns trigger as $$ + begin new.action := 'not.a.real.action'; return new; end; + $$ language plpgsql`, + ); + await safeQuery( + admin, + `create trigger ekklesia_test_break_bootstrap_trigger before insert on "ekklesia_core_authorization_audit" + for each row execute function ekklesia_test_break_bootstrap()`, + ); + try { + let captured: unknown; + try { + await bootstrapPlatformOwner(db, { userId: ownerId }); + } catch (error) { + captured = error; + } + expect(captured).toMatchObject({ code: "PLATFORM_OWNER_BOOTSTRAP_FAILED" }); + // The sentinel, the owner row, and the audit row all rolled back together, so the one + // legitimate first use is still available. + expect(await consumedCount(admin)).toBe(0); + expect(await ownerRowCount(admin)).toBe(0); + } finally { + await safeQuery( + admin, + `drop trigger if exists ekklesia_test_break_bootstrap_trigger on "ekklesia_core_authorization_audit"`, + ); + await safeQuery(admin, `drop function if exists ekklesia_test_break_bootstrap()`); + } + + // With the failure removed, bootstrap still works — it was never burned. + await bootstrapPlatformOwner(db, { userId: ownerId }); + expect(await consumedCount(admin)).toBe(1); + expect(await ownerRowCount(admin)).toBe(1); + } finally { + await close(); + await safeEnd(admin); + await dropDatabase(database.name); + } + }); +}); + +/** + * Upgrade path: an installation created before this migration must not gain a fresh first use. + * These scenarios apply only 0000/0001, plant representative pre-migration state through the + * test-only administrative client, then apply 0002 and assert the backfill's effect. + */ +describe.sequential("live Core migration 0002 — bootstrap-consumed backfill", () => { + async function applyThrough(url: string, upTo: number): Promise { + const emptyModulesDirectory = await mkdtemp( + path.join(tmpdir(), "ekklesia-upgrade-no-modules-"), + ); + try { + const aggregate = await discoverAllMigrations({ + coreMigrationsDirectory, + moduleDiscovery: { + moduleSdkVersion: "0.1.0", + modulesDirectory: emptyModulesDirectory, + platformVersion: "0.1.0", + }, + }); + const selected = { + ...aggregate, + migrations: aggregate.migrations.slice(0, upTo), + }; + await applyModuleMigrations({ + aggregate: selected, + database: createPostgresMigrationDatabase(url), + }); + } finally { + await rm(emptyModulesDirectory, { recursive: true, force: true }); + } + } + + async function consumedRows(client: Client) { + return await safeQuery<{ consumed_for_user_id: string | null; id: string }>( + client, + `select id, consumed_for_user_id from "ekklesia_core_platform_owner_bootstrap"`, + ); + } + + it("backfills consumed state for an installation that already has an owner, including a revoked one", async () => { + for (const [label, revoke] of [ + ["active_owner", false], + ["revoked_owner", true], + ] as const) { + const database = await createDisposableDatabase(label); + const admin = await openAdministrativeClient(database.url); + try { + await applyThrough(database.url, 2); + const ownerId = parseEkklesiaUserId(`upgrade_owner_${label}`); + const nextId = parseEkklesiaUserId(`upgrade_next_${label}`); + await seedUser(admin, ownerId, `upgrade-${label}@example.test`); + await seedUser(admin, nextId, `upgrade-next-${label}@example.test`); + await safeQuery( + admin, + `insert into "ekklesia_core_platform_owner" (user_id, granted_by_actor_type, granted_by_actor_id) + values ($1, 'system', 'authorization-bootstrap-cli')`, + [ownerId], + ); + await safeQuery( + admin, + `insert into "ekklesia_core_authorization_audit" (action, actor_type, actor_id, subject_user_id) + values ('platform_owner.grant', 'system', 'authorization-bootstrap-cli', $1)`, + [ownerId], + ); + if (revoke) { + await safeQuery( + admin, + `update "ekklesia_core_platform_owner" set revoked_at = now(), revoked_by_actor_type = 'system' where user_id = $1`, + [ownerId], + ); + } + + await applyThrough(database.url, 3); + + const consumed = await consumedRows(admin); + expect(consumed, label).toHaveLength(1); + expect(consumed[0]?.id).toBe("singleton"); + // A backfill cannot know which of several historical owner rows the original single use + // targeted, so it deliberately records no subject rather than fabricating one. + expect(consumed[0]?.consumed_for_user_id).toBeNull(); + // Every pre-existing audit row survives the migration untouched. + expect( + await safeQuery(admin, `select id from "ekklesia_core_authorization_audit"`), + ).toHaveLength(1); + + const { close, db } = createAuthorizationDrizzleDatabase(database.url); + try { + let captured: unknown; + try { + await bootstrapPlatformOwner(db, { userId: nextId }); + } catch (error) { + captured = error; + } + expect(captured, label).toMatchObject({ + code: "PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED", + }); + } finally { + await close(); + } + } finally { + await safeEnd(admin); + await dropDatabase(database.name); + } + } + }); + + it("leaves bootstrap available exactly once for an installation that never had an owner", async () => { + const database = await createDisposableDatabase("never_owned"); + const admin = await openAdministrativeClient(database.url); + try { + await applyThrough(database.url, 2); + const userId = parseEkklesiaUserId("never_owned_user"); + await seedUser(admin, userId, "never-owned@example.test"); + + await applyThrough(database.url, 3); + + // A legitimately never-bootstrapped installation must retain its one first use. + expect(await consumedRows(admin)).toHaveLength(0); + + const { close, db } = createAuthorizationDrizzleDatabase(database.url); + try { + await bootstrapPlatformOwner(db, { userId }); + expect(await consumedRows(admin)).toHaveLength(1); + let captured: unknown; + try { + await bootstrapPlatformOwner(db, { userId }); + } catch (error) { + captured = error; + } + expect(captured).toMatchObject({ code: "PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED" }); + } finally { + await close(); + } + } finally { + await safeEnd(admin); + await dropDatabase(database.name); + } + }); +}); diff --git a/core/tests/authorization-store.integration.ts b/core/tests/authorization-store.integration.ts index 5e8ced7..fd8d31b 100644 --- a/core/tests/authorization-store.integration.ts +++ b/core/tests/authorization-store.integration.ts @@ -171,6 +171,7 @@ async function applyCoreMigrations(url: string): Promise { expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([ "core:0000_identity_core_tables", "core:0001_authorization_core_tables", + "core:0002_authorization_integrity", ]); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); } finally { @@ -222,6 +223,7 @@ describe.sequential("live Core authorization store", () => { "ekklesia_core_grant", "ekklesia_core_migrations", "ekklesia_core_platform_owner", + "ekklesia_core_platform_owner_bootstrap", "ekklesia_core_session", "ekklesia_core_user", "ekklesia_core_verification", diff --git a/docs/development/authorization.md b/docs/development/authorization.md index a4cc63f..4bec5d5 100644 --- a/docs/development/authorization.md +++ b/docs/development/authorization.md @@ -20,8 +20,10 @@ registered permission, Core-owned or module-owned; a module can register its own have them mounted, enforced, and reachable in production (see [API](api.md#module-http-routes)); and every enforced route fails closed on infrastructure trouble instead of silently allowing or denying. It is **not** possible to manage roles, groups, teams, or -tenants; there is no owner-management HTTP endpoint (revoking or re-granting platform-owner status -is not exposed at all); there are no service accounts or API tokens, so only cookie-authenticated +tenants; there is no user-deletion endpoint, owner-management HTTP endpoint, or owner-recovery +workflow (revoking or re-granting platform-owner status is not exposed at all, and deleting the last +owner does not reactivate bootstrap); there are no service accounts or API tokens, so only +cookie-authenticated browser callers can reach mutation routes, Core-owned or module-owned; and relationship-derived grants (for example, automatic access from a roster assignment) cannot yet be created by anything in this repository — the generic grants API structurally cannot create or revoke them either. @@ -33,7 +35,41 @@ Established as a prior checkpoint on this branch (`ekklesia_core_platform_owner` `AuthorizationStore`; `@ekklesia/contracts`'s `PermissionId`/`GrantId`/`AuthorizationScope` branded types). This page does not restate that design — read `core/src/authorization/schema.ts`, `evaluator.ts`, and `store.ts` directly for it. This page covers what pass 2 added on top: the -permission registry, the bootstrap operation, and HTTP enforcement. +permission registry, the bootstrap operation, and HTTP enforcement — plus the Phase 5D pass 3 +integrity rules recorded in +[ADR 0019](../architecture/decisions/0019-durable-authorization-history-and-grant-management.md). + +### What user deletion removes, and what it does not + +Deleting an identity user still cascades to that user's **live** authorization rows: their grants +(`ekklesia_core_grant`) and their platform-owner row (`ekklesia_core_platform_owner`) are removed, +so nothing continues authorizing a principal that no longer exists. + +It does **not** remove security records. `ekklesia_core_authorization_audit.subject_user_id` is a +retained, non-null historical snapshot with no foreign key to the user table, and +`ekklesia_core_platform_owner_bootstrap` has no user foreign key at all. Both survive user +deletion. Previously the audit subject cascaded, so deleting a user destroyed exactly the record of +what they had been granted, by whom, and when it was revoked. + +Only opaque identifiers are retained — a subject id and an actor id. No name, email, or other +profile data is ever copied into an authorization table. + +**Append-only is an application convention, not a database guarantee.** Core's authorization store +exposes no update or delete path for audit rows. Nothing in PostgreSQL enforces that: no role, +trigger, or rule prevents a database administrator, a future migration, or anything else holding +the same credentials from modifying them. They are not cryptographically immutable. + +### Deploying these changes + +All of the above depends on Core migration `0002_authorization_integrity`, which creates +`ekklesia_core_platform_owner_bootstrap`, drops the cascading audit-subject foreign key, and +backfills consumed state. **Migrations remain explicit operator work and never run at application +startup** — apply `0002` with `pnpm migrations:apply` *before* starting the new code, exactly as +with every other migration. It is classified `{"mode":"none"}` in +`core/drizzle/meta/_ekklesia.json`: it creates a table, removes one referential constraint +specifically to *retain* data, and inserts one backfill row; it drops no table, column, or row. +Migrations `0000_identity_core_tables` and `0001_authorization_core_tables` are byte-identical and +retain their existing checksums. ## Permission registry @@ -236,13 +272,37 @@ twice. `core/src/authorization/bootstrap.ts`'s `bootstrapPlatformOwner(db, {userId})` is a dedicated, Hono-independent Core operation — deliberately *not* a composition of "check for an owner, then -call the existing grant function." "Already consumed" means **any row has ever existed** in -`ekklesia_core_platform_owner`, for any user, including one whose `revoked_at` is set. There is no -owner-management flow in this slice that clears that state; a revoked owner staying revoked (and -bootstrap staying consumed) is intentional here. +call the existing grant function." -The single-use check, the target user's existence check, the owner-grant insert, and its audit -entry are all one PostgreSQL transaction. The very first statement inside that transaction is a +"Already consumed" means **the singleton row exists** in `ekklesia_core_platform_owner_bootstrap` +— a dedicated Core-owned table with `id` pinned by a check constraint to the literal `'singleton'` +and **no foreign key to any user**. Core never deletes from it. + +This is deliberately no longer "an owner row exists". That marker cascaded on user deletion, so +deleting the bootstrapped owner's user row removed the owner row *and* its audit evidence, after +which the one operation that establishes a platform's first administrator could be run again. The +operational consequence of the fix is explicit and intended: **deleting the last platform owner +does not reactivate bootstrap**, and neither does revoking one. Re-establishing an owner after that +requires a separately designed owner-management flow, which does not exist yet. There is no reset +command, deletion path, HTTP endpoint, environment override, or force flag. + +Upgrading an existing installation is handled by Core migration `0002_authorization_integrity`, +which inserts the singleton if any platform-owner row already exists — active or revoked — so an +existing deployment cannot gain a fresh first use. It records no `consumed_for_user_id`, because a +backfill cannot know which of several historical owner rows the original single use targeted, and +guessing would fabricate history. An installation that never had an owner is left untouched and +keeps its one first use. + +One upgrade limitation is irrecoverable: if an operator manually deleted the bootstrapped owner's +user row *before* this migration, the old cascade already erased both the owner row and its audit +evidence, and the migration has nothing left to detect — such an installation backfills as +never-bootstrapped. At the current checkpoint no supported Better Auth or API user-deletion path +exists that could produce this state; it requires direct database access. + +The sentinel claim, the target user's existence check, the owner-grant insert, and its audit entry +are all one PostgreSQL transaction. Consumption is therefore tied to *success*: a missing target +user, a constraint violation, or any other failure rolls the sentinel back with everything else, +leaving the one legitimate first use available. The very first statement inside that transaction is a transaction-scoped advisory lock: ```sql @@ -281,7 +341,7 @@ path, or stack — only a stable `code` and a fixed message: | Code | Meaning | | --- | --- | -| `PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED` | A platform-owner row already exists (any user, revoked or not). | +| `PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED` | The durable bootstrap-consumed singleton exists; bootstrap has already been used and cannot be used again. | | `PLATFORM_OWNER_BOOTSTRAP_USER_NOT_FOUND` | `--user-id` does not match an existing identity user. | | `PLATFORM_OWNER_BOOTSTRAP_FAILED` | Any other failure (database/infrastructure). | @@ -337,28 +397,84 @@ generic HTTP API structurally cannot create or revoke a relationship-derived gra | Route | Origin required | Notes | | --- | --- | --- | -| `GET /api/v1/grants?userId=` | No | Lists the subject's explicit grants only (`sourceKind: "explicit"` filter applied server-side). | -| `POST /api/v1/grants` | Yes | Creates a new explicit grant. | -| `POST /api/v1/grants/:grantId/revoke` | Yes | Revokes an explicit grant without deleting it. | +| `GET /api/v1/grants?userId=&limit=<1..100>&cursor=` | No | One bounded page of the subject's explicit grants only (`sourceKind: "explicit"` filter applied server-side on every page). | +| `POST /api/v1/grants` | Yes | Creates a new **unscoped** explicit grant. | +| `POST /api/v1/grants/:grantId/revoke` | Yes | Revokes an explicit grant, and every unrevoked equivalent of it, without deleting anything. | + +`:grantId` must be the canonical lowercase hyphenated UUID PostgreSQL emits (`8-4-4-4-12` lowercase +hexadecimal). Uppercase, whitespace-padded, shortened, overlong, and non-UUID values are rejected +with `400 {"code":"INVALID_REQUEST"}` after authorization but strictly before any store or database +call — previously such a value reached PostgreSQL's own UUID comparison and returned an +indistinguishable `503`. A *canonical* id that does not exist still returns the sanitized `404`. ### `POST /api/v1/grants` request body -Only four keys are accepted; any other key present (`actor`, `owner`, `isOwner`, `sourceKind`, -`sourceRef`, `id`, `createdAt`, `revokedAt`, `createdByActorId`, `effectivePermissions`, or -anything else) makes the **entire body** invalid rather than being silently dropped, so a caller -cannot smuggle a server-derived field through a request that otherwise looks well-formed: +Only three keys are accepted; any other key present (`scope`, `actor`, `owner`, `isOwner`, +`sourceKind`, `sourceRef`, `id`, `createdAt`, `revokedAt`, `createdByActorId`, +`effectivePermissions`, or anything else) makes the **entire body** invalid rather than being +silently dropped, so a caller cannot smuggle a server-derived field through a request that +otherwise looks well-formed: | Field | Required | Notes | | --- | --- | --- | | `subjectUserId` | Yes | Must parse as a valid `EkklesiaUserId`. | | `permission` | Yes | Must parse as a valid `PermissionId` **and** be registered in the running API's composed platform registry (Core's own declarations plus every installed module's — see [API startup](#api-startup-discovery-composition-and-enforcement)). A malformed value is a `400`; a well-formed but unregistered one is a `403`. | -| `scope` | No | Must parse as a valid, non-empty `AuthorizationScope` if present. | -| `expiresAt` | No | An ISO date string if present. | +| `expiresAt` | No | Exactly `YYYY-MM-DDTHH:mm:ss.sssZ`, byte-for-byte equal to the parsed date's own `toISOString()`, and strictly in the future. | The server always derives, and the request body can never supply: the grant id, `sourceKind` (always `"explicit"`), the actor (always the authenticated caller, via `createUserActor(principal.userId)`), and all audit/timestamp fields. +**The generic HTTP surface creates unscoped explicit grants only.** A `scope` field is rejected in +every form — including `null`, empty, malformed, and otherwise valid scope strings — with +`400 {"code":"INVALID_REQUEST"}` before any store mutation. No HTTP route middleware supplies a +scope to the evaluator today, so a scope accepted here would produce a grant that persists, appears +in listings, and can authorize nothing. Core's `AuthorizationScope`, the evaluator's exact-scope +semantics, and the store's scope column are unchanged and remain available to trusted internal +callers; listing still shows a scope on historical or internally created grants. **Scopes cannot +authorize a module HTTP route today**, and no route-parameter-to-scope resolver was added. + +`expiresAt` deliberately accepts one representation and nothing else. Rejected: offsets +(`2027-01-01T00:00:00.000+02:00`), missing milliseconds (`2027-01-01T00:00:00Z`), excess fractional +precision, lowercase separators, whitespace padding, date-only (`2027-01-01`), locale forms +(`March 5, 2027`), extra trailing text, normalized impossible dates (`2026-02-31T00:00:00.000Z`, +which bare `new Date()` silently rewrote to March 3rd), and any instant at or before the acceptance +clock. Core's store independently rejects an invalid or non-future `Date` before any SQL, so a +trusted internal caller cannot create a dead-on-arrival grant either. A rejected expiration creates +no grant and no audit row. There is no maximum lifetime, renewal workflow, scheduler, or background +expiration job. + +### Duplicate creation and all-equivalent revocation + +Two grant issuances are the **same authorization source** when all five of these are equal, with +`NULL` equal only to `NULL`: `user_id`, `permission_id`, `scope`, `source_kind`, `source_ref`. +`expires_at`, creation metadata, actor metadata, and the grant id do not distinguish them. + +Creating a grant while an equivalent one is *currently effective* (`revoked_at IS NULL` and +`expires_at IS NULL OR expires_at > now()`) fails with +`409 {"code":"AUTHORIZATION_GRANT_ALREADY_EXISTS"}` and `Cache-Control: no-store`. The response +names no existing grant id, actor, scope, source reference, expiry, database detail, SQL, or stack — +only the code. A rejected duplicate creates no grant and no audit row. A revoked or already-expired +equivalent does not block a fresh issuance. This is a conflict rather than an idempotent return of +the existing grant: the caller asked to record a new authorization with their own actor and expiry, +and answering "done" while recording neither would hide that nothing happened. + +Enforcement is database-backed, not process-local: every create and revoke touching one equivalence +tuple takes a transaction-scoped `pg_advisory_xact_lock` keyed by a hash of that tuple, so multiple +API processes serialize identically. Its single-key lock space is disjoint from the two-key space +used by the migration runner and platform-owner bootstrap. + +**One revocation revokes every unrevoked equivalent**, including expired-but-unrevoked legacy rows, +in one transaction, writing one `grant.revoke` audit row per grant actually revoked, each carrying +that row's own grant id. Grants with a different subject, permission, scope, source kind, or source +reference are untouched. This replaces behavior where revoking one of two equivalent grants returned +`success` while the other kept authorizing the same user. The public success response remains +`200 {"success": true}`. + +Historical duplicates are **not** silently revoked by any migration. The store now prevents creating +another effective equivalent, and an operator's explicit revocation resolves the whole equivalence +class. + ### Response shapes A grant, wherever returned: @@ -376,9 +492,48 @@ A grant, wherever returned: } ``` -`GET /api/v1/grants` → `200 {"grants": [...]}`. `POST /api/v1/grants` → `201 {"grant": {...}}`. +`POST /api/v1/grants` → `201 {"grant": {...}}`. `POST /api/v1/grants/:grantId/revoke` → `200 {"success": true}`. +`GET /api/v1/grants` → `200 {"grants": [...], "nextCursor": }`. + +### Listing is bounded keyset pagination + +```text +GET /api/v1/grants?userId=&limit=<1..100>&cursor= +``` + +| Parameter | Required | Notes | +| --- | --- | --- | +| `userId` | Yes | The subject whose explicit grants to list. | +| `limit` | No | Canonical base-10 integer, 1 through 100. Defaults to 50. | +| `cursor` | No | An opaque position string returned as a previous page's `nextCursor`. | + +Unknown query keys, duplicated `userId`/`limit`/`cursor` keys, empty values, non-canonical numbers +(`0`, `101`, `-1`, `+5`, `01`, `1.5`, `1e2`, whitespace-padded), and malformed cursors are all +rejected with `400 {"code":"INVALID_REQUEST"}` before any store or database work. Permission +enforcement completes first, so query validation never discloses a validation distinction — or the +existence of any subject or grant — to an unauthorized caller. + +Rows are ordered `created_at DESC, id DESC`, with a strict keyset boundary on **both** values, so +grants sharing a `created_at` can neither be skipped nor repeated across pages. At most `limit + 1` +rows are read internally and at most `limit` returned. The final page returns `"nextCursor": null`. +Relationship grants never appear on any page. There is no total count, offset pagination, search, or +sorting option. + +The cursor is a small versioned payload containing only the subject user id, boundary creation +timestamp, and boundary grant id, encoded as canonical unpadded base64url. Decoding is strict — +exact version, exact keys, a subject matching the *requested* one, a canonical `toISOString()` +timestamp, a canonical `GrantId`, and a re-encoding that reproduces the supplied string +byte-for-byte — and a rejected cursor reveals nothing it decoded. + +**It is a pagination position, not an authorization token.** It is deliberately unsigned: the +permission check and the server-side subject filter remain authoritative on every page, so a forged +cursor can at most name a different position within grants the caller may already list. It carries +no permission, scope, source reference, actor data, credential, or secret, and it is not a +consistency snapshot — rows created or revoked between pages are seen or not seen according to the +ordering. + ### Status-code mapping | Status | When | @@ -387,7 +542,8 @@ A grant, wherever returned: | `400 {"code":"INVALID_REQUEST"}` | Malformed query/body/param (including a disallowed extra field). | | `403 {"code":"AUTHORIZATION_DENIED"}` | Caller lacks `core.authorization.grants.manage`; the caller's own required permission id was unregistered; or, for `POST /api/v1/grants`, the well-formed *target* permission named in the body is not registered. | | `403 {"code":"UNTRUSTED_ORIGIN"}` | Missing or untrusted `Origin` on a mutation route. | -| `404 {"code":"AUTHORIZATION_GRANT_NOT_FOUND"}` | The targeted grant id does not exist as an *explicit* grant — this is also what revoking a real relationship-derived grant's id looks like, indistinguishably. | +| `404 {"code":"AUTHORIZATION_GRANT_NOT_FOUND"}` | The canonical grant id does not exist as an unrevoked *explicit* grant — this is also what revoking a real relationship-derived grant's id looks like, indistinguishably. | +| `409 {"code":"AUTHORIZATION_GRANT_ALREADY_EXISTS"}` | An equivalent grant is already currently effective for the subject. | | `503` | Principal resolution, evaluation, or store failure (`PRINCIPAL_RESOLUTION_FAILED`, `AUTHORIZATION_EVALUATION_FAILED`, or an `AuthorizationStoreError` code). | Every response sets `Cache-Control: no-store`. @@ -453,7 +609,23 @@ module database usage), and exactly one `pool.end()` call regardless of how many being imported. - `core/tests/authorization-bootstrap.integration.ts` (live PostgreSQL) — successful bootstrap and its audit row, nonexistent-user rollback, second-bootstrap rejection, a revoked historical owner - still blocking reuse, and the concurrency scenario described above. + still blocking reuse, and the concurrency scenario described above. Pass-3 scenarios add: exactly + one consumed row plus one owner row plus one audit row in one transaction; deleting the owner's + user row keeping the sentinel and the audit history while removing the live owner row and not + reactivating bootstrap; a grant subject's create/revoke audit rows surviving that subject's + deletion while their live grant cascades; a forced failure after the sentinel was claimed rolling + everything back so the one first use is not burned; and migration `0002` backfilling consumed + state for an active *and* a revoked existing owner while leaving a never-owned installation with + exactly one first use. +- `core/tests/authorization-store.integration.ts` (live PostgreSQL) — pass-3 grant-integrity + scenarios: sequential and six-way concurrent equivalent creation yielding exactly one grant and + one create audit row; a differing expiry not bypassing the conflict; different subject/permission/ + scope/source-kind/source-ref being non-equivalent; fresh issuance after a revoked or expired + equivalent; one revocation clearing every unrevoked equivalent (including an expired-but-unrevoked + one) with per-row audit entries while leaving differently scoped or sourced grants untouched; a + concurrent create/revoke race serializing without an unexplained duplicate; a forced transaction + failure rolling back every grant and audit change; and bounded keyset pagination traversing seven + rows that all share one `created_at` with no duplicates or omissions. - `apps/api/tests/authorization-middleware.test.ts` — allow/deny/unregistered/infrastructure- failure paths for `requirePermission`, and `requireTrustedOrigin`'s allow/reject behavior; a dedicated block proves `requirePermission` enforces module-owned `public`/`authenticated`/ @@ -466,7 +638,11 @@ module database usage), and exactly one `pool.end()` call regardless of how many unexpected-exception fallback to a fixed generic detail, schema/migration checks continuing independently after a composition failure, and the exact documented terminal report shape. - `apps/api/tests/grants-routes.test.ts` — all three routes' allow/deny paths, disallowed-field - and malformed-field rejection, a well-formed but unregistered target permission rejected with + and malformed-field rejection (including every form of a `scope` field), canonical-`GrantId` and + canonical-future-`expiresAt` rejection with the store never called, the sanitized `409` conflict, + the full pagination contract (default/boundary limits, non-canonical limit rejection, unknown and + duplicated query keys, multi-page traversal, and every malformed or foreign cursor), + a well-formed but unregistered target permission rejected with `403` and `store.createGrant` never called, exact server-derived-argument assertions, sourceKind-filtered revoke returning 404 for a non-explicit grant, origin enforcement, and store-error mapping. diff --git a/docs/development/testing.md b/docs/development/testing.md index 4eabe3b..7e7296f 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -113,6 +113,14 @@ database failure and asserts, against actual `console.error`/`warn`/`log` output Auth's own internal error logging — configured explicitly in `core/src/identity/runtime.ts` — never emits a raw exception, SQL, connection detail, or the rejected request's origin. +A dedicated group in the API suite also drives Pass 3's explicit-grant invariants through a real +authorized grants-management session: a malformed non-UUID revoke returning a sanitized `400` with +no database work, a canonical unknown UUID returning `404`, a scoped create body returning `400` +with nothing written, normalized-impossible/non-canonical/past expirations returning `400`, an +equivalent creation returning a sanitized `409` that names no existing grant, a bounded paginated +list traversed across three pages with relationship grants absent from every one, and one HTTP +revocation clearing every unrevoked legacy equivalent with a per-row audit entry. + A third dedicated suite (`apps/api/tests/module-database.integration.ts`, included in the same `pnpm test:migrations:integration` run) proves `context.services.database` against a real, installed test module (`core/tests/fixtures/module-database-integration/db-facade/` — never the