fix(db): make the R-009 pen test isolation-proof against the shared CI database
ci / test-pglite (PGlite, default) (push) Failing after 1m47s
ci / test-postgres (Postgres 16, G-022 RLS) (push) Successful in 1m32s
ci / test-pglite (PGlite, default) (pull_request) Failing after 1m48s
ci / test-postgres (Postgres 16, G-022 RLS) (pull_request) Successful in 1m33s
ci / test-pglite (PGlite, default) (push) Failing after 1m47s
ci / test-postgres (Postgres 16, G-022 RLS) (push) Successful in 1m32s
ci / test-pglite (PGlite, default) (pull_request) Failing after 1m48s
ci / test-postgres (Postgres 16, G-022 RLS) (pull_request) Successful in 1m33s
This commit is contained in:
@@ -38,6 +38,9 @@ const T1 = "00000000-0000-0000-0000-000000000001";
|
||||
const T2 = "00000000-0000-0000-0000-000000000002";
|
||||
const U1 = "00000000-0000-0000-0000-000000000011";
|
||||
const U2 = "00000000-0000-0000-0000-000000000012";
|
||||
/** Hostnames seeded by this file. No other suite uses them. */
|
||||
const T1_HOST = "t1-host";
|
||||
const T2_HOST = "t2-host";
|
||||
|
||||
/** DB_MODE env: 'pg' → real Postgres 16 (CI); unset → PGlite (dev). [G-022] */
|
||||
const isPgMode = process.env.DB_MODE === "pg";
|
||||
@@ -97,6 +100,19 @@ describe("cross-tenant isolation (REQ-039 pen test)", () => {
|
||||
[T1, U1, T2, U2],
|
||||
);
|
||||
}
|
||||
// The seed must be idempotent. This file runs TWICE in the CI postgres
|
||||
// job — once inside `pnpm test`, once in the `test:pen` step — against
|
||||
// ONE database, and `targets` has no unique constraint on (tenant_id,
|
||||
// hostname), so `ON CONFLICT DO NOTHING` does NOT dedupe: the second run
|
||||
// would add a second 't1-host' row and the row counts below would grow.
|
||||
// Delete this file's own rows first (the app role holds DELETE; RLS
|
||||
// scopes the delete to the tenant).
|
||||
await withTenant(T1, async (c) => {
|
||||
await c.query("DELETE FROM targets WHERE hostname = $1", [T1_HOST]);
|
||||
});
|
||||
await withTenant(T2, async (c) => {
|
||||
await c.query("DELETE FROM targets WHERE hostname = $1", [T2_HOST]);
|
||||
});
|
||||
// Seed targets via withTenant (RLS WITH CHECK requires the row's
|
||||
// tenant_id to match the current app.tenant_id).
|
||||
await withTenant(T1, async (c) => {
|
||||
@@ -123,9 +139,14 @@ describe("cross-tenant isolation (REQ-039 pen test)", () => {
|
||||
);
|
||||
return res.rows;
|
||||
});
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]?.hostname).toBe("t1-host");
|
||||
// The row COUNT is not this test's to assert: the CI postgres job runs
|
||||
// every workspace suite against ONE database and the relay-ws suites
|
||||
// register targets for these same two tenant ids. The ISOLATION property
|
||||
// is what is asserted here: this tenant's own seeded row is visible,
|
||||
// every visible row belongs to T1, and T2's seeded row is not among them.
|
||||
expect(rows.filter((r) => r.hostname === T1_HOST)).toHaveLength(1);
|
||||
expect(rows.every((r) => r.tenant_id === T1)).toBe(true);
|
||||
expect(rows.some((r) => r.hostname === T2_HOST)).toBe(false);
|
||||
});
|
||||
|
||||
it("T2 sees only T2 targets, not T1", async () => {
|
||||
@@ -136,8 +157,9 @@ describe("cross-tenant isolation (REQ-039 pen test)", () => {
|
||||
);
|
||||
return res.rows;
|
||||
});
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]?.hostname).toBe("t2-host");
|
||||
expect(rows.filter((r) => r.hostname === T2_HOST)).toHaveLength(1);
|
||||
expect(rows.every((r) => r.tenant_id === T2)).toBe(true);
|
||||
expect(rows.some((r) => r.hostname === T1_HOST)).toBe(false);
|
||||
});
|
||||
|
||||
it("a query scoped to T1 cannot read T2's targets by ID", async () => {
|
||||
@@ -151,13 +173,35 @@ describe("cross-tenant isolation (REQ-039 pen test)", () => {
|
||||
);
|
||||
return res.rows;
|
||||
});
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]?.hostname).toBe("t1-host");
|
||||
expect(rows.filter((r) => r.hostname === T1_HOST)).toHaveLength(1);
|
||||
expect(rows.some((r) => r.hostname === T2_HOST)).toBe(false);
|
||||
// In prod (real Postgres), RLS would block even a bare `SELECT * FROM targets`
|
||||
// without the WHERE clause. PGlite 0.5.7 doesn't enforce RLS on SELECT,
|
||||
// so the application-layer WHERE is the primary enforcement in dev/test.
|
||||
});
|
||||
|
||||
it("RLS hides the other tenant's rows from a bare SELECT (real Postgres only)", async () => {
|
||||
// PGlite 0.5.7 does not enforce RLS on SELECT, so this assertion only
|
||||
// runs against real Postgres. This is the R-009 property in its purest
|
||||
// form: inside withTenant(T1), with NO application-layer WHERE clause,
|
||||
// the DATABASE returns only T1's rows. It also proves the tenant scope
|
||||
// reached the connection that ran the statement (coreci-chat#14): a
|
||||
// scope set on another connection would show no rows at all.
|
||||
if (!isPgMode) {
|
||||
expect(true).toBe(true); // PGlite: RLS on SELECT is not enforced
|
||||
return;
|
||||
}
|
||||
const rows = await withTenant(T1, async (c) => {
|
||||
const res = await c.query<{ tenant_id: string; hostname: string }>(
|
||||
"SELECT tenant_id, hostname FROM targets",
|
||||
);
|
||||
return res.rows;
|
||||
});
|
||||
expect(rows.length).toBeGreaterThan(0);
|
||||
expect(rows.every((r) => r.tenant_id === T1)).toBe(true);
|
||||
expect(rows.some((r) => r.hostname === T2_HOST)).toBe(false);
|
||||
});
|
||||
|
||||
it("a query OUTSIDE withTenant() has no active tenant context", async () => {
|
||||
const ctx = await getTenantContext();
|
||||
expect(ctx).toBe(null); // no active tenant context → prod RLS returns nothing
|
||||
|
||||
Reference in New Issue
Block a user