Files
tessera-ctl/apps/api/prisma/migrations/20260909160000_auth_lookup_functions/migration.sql
T
schalli de50297467 feat(quick-260909-eor): schmale SECURITY-DEFINER-Ausnahme fuer den Anmeldeweg
WINDOWS #18/#20, Aufgabe 2: der Anmeldeweg muss den passenden Benutzer
finden, bevor sein Mandant bekannt ist — unter der kuenftigen Rolle ohne
BYPASSRLS (tessera_app) wuerde ein gewoehnlicher SELECT auf "User" sonst
null Zeilen liefern und die Anmeldung waere unmoeglich.

Drei SECURITY-DEFINER-Funktionen (STABLE, fester Suchpfad public/pg_temp,
fester Spaltensatz, LIMIT 1, Ausfuehrungsrecht ausschliesslich fuer
tessera_app) ersetzen die drei pre-tenant Lesezugriffe in auth.service.ts:

- auth_lookup_user_by_username (validateUser)
- auth_lookup_user_by_email (requestPasswordReset)
- auth_lookup_reset_token (resetPassword)

Sobald der Benutzer und damit sein Mandant bekannt sind, laufen alle
Schreibzugriffe (lastLoginAt, passwordHash, Reset-Token) ueber forTenant(),
gebunden an genau diesen Mandanten (Aufgabe 1). getMe/changePassword/
adminResetPassword bleiben bewusst unangetastet — sie kennen den Mandanten
bereits aus dem Sitzungsnachweis und gehoeren in Etappe 2.

rls-scratch-check.mjs um einen zweiten Abschnitt erweitert: spielt die
Migration in die Wegwerf-Datenbank ein und misst live unter der Rolle ohne
BYPASSRLS — Anmeldesuche findet den Benutzer, unbekannter Name liefert
nichts ohne zu werfen, gewoehnlicher SELECT auf "User" liefert null Zeilen.
Alle 8 Pruefungen (5 aus Aufgabe 1 + 3 neue) bestehen gegen die lokale
Datenbank. Volle Testsuite (695 Tests) und type-check bleiben gruen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FYZcd3SSmo14QTqWx2KKzU
2026-09-09 10:56:41 +02:00

154 lines
5.2 KiB
PL/PgSQL

-- WINDOWS #18/#20, 260909-eor Aufgabe 2 — der Anmeldeweg bekommt eine
-- bewusst schmale, begruendete Ausnahme von der Mandantentrennung.
--
-- DIE ENTSCHEIDUNG UND IHRE BEGRUENDUNG. Von drei erwogenen Wegen faellt die
-- Wahl auf SECURITY-DEFINER-Funktionen:
--
-- 1. Eine zusaetzliche Policy auf "User" scheidet aus, weil eine Policy
-- ein ZEILENPRAEDIKAT ist und nicht die FORM der Abfrage einschraenken
-- kann: eine Regel, die eine Suche nach Benutzername erlaubt, erlaubt
-- zwangslaeufig auch das Auslesen aller Zeilen.
-- 2. Eine zweite Datenbankrolle nur fuer die Anmeldung scheidet aus, weil
-- sie einen zweiten Verbindungspool und einen zweiten Prisma-Client
-- verlangt und auf "User" ohnehin dieselbe Breite haette.
-- 3. Eine Funktion dagegen bindet die Ausnahme an eine FESTE Abfrage mit
-- festem Spaltensatz, fester Gleichheitsbedingung und LIMIT 1 — ein
-- kompromittierter Aufrufer kann damit einen einzelnen Benutzernamen
-- erraten, aber die Tabelle nicht ausleeren.
--
-- Alle drei Funktionen sind SECURITY DEFINER, STABLE, mit fest angeheftetem
-- Suchpfad auf "public, pg_temp" und LIMIT 1. Der feste Suchpfad ist bei
-- SECURITY DEFINER kein Schoenheitsfehler, sondern die eigentliche
-- Absicherung: ohne ihn koennte eine untergeschobene Schema-Definition
-- (z.B. ein Schema namens "pg_temp" oder ein frueh in search_path
-- platziertes Schema mit gleichnamiger Tabelle) den Tabellenbezug in der
-- Funktion umlenken, und der Aufrufer erbte die Rechte des Eigentuemers auf
-- die falsche Tabelle.
--
-- Keine dieser Funktionen schreibt. Nach jeder Anlage werden zuerst
-- saemtliche Rechte von PUBLIC entzogen und danach ausschliesslich
-- tessera_app das Ausfuehrungsrecht erteilt — die Rolle, die spaeter
-- tatsaechlich verbindet (siehe 20260909130000_rls_app_role), ist die
-- einzige, die die Ausnahme nutzen darf.
--
-- Wiederholbar: CREATE OR REPLACE FUNCTION ist idempotent, REVOKE/GRANT
-- sind es ebenfalls. Existiert tessera_app nicht (z.B. auf einer frischen
-- Installation vor 20260909130000), scheitert das GRANT laut mit einer
-- verstaendlichen Anleitung statt still zu ueberspringen — dasselbe Muster
-- wie in 20260909130000_rls_app_role/migration.sql.
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'tessera_app') THEN
RAISE EXCEPTION
'tessera_app existiert nicht. Diese Migration setzt 20260909130000_rls_app_role '
'voraus. Siehe docs/mandantentrennung-datenbankrolle.md.';
END IF;
END
$$;
-- auth_lookup_user_by_username — ersetzt this.prisma.user.findUnique in
-- auth.service.ts validateUser() (Zeile 39). Liefert nur die Felder, die
-- der Anmeldeweg tatsaechlich braucht: Kennung, Benutzername, Mandant,
-- Kennwort-Hash, LDAP-DN, Aktiv-Merkmal, Rolle, Anzeigename,
-- Kennwortwechsel-Merkmal. Kein E-Mail-Feld — der Anmeldeweg braucht es
-- hier nicht.
CREATE OR REPLACE FUNCTION auth_lookup_user_by_username(p_username text)
RETURNS TABLE (
id text,
username text,
"tenantId" text,
"passwordHash" text,
"ldapDn" text,
"isActive" boolean,
role "Role",
"displayName" text,
"mustChangePassword" boolean
)
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
SELECT
u.id,
u.username,
u."tenantId",
u."passwordHash",
u."ldapDn",
u."isActive",
u.role,
u."displayName",
u."mustChangePassword"
FROM "User" u
WHERE u.username = p_username
LIMIT 1;
$$;
REVOKE ALL ON FUNCTION auth_lookup_user_by_username(text) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION auth_lookup_user_by_username(text) TO tessera_app;
-- auth_lookup_user_by_email — ersetzt this.prisma.user.findUnique in
-- auth.service.ts requestPasswordReset() (Zeile 144). Liefert Kennung,
-- Mandant, E-Mail und Aktiv-Merkmal; keinen Kennwort-Hash, denn dieser Pfad
-- prueft kein Kennwort.
CREATE OR REPLACE FUNCTION auth_lookup_user_by_email(p_email text)
RETURNS TABLE (
id text,
"tenantId" text,
email text,
"isActive" boolean
)
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
SELECT
u.id,
u."tenantId",
u.email,
u."isActive"
FROM "User" u
WHERE u.email = p_email
LIMIT 1;
$$;
REVOKE ALL ON FUNCTION auth_lookup_user_by_email(text) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION auth_lookup_user_by_email(text) TO tessera_app;
-- auth_lookup_reset_token — ersetzt this.prisma.passwordResetToken.findUnique
-- (mit include: { user: true }) in auth.service.ts resetPassword()
-- (Zeile 178). Gleichheitsvergleich gegen das Token, liefert den
-- Token-Datensatz zusammen mit Benutzerkennung und Mandant des Benutzers.
-- Das Token ist eine randomUUID() und nicht erratbar (T-EOR-04).
CREATE OR REPLACE FUNCTION auth_lookup_reset_token(p_token text)
RETURNS TABLE (
id text,
token text,
"userId" text,
"expiresAt" timestamp(3),
"usedAt" timestamp(3),
"tenantId" text
)
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
SELECT
prt.id,
prt.token,
prt."userId",
prt."expiresAt",
prt."usedAt",
u."tenantId"
FROM "PasswordResetToken" prt
JOIN "User" u ON u.id = prt."userId"
WHERE prt.token = p_token
LIMIT 1;
$$;
REVOKE ALL ON FUNCTION auth_lookup_reset_token(text) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION auth_lookup_reset_token(text) TO tessera_app;