Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/frontend/src/lib/api/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ export interface DbTableStat {
table_name: string;
live_rows: string;
dead_rows: string;
last_vacuum: string | null;
last_autovacuum: string | null;
last_analyze: string | null;
last_autoanalyze: string | null;
size: string;
size_bytes: string;
Expand Down
10 changes: 8 additions & 2 deletions apps/frontend/src/pages/DbMaintenancePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -140,10 +140,10 @@ function TableStatRow({
</span>
</TableCell>
<TableCell className="min-w-40 whitespace-nowrap type-footnote tabular-nums text-label-secondary">
{fmt(row.last_autovacuum)}
{fmt(latest(row.last_vacuum, row.last_autovacuum))}
</TableCell>
<TableCell className="min-w-40 whitespace-nowrap type-footnote tabular-nums text-label-secondary">
{fmt(row.last_autoanalyze)}
{fmt(latest(row.last_analyze, row.last_autoanalyze))}
</TableCell>
<TableCell className="min-w-24 whitespace-nowrap text-right font-medium tabular-nums">
{row.size}
Expand Down Expand Up @@ -177,6 +177,12 @@ function TableStatRow({
);
}

/** The later of a manual and an automatic run; either may be NULL. */
function latest(manual: string | null, auto: string | null): string | null {
if (!manual || !auto) return manual ?? auto;
return Date.parse(manual) >= Date.parse(auto) ? manual : auto;
}

// ── Page ──────────────────────────────────────────────────────────────────────

export default function DbMaintenancePage() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ const statsWithTable = {
table_name: "transactions",
live_rows: "1234",
dead_rows: "5",
last_vacuum: null,
last_autovacuum: "2025-01-01T00:00:00.000Z",
last_analyze: null,
last_autoanalyze: "2025-01-01T00:00:00.000Z",
size: "4096 kB",
size_bytes: "4194304",
Expand Down
6 changes: 5 additions & 1 deletion apps/frontend/src/types/generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5186,7 +5186,11 @@ export interface components {
live_rows: string;
/** @description BIGINT estimate from pg_stat_user_tables, as decimal text */
dead_rows: string;
/** @description Last manual VACUUM (pg_stat_user_tables.last_vacuum as text) */
last_vacuum: string | null;
last_autovacuum: string | null;
/** @description Last manual ANALYZE (pg_stat_user_tables.last_analyze as text) */
last_analyze: string | null;
last_autoanalyze: string | null;
/** @description pg_size_pretty of the total relation size */
size: string;
Expand Down Expand Up @@ -17312,7 +17316,7 @@ export interface operations {
};
content?: never;
};
/** @description Insufficient database privileges to VACUUM */
/** @description The database role lacks MAINTAIN on a requested table; nothing is vacuumed */
403: {
headers: {
[name: string]: unknown;
Expand Down
32 changes: 26 additions & 6 deletions apps/node-backend/src/database/roleBootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,9 @@ function renderGrantStatements({
* role can no longer grant privileges on those views and PostgreSQL rejects the
* entire broad GRANT before granting ordinary tables. Expanding the statement
* lets app-owned objects be skipped and isolates any third-party ownership
* failure to that one relation.
* failure to that one relation. Each relation gets only the template
* privileges that apply to its kind: a materialized view takes SELECT and
* MAINTAIN, and a view or foreign table cannot be maintained.
*
* @returns number of relation-specific grant failures
*/
Expand All @@ -178,6 +180,7 @@ const canCreateRowSchema = z
async function grantCurrentRelations(
client: PgOneShotClient,
appRole: string,
templatePrivileges: readonly string[],
log: BootstrapLogger,
): Promise<number> {
const result = await client.query(`
Expand All @@ -196,8 +199,16 @@ async function grantCurrentRelations(
let failures = 0;
for (const row of checkRows(relationGrantRowSchema, result.rows)) {
if (row.owner_name === appRole) continue;
const privileges =
row.relkind === "m" ? "SELECT" : "SELECT, INSERT, UPDATE, DELETE";
const applicable =
row.relkind === "m"
? ["SELECT", "MAINTAIN"]
: row.relkind === "v" || row.relkind === "f"
? ["SELECT", "INSERT", "UPDATE", "DELETE"]
: templatePrivileges;
const privileges = templatePrivileges
.filter((privilege) => applicable.includes(privilege))
.join(", ");
if (!privileges) continue;
const relation = `${quoteIdent(row.schema_name)}.${quoteIdent(row.relation_name)}`;
try {
await client.query(
Expand All @@ -216,8 +227,15 @@ async function grantCurrentRelations(
/** undefined_table, undefined_function, invalid_schema_name */
const UNDEFINED_OBJECT_CODES = new Set(["42P01", "42883", "3F000"]);

function isAllTablesGrant(statement: string): boolean {
return /\bON\s+ALL\s+TABLES\s+IN\s+SCHEMA\s+public\b/i.test(statement);
/** The privilege list of a `GRANT ... ON ALL TABLES IN SCHEMA public`, else null. */
function allTablesGrantPrivileges(statement: string): string[] | null {
const match =
/^GRANT\s+(.+?)\s+ON\s+ALL\s+TABLES\s+IN\s+SCHEMA\s+public\b/i.exec(
statement,
);
return match?.[1]
? match[1].split(",").map((privilege) => privilege.trim().toUpperCase())
: null;
}

/**
Expand Down Expand Up @@ -386,10 +404,12 @@ export async function ensureAppRole({
let grantFailures = 0;
for (const stmt of grantStatements) {
try {
if (isAllTablesGrant(stmt)) {
const tablePrivileges = allTablesGrantPrivileges(stmt);
if (tablePrivileges) {
grantFailures += await grantCurrentRelations(
client,
appConn.user,
tablePrivileges,
log,
);
continue;
Expand Down
7 changes: 5 additions & 2 deletions apps/node-backend/src/database/rows/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,11 @@ export const tableStatsRowSchema = z
table_name: z.string(),
live_rows: pgBigint,
dead_rows: pgBigint,
/** `last_vacuum::text`: NULL until the first manual VACUUM. */
last_vacuum: z.string().nullable(),
/** `last_autovacuum::text`: NULL until the first autovacuum. */
last_autovacuum: z.string().nullable(),
last_analyze: z.string().nullable(),
last_autoanalyze: z.string().nullable(),
/** `pg_size_pretty(...)` */
size: z.string(),
Expand All @@ -30,7 +33,7 @@ export const databaseSizeRowSchema = z
.object({ db_size: z.string() })
.describe("database size row");

/** `SELECT relname FROM pg_stat_user_tables`. */
/** `pg_stat_user_tables` names with the role's MAINTAIN privilege. */
export const userTableNameRowSchema = z
.object({ relname: z.string() })
.object({ relname: z.string(), can_maintain: z.boolean() })
.describe("user table name row");
41 changes: 32 additions & 9 deletions apps/node-backend/src/routes/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -358,7 +358,9 @@ router.get("/database/stats", async (_req, res) => {
relname AS table_name,
n_live_tup AS live_rows,
n_dead_tup AS dead_rows,
last_vacuum::text,
last_autovacuum::text,
last_analyze::text,
last_autoanalyze::text,
pg_size_pretty(pg_total_relation_size(relid)) AS size,
pg_total_relation_size(relid) AS size_bytes
Expand All @@ -383,28 +385,49 @@ router.get("/database/stats", async (_req, res) => {
router.post("/database/vacuum", adminMutateLimiter, async (req, res) => {
const { table } = parseInput(vacuumBodySchema, req.body ?? {});

// Validate table name against actual user tables to prevent injection
// Validate table name against actual user tables to prevent injection.
// PostgreSQL does not fail a VACUUM the role may not run: it logs
// "permission denied to vacuum ..., skipping it" as a WARNING and reports
// success. Check the MAINTAIN privilege (PostgreSQL 17+) up front so a
// skipped table is a 403, never a false success.
const allowed = await checkedRows(
userTableNameRowSchema,
sql`SELECT relname FROM pg_stat_user_tables WHERE schemaname = 'public'`,
sql`
SELECT relname, has_table_privilege(relid, 'MAINTAIN') AS can_maintain
FROM pg_stat_user_tables
WHERE schemaname = 'public'
ORDER BY relname
`,
);
const allowedNames = new Set(allowed.map((r) => r.relname));

if (table != null && !allowedNames.has(table)) {
throw new ValidationError(`Unknown table: ${table}`);
}

const targets = allowed.filter((r) => table == null || r.relname === table);
const denied = targets.filter((r) => !r.can_maintain).map((r) => r.relname);
if (denied.length > 0) {
throw new ForbiddenError(
`Insufficient database privileges to VACUUM ${denied.join(", ")}`,
);
}

// VACUUM cannot run inside a transaction block — use raw client
const client = await getClient();
try {
await client.query("SET statement_timeout = 120000");
// `table` is validated against the allowlist populated from
// pg_stat_user_tables above and quoted as an identifier. VACUUM rejects
// parameterized identifiers, so it cannot be a bound parameter.
const vacuum = compileQuery(
table ? sql`VACUUM ANALYZE ${sql.id(table)}` : sql`VACUUM ANALYZE`,
);
await client.query(vacuum.sql);
// Table names come from pg_stat_user_tables above and are quoted as
// identifiers. VACUUM rejects parameterized identifiers, so they cannot
// be bound parameters. "All" names every public table explicitly: a bare
// VACUUM would also try the system catalogs, which only their owner may
// vacuum.
if (targets.length > 0) {
const vacuum = compileQuery(
sql`VACUUM ANALYZE ${sql.join(targets.map((r) => sql.id(r.relname)))}`,
);
await client.query(vacuum.sql);
}
} catch (err) {
if (err instanceof Error && "code" in err && err.code === "42501") {
// insufficient_privilege
Expand Down
12 changes: 12 additions & 0 deletions apps/node-backend/tests/aiAuditRowContracts.db.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,18 @@ describe.skipIf(!hasTestDatabase())(
.post("/database/vacuum")
.send({ table: "no_such_table_row_contracts" });
expect(rejected.status).toBe(400);
const vacuumed = await agent
.post("/database/vacuum")
.send({ table: "categories" });
expect(vacuumed.status).toBe(200);
const after = await agent.get("/database/stats");
const tables = bodyAt(after, "data", "tables") as {
table_name: string;
last_vacuum: string | null;
}[];
expect(
tables.find((row) => row.table_name === "categories")?.last_vacuum,
).toEqual(expect.any(String));
});
},
);
15 changes: 14 additions & 1 deletion apps/node-backend/tests/roleBootstrap.db.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ describe.skipIf(!hasTestDatabase())("roleBootstrap (real Postgres)", () => {
ownerRole: "own",
dbName: "db",
});
expect(stmts).toHaveLength(6);
expect(stmts).toHaveLength(8);
const joined = stmts.join(";\n");
expect(joined).not.toContain(':"');
expect(joined).toContain('"a""pp"');
Expand Down Expand Up @@ -223,6 +223,13 @@ describe.skipIf(!hasTestDatabase())("roleBootstrap (real Postgres)", () => {
"INSERT INTO categories (general, detail) VALUES ('vb_rb', 'probe') RETURNING id, detail",
);
expect(must(inserted[0], "inserted[0]").detail).toBe("probe");
// MAINTAIN lets the admin page VACUUM; without it PostgreSQL only warns.
const maintain = await queryAs(
APP_ROLE,
APP_PASS,
"SELECT has_table_privilege('categories', 'MAINTAIN') AS ok",
);
expect(must(maintain[0], "maintain[0]").ok).toBe(true);
const read = await queryAs(
APP_ROLE,
APP_PASS,
Expand Down Expand Up @@ -257,6 +264,12 @@ describe.skipIf(!hasTestDatabase())("roleBootstrap (real Postgres)", () => {
"INSERT INTO vb_rb_future_table (v) VALUES ('later') RETURNING id, v",
);
expect(rows[0]).toMatchObject({ id: 1, v: "later" });
const maintain = await queryAs(
APP_ROLE,
APP_PASS,
"SELECT has_table_privilege('vb_rb_future_table', 'MAINTAIN') AS ok",
);
expect(must(maintain[0], "maintain[0]").ok).toBe(true);
await db().query("DROP TABLE vb_rb_future_table");
});

Expand Down
30 changes: 29 additions & 1 deletion apps/node-backend/tests/routes/adminValidation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,10 @@ describe("Admin route request validation", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(query).mockResolvedValue({
rows: [{ relname: "transactions" }],
rows: [
{ relname: "accounts", can_maintain: true },
{ relname: "transactions", can_maintain: true },
],
} as never);
vi.mocked(getClient).mockResolvedValue(client);
client.query.mockResolvedValue({ rows: [] });
Expand All @@ -93,9 +96,34 @@ describe("Admin route request validation", () => {
.post(`${BASE}/database/vacuum`)
.send({ table: null })
.expect(200);
expect(client.query).toHaveBeenLastCalledWith(
'VACUUM ANALYZE "accounts", "transactions"',
);
expect(bodyAt(all, "data")).toEqual({ vacuumed: "all" });
});

it("refuses with 403 instead of a skipped VACUUM when MAINTAIN is missing", async () => {
vi.mocked(query).mockResolvedValue({
rows: [
{ relname: "accounts", can_maintain: true },
{ relname: "transactions", can_maintain: false },
],
} as never);
for (const table of ["transactions", null]) {
const res = await api
.post(`${BASE}/database/vacuum`)
.send({ table })
.expect(403);
expect(res.body).toEqual(
errEnvelope({
code: "FORBIDDEN",
message: "Insufficient database privileges to VACUUM transactions",
}),
);
}
expect(getClient).not.toHaveBeenCalled();
});

it("rejects a non-string table and keeps the unknown-table message", async () => {
expectValidationError(
await api
Expand Down
7 changes: 7 additions & 0 deletions config/postgres/app-role-grants.sql.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,17 @@ GRANT USAGE, CREATE ON SCHEMA public TO :"app_role";

GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO :"app_role";

-- MAINTAIN (PostgreSQL 17+) lets the admin page run VACUUM ANALYZE. Without
-- it PostgreSQL skips each table with only a warning and reports success.
GRANT MAINTAIN ON ALL TABLES IN SCHEMA public TO :"app_role";

GRANT USAGE, SELECT, UPDATE ON ALL SEQUENCES IN SCHEMA public TO :"app_role";

ALTER DEFAULT PRIVILEGES FOR ROLE :"owner_role" IN SCHEMA public
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO :"app_role";

ALTER DEFAULT PRIVILEGES FOR ROLE :"owner_role" IN SCHEMA public
GRANT MAINTAIN ON TABLES TO :"app_role";

ALTER DEFAULT PRIVILEGES FOR ROLE :"owner_role" IN SCHEMA public
GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO :"app_role";
Loading