feat(quick-260909-dgj): Anwendungsrolle tessera_app ohne RLS-Umgehungsrecht (Task 1/4)

- Migration 20260909130000_rls_app_role legt tessera_app mit NOSUPERUSER
  NOBYPASSRLS wiederholbar an bzw. konvergiert eine vorhandene Rolle darauf
- Kein Kennwort im SQL, Datenbankname und Eigentuemer dynamisch gebildet
- 7 Tests in rls-app-role.spec.ts (rot vor der Migration, jetzt gruen)
- Rolle wird von niemandem benutzt — WINDOWS #18 bleibt offen

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FYZcd3SSmo14QTqWx2KKzU
This commit is contained in:
2026-09-09 09:59:21 +02:00
parent 8cb2d43f88
commit b3375ae016
2 changed files with 177 additions and 0 deletions
@@ -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
$$;