de50297467
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
154 lines
5.2 KiB
PL/PgSQL
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;
|