diff --git a/apps/api/prisma/migrations/20260909130000_rls_app_role/migration.sql b/apps/api/prisma/migrations/20260909130000_rls_app_role/migration.sql new file mode 100644 index 0000000..856c4e9 --- /dev/null +++ b/apps/api/prisma/migrations/20260909130000_rls_app_role/migration.sql @@ -0,0 +1,103 @@ +-- WINDOWS #18 — Anwendungsrolle ohne RLS-Umgehungsrecht (T-DGJ-01, T-DGJ-04, +-- T-DGJ-05). Erste von zwei Migrationen; die zweite (20260909140000) ergaenzt +-- die restlichen Policies. +-- +-- Befund (gemessen am 2026-09-09, siehe docker-compose.yml:33/:76): Die API +-- verbindet als Rolle "tessera". Diese Rolle entsteht aus POSTGRES_USER und +-- ist damit Superuser des Postgres-Clusters (auf alpha gemessen: +-- rolsuper = t, rolbypassrls = t). PostgreSQL wendet Row-Level Security auf +-- Superuser-Rollen und Rollen ohne NOBYPASSRLS grundsaetzlich nicht an; +-- FORCE ROW LEVEL SECURITY aendert daran nichts, weil es nur den +-- Tabelleneigentuemer erfasst, nicht Rollen mit Umgehungsrecht. Die sieben +-- vorhandenen Policies +-- (User, Group, GroupMembership, LdapConfig, LdapFieldMapping, ModuleGrant, +-- PasswordResetToken) sind unter der Rolle "tessera" deshalb ohne Wirkung. +-- +-- Diese Migration allein stellt noch nichts um — sie legt lediglich die +-- Rolle "tessera_app" an und konvergiert sie bei Wiederholung auf +-- NOSUPERUSER/NOBYPASSRLS. Niemand verbindet mit ihr, solange +-- DATABASE_URL nicht umgestellt wird. Der volle Ablauf inklusive +-- Kennwortvergabe steht in docs/mandantentrennung-datenbankrolle.md — das +-- Kennwort wird bewusst NICHT hier gesetzt, sondern vom Betreiber von Hand, +-- weil es sonst im Klartext in dieser versionierten Datei laenden wuerde. +DO $$ +DECLARE + can_manage_roles boolean; +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'tessera_app') THEN + -- Rolle existiert noch nicht: current_user braucht die Berechtigung, + -- Rollen anzulegen. Lautes Scheitern mit Anleitung ist hier richtig — + -- ein stilles Ueberspringen wuerde einen Betreiber im Glauben lassen, + -- die Rolle existiere bereits. + SELECT rolsuper OR rolcreaterole INTO can_manage_roles + FROM pg_roles WHERE rolname = current_user; + + IF NOT can_manage_roles THEN + RAISE EXCEPTION + 'tessera_app existiert nicht und current_user (%) darf keine Rollen anlegen. ' + 'Einmalig als Datenbank-Superuser ausfuehren: ' + 'CREATE ROLE tessera_app WITH LOGIN NOSUPERUSER NOBYPASSRLS NOCREATEDB NOCREATEROLE; ' + 'Siehe docs/mandantentrennung-datenbankrolle.md.', current_user; + END IF; + + CREATE ROLE tessera_app WITH LOGIN NOSUPERUSER NOBYPASSRLS NOCREATEDB NOCREATEROLE; + ELSE + -- Rolle existiert bereits: auf denselben Stand konvergieren. Nur ein + -- Superuser darf SUPERUSER/NOBYPASSRLS setzen oder entziehen — ist + -- current_user keiner, pruefen, ob die Merkmale bereits beide falsch + -- sind, statt das ALTER zu versuchen. + SELECT rolsuper INTO can_manage_roles FROM pg_roles WHERE rolname = current_user; + + IF can_manage_roles THEN + ALTER ROLE tessera_app WITH LOGIN NOSUPERUSER NOBYPASSRLS NOCREATEDB NOCREATEROLE; + ELSE + IF EXISTS ( + SELECT 1 FROM pg_roles + WHERE rolname = 'tessera_app' AND (rolsuper OR rolbypassrls) + ) THEN + RAISE EXCEPTION + 'tessera_app traegt noch rolsuper oder rolbypassrls, und current_user (%) ' + 'ist kein Superuser, um das zu korrigieren. Einmalig als ' + 'Datenbank-Superuser ausfuehren: ' + 'ALTER ROLE tessera_app WITH NOSUPERUSER NOBYPASSRLS; ' + 'Siehe docs/mandantentrennung-datenbankrolle.md.', current_user; + END IF; + -- rolsuper und rolbypassrls sind bereits beide falsch — reiner Durchlauf. + END IF; + END IF; +END +$$; + +-- Rechte, alle idempotent (ein wiederholtes GRANT ist in PostgreSQL +-- folgenlos). Datenbankname und Eigentuemer werden dynamisch gebildet, +-- damit die Migration auch gegen eine anders benannte Installation bzw. +-- eine andere migrierende Rolle laeuft. +DO $$ +BEGIN + EXECUTE format('GRANT CONNECT ON DATABASE %I TO tessera_app', current_database()); +END +$$; + +GRANT USAGE ON SCHEMA public TO tessera_app; +GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO tessera_app; +-- Das Schema nutzt derzeit keine Sequenzen (kein einziges autoincrement +-- nachgezaehlt) — die Vergabe kostet nichts und verhindert einen spaeteren +-- Stolperstein, sollte eine kuenftige Migration eine Sequenz einfuehren. +GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO tessera_app; + +-- Vorgaberechte, damit kuenftige Migrationen (angewendet von current_user, +-- also der migrierenden Rolle — nicht fest "tessera" eingetragen, weil die +-- auf einer anderen Installation anders heissen kann) nicht jedes Mal +-- nachziehen muessen. +DO $$ +BEGIN + EXECUTE format( + 'ALTER DEFAULT PRIVILEGES FOR ROLE %I IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO tessera_app', + current_user + ); + EXECUTE format( + 'ALTER DEFAULT PRIVILEGES FOR ROLE %I IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO tessera_app', + current_user + ); +END +$$; diff --git a/apps/api/src/prisma/rls-app-role.spec.ts b/apps/api/src/prisma/rls-app-role.spec.ts new file mode 100644 index 0000000..67da7a6 --- /dev/null +++ b/apps/api/src/prisma/rls-app-role.spec.ts @@ -0,0 +1,74 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +/** + * Prueft die neue Rollen-Migration (Task 1, WINDOWS #18) rein textuell gegen + * die generierte migration.sql — keine Datenbank noetig. Baut die + * Hilfsfunktion aus apps/api/src/groups/migration-sql.spec.ts bewusst nach, + * statt sie zu importieren: die vorhandene ist in ihrer Datei privat, und + * eine Kopie von zwoelf Zeilen ist billiger als eine neue Abhaengigkeit + * zwischen zwei Testdateien. + */ + +const MIGRATIONS_DIR = join(__dirname, '../../prisma/migrations'); + +function readMigrationSql(suffix: string): string { + const dirs = readdirSync(MIGRATIONS_DIR, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && entry.name.endsWith(suffix)) + .map((entry) => entry.name); + + if (dirs.length !== 1) { + throw new Error( + `Expected exactly one migration directory ending in "${suffix}", found ${dirs.length}: ${dirs.join(', ')}`, + ); + } + + return readFileSync(join(MIGRATIONS_DIR, dirs[0], 'migration.sql'), 'utf-8'); +} + +describe('rls_app_role migration.sql (WINDOWS #18, T-DGJ-01/T-DGJ-04/T-DGJ-05)', () => { + const sql = readMigrationSql('_rls_app_role'); + + it('nennt die Rolle tessera_app', () => { + expect(sql).toContain('tessera_app'); + }); + + it('entzieht ausdruecklich beide Umgehungswege (NOSUPERUSER, NOBYPASSRLS) — BYPASSRLS kommt nie ohne vorangestelltes NO vor', () => { + expect(sql).toContain('NOSUPERUSER'); + expect(sql).toContain('NOBYPASSRLS'); + + const bypassOccurrences = sql.match(/BYPASSRLS/g) ?? []; + for (const _ of bypassOccurrences) { + // jedes Vorkommen von BYPASSRLS muss Teil von NOBYPASSRLS sein + } + const bareBypassrls = sql.match(/(? { + expect(sql).toContain('pg_roles'); + expect(sql).toContain('DO $$'); + expect(sql).toMatch(/IF NOT EXISTS/); + }); + + it('bildet den Datenbanknamen dynamisch ueber current_database(), nicht fest verdrahtet', () => { + expect(sql).toContain('current_database()'); + }); + + it('setzt die Vorgaberechte fuer current_user, nicht fuer einen fest verdrahteten Rollennamen', () => { + expect(sql).toContain('FOR ROLE'); + expect(sql).toContain('current_user'); + }); + + it('enthaelt kein Kennwort — kein PASSWORD gefolgt von einem Hochkomma', () => { + expect(sql).not.toMatch(/PASSWORD\s*'/); + }); + + it('erteilt alle vier Datenzugriffsarten (SELECT, INSERT, UPDATE, DELETE)', () => { + for (const verb of ['SELECT', 'INSERT', 'UPDATE', 'DELETE']) { + expect(sql).toContain(verb); + } + expect(sql).toMatch(/GRANT[^;]*SELECT[^;]*INSERT[^;]*UPDATE[^;]*DELETE/); + }); +});