212 lines
8.0 KiB
JavaScript
212 lines
8.0 KiB
JavaScript
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
|
|
import { formatRlsReleaseReadiness, inspectRlsReleaseReadiness } from "./verify-rls-release-readiness.mjs";
|
|
|
|
const temporaryRoots = [];
|
|
|
|
function fixtureRoot() {
|
|
const root = mkdtempSync(path.join(os.tmpdir(), "hivegrid-rls-readiness-"));
|
|
temporaryRoots.push(root);
|
|
return root;
|
|
}
|
|
|
|
function write(root, relativePath, contents) {
|
|
const target = path.join(root, relativePath);
|
|
mkdirSync(path.dirname(target), { recursive: true });
|
|
writeFileSync(target, contents, "utf8");
|
|
}
|
|
|
|
function tenantModel(name = "TenantRow") {
|
|
return `model ${name} {
|
|
id String @id
|
|
tenantId String
|
|
}
|
|
`;
|
|
}
|
|
|
|
function directPolicy(table = "TenantRow") {
|
|
return `ALTER TABLE "${table}" ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE "${table}" FORCE ROW LEVEL SECURITY;
|
|
CREATE POLICY "${table}_tenant" ON "${table}"
|
|
USING ("tenantId" = current_setting('app.tenant_id', true))
|
|
WITH CHECK ("tenantId" = current_setting('app.tenant_id', true));
|
|
GRANT SELECT, INSERT, UPDATE, DELETE ON "${table}" TO hivegrid_app;
|
|
`;
|
|
}
|
|
|
|
function tenantRequiredManifest(...models) {
|
|
return Object.fromEntries(models.map((model) => [model, { scope: "tenant-required", enforcement: "tenant-policy" }]));
|
|
}
|
|
|
|
function inspectFixture(root, ...models) {
|
|
return inspectRlsReleaseReadiness(root, { manifest: tenantRequiredManifest(...models) });
|
|
}
|
|
|
|
afterEach(() => {
|
|
for (const root of temporaryRoots.splice(0)) rmSync(root, { recursive: true, force: true });
|
|
});
|
|
|
|
describe("RLS release readiness verifier", () => {
|
|
it("passes only when artifact and active migration both cover every tenant model", () => {
|
|
const root = fixtureRoot();
|
|
write(root, "prisma/schema/core.prisma", tenantModel());
|
|
write(root, "prisma/rls/core-isolation.sql", directPolicy());
|
|
write(root, "prisma/migrations/20260101000000_rls/migration.sql", directPolicy());
|
|
|
|
const result = inspectFixture(root, "TenantRow");
|
|
|
|
expect(result.ok).toBe(true);
|
|
expect(result.failures).toEqual([]);
|
|
expect(formatRlsReleaseReadiness(result)).toContain("ENABLE 1/1, FORCE 1/1, POLICY 1/1");
|
|
});
|
|
|
|
it("recognizes schema-qualified pg_dump RLS statements in a baseline migration", () => {
|
|
const root = fixtureRoot();
|
|
write(root, "prisma/schema/core.prisma", tenantModel());
|
|
write(root, "prisma/rls/core-isolation.sql", directPolicy());
|
|
write(
|
|
root,
|
|
"prisma/migrations/00000000000000_baseline/migration.sql",
|
|
directPolicy().replaceAll('ALTER TABLE "', 'ALTER TABLE public."').replace(' ON "', ' ON public."')
|
|
);
|
|
|
|
const result = inspectFixture(root, "TenantRow");
|
|
|
|
expect(result.ok).toBe(true);
|
|
expect(result.migrationWithoutEnable).toEqual([]);
|
|
expect(result.migrationWithoutForce).toEqual([]);
|
|
expect(result.migrationWithoutPolicy).toEqual([]);
|
|
});
|
|
|
|
it("does not count commented rollout examples as deployed RLS", () => {
|
|
const root = fixtureRoot();
|
|
write(root, "prisma/schema/core.prisma", tenantModel());
|
|
write(root, "prisma/rls/core-isolation.sql", directPolicy());
|
|
write(root, "prisma/migrations/20260101000000_schema/migration.sql", `-- ${directPolicy().replaceAll("\n", "\n-- ")}`);
|
|
|
|
const result = inspectFixture(root, "TenantRow");
|
|
|
|
expect(result.ok).toBe(false);
|
|
expect(result.migrationWithoutEnable).toHaveLength(1);
|
|
expect(result.migrationWithoutForce).toHaveLength(1);
|
|
expect(result.migrationWithoutPolicy).toHaveLength(1);
|
|
});
|
|
|
|
it("reports schema-grouped artifact gaps instead of treating tenantId registry coverage as RLS", () => {
|
|
const root = fixtureRoot();
|
|
write(root, "prisma/schema/accounts.prisma", tenantModel("TenantMembership") + tenantModel("TenantInvite"));
|
|
|
|
const result = inspectFixture(root, "TenantMembership", "TenantInvite");
|
|
const output = formatRlsReleaseReadiness(result);
|
|
|
|
expect(result.missingArtifacts.map((entry) => entry.model)).toEqual(["TenantInvite", "TenantMembership"]);
|
|
expect(output).toContain("accounts.prisma: TenantInvite, TenantMembership");
|
|
expect(output).toContain("Release blocker: artifact SQL is not deployment");
|
|
});
|
|
|
|
it("includes FK-derived children without a direct tenantId in the RLS denominator", () => {
|
|
const root = fixtureRoot();
|
|
write(
|
|
root,
|
|
"prisma/schema/module.prisma",
|
|
`model TenantParent {
|
|
id String @id
|
|
tenantId String
|
|
children TenantChild[]
|
|
}
|
|
|
|
model TenantChild {
|
|
id String @id
|
|
parentId String
|
|
parent TenantParent @relation(fields: [parentId], references: [id])
|
|
}
|
|
`
|
|
);
|
|
const manifest = {
|
|
TenantParent: { scope: "tenant-required", enforcement: "tenant-policy" },
|
|
TenantChild: { scope: "derived-child", enforcement: "join-policy" },
|
|
};
|
|
|
|
const result = inspectRlsReleaseReadiness(root, { manifest });
|
|
|
|
expect(result.scopeValidation.errors).toEqual([]);
|
|
expect(result.models.map((entry) => entry.model)).toEqual(["TenantChild", "TenantParent"]);
|
|
expect(result.missingArtifacts).toHaveLength(2);
|
|
});
|
|
|
|
it("recognizes DO-loop rollout artifacts without mistaking them for migrations", () => {
|
|
const root = fixtureRoot();
|
|
write(root, "prisma/schema/module.prisma", tenantModel());
|
|
write(
|
|
root,
|
|
"prisma/rls/module-isolation.sql",
|
|
`DO $$
|
|
DECLARE tbl text;
|
|
BEGIN
|
|
FOREACH tbl IN ARRAY ARRAY['TenantRow'] LOOP
|
|
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY;', tbl);
|
|
EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY;', tbl);
|
|
EXECUTE format('CREATE POLICY %I ON %I USING ("tenantId" = current_setting(''app.tenant_id'', true)) WITH CHECK ("tenantId" = current_setting(''app.tenant_id'', true));', tbl || '_tenant', tbl);
|
|
EXECUTE format('GRANT SELECT, INSERT, UPDATE, DELETE ON %I TO hivegrid_app;', tbl);
|
|
END LOOP;
|
|
END $$;
|
|
`
|
|
);
|
|
|
|
const result = inspectFixture(root, "TenantRow");
|
|
|
|
expect(result.missingArtifacts).toEqual([]);
|
|
expect(result.artifactWithoutEnable).toEqual([]);
|
|
expect(result.artifactWithoutForce).toEqual([]);
|
|
expect(result.artifactWithoutPolicy).toEqual([]);
|
|
expect(result.artifactWithoutGrant).toEqual([]);
|
|
expect(result.ok).toBe(false);
|
|
expect(result.migrationWithoutPolicy).toHaveLength(1);
|
|
});
|
|
|
|
it("rejects a policy that uses the wrong tenant context name", () => {
|
|
const root = fixtureRoot();
|
|
write(root, "prisma/schema/core.prisma", tenantModel());
|
|
const wrongPolicy = directPolicy().replaceAll("app.tenant_id", "app.current_tenant_id");
|
|
write(root, "prisma/rls/core-isolation.sql", wrongPolicy);
|
|
write(root, "prisma/migrations/20260101000000_rls/migration.sql", wrongPolicy);
|
|
|
|
const result = inspectFixture(root, "TenantRow");
|
|
|
|
expect(result.artifactWithoutPolicy).toHaveLength(1);
|
|
expect(result.migrationWithoutPolicy).toHaveLength(1);
|
|
expect(result.ok).toBe(false);
|
|
});
|
|
|
|
it("recognizes one direct grant covering multiple quoted tables", () => {
|
|
const root = fixtureRoot();
|
|
write(root, "prisma/schema/core.prisma", tenantModel("FirstRow") + tenantModel("SecondRow"));
|
|
const policies =
|
|
directPolicy("FirstRow").replace(/GRANT[^;]+;/, "") +
|
|
directPolicy("SecondRow").replace(/GRANT[^;]+;/, "") +
|
|
'GRANT SELECT, INSERT, UPDATE, DELETE ON "FirstRow", "SecondRow" TO hivegrid_app;\n';
|
|
write(root, "prisma/rls/core-isolation.sql", policies);
|
|
|
|
const result = inspectFixture(root, "FirstRow", "SecondRow");
|
|
|
|
expect(result.artifactWithoutGrant).toEqual([]);
|
|
});
|
|
|
|
it("accepts intentionally partial DML grants for immutable app policies", () => {
|
|
const root = fixtureRoot();
|
|
write(root, "prisma/schema/core.prisma", tenantModel());
|
|
const policy = directPolicy().replace("GRANT SELECT, INSERT, UPDATE, DELETE", "GRANT SELECT, INSERT");
|
|
write(root, "prisma/rls/core-isolation.sql", policy);
|
|
write(root, "prisma/migrations/20260101000000_rls/migration.sql", policy);
|
|
|
|
const result = inspectFixture(root, "TenantRow");
|
|
|
|
expect(result.artifactWithoutGrant).toEqual([]);
|
|
expect(result.ok).toBe(true);
|
|
});
|
|
});
|