phase-5/security-hardening #14
@@ -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