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
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
-- 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;
|
||||
Reference in New Issue
Block a user