feat(15-01): RLS policies for Group/GroupMembership/ModuleGrant (T-15-11)

- Second, deliberately separate migration (pure hand-SQL, no Prisma-
  generated DDL): ENABLE/FORCE ROW LEVEL SECURITY plus a
  tenant_isolation_policy for each of the three new tables, following
  the pattern of 20260618112133_rls_policies (Auth-Kerntabellen)
  rather than the RLS-exempt Tender* app-layer tables
- Group/ModuleGrant compare tenantId directly against
  current_tenant_id(); GroupMembership has no own tenantId and follows
  the PasswordResetToken join pattern (groupId IN (SELECT id FROM
  Group WHERE tenantId = ...))
- migration-sql.spec.ts extended with a second describe block covering
  both migration files (6x ROW LEVEL SECURITY, 3x CREATE POLICY, the
  join vs. direct-comparison shape)
- Re-ran the Task-2 end-to-end proof after applying this migration:
  identical result (USER without grant 403 + empty list, USER with
  direct grant 200 + slug present, ADMIN 200) — the app's DB role
  (tessera) is a Postgres superuser with rolbypassrls=true, so it
  bypasses RLS as documented as an acceptable outcome by the plan;
  RLS remains the defense-in-depth net for any future non-superuser
  connection
This commit is contained in:
2026-08-04 15:11:33 +02:00
parent 9a4ba8a33c
commit 92e8eaffa5
2 changed files with 65 additions and 0 deletions
@@ -0,0 +1,37 @@
-- Phase 15 (T-15-11, 15-RESEARCH.md Pitfall 4 / Annahme A1) — RLS für
-- Group, GroupMembership und ModuleGrant.
--
-- Begründung: diese drei Tabellen steuern unmittelbar, wer auf was
-- zugreifen darf, und folgen damit dem Muster der Auth-Kerntabellen
-- (User, LdapConfig, LdapFieldMapping, PasswordResetToken — siehe
-- 20260618112133_rls_policies), NICHT dem App-Layer-Muster der
-- Tender*-Tabellen (die bewusst ohne RLS bleiben, D-03). RLS ist hier
-- ein zweites Sicherheitsnetz für den Fall, dass irgendwo ein
-- `where: { tenantId }` vergessen wird — ModuleAccessService liest über
-- unskaliertes `this.prisma`, ohne dass `app.current_tenant` gesetzt
-- ist; dieser zweite Netz-Layer greift nur, wenn die Datenbankrolle RLS
-- nicht ohnehin umgeht.
-- Enable RLS on Group table (direkte tenantId-Spalte)
ALTER TABLE "Group" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "Group" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON "Group"
USING ("tenantId" = current_tenant_id());
-- Enable RLS on GroupMembership table (keine eigene tenantId-Spalte,
-- Join-Muster wie PasswordResetToken -> User)
ALTER TABLE "GroupMembership" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "GroupMembership" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON "GroupMembership"
USING ("groupId" IN (
SELECT "id" FROM "Group" WHERE "tenantId" = current_tenant_id()
));
-- Enable RLS on ModuleGrant table (direkte tenantId-Spalte)
ALTER TABLE "ModuleGrant" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "ModuleGrant" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON "ModuleGrant"
USING ("tenantId" = current_tenant_id());
+28
View File
@@ -66,3 +66,31 @@ describe('add_groups_and_module_grants migration.sql (D-04, D-06, D-13)', () =>
expect(membershipIdx).toBeLessThan(grantIdx); expect(membershipIdx).toBeLessThan(grantIdx);
}); });
}); });
describe('groups_rls_policies migration.sql (T-15-11)', () => {
const sql = readMigrationSql('_groups_rls_policies');
it('aktiviert ENABLE und FORCE ROW LEVEL SECURITY für alle drei Tabellen (6 Anweisungen)', () => {
const occurrences = sql.match(/ROW LEVEL SECURITY/g) ?? [];
expect(occurrences.length).toBe(6);
for (const table of ['"Group"', '"GroupMembership"', '"ModuleGrant"']) {
expect(sql).toContain(`ALTER TABLE ${table} ENABLE ROW LEVEL SECURITY`);
expect(sql).toContain(`ALTER TABLE ${table} FORCE ROW LEVEL SECURITY`);
}
});
it('legt für jede der drei Tabellen eine tenant_isolation_policy an (3 CREATE POLICY)', () => {
const occurrences = sql.match(/CREATE POLICY tenant_isolation_policy/g) ?? [];
expect(occurrences.length).toBe(3);
});
it('GroupMembership folgt dem Join-Muster (kein direktes tenantId, Join über Group)', () => {
expect(sql).toContain('"groupId" IN (');
expect(sql).toContain('SELECT "id" FROM "Group" WHERE "tenantId" = current_tenant_id()');
});
it('Group und ModuleGrant vergleichen direkt gegen current_tenant_id() (eigene tenantId-Spalte)', () => {
const directPolicyCount = (sql.match(/USING \("tenantId" = current_tenant_id\(\)\)/g) ?? []).length;
expect(directPolicyCount).toBe(2);
});
});