diff --git a/docs/superpowers/plans/2026-07-20-postgres-quality-gate-foundation.md b/docs/superpowers/plans/2026-07-20-postgres-quality-gate-foundation.md index d93c96a1..9da03f40 100644 --- a/docs/superpowers/plans/2026-07-20-postgres-quality-gate-foundation.md +++ b/docs/superpowers/plans/2026-07-20-postgres-quality-gate-foundation.md @@ -500,17 +500,11 @@ alter default privileges for role schema_owner in schema public revoke all on tables from public; alter default privileges for role schema_owner in schema audit revoke all on tables from public; -alter default privileges for role schema_owner in schema identity - grant select, insert, update, delete on tables to identity_runtime; -alter default privileges for role schema_owner in schema identity - grant select on tables to admin_runtime; -alter default privileges for role schema_owner in schema public - grant select, insert, update, delete on tables to app_runtime, admin_runtime; -alter default privileges for role schema_owner in schema audit - grant select, insert on tables to admin_runtime; ``` -Do not create business or auth tables. +Do not create business or auth tables. The foundation grants schema discovery only; +later reviewed migrations grant access to named tables and narrow functions. Never +grant runtime roles broad default DML on future tables. - [ ] **Step 7: Put runner in final web image** diff --git a/frontend/db/migrations/20260720000100_backend_foundation.sql b/frontend/db/migrations/20260720000100_backend_foundation.sql index 85d67e42..ca47fee9 100644 --- a/frontend/db/migrations/20260720000100_backend_foundation.sql +++ b/frontend/db/migrations/20260720000100_backend_foundation.sql @@ -13,11 +13,3 @@ alter default privileges for role schema_owner in schema public revoke all on tables from public; alter default privileges for role schema_owner in schema audit revoke all on tables from public; -alter default privileges for role schema_owner in schema identity - grant select, insert, update, delete on tables to identity_runtime; -alter default privileges for role schema_owner in schema identity - grant select on tables to admin_runtime; -alter default privileges for role schema_owner in schema public - grant select, insert, update, delete on tables to app_runtime, admin_runtime; -alter default privileges for role schema_owner in schema audit - grant select, insert on tables to admin_runtime; diff --git a/frontend/scripts/db-migrate.mjs b/frontend/scripts/db-migrate.mjs index 217e6f8c..737b9a73 100644 --- a/frontend/scripts/db-migrate.mjs +++ b/frontend/scripts/db-migrate.mjs @@ -17,6 +17,18 @@ async function loadMigrationFiles(migrationsDirectory) { throw new SafeMigrationError("unable to read migrations directory"); } + const malformedSqlEntry = entries.find( + (entry) => + entry.isFile() && + entry.name.endsWith(".sql") && + !migrationFilenamePattern.test(entry.name), + ); + if (malformedSqlEntry) { + throw new SafeMigrationError( + `invalid migration filename: ${malformedSqlEntry.name}`, + ); + } + return Promise.all( entries .filter( diff --git a/frontend/tests/database-foundation.test.ts b/frontend/tests/database-foundation.test.ts index bd32b0ad..dda1715d 100644 --- a/frontend/tests/database-foundation.test.ts +++ b/frontend/tests/database-foundation.test.ts @@ -20,6 +20,7 @@ const migrationFilename = "20260720000100_backend_foundation.sql"; const pendingFilename = "20260720000200_pending_check.sql"; const concurrentFilename = "20260720000300_concurrent_lock.sql"; const failingFilename = "20260720000400_atomic_rollback.sql"; +const malformedFilename = "20260720_malformed.sql"; const runnerPath = fileURLToPath( new URL("../scripts/db-migrate.mjs", import.meta.url), ); @@ -128,6 +129,17 @@ test("migration runner is serialized, atomic, drift-safe, and read-only in check try { copyFileSync(migrationPath, copiedMigration); + const malformedPath = join(temporaryDirectory, malformedFilename); + writeFileSync(malformedPath, "select 1;\n"); + const malformedCheck = runMigration(schemaUrl, { + check: true, + migrationsDirectory: temporaryDirectory, + }); + results.push(malformedCheck); + assert.equal(malformedCheck.status, 1); + assert.match(malformedCheck.stderr, /invalid migration filename/); + unlinkSync(malformedPath); + const missingLedgerCheck = runMigration(schemaUrl, { check: true, migrationsDirectory: temporaryDirectory, @@ -330,3 +342,91 @@ insert into atomic_rollback_probe.child (parent_id) values (1); rmSync(temporaryDirectory, { force: true, recursive: true }); } }); + +test("foundation grants no direct runtime table DML and exposes only reviewed functions", () => { + const fixture = startPostgresFixture(); + const temporaryDirectory = mkdtempSync(join(tmpdir(), "jyotisha-privileges-")); + const schemaUrl = fixture.connectionUrl( + "schema_owner", + "schema-owner-test-password", + ); + + try { + copyFileSync(migrationPath, join(temporaryDirectory, migrationFilename)); + const migration = runMigration(schemaUrl, { + migrationsDirectory: temporaryDirectory, + }); + assert.equal(migration.status, 0, migration.stderr); + + fixture.psqlAs( + "schema_owner", + "schema-owner-test-password", + ` + create table public.runtime_boundary_probe ( + id integer primary key, + value text not null + ); + create table identity.runtime_boundary_probe ( + id integer primary key, + value text not null + ); + create table audit.admin_event_probe ( + value text not null + ); + create function audit.record_admin_event_probe(event_value text) + returns void + language sql + security definer + set search_path = pg_catalog, audit + as 'insert into audit.admin_event_probe(value) values (event_value)'; + revoke all on function audit.record_admin_event_probe(text) from public; + grant execute on function audit.record_admin_event_probe(text) to admin_runtime; + `, + ); + + assert.throws(() => + fixture.psqlAs( + "app_runtime", + "app-runtime-test-password", + "insert into public.runtime_boundary_probe values (1, 'denied')", + ), + ); + assert.throws(() => + fixture.psqlAs( + "admin_runtime", + "admin-runtime-test-password", + "insert into audit.admin_event_probe values ('denied')", + ), + ); + assert.throws(() => + fixture.psqlAs( + "identity_runtime", + "identity-runtime-test-password", + "insert into identity.runtime_boundary_probe values (1, 'denied')", + ), + ); + assert.throws(() => + fixture.psqlAs( + "admin_runtime", + "admin-runtime-test-password", + "update public.runtime_boundary_probe set value = 'denied'", + ), + ); + + assert.equal( + fixture.psqlAs( + "admin_runtime", + "admin-runtime-test-password", + "select audit.record_admin_event_probe('approved')", + ), + "", + ); + assert.equal( + fixture.psql("select value from audit.admin_event_probe"), + "approved", + ); + } finally { + fixture.stop(); + rmSync(temporaryDirectory, { force: true, recursive: true }); + } +}); diff --git a/frontend/tests/helpers/postgres-fixture.ts b/frontend/tests/helpers/postgres-fixture.ts index 5fb9aa0a..ebda7d75 100644 --- a/frontend/tests/helpers/postgres-fixture.ts +++ b/frontend/tests/helpers/postgres-fixture.ts @@ -15,6 +15,7 @@ export type PostgresFixture = { hostPort: number; connectionUrl(role: string, password: string): string; psql(sql: string): string; + psqlAs(role: string, password: string, sql: string): string; stop(): void; }; @@ -145,6 +146,29 @@ export function startPostgresFixture(): PostgresFixture { .trim() .replace(/(^|:)false(?=:|$)/gm, "$1f"); }, + psqlAs(role, password, sql) { + return execFileSync( + "docker", + [ + ...composeArguments, + "exec", + "-T", + "-e", + `PGPASSWORD=${password}`, + "postgres", + "psql", + "-v", + "ON_ERROR_STOP=1", + "-U", + role, + "-d", + "jyotisha", + "-Atc", + sql, + ], + { encoding: "utf8", env: environment }, + ).trim(); + }, stop() { try { execFileSync(