From a13ce30c1a870ddbe64d0711ad8c150aa8ded1b8 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Wed, 2 Sep 2026 20:20:14 +0800 Subject: [PATCH 1/3] feat(authority): enforce PostgreSQL tenant isolation Signed-off-by: huangruiteng --- .../coordination/authority_store.ts | 5 +- .../postgresql_authority_store.ts | 286 ++++++++++++------ .../control_plane_ts/authority_store.test.ts | 10 + ...gresql_authority_store.integration.test.ts | 162 ++++++++++ 4 files changed, 371 insertions(+), 92 deletions(-) diff --git a/loopx/control_plane/coordination/authority_store.ts b/loopx/control_plane/coordination/authority_store.ts index 21727d434a..45e316364d 100644 --- a/loopx/control_plane/coordination/authority_store.ts +++ b/loopx/control_plane/coordination/authority_store.ts @@ -72,9 +72,10 @@ export const AUTHORITY_STORE_PROVIDER_PROFILES = { atomic_commit_mapping: "one_sql_transaction_over_head_events_and_receipts", receipt_and_cursor_mapping: "unique_operation_row_and_per_goal_sequence", store_lineage_mapping: "service_managed_database_incarnation", - trust_boundary: "authenticated_tenant_scoped_loopx_service_role", + trust_boundary: "transaction_local_tenant_scoped_service_database_role", qualification_holds: [ - "service_authentication_database_role_and_audit_policy", + "service_api_authentication_and_tenant_authorization", + "service_role_provisioning_and_audit_policy", "restore_incarnation_rotation", "failover_pool_exhaustion_and_cancellation", "shadow_parity_and_authority_source_promotion", diff --git a/loopx/control_plane/coordination/postgresql_authority_store.ts b/loopx/control_plane/coordination/postgresql_authority_store.ts index 1bc48a84ce..bcee525136 100644 --- a/loopx/control_plane/coordination/postgresql_authority_store.ts +++ b/loopx/control_plane/coordination/postgresql_authority_store.ts @@ -33,7 +33,8 @@ export interface PostgreSqlAuthorityConnection { rows: readonly unknown[]; rowCount: number | null; }>; - release(error?: Error): void; + /** A supplied error marks unknown session state and must evict the connection. */ + release(error?: Error): void | Promise; } export interface PostgreSqlAuthorityDatabase { @@ -120,6 +121,43 @@ CREATE TABLE IF NOT EXISTS loopx_control_plane.authority_receipts ( REFERENCES loopx_control_plane.authority_commits (tenant_id, goal_id, cursor) ON DELETE CASCADE ); + +ALTER TABLE loopx_control_plane.authority_heads ENABLE ROW LEVEL SECURITY; +ALTER TABLE loopx_control_plane.authority_heads FORCE ROW LEVEL SECURITY; +ALTER TABLE loopx_control_plane.authority_commits ENABLE ROW LEVEL SECURITY; +ALTER TABLE loopx_control_plane.authority_commits FORCE ROW LEVEL SECURITY; +ALTER TABLE loopx_control_plane.authority_events ENABLE ROW LEVEL SECURITY; +ALTER TABLE loopx_control_plane.authority_events FORCE ROW LEVEL SECURITY; +ALTER TABLE loopx_control_plane.authority_receipts ENABLE ROW LEVEL SECURITY; +ALTER TABLE loopx_control_plane.authority_receipts FORCE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS authority_heads_tenant_scope + ON loopx_control_plane.authority_heads; +CREATE POLICY authority_heads_tenant_scope + ON loopx_control_plane.authority_heads + USING (tenant_id = current_setting('loopx.tenant_id', TRUE)) + WITH CHECK (tenant_id = current_setting('loopx.tenant_id', TRUE)); + +DROP POLICY IF EXISTS authority_commits_tenant_scope + ON loopx_control_plane.authority_commits; +CREATE POLICY authority_commits_tenant_scope + ON loopx_control_plane.authority_commits + USING (tenant_id = current_setting('loopx.tenant_id', TRUE)) + WITH CHECK (tenant_id = current_setting('loopx.tenant_id', TRUE)); + +DROP POLICY IF EXISTS authority_events_tenant_scope + ON loopx_control_plane.authority_events; +CREATE POLICY authority_events_tenant_scope + ON loopx_control_plane.authority_events + USING (tenant_id = current_setting('loopx.tenant_id', TRUE)) + WITH CHECK (tenant_id = current_setting('loopx.tenant_id', TRUE)); + +DROP POLICY IF EXISTS authority_receipts_tenant_scope + ON loopx_control_plane.authority_receipts; +CREATE POLICY authority_receipts_tenant_scope + ON loopx_control_plane.authority_receipts + USING (tenant_id = current_setting('loopx.tenant_id', TRUE)) + WITH CHECK (tenant_id = current_setting('loopx.tenant_id', TRUE)); `; const SELECT_HEAD_SQL = ` @@ -246,12 +284,41 @@ function readFailure(error: unknown): AuthorityStoreReadFailure { }; } -async function rollback(connection: PostgreSqlAuthorityConnection): Promise { +function asError(error: unknown): Error { + return error instanceof Error ? error : new Error("PostgreSQL connection failed"); +} + +async function rollback(connection: PostgreSqlAuthorityConnection): Promise { try { await connection.query("ROLLBACK"); - } catch { + return null; + } catch (error) { // A failed connection also causes PostgreSQL to discard an uncommitted - // transaction. No COMMIT was attempted at this point. + // transaction. The caller must evict that connection from its pool. + return asError(error); + } +} + +async function beginTenantTransaction( + connection: PostgreSqlAuthorityConnection, + tenantId: string, + options: { readOnly: boolean }, +): Promise { + await connection.query(options.readOnly ? "BEGIN READ ONLY" : "BEGIN"); + try { + const context = oneRow(await connection.query( + "SELECT set_config('loopx.tenant_id', $1, TRUE) AS tenant_id", + [tenantId], + ), "PostgreSQL tenant context"); + if (context?.tenant_id !== tenantId) { + throw new AuthorityStoreProtocolError( + "PostgreSQL transaction did not retain the requested tenant context", + ); + } + } catch (error) { + const rollbackError = await rollback(connection); + if (rollbackError !== null) throw rollbackError; + throw error; } } @@ -287,6 +354,7 @@ export async function installPostgreSqlAuthorityStoreSchema( } const connection = await database.connect(); let commitStarted = false; + let releaseError: Error | undefined; try { await connection.query("BEGIN"); await connection.query(POSTGRESQL_AUTHORITY_STORE_SCHEMA_SQL); @@ -314,10 +382,14 @@ export async function installPostgreSqlAuthorityStoreSchema( commitStarted = true; await connection.query("COMMIT"); } catch (error) { - if (!commitStarted) await rollback(connection); + if (commitStarted) { + releaseError = asError(error); + } else { + releaseError = (await rollback(connection)) ?? undefined; + } throw error; } finally { - connection.release(); + await connection.release(releaseError); } } @@ -340,43 +412,74 @@ export class PostgreSqlAuthorityStore implements AuthorityStore { return await this.database.connect(); } + private async readInTenantTransaction( + operation: (connection: PostgreSqlAuthorityConnection) => Promise, + ): Promise { + const connection = await this.connect(); + let transactionOpen = false; + let releaseError: Error | undefined; + try { + await beginTenantTransaction(connection, this.tenantId, { readOnly: true }); + transactionOpen = true; + const result = await operation(connection); + const rollbackError = await rollback(connection); + transactionOpen = false; + if (rollbackError !== null) { + releaseError = rollbackError; + throw rollbackError; + } + return result; + } catch (error) { + if (transactionOpen) { + releaseError = (await rollback(connection)) ?? undefined; + } else if (releaseError === undefined) { + // BEGIN or tenant-context setup may have failed after reaching the + // server. Do not return a session with unknown state to the pool. + releaseError = asError(error); + } + throw error; + } finally { + await connection.release(releaseError); + } + } + async storeIdentity(): Promise { let connection: PostgreSqlAuthorityConnection | null = null; + let releaseError: Error | undefined; try { connection = await this.connect(); return { status: "available", store_identity: await requireStoreIdentity(connection) }; } catch (error) { + releaseError = asError(error); const failure = readFailure(error); return failure.status === "failed" ? { ...failure, reason_code: "store_identity_invalid" } : { ...failure, reason_code: "store_identity_unavailable" }; } finally { - connection?.release(); + await connection?.release(releaseError); } } async loadAuthority(): Promise { - let connection: PostgreSqlAuthorityConnection | null = null; try { - connection = await this.connect(); - const storeIdentity = await requireStoreIdentity(connection); - const value = oneRow( - await connection.query(SELECT_HEAD_SQL, [this.tenantId, this.goalId]), - "PostgreSQL authority head", - ); - if (value === null) return { status: "missing" }; - const head = decodeHeadRow(value); - if (head.head === null) return { status: "missing" }; - return { - status: "loaded", - head: structuredClone(head.head), - provider_revision: providerRevisionToken(storeIdentity, head.provider_revision), - cursor: head.cursor, - }; + return await this.readInTenantTransaction(async (connection) => { + const storeIdentity = await requireStoreIdentity(connection); + const value = oneRow( + await connection.query(SELECT_HEAD_SQL, [this.tenantId, this.goalId]), + "PostgreSQL authority head", + ); + if (value === null) return { status: "missing" } as const; + const head = decodeHeadRow(value); + if (head.head === null) return { status: "missing" } as const; + return { + status: "loaded", + head: structuredClone(head.head), + provider_revision: providerRevisionToken(storeIdentity, head.provider_revision), + cursor: head.cursor, + } as const; + }); } catch (error) { return readFailure(error); - } finally { - connection?.release(); } } @@ -406,8 +509,9 @@ export class PostgreSqlAuthorityStore implements AuthorityStore { } let commitStarted = false; + let releaseError: Error | undefined; try { - await connection.query("BEGIN"); + await beginTenantTransaction(connection, this.tenantId, { readOnly: false }); const storeIdentity = await requireStoreIdentity(connection); await connection.query( `INSERT INTO loopx_control_plane.authority_heads @@ -429,7 +533,7 @@ export class PostgreSqlAuthorityStore implements AuthorityStore { currentRevision !== (expectedRevision?.revision ?? null) || (expectedRevision !== null && expectedRevision.store_identity !== storeIdentity) ) { - await rollback(connection); + releaseError = (await rollback(connection)) ?? undefined; return { status: "conflict", conflict_kind: "provider_revision_mismatch", @@ -447,7 +551,7 @@ export class PostgreSqlAuthorityStore implements AuthorityStore { [this.tenantId, this.goalId, normalized.operation_id], ), "PostgreSQL operation identity"); if (existing !== null) { - await rollback(connection); + releaseError = (await rollback(connection)) ?? undefined; return { status: "conflict", conflict_kind: "operation_id_exists", @@ -517,13 +621,14 @@ export class PostgreSqlAuthorityStore implements AuthorityStore { }; } catch (error) { if (commitStarted) { + releaseError = asError(error); return { status: "ambiguous", reason_code: "commit_outcome_unknown", reason: "PostgreSQL COMMIT outcome is unknown; reconcile by operation receipt", }; } - await rollback(connection); + releaseError = (await rollback(connection)) ?? undefined; return { status: "failed", reason_code: error instanceof AuthorityStoreProtocolError @@ -534,7 +639,7 @@ export class PostgreSqlAuthorityStore implements AuthorityStore { : "PostgreSQL transaction failed before COMMIT", }; } finally { - connection.release(); + await connection.release(releaseError); } } @@ -549,27 +654,25 @@ export class PostgreSqlAuthorityStore implements AuthorityStore { reason: error instanceof Error ? error.message : "invalid operation id", }; } - let connection: PostgreSqlAuthorityConnection | null = null; try { - connection = await this.connect(); - const storeIdentity = await requireStoreIdentity(connection); - const value = oneRow(await connection.query( - `${SELECT_TRANSACTION_COLUMNS_SQL} - WHERE commit.tenant_id = $1 AND commit.goal_id = $2 AND commit.operation_id = $3`, - [this.tenantId, this.goalId, normalized], - ), "PostgreSQL authority receipt"); - if (value === null) return { status: "missing" }; - const transaction = decodeTransactionRow(value); - return { - status: "found", - cursor: transaction.cursor, - provider_revision: providerRevisionToken(storeIdentity, transaction.provider_revision), - receipts: structuredClone(transaction.receipts), - }; + return await this.readInTenantTransaction(async (connection) => { + const storeIdentity = await requireStoreIdentity(connection); + const value = oneRow(await connection.query( + `${SELECT_TRANSACTION_COLUMNS_SQL} + WHERE commit.tenant_id = $1 AND commit.goal_id = $2 AND commit.operation_id = $3`, + [this.tenantId, this.goalId, normalized], + ), "PostgreSQL authority receipt"); + if (value === null) return { status: "missing" } as const; + const transaction = decodeTransactionRow(value); + return { + status: "found", + cursor: transaction.cursor, + provider_revision: providerRevisionToken(storeIdentity, transaction.provider_revision), + receipts: structuredClone(transaction.receipts), + } as const; + }); } catch (error) { return readFailure(error); - } finally { - connection?.release(); } } @@ -590,52 +693,55 @@ export class PostgreSqlAuthorityStore implements AuthorityStore { reason: error instanceof Error ? error.message : "invalid scan request", }; } - let connection: PostgreSqlAuthorityConnection | null = null; try { - connection = await this.connect(); - const storeIdentity = await requireStoreIdentity(connection); - const current = oneRow( - await connection.query(SELECT_HEAD_SQL, [this.tenantId, this.goalId]), - "PostgreSQL authority head", - ); - if (current === null) { - return { status: "page", transactions: [], next_cursor: afterCursor, has_more: false }; - } - const head = decodeHeadRow(current); - if (offset > BigInt(head.cursor)) { + return await this.readInTenantTransaction(async (connection) => { + const storeIdentity = await requireStoreIdentity(connection); + const current = oneRow( + await connection.query(SELECT_HEAD_SQL, [this.tenantId, this.goalId]), + "PostgreSQL authority head", + ); + if (current === null) { + return { + status: "page", + transactions: [], + next_cursor: afterCursor, + has_more: false, + } as const; + } + const head = decodeHeadRow(current); + if (offset > BigInt(head.cursor)) { + return { + status: "failed", + reason_code: "scan_cursor_out_of_range", + reason: "scan cursor is ahead of the provider head", + } as const; + } + const result = rows(await connection.query( + `${SELECT_TRANSACTION_COLUMNS_SQL} + WHERE commit.tenant_id = $1 AND commit.goal_id = $2 AND commit.cursor > $3::bigint + ORDER BY commit.cursor + LIMIT $4`, + [this.tenantId, this.goalId, offset.toString(), (BigInt(limit) + 1n).toString()], + )).map(decodeTransactionRow); + const hasMore = result.length > limit; + const page = result.slice(0, limit); + const transactions: AuthorityStoreCommittedTransaction[] = page.map((value) => ({ + cursor: value.cursor, + provider_revision: providerRevisionToken(storeIdentity, value.provider_revision), + operation_id: value.operation_id, + events: structuredClone(value.events), + projection: structuredClone(value.projection), + receipts: structuredClone(value.receipts), + })); return { - status: "failed", - reason_code: "scan_cursor_out_of_range", - reason: "scan cursor is ahead of the provider head", - }; - } - const result = rows(await connection.query( - `${SELECT_TRANSACTION_COLUMNS_SQL} - WHERE commit.tenant_id = $1 AND commit.goal_id = $2 AND commit.cursor > $3::bigint - ORDER BY commit.cursor - LIMIT $4`, - [this.tenantId, this.goalId, offset.toString(), (BigInt(limit) + 1n).toString()], - )).map(decodeTransactionRow); - const hasMore = result.length > limit; - const page = result.slice(0, limit); - const transactions: AuthorityStoreCommittedTransaction[] = page.map((value) => ({ - cursor: value.cursor, - provider_revision: providerRevisionToken(storeIdentity, value.provider_revision), - operation_id: value.operation_id, - events: structuredClone(value.events), - projection: structuredClone(value.projection), - receipts: structuredClone(value.receipts), - })); - return { - status: "page", - transactions, - next_cursor: transactions.at(-1)?.cursor ?? afterCursor, - has_more: hasMore, - }; + status: "page", + transactions, + next_cursor: transactions.at(-1)?.cursor ?? afterCursor, + has_more: hasMore, + } as const; + }); } catch (error) { return readFailure(error); - } finally { - connection?.release(); } } } diff --git a/tests/control_plane_ts/authority_store.test.ts b/tests/control_plane_ts/authority_store.test.ts index a9b1aa37b5..3dd160e5db 100644 --- a/tests/control_plane_ts/authority_store.test.ts +++ b/tests/control_plane_ts/authority_store.test.ts @@ -65,6 +65,16 @@ test("provider profiles map one logical contract onto different backend primitiv ); assert.equal(AUTHORITY_STORE_PROVIDER_PROFILES.postgresql.stage, "stage2b_candidate"); assert.match(AUTHORITY_STORE_PROVIDER_PROFILES.postgresql.trust_boundary, /tenant_scoped/); + assert.ok( + AUTHORITY_STORE_PROVIDER_PROFILES.postgresql.qualification_holds.includes( + "service_api_authentication_and_tenant_authorization", + ), + ); + assert.ok( + AUTHORITY_STORE_PROVIDER_PROFILES.postgresql.qualification_holds.includes( + "service_role_provisioning_and_audit_policy", + ), + ); assert.notDeepEqual( AUTHORITY_STORE_PROVIDER_PROFILES.file, AUTHORITY_STORE_PROVIDER_PROFILES.nokv, diff --git a/tests/control_plane_ts/postgresql_authority_store.integration.test.ts b/tests/control_plane_ts/postgresql_authority_store.integration.test.ts index 560bf9a838..6618288da7 100644 --- a/tests/control_plane_ts/postgresql_authority_store.integration.test.ts +++ b/tests/control_plane_ts/postgresql_authority_store.integration.test.ts @@ -5,6 +5,7 @@ import { Pool, type PoolClient } from "pg"; import { installPostgreSqlAuthorityStoreSchema, + POSTGRESQL_AUTHORITY_STORE_SCHEMA_SQL, PostgreSqlAuthorityStore, type PostgreSqlAuthorityConnection, type PostgreSqlAuthorityDatabase, @@ -17,6 +18,12 @@ import { const connectionString = process.env.LOOPX_TEST_POSTGRES_URL; const pool = connectionString ? new Pool({ connectionString, max: 12 }) : null; const STORE_IDENTITY = `postgresql:${"b".repeat(32)}`; +const TENANT_SCOPED_TABLES = [ + "authority_heads", + "authority_commits", + "authority_events", + "authority_receipts", +] as const; function wrapClient(client: PoolClient): PostgreSqlAuthorityConnection { return { @@ -29,6 +36,66 @@ function databaseFromPool(value: Pool): PostgreSqlAuthorityDatabase { return { connect: async () => wrapClient(await value.connect()) }; } +function quotedRole(role: string): string { + assert.match(role, /^[a-z][a-z0-9_]+$/); + return `"${role}"`; +} + +async function createRuntimeRole(role: string): Promise { + const identifier = quotedRole(role); + await pool!.query(`CREATE ROLE ${identifier} NOLOGIN`); + await pool!.query(`GRANT USAGE ON SCHEMA loopx_control_plane TO ${identifier}`); + await pool!.query( + `GRANT SELECT ON loopx_control_plane.authority_store_metadata TO ${identifier}`, + ); + await pool!.query( + `GRANT SELECT, INSERT, UPDATE ON loopx_control_plane.authority_heads TO ${identifier}`, + ); + await pool!.query( + `GRANT SELECT, INSERT ON loopx_control_plane.authority_commits, + loopx_control_plane.authority_events, + loopx_control_plane.authority_receipts TO ${identifier}`, + ); +} + +function databaseForRuntimeRole(value: Pool, role: string): PostgreSqlAuthorityDatabase { + const identifier = quotedRole(role); + return { + connect: async () => { + const client = await value.connect(); + await client.query("RESET ROLE"); + await client.query(`SET ROLE ${identifier}`); + return { + query: async (text, values) => + await client.query(text, values ? [...values] : undefined), + release: async (error) => { + try { + await client.query("RESET ROLE"); + client.release(error); + } catch (resetError) { + client.release(resetError as Error); + } + }, + }; + }, + }; +} + +async function queryAsRuntimeRole( + value: Pool, + role: string, + operation: (client: PoolClient) => Promise, +): Promise { + const client = await value.connect(); + try { + await client.query(`SET ROLE ${quotedRole(role)}`); + await operation(client); + } finally { + await client.query("RESET ROLE"); + client.release(); + } +} + const database = pool ? databaseFromPool(pool) : null; const installed = database ? installPostgreSqlAuthorityStoreSchema(database, STORE_IDENTITY) @@ -192,6 +259,84 @@ if (database && installed) { /database incarnation/, ); }); + + test("PostgreSQL runtime role is confined by transaction-local tenant RLS", async (t) => { + await installed; + const role = `loopx_test_runtime_${randomUUID().replaceAll("-", "")}`; + const firstTenant = `tenant-${randomUUID()}`; + const secondTenant = `tenant-${randomUUID()}`; + const goalId = `goal-${randomUUID()}`; + const runtimePool = new Pool({ connectionString, max: 1 }); + await createRuntimeRole(role); + t.after(async () => { + await runtimePool.end(); + await cleanScope(firstTenant, goalId); + await cleanScope(secondTenant, goalId); + await pool!.query(`DROP OWNED BY ${quotedRole(role)}`); + await pool!.query(`DROP ROLE ${quotedRole(role)}`); + }); + + const runtimeDatabase = databaseForRuntimeRole(runtimePool, role); + const first = new PostgreSqlAuthorityStore(runtimeDatabase, { + tenant_id: firstTenant, + goal_id: goalId, + }); + const second = new PostgreSqlAuthorityStore(runtimeDatabase, { + tenant_id: secondTenant, + goal_id: goalId, + }); + assert.equal((await first.commitAuthority(commit(null, "operation-a", 1, 1))).status, "applied"); + assert.equal((await second.commitAuthority(commit(null, "operation-b", 1, 1))).status, "applied"); + assert.equal((await first.loadAuthority()).status, "loaded"); + assert.equal((await second.readReceipt("operation-b")).status, "found"); + + await queryAsRuntimeRole(runtimePool, role, async (client) => { + for (const table of TENANT_SCOPED_TABLES) { + const unscoped = await client.query( + `SELECT tenant_id FROM loopx_control_plane.${table}`, + ); + assert.equal(unscoped.rowCount, 0, `${table} must fail closed without tenant context`); + } + + await client.query("BEGIN READ ONLY"); + await client.query( + "SELECT set_config('loopx.tenant_id', $1, TRUE)", + [firstTenant], + ); + for (const table of TENANT_SCOPED_TABLES) { + const scoped = await client.query( + `SELECT DISTINCT tenant_id FROM loopx_control_plane.${table}`, + ); + assert.deepEqual(scoped.rows, [{ tenant_id: firstTenant }]); + } + await client.query("ROLLBACK"); + + await client.query("BEGIN"); + await client.query( + "SELECT set_config('loopx.tenant_id', $1, TRUE)", + [firstTenant], + ); + await assert.rejects( + client.query( + `INSERT INTO loopx_control_plane.authority_heads + (tenant_id, goal_id, provider_revision, cursor, head) + VALUES ($1, $2, 0, 0, NULL)`, + [secondTenant, `forbidden-${randomUUID()}`], + ), + /row-level security policy/, + ); + await client.query("ROLLBACK"); + + await assert.rejects( + client.query( + `UPDATE loopx_control_plane.authority_store_metadata + SET store_identity = $1 WHERE singleton = TRUE`, + [`postgresql:${"d".repeat(32)}`], + ), + /permission denied/, + ); + }); + }); } else { test("PostgreSQL authority-store integration (set LOOPX_TEST_POSTGRES_URL)", { skip: true }, () => {}); } @@ -199,3 +344,20 @@ if (database && installed) { test.after(async () => { await pool?.end(); }); + +test("PostgreSQL schema declares fail-closed tenant RLS on every scoped table", () => { + for (const table of TENANT_SCOPED_TABLES) { + assert.match( + POSTGRESQL_AUTHORITY_STORE_SCHEMA_SQL, + new RegExp(`ALTER TABLE loopx_control_plane\\.${table} FORCE ROW LEVEL SECURITY`), + ); + assert.match( + POSTGRESQL_AUTHORITY_STORE_SCHEMA_SQL, + new RegExp(`CREATE POLICY ${table}_tenant_scope`), + ); + } + assert.match( + POSTGRESQL_AUTHORITY_STORE_SCHEMA_SQL, + /current_setting\('loopx\.tenant_id', TRUE\)/, + ); +}); From f731632fa04dc2ab4ab5c72fc0a2d1913365a67a Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Wed, 2 Sep 2026 20:20:14 +0800 Subject: [PATCH 2/3] docs(rfc): define PostgreSQL trust boundary Signed-off-by: huangruiteng --- ...shared-goal-authority-state-provider-v0.md | 55 +++++++++++++------ ...-goal-authority-state-provider-v0.zh-CN.md | 46 +++++++++++----- 2 files changed, 69 insertions(+), 32 deletions(-) diff --git a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md index 13bae66bd8..6ff24b079d 100644 --- a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md +++ b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md @@ -13,9 +13,10 @@ qualification admits only that SDK contract and this checkout's helper. It remains candidate evidence, not a merge gate or authority promotion - PostgreSQL baseline: the TypeScript Stage 2B candidate implements the store - contract and has passed a real PostgreSQL 16 transaction matrix. No shared - authority service, runtime caller, authentication boundary, or authority - promotion ships yet + contract, transaction-local tenant context, forced row-level security, and + has passed a real PostgreSQL 16 transaction matrix. No shared authority + service, runtime caller, principal authentication/tenant authorization, or + authority promotion ships yet - Language note: the [Chinese version](./shared-goal-authority-state-provider-v0.zh-CN.md) and this English version are semantic mirrors. A difference between them is a defect. @@ -1136,7 +1137,7 @@ independent version domains. Likewise, a restore may preserve frozen bytes and lineage without granting current authority: promoting restored state to the live authority head requires an explicit lineage and binding fence. -#### Stage 2B PostgreSQL candidate status (2026-09-01) +#### Stage 2B PostgreSQL candidate status (2026-09-02) The first PostgreSQL candidate now implements the LoopX-owned TypeScript store contract instead of introducing a second semantic authority. A store handle is @@ -1151,6 +1152,21 @@ An error before `COMMIT` is rolled back and typed `failed`; an error after the receipt readback. Database-incarnation metadata is installed administratively and cannot be rebound implicitly. +The database trust-boundary slice now gives each provider operation a +transaction-local `loopx.tenant_id` context and enables plus forces PostgreSQL +row-level security on every tenant-scoped table. Reads use a read-only +transaction and roll it back before returning, so a pooled session cannot +retain a previous tenant context. A missing context sees no scoped rows; +`WITH CHECK` rejects a row for any tenant other than the active context. The +qualified restricted-role profile receives only schema usage, metadata read, +and the minimum scoped table privileges, so it cannot rebind +database-incarnation metadata or install schema policy. + +This is defense in depth inside the service, not tenant authentication. The +service still owns the database role and chooses the transaction context after +authenticating a principal and authorizing its tenant. An Agent never receives +that role, and RLS does not make a caller-supplied tenant id trustworthy. + This slice also moves strict JSON validation and commit normalization out of the file implementation into one TypeScript authority-store codec. File and PostgreSQL now run the same provider-neutral conformance suite for atomic @@ -1159,22 +1175,27 @@ operation fencing, ordered cursor scans, isolation of returned values, and pre-write rejection of malformed JSON. Real PostgreSQL qualification starts here, not at shadow or canary. A -PostgreSQL 16 instance passed nine durable rows: the shared conformance matrix, -same-head concurrent CAS, tenant-scoped reuse of the same goal and operation -ids, transaction rollback with no visible head or receipt, receipt recovery -after a committed transaction loses its response, and database-incarnation -rebind refusal. A fake can still exercise adapter branches, but it cannot prove -row locking, unique constraints, rollback, or commit visibility; every later -PostgreSQL provider slice must therefore retain a real-database gate. +PostgreSQL 16 instance passed the shared conformance matrix, same-head +concurrent CAS, tenant-scoped reuse of the same goal and operation ids, +transaction rollback with no visible head or receipt, receipt recovery after a +committed transaction loses its response, database-incarnation rebind refusal, +and a restricted-role two-tenant RLS matrix. The latter proves that missing +transaction context exposes no scoped row, cross-context writes fail, and the +runtime role cannot mutate administrative metadata. A fake can still exercise +adapter branches, but it cannot prove row locking, unique constraints, +rollback, commit visibility, privileges, or RLS; every later PostgreSQL +provider slice must therefore retain a real-database gate. The candidate remains coverage-only. No production LoopX entry point constructs it, local mode remains unchanged, and Agents cannot receive the injected pool. -Service authentication and database roles, tenant authorization/RLS, restore -incarnation rotation, pool exhaustion/cancellation/failover, one-way shadow -parity, and authority-source promotion remain explicit holds. The expected -route to the TEST ONLY canary is three further reviewed slices: service trust -and deployment boundaries; one-way runtime shadow plus parity; then the bounded -canary and promotion gate. +The database runtime-role and RLS behavior within the service trust boundary is +now implemented and qualified. Service API authentication, +principal-to-tenant authorization, the production runtime caller, +restore-incarnation rotation, pool +exhaustion/cancellation/failover, one-way shadow parity, the TEST ONLY canary, +and authority-source promotion remain explicit holds. The next PostgreSQL +slice must qualify the authenticated service/deployment and failure boundary; +it must not treat database RLS as that missing API authorization layer. The file-backed provider shadow is Stage 2; its first slice is merged on `main` through #3529, and the evidence behind it is recorded in the Stage 2 diff --git a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md index 82e6ce9a95..4de2427c88 100644 --- a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md +++ b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md @@ -12,9 +12,10 @@ (release 0.11.0、Python API 1、Holt 固定为 0.8.6)。Stage 2A 的可执行资格 验证只接受这份 SDK 合同与本 checkout 的 helper;它仍是候选证据,不是合并门槛 或 authority promotion -- PostgreSQL 基线:TypeScript Stage 2B candidate 已实现 store contract,且已通过 - 真实 PostgreSQL 16 transaction matrix;shared authority service、runtime caller、 - authentication boundary 与 authority promotion 均尚未交付 +- PostgreSQL 基线:TypeScript Stage 2B candidate 已实现 store contract、 + transaction-local tenant context 与 forced row-level security,且已通过真实 + PostgreSQL 16 transaction matrix;shared authority service、runtime caller、 + principal authentication/tenant authorization 与 authority promotion 均尚未交付 - 语言说明:[英文版](./shared-goal-authority-state-provider-v0.md)与本中文版互为 语义镜像;两者不一致属于缺陷 @@ -915,7 +916,7 @@ Stage 2 的 aggregate 与 provider shadow。该 aggregate 必须把 `handoff_mod lineage,却不会因此获得当前权威;把恢复状态晋升为 live authority head,必须经过 显式的 lineage 与 binding fence。 -#### Stage 2B PostgreSQL candidate 状态(2026-09-01) +#### Stage 2B PostgreSQL candidate 状态(2026-09-02) 首个 PostgreSQL candidate 已实现由 LoopX 持有的 TypeScript store contract,而非 引入第二个语义权威。Store handle 绑定 `(tenant_id, goal_id)`,只接收 service 持有的 @@ -927,6 +928,18 @@ scoped head row,校验 opaque provider revision,以 unique constraint fence 的错误返回 typed `ambiguous`,只能通过 receipt readback reconcile。Database incarnation metadata 由行政部署路径安装,不能被隐式重新绑定。 +数据库 trust-boundary 切片现在会为每次 provider operation 设置 transaction-local +`loopx.tenant_id` context,并在所有 tenant-scoped table 上同时 enable 与 force +PostgreSQL row-level security。读操作使用 read-only transaction,并在返回前 +rollback,因此 pooled session 不会残留上一个 tenant context。缺少 context 时看不到 +任何 scoped row;`WITH CHECK` 会拒绝写入 active context 之外的 tenant。资格化所用的 +restricted-role profile 只获得 schema usage、metadata read 与 scoped table 所需的最小 +权限,因此不能重新绑定 database-incarnation metadata,也不能安装 schema policy。 + +这只是 service 内部的 defense in depth,不是 tenant authentication。Service 仍持有 +database role,并且必须先认证 principal、授权其 tenant,才能选择 transaction context。 +Agent 永远拿不到该 role;RLS 也不会让 caller 自报的 tenant id 自动变可信。 + 本切片还把 strict JSON validation 与 commit normalization 从 file 实现抽到统一的 TypeScript authority-store codec。File 与 PostgreSQL 现在运行同一套 provider-neutral conformance suite,覆盖 projection-plus-receipt 原子提交、CAS contention、历史 receipt @@ -934,19 +947,22 @@ replay、operation fencing、有序 cursor scan、返回值隔离,以及 malfo 被拒绝。 真实 PostgreSQL qualification 从这里开始,而不是等到 shadow 或 canary。一个真实 -PostgreSQL 16 实例已通过九行 durable 验证:共享 conformance matrix、同一 head 的并发 -CAS、不同 tenant 复用相同 goal 与 operation id、transaction rollback 后不暴露 head -或 receipt、已提交 transaction 丢失响应后的 receipt 恢复,以及拒绝 database -incarnation rebind。Fake 仍可覆盖 adapter 分支,但不能证明 row lock、unique -constraint、rollback 或 commit visibility;因此后续每个 PostgreSQL provider 切片都 -必须保留真实数据库门禁。 +PostgreSQL 16 实例已通过共享 conformance matrix、同一 head 的并发 CAS、不同 tenant +复用相同 goal 与 operation id、transaction rollback 后不暴露 head 或 receipt、已提交 +transaction 丢失响应后的 receipt 恢复、拒绝 database incarnation rebind,以及受限 +role 的双 tenant RLS matrix。最后一项证明:缺少 transaction context 时看不到 scoped +row,跨 context 写入失败,runtime role 也不能修改行政 metadata。Fake 仍可覆盖 adapter +分支,但不能证明 row lock、unique constraint、rollback、commit visibility、privilege +或 RLS;因此后续每个 PostgreSQL provider 切片都必须保留真实数据库门禁。 该 candidate 仍是 coverage-only。没有 production LoopX entry point 构造它,本地模式 -保持不变,Agent 也不能获得注入的 pool。Service authentication 与 database role、 -tenant authorization/RLS、restore incarnation rotation、pool exhaustion/cancellation/ -failover、单向 shadow parity 与 authority-source promotion 仍是显式 hold。从这里到 -TEST ONLY canary,预计还需三个经 review 的切片:service trust 与 deployment boundary; -单向 runtime shadow 加 parity;最后是有界 canary 与 promotion gate。 +保持不变,Agent 也不能获得注入的 pool。Service trust boundary 内的数据库 +runtime-role/RLS 行为现已实现并完成资格化。Service API authentication、 +principal-to-tenant authorization、production runtime caller、restore +incarnation rotation、pool exhaustion/cancellation/failover、单向 shadow parity、TEST +ONLY canary 与 authority-source promotion 仍是显式 hold。下一个 PostgreSQL 切片必须 +资格化 authenticated service/deployment 与 failure boundary,不能把 database RLS 当成 +仍缺失的 API authorization layer。 File-backed provider shadow 属于 Stage 2;其第一个切片已通过 #3529 合入 `main`,证据记录在下方的 Stage 2 状态小节。 From d48e17c02c482b377289d1e62b154d383204180e Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Thu, 3 Sep 2026 02:59:15 +0800 Subject: [PATCH 3/3] test(authority): cover uncertain PostgreSQL read cleanup Signed-off-by: huangruiteng --- ...gresql_authority_store.integration.test.ts | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/tests/control_plane_ts/postgresql_authority_store.integration.test.ts b/tests/control_plane_ts/postgresql_authority_store.integration.test.ts index 6618288da7..0274c2b841 100644 --- a/tests/control_plane_ts/postgresql_authority_store.integration.test.ts +++ b/tests/control_plane_ts/postgresql_authority_store.integration.test.ts @@ -361,3 +361,63 @@ test("PostgreSQL schema declares fail-closed tenant RLS on every scoped table", /current_setting\('loopx\.tenant_id', TRUE\)/, ); }); + +test("PostgreSQL provider evicts and awaits cleanup-uncertain read connections", async () => { + const tenantId = "tenant-cleanup-fault"; + const rollbackFailure = new Error("injected rollback failure"); + const queries: string[] = []; + let finishRelease: (() => void) | undefined; + let resultSettled = false; + const releaseGate = new Promise((resolve) => { + finishRelease = resolve; + }); + let reportRelease: ((error: Error | undefined) => void) | undefined; + const releaseStarted = new Promise((resolve) => { + reportRelease = resolve; + }); + const faultingDatabase: PostgreSqlAuthorityDatabase = { + connect: async () => ({ + query: async (text) => { + queries.push(text); + if (text === "ROLLBACK") throw rollbackFailure; + if (text.includes("set_config")) { + return { rows: [{ tenant_id: tenantId }], rowCount: 1 }; + } + if (text.includes("authority_store_metadata")) { + return { + rows: [{ schema_version: "loopx_postgresql_authority_store_v0", store_identity: STORE_IDENTITY }], + rowCount: 1, + }; + } + if (text.includes("authority_heads")) { + return { rows: [], rowCount: 0 }; + } + return { rows: [], rowCount: 0 }; + }, + release: async (error) => { + reportRelease?.(error); + await releaseGate; + }, + }), + }; + const store = new PostgreSqlAuthorityStore(faultingDatabase, { + tenant_id: tenantId, + goal_id: "goal-cleanup-fault", + }); + + const resultPromise = store.loadAuthority().then((result) => { + resultSettled = true; + return result; + }); + assert.strictEqual(await releaseStarted, rollbackFailure); + assert.equal(resultSettled, false, "read result must wait for asynchronous connection eviction"); + assert.ok(finishRelease); + finishRelease(); + + assert.deepEqual(await resultPromise, { + status: "unavailable", + reason_code: "provider_read_unavailable", + reason: "PostgreSQL authority store is unavailable", + }); + assert.equal(queries.filter((query) => query === "ROLLBACK").length, 1); +});