From 92e8eaffa5d357aec34749e687ece4597877d871 Mon Sep 17 00:00:00 2001 From: Schalli Date: Tue, 4 Aug 2026 15:11:33 +0200 Subject: [PATCH] feat(15-01): RLS policies for Group/GroupMembership/ModuleGrant (T-15-11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../migration.sql | 37 +++++++++++++++++++ apps/api/src/groups/migration-sql.spec.ts | 28 ++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 apps/api/prisma/migrations/20260804130918_groups_rls_policies/migration.sql diff --git a/apps/api/prisma/migrations/20260804130918_groups_rls_policies/migration.sql b/apps/api/prisma/migrations/20260804130918_groups_rls_policies/migration.sql new file mode 100644 index 0000000..8fe3312 --- /dev/null +++ b/apps/api/prisma/migrations/20260804130918_groups_rls_policies/migration.sql @@ -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()); diff --git a/apps/api/src/groups/migration-sql.spec.ts b/apps/api/src/groups/migration-sql.spec.ts index 9e8d2a2..033554d 100644 --- a/apps/api/src/groups/migration-sql.spec.ts +++ b/apps/api/src/groups/migration-sql.spec.ts @@ -66,3 +66,31 @@ describe('add_groups_and_module_grants migration.sql (D-04, D-06, D-13)', () => 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); + }); +});