fix: enforce postgres least privilege

This commit is contained in:
Jesse_Chen
2026-07-21 03:45:09 +08:00
parent 92cc635b45
commit 53d9aaf846
5 changed files with 139 additions and 17 deletions
@@ -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**
@@ -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;
+12
View File
@@ -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(
+100
View File
@@ -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 });
}
});
@@ -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(