fix: enforce postgres least privilege
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user