fix: preserve authorization security history
Two security records did not survive user deletion, because both hung off a cascading foreign key to the live Better Auth user row. Deleting a user with authorization history deleted that history. A subject with a create and a revoke audit row had zero rows afterwards: the record of what they had been granted, by whom, and when it was revoked disappeared at exactly the moment it mattered most. `ekklesia_core_authorization_audit.subject_user_id` is now a retained, non-null historical snapshot with no foreign key — a plain identifier that may name a user row which no longer exists. No name, email, or other profile data is copied into authorization tables. The Drizzle relation to the user table is dropped too, because modelling a retained snapshot as a live relation misdescribes the data. Live rows still cascade, as intended: deleting a user removes their grants and platform-owner row, so nothing continues authorizing a principal that no longer exists. Platform-owner bootstrap's single use was equally fragile. "Already consumed" meant "a row exists in ekklesia_core_platform_owner", and that table cascades too — so deleting the bootstrapped owner's user row removed the owner row, its audit evidence, and with them the only thing preventing the operation that establishes a platform's first administrator from being run again. A dedicated `ekklesia_core_platform_owner_bootstrap` singleton now carries that authority. Its id is pinned by a check constraint to the literal 'singleton', so the primary key makes a second row structurally impossible rather than merely unwritten by convention; it has no user foreign key, and Core never deletes from it. Bootstrap claims that record inside the same transaction as the owner row and its audit entry, so consumption is tied to success: a missing target user or any other failure rolls the sentinel back and leaves the one legitimate first use available. The existing transaction-scoped advisory lock still serializes concurrent first attempts to exactly one success. The operational consequence is deliberate and permanent: deleting the last platform owner does not reactivate bootstrap, and neither does revoking one. Core migration 0002_authorization_integrity creates the singleton table, drops the cascading audit-subject constraint, and backfills consumed state for any installation that already holds a platform-owner row — active or revoked — so an existing deployment cannot gain a fresh first use by upgrading. It records no subject, because a backfill cannot know which of several historical owner rows the original use targeted and guessing would fabricate history. A never-owned installation is untouched and keeps its one first use. One upgrade limitation is irrecoverable and is documented rather than papered over: if an operator manually deleted the bootstrapped owner's user row before this migration, the old cascade already erased the owner row and its audit evidence, so nothing remains to detect. At this checkpoint no supported Better Auth or API user-deletion path can produce that state; it requires direct database access. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
6b6b77c105
commit
532ce3803b
@@ -188,6 +188,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
|||||||
expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([
|
expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([
|
||||||
"core:0000_identity_core_tables",
|
"core:0000_identity_core_tables",
|
||||||
"core:0001_authorization_core_tables",
|
"core:0001_authorization_core_tables",
|
||||||
|
"core:0002_authorization_integrity",
|
||||||
]);
|
]);
|
||||||
expect(aggregate.migrations[0]?.checksum).toBe(
|
expect(aggregate.migrations[0]?.checksum).toBe(
|
||||||
"f8f2086ef06f7a6943e8ffc4350bb010b37aed04c0a72030d2b153c986b1325f",
|
"f8f2086ef06f7a6943e8ffc4350bb010b37aed04c0a72030d2b153c986b1325f",
|
||||||
@@ -195,6 +196,10 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
|||||||
expect(aggregate.migrations[1]?.checksum).toBe(
|
expect(aggregate.migrations[1]?.checksum).toBe(
|
||||||
"830e1fbc26d05af08df8d8710c3d10e1f3016d32c41b8363cb661c49afcbe81e",
|
"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) });
|
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||||
} finally {
|
} finally {
|
||||||
await rm(emptyModulesDirectory, { recursive: true, force: true });
|
await rm(emptyModulesDirectory, { recursive: true, force: true });
|
||||||
|
|||||||
@@ -219,6 +219,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
|||||||
expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([
|
expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([
|
||||||
"core:0000_identity_core_tables",
|
"core:0000_identity_core_tables",
|
||||||
"core:0001_authorization_core_tables",
|
"core:0001_authorization_core_tables",
|
||||||
|
"core:0002_authorization_integrity",
|
||||||
]);
|
]);
|
||||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -239,9 +240,11 @@ async function applyMigrationsWithModule(url: string): Promise<void> {
|
|||||||
platformVersion: "0.1.0",
|
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([
|
expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([
|
||||||
"core:0000_identity_core_tables",
|
"core:0000_identity_core_tables",
|
||||||
"core:0001_authorization_core_tables",
|
"core:0001_authorization_core_tables",
|
||||||
|
"core:0002_authorization_integrity",
|
||||||
"db-facade:0000_initial",
|
"db-facade:0000_initial",
|
||||||
]);
|
]);
|
||||||
expect(aggregate.migrations[0]?.checksum).toBe(
|
expect(aggregate.migrations[0]?.checksum).toBe(
|
||||||
@@ -250,6 +253,9 @@ async function applyMigrationsWithModule(url: string): Promise<void> {
|
|||||||
expect(aggregate.migrations[1]?.checksum).toBe(
|
expect(aggregate.migrations[1]?.checksum).toBe(
|
||||||
"830e1fbc26d05af08df8d8710c3d10e1f3016d32c41b8363cb661c49afcbe81e",
|
"830e1fbc26d05af08df8d8710c3d10e1f3016d32c41b8363cb661c49afcbe81e",
|
||||||
);
|
);
|
||||||
|
expect(aggregate.migrations[2]?.checksum).toBe(
|
||||||
|
"63429473118065dfe3877a1baec61b8f61663b88127d9cb17707c859f27f6036",
|
||||||
|
);
|
||||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
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(
|
expect(await migrationHistoryCount(database.url, "core:0001_authorization_core_tables")).toBe(
|
||||||
1,
|
1,
|
||||||
);
|
);
|
||||||
|
expect(await migrationHistoryCount(database.url, "core:0002_authorization_integrity")).toBe(
|
||||||
|
1,
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
await dropDatabase(database.name);
|
await dropDatabase(database.name);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,12 @@
|
|||||||
"destructive": {
|
"destructive": {
|
||||||
"mode": "none"
|
"mode": "none"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag": "0002_authorization_integrity",
|
||||||
|
"destructive": {
|
||||||
|
"mode": "none"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,13 @@
|
|||||||
"when": 1784819635300,
|
"when": 1784819635300,
|
||||||
"tag": "0001_authorization_core_tables",
|
"tag": "0001_authorization_core_tables",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1785104670124,
|
||||||
|
"tag": "0002_authorization_integrity",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,12 @@ import type { EkklesiaUserId } from "@ekklesia/contracts";
|
|||||||
import { eq, sql } from "drizzle-orm";
|
import { eq, sql } from "drizzle-orm";
|
||||||
import { ekklesiaCoreUser } from "../identity/schema.js";
|
import { ekklesiaCoreUser } from "../identity/schema.js";
|
||||||
import type { AuthorizationDrizzleDatabase } from "./database.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 =
|
export type PlatformOwnerBootstrapErrorCode =
|
||||||
| "PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED"
|
| "PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED"
|
||||||
@@ -54,17 +59,25 @@ export interface BootstrapPlatformOwnerInput {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Core's dedicated, Hono-independent platform-owner bootstrap operation. Deliberately not a
|
* 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
|
* 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
|
* 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
|
* The second caller blocks on the lock until the first's transaction commits or rolls back, then
|
||||||
* re-evaluates against the now-current state.
|
* re-evaluates against the now-current state.
|
||||||
*
|
*
|
||||||
* "Already consumed" is global and permanent: it is true the moment
|
* **Single-use authority lives in `ekklesia_core_platform_owner_bootstrap`, never in the continued
|
||||||
* `ekklesia_core_platform_owner` has ever held any row for any user, including one whose
|
* existence of a platform-owner row.** The owner table's `user_id` cascades on user deletion, so
|
||||||
* `revoked_at` is set — a later, separate owner-management flow (not part of this slice) is how a
|
* treating "an owner row exists" as the consumed marker meant deleting the bootstrapped owner's
|
||||||
* platform owner would ever be re-established after a revocation, not this command.
|
* 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(
|
export async function bootstrapPlatformOwner(
|
||||||
db: AuthorizationDrizzleDatabase,
|
db: AuthorizationDrizzleDatabase,
|
||||||
@@ -76,11 +89,11 @@ export async function bootstrapPlatformOwner(
|
|||||||
sql`select pg_advisory_xact_lock(${bootstrapLockNamespace}, ${bootstrapLockKey})`,
|
sql`select pg_advisory_xact_lock(${bootstrapLockNamespace}, ${bootstrapLockKey})`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const existingOwnerRows = await tx
|
const consumedRows = await tx
|
||||||
.select({ userId: ekklesiaCorePlatformOwner.userId })
|
.select({ id: ekklesiaCorePlatformOwnerBootstrap.id })
|
||||||
.from(ekklesiaCorePlatformOwner)
|
.from(ekklesiaCorePlatformOwnerBootstrap)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
if (existingOwnerRows.length > 0) {
|
if (consumedRows.length > 0) {
|
||||||
throw new PlatformOwnerBootstrapError("PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED");
|
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");
|
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({
|
await tx.insert(ekklesiaCorePlatformOwner).values({
|
||||||
grantedByActorId: BOOTSTRAP_ACTOR_ID,
|
grantedByActorId: BOOTSTRAP_ACTOR_ID,
|
||||||
grantedByActorType: BOOTSTRAP_ACTOR_TYPE,
|
grantedByActorType: BOOTSTRAP_ACTOR_TYPE,
|
||||||
|
|||||||
@@ -72,11 +72,20 @@ export const ekklesiaCorePlatformOwner = pgTable(
|
|||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Append-only: no update or delete path is ever exposed by Core's authorization store. `scope`
|
* Append-only *by application convention*: Core's authorization store exposes no update or delete
|
||||||
* intentionally rejects an empty (but non-null) string — defense in depth matching the
|
* path for these rows. That is a property of this codebase, not a database guarantee — no
|
||||||
* application-layer `AuthorizationScope` contract, which never produces one — so a coding defect
|
* PostgreSQL role, trigger, or rule prevents a database administrator, a future migration, or
|
||||||
* that slipped an empty scope past that validation fails the whole grant-plus-audit transaction
|
* anything else holding the same credentials from modifying them. `scope` intentionally rejects an
|
||||||
* atomically instead of recording a meaningless audit entry.
|
* 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(
|
export const ekklesiaCoreAuthorizationAudit = pgTable(
|
||||||
"ekklesia_core_authorization_audit",
|
"ekklesia_core_authorization_audit",
|
||||||
@@ -85,9 +94,7 @@ export const ekklesiaCoreAuthorizationAudit = pgTable(
|
|||||||
action: text("action").notNull(),
|
action: text("action").notNull(),
|
||||||
actorType: text("actor_type").notNull(),
|
actorType: text("actor_type").notNull(),
|
||||||
actorId: text("actor_id"),
|
actorId: text("actor_id"),
|
||||||
subjectUserId: text("subject_user_id")
|
subjectUserId: text("subject_user_id").notNull(),
|
||||||
.notNull()
|
|
||||||
.references(() => ekklesiaCoreUser.id, { onDelete: "cascade" }),
|
|
||||||
permissionId: text("permission_id"),
|
permissionId: text("permission_id"),
|
||||||
scope: text("scope"),
|
scope: text("scope"),
|
||||||
grantId: uuid("grant_id"),
|
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 }) => ({
|
export const ekklesiaCoreGrantRelations = relations(ekklesiaCoreGrant, ({ one }) => ({
|
||||||
user: one(ekklesiaCoreUser, {
|
user: one(ekklesiaCoreUser, {
|
||||||
fields: [ekklesiaCoreGrant.userId],
|
fields: [ekklesiaCoreGrant.userId],
|
||||||
@@ -127,19 +168,14 @@ export const ekklesiaCorePlatformOwnerRelations = relations(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const ekklesiaCoreAuthorizationAuditRelations = relations(
|
// `ekklesiaCoreAuthorizationAudit` deliberately declares no `relations(...)` to the user table:
|
||||||
ekklesiaCoreAuthorizationAudit,
|
// its subject is a retained historical identifier that may name a user row that no longer exists,
|
||||||
({ one }) => ({
|
// so modelling it as a live relation would misdescribe the data.
|
||||||
subject: one(ekklesiaCoreUser, {
|
|
||||||
fields: [ekklesiaCoreAuthorizationAudit.subjectUserId],
|
|
||||||
references: [ekklesiaCoreUser.id],
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
/** Explicit map passed to Drizzle, mirroring `identityAdapterSchema`'s explicit-mapping style. */
|
/** Explicit map passed to Drizzle, mirroring `identityAdapterSchema`'s explicit-mapping style. */
|
||||||
export const authorizationSchema = {
|
export const authorizationSchema = {
|
||||||
authorizationAudit: ekklesiaCoreAuthorizationAudit,
|
authorizationAudit: ekklesiaCoreAuthorizationAudit,
|
||||||
grant: ekklesiaCoreGrant,
|
grant: ekklesiaCoreGrant,
|
||||||
platformOwner: ekklesiaCorePlatformOwner,
|
platformOwner: ekklesiaCorePlatformOwner,
|
||||||
|
platformOwnerBootstrap: ekklesiaCorePlatformOwnerBootstrap,
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -3,12 +3,13 @@ import { mkdtemp, rm } from "node:fs/promises";
|
|||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { parseEkklesiaUserId } from "@ekklesia/contracts";
|
import { createUserActor, parseEkklesiaUserId, parsePermissionId } from "@ekklesia/contracts";
|
||||||
import { Client, type QueryResultRow } from "pg";
|
import { Client, type QueryResultRow } from "pg";
|
||||||
import { afterAll, describe, expect, it } from "vitest";
|
import { afterAll, describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
bootstrapPlatformOwner,
|
bootstrapPlatformOwner,
|
||||||
createAuthorizationDrizzleDatabase,
|
createAuthorizationDrizzleDatabase,
|
||||||
|
createAuthorizationStore,
|
||||||
PlatformOwnerBootstrapError,
|
PlatformOwnerBootstrapError,
|
||||||
} from "../src/authorization/index.js";
|
} from "../src/authorization/index.js";
|
||||||
import {
|
import {
|
||||||
@@ -255,7 +256,7 @@ describe.sequential("live platform-owner bootstrap", () => {
|
|||||||
expect(audit).toHaveLength(1);
|
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(
|
await safeQuery(
|
||||||
admin,
|
admin,
|
||||||
`update "ekklesia_core_platform_owner" set revoked_at = now(), revoked_by_actor_type = 'system' where user_id = $1`,
|
`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<number> {
|
||||||
|
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, "[email protected]");
|
||||||
|
await seedUser(admin, laterId, "[email protected]");
|
||||||
|
|
||||||
|
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, "[email protected]");
|
||||||
|
await seedUser(admin, managerId, "[email protected]");
|
||||||
|
|
||||||
|
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, "[email protected]");
|
||||||
|
|
||||||
|
// 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<void> {
|
||||||
|
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, "[email protected]");
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -171,6 +171,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
|||||||
expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([
|
expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([
|
||||||
"core:0000_identity_core_tables",
|
"core:0000_identity_core_tables",
|
||||||
"core:0001_authorization_core_tables",
|
"core:0001_authorization_core_tables",
|
||||||
|
"core:0002_authorization_integrity",
|
||||||
]);
|
]);
|
||||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -222,6 +223,7 @@ describe.sequential("live Core authorization store", () => {
|
|||||||
"ekklesia_core_grant",
|
"ekklesia_core_grant",
|
||||||
"ekklesia_core_migrations",
|
"ekklesia_core_migrations",
|
||||||
"ekklesia_core_platform_owner",
|
"ekklesia_core_platform_owner",
|
||||||
|
"ekklesia_core_platform_owner_bootstrap",
|
||||||
"ekklesia_core_session",
|
"ekklesia_core_session",
|
||||||
"ekklesia_core_user",
|
"ekklesia_core_user",
|
||||||
"ekklesia_core_verification",
|
"ekklesia_core_verification",
|
||||||
|
|||||||
@@ -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
|
have them mounted, enforced, and reachable in production (see
|
||||||
[API](api.md#module-http-routes)); and every enforced route fails closed on infrastructure trouble
|
[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
|
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
|
tenants; there is no user-deletion endpoint, owner-management HTTP endpoint, or owner-recovery
|
||||||
is not exposed at all); there are no service accounts or API tokens, so only cookie-authenticated
|
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
|
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
|
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.
|
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`
|
`AuthorizationStore`; `@ekklesia/contracts`'s `PermissionId`/`GrantId`/`AuthorizationScope`
|
||||||
branded types). This page does not restate that design — read `core/src/authorization/schema.ts`,
|
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
|
`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
|
## Permission registry
|
||||||
|
|
||||||
@@ -236,13 +272,37 @@ twice.
|
|||||||
|
|
||||||
`core/src/authorization/bootstrap.ts`'s `bootstrapPlatformOwner(db, {userId})` is a dedicated,
|
`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
|
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
|
call the existing grant function."
|
||||||
`ekklesia_core_platform_owner`, for any user, including one whose `revoked_at` is set. There is no
|
|
||||||
owner-management flow in this slice that clears that state; a revoked owner staying revoked (and
|
|
||||||
bootstrap staying consumed) is intentional here.
|
|
||||||
|
|
||||||
The single-use check, the target user's existence check, the owner-grant insert, and its audit
|
"Already consumed" means **the singleton row exists** in `ekklesia_core_platform_owner_bootstrap`
|
||||||
entry are all one PostgreSQL transaction. The very first statement inside that transaction is a
|
— 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:
|
transaction-scoped advisory lock:
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
@@ -281,7 +341,7 @@ path, or stack — only a stable `code` and a fixed message:
|
|||||||
|
|
||||||
| Code | Meaning |
|
| 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_USER_NOT_FOUND` | `--user-id` does not match an existing identity user. |
|
||||||
| `PLATFORM_OWNER_BOOTSTRAP_FAILED` | Any other failure (database/infrastructure). |
|
| `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 |
|
| Route | Origin required | Notes |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `GET /api/v1/grants?userId=<id>` | No | Lists the subject's explicit grants only (`sourceKind: "explicit"` filter applied server-side). |
|
| `GET /api/v1/grants?userId=<id>&limit=<1..100>&cursor=<opaque>` | 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 explicit grant. |
|
| `POST /api/v1/grants` | Yes | Creates a new **unscoped** explicit grant. |
|
||||||
| `POST /api/v1/grants/:grantId/revoke` | Yes | Revokes an explicit grant without deleting it. |
|
| `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
|
### `POST /api/v1/grants` request body
|
||||||
|
|
||||||
Only four keys are accepted; any other key present (`actor`, `owner`, `isOwner`, `sourceKind`,
|
Only three keys are accepted; any other key present (`scope`, `actor`, `owner`, `isOwner`,
|
||||||
`sourceRef`, `id`, `createdAt`, `revokedAt`, `createdByActorId`, `effectivePermissions`, or
|
`sourceKind`, `sourceRef`, `id`, `createdAt`, `revokedAt`, `createdByActorId`,
|
||||||
anything else) makes the **entire body** invalid rather than being silently dropped, so a caller
|
`effectivePermissions`, or anything else) makes the **entire body** invalid rather than being
|
||||||
cannot smuggle a server-derived field through a request that otherwise looks well-formed:
|
silently dropped, so a caller cannot smuggle a server-derived field through a request that
|
||||||
|
otherwise looks well-formed:
|
||||||
|
|
||||||
| Field | Required | Notes |
|
| Field | Required | Notes |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `subjectUserId` | Yes | Must parse as a valid `EkklesiaUserId`. |
|
| `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`. |
|
| `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 | Exactly `YYYY-MM-DDTHH:mm:ss.sssZ`, byte-for-byte equal to the parsed date's own `toISOString()`, and strictly in the future. |
|
||||||
| `expiresAt` | No | An ISO date string if present. |
|
|
||||||
|
|
||||||
The server always derives, and the request body can never supply: the grant id, `sourceKind`
|
The server always derives, and the request body can never supply: the grant id, `sourceKind`
|
||||||
(always `"explicit"`), the actor (always the authenticated caller, via
|
(always `"explicit"`), the actor (always the authenticated caller, via
|
||||||
`createUserActor(principal.userId)`), and all audit/timestamp fields.
|
`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
|
### Response shapes
|
||||||
|
|
||||||
A grant, wherever returned:
|
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}`.
|
`POST /api/v1/grants/:grantId/revoke` → `200 {"success": true}`.
|
||||||
|
|
||||||
|
`GET /api/v1/grants` → `200 {"grants": [...], "nextCursor": <string|null>}`.
|
||||||
|
|
||||||
|
### Listing is bounded keyset pagination
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /api/v1/grants?userId=<id>&limit=<1..100>&cursor=<opaque>
|
||||||
|
```
|
||||||
|
|
||||||
|
| 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-code mapping
|
||||||
|
|
||||||
| Status | When |
|
| Status | When |
|
||||||
@@ -387,7 +542,8 @@ A grant, wherever returned:
|
|||||||
| `400 {"code":"INVALID_REQUEST"}` | Malformed query/body/param (including a disallowed extra field). |
|
| `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":"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. |
|
| `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). |
|
| `503` | Principal resolution, evaluation, or store failure (`PRINCIPAL_RESOLUTION_FAILED`, `AUTHORIZATION_EVALUATION_FAILED`, or an `AuthorizationStoreError` code). |
|
||||||
|
|
||||||
Every response sets `Cache-Control: no-store`.
|
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.
|
being imported.
|
||||||
- `core/tests/authorization-bootstrap.integration.ts` (live PostgreSQL) — successful bootstrap and
|
- `core/tests/authorization-bootstrap.integration.ts` (live PostgreSQL) — successful bootstrap and
|
||||||
its audit row, nonexistent-user rollback, second-bootstrap rejection, a revoked historical owner
|
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-
|
- `apps/api/tests/authorization-middleware.test.ts` — allow/deny/unregistered/infrastructure-
|
||||||
failure paths for `requirePermission`, and `requireTrustedOrigin`'s allow/reject behavior;
|
failure paths for `requirePermission`, and `requireTrustedOrigin`'s allow/reject behavior;
|
||||||
a dedicated block proves `requirePermission` enforces module-owned `public`/`authenticated`/
|
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
|
unexpected-exception fallback to a fixed generic detail, schema/migration checks continuing
|
||||||
independently after a composition failure, and the exact documented terminal report shape.
|
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
|
- `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,
|
`403` and `store.createGrant` never called, exact server-derived-argument assertions,
|
||||||
sourceKind-filtered revoke returning 404 for a non-explicit grant, origin enforcement, and
|
sourceKind-filtered revoke returning 404 for a non-explicit grant, origin enforcement, and
|
||||||
store-error mapping.
|
store-error mapping.
|
||||||
|
|||||||
@@ -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
|
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.
|
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
|
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,
|
`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
|
installed test module (`core/tests/fixtures/module-database-integration/db-facade/` — never the
|
||||||
|
|||||||
Reference in New Issue
Block a user