Instruction file imported from Synanon-gif/IndexCasting (
.cursor/rules/admin-security.mdc). Copyright stays with the author.
Admin Security & Always-Access — Verbindliche Regeln
Identität (unveränderbar)
ADMIN_UUID: fb0ab854-d0c3-4e09-a39c-269d60246927
ADMIN_EMAIL: rubenelge@t-online.de
Nur diese eine Person ist Admin. Niemand sonst. Immer.
Regel 1: signIn() — bootstrapThenLoadProfile IMMER zuerst, isoliert
Pattern (NIEMALS ändern):
const signIn = async (email, password) => {
const { data, error } = await supabase.auth.signInWithPassword({ email, password });
if (error) return { error: error.message };
// STEP 1: Profil-Bootstrap — IMMER als erstes, eigener try-catch
// NIEMALS in demselben try-Block wie Nebeneffekte (linkModelByEmail etc.)
if (data?.user) {
try {
const result = await bootstrapThenLoadProfile(data.user.id);
// handle deactivated...
} catch (e) {
console.error('signIn bootstrapThenLoadProfile error:', e);
}
}
// STEP 2: Nebeneffekte — vollständig isoliert, können NICHT Step 1 blockieren
try { await linkModelByEmail(); } catch (e) { console.error(e); }
return { error: null };
};
Warum: linkModelByEmail() im selben try-Block → wenn es wirft, wird bootstrapThenLoadProfile nie aufgerufen → Admin sieht Login-Screen trotz korrekter Daten.
Regel 2: loadProfile() — 3-Ebenen Admin-Detection
Pattern (NIEMALS reduzieren):
// Ebene 1: get_own_admin_flags() TABLE-RPC (UUID+email-gepinnt)
// Ebene 2: is_current_user_admin() boolean-RPC (UUID+email-gepinnt, anderer Code-Pfad)
// Ebene 3: data.role === 'admin' (Trigger-geschützt, nur wenn beide RPCs tot)
// → Alle drei müssen gleichzeitig versagen um Admin auszusperren — praktisch unmöglich
Regel 3: App.tsx Routing — immer dual-guard
// Admin MUSS vor effectiveRole-Check kommen (role='admin' → kein effectiveRole → AuthScreen)
if (profile?.is_admin || profile?.role === 'admin') {
return <AdminDashboard />;
}
role === 'admin' ist sicher weil:
- BEFORE UPDATE Trigger blockiert jede Änderung via API
handle_new_userAllowlist: nur client/agent/model/guest bei Signup- AdminDashboard-RPCs prüfen server-seitig unabhängig
Regel 4: DB-Funktionen — UUID+Email-Pin (NIEMALS entfernen)
-- Alle 3 Bedingungen müssen gleichzeitig erfüllt sein:
WHERE p.id = auth.uid()
AND p.id = 'fb0ab854-d0c3-4e09-a39c-269d60246927'
AND u.email = 'rubenelge@t-online.de'
-- Gilt für: get_own_admin_flags(), is_current_user_admin(),
-- is_current_user_super_admin(), assert_is_admin()
Regel 5: handle_new_user — Allowlist (NIEMALS erweitern ohne Prüfung)
v_role := CASE
WHEN v_raw_role IN ('client', 'agent', 'model', 'guest') THEN v_raw_role
ELSE 'client' -- 'admin' → immer 'client', is_admin immer false
END;
Regel 6: Neue Admin-RPCs — immer assert_is_admin()
CREATE OR REPLACE FUNCTION public.admin_neue_funktion(...)
...AS $$
BEGIN
PERFORM public.assert_is_admin(); -- ERSTE Zeile, kein anderer Guard
-- ... Funktion ...
END;$$;
assert_is_admin() → is_current_user_admin() → UUID+email-pin → loggt Fehlversuche.
Regel 7: RLS-Policies — NIEMALS Rekursionszyklen erzeugen (KRITISCH)
Jede RLS-Policy, die im SELECT-Pfad von profiles liegt, kann den Admin-Login zerstören.
Verbotene Muster (verursachen PostgreSQL 42P17 → 500 auf JEDEN profiles-Query):
profiles → models → [Tabelle X] → models (Zyklus)
profiles → models → [Tabelle X] → profiles (Zyklus)
Bekannter Fix-Fall (2026-04-05):
Zwei ALL-Policies auf model_agency_territories enthielten SELECT-Subqueries
zurück auf models und profiles. PostgreSQL expandiert ALL-Policies auch für
SELECT, erkennt den Zirkelschluss und wirft 42P17 — selbst wenn eine separate
SELECT-Policy mit true existiert.
Lösung: ALL-Policies auf Tabellen, die von models- oder profiles-SELECT-Policies
referenziert werden, MÜSSEN in separate INSERT/UPDATE/DELETE-Policies aufgespalten
werden. Die SELECT-Komponente darf NICHT auf models oder profiles zurückverweisen.
Prüfpflicht bei JEDER neuen RLS-Policy:
- Wird die Tabelle (direkt oder indirekt) von einer
profiles- odermodels-SELECT-Policy referenziert? - Enthält die neue Policy einen SELECT auf
profiles,models, oder eine Funktion die das tut? - Ist die Policy
FOR ALL? → ALL inkludiert SELECT → Rekursionsgefahr! - Wenn 1+2 oder 1+3 zutreffen → Policy MUSS als INSERT/UPDATE/DELETE aufgespalten werden.
SECURITY DEFINER Funktionen in Policies:
Alle SECURITY DEFINER Funktionen, die aus RLS-Policies aufgerufen werden und
RLS-geschützte Tabellen lesen, MÜSSEN SET row_security TO off haben:
CREATE OR REPLACE FUNCTION public.beispiel_funktion()
RETURNS boolean
LANGUAGE sql STABLE SECURITY DEFINER
SET search_path = public
SET row_security TO off -- PFLICHT wenn aus RLS-Policy aufgerufen
AS $$ ... $$;
Ohne SET row_security TO off wendet PG15+ RLS auch innerhalb der
SECURITY DEFINER Funktion an → latente Rekursionsgefahr.
Regel 8: Partieller Unique-Index — nur ein Admin (DB-Garantie)
CREATE UNIQUE INDEX IF NOT EXISTS one_admin_only
ON public.profiles ((role))
WHERE role = 'admin';
Dieser Index garantiert auf DB-Ebene, dass maximal EIN Profil role='admin'
haben kann. Ergänzt die Trigger und UUID/Email-Pins als zusätzliche Schicht.
Regel 9: admin_convert_org_type — Client↔Agency nur atomar (kein manuelles Mischen)
Problem: Nur profiles.role oder nur organizations.type ändern erzeugt inkonsistente Zustände (z. B. Org noch client, Profil schon agent; falsche organization_members-Rollen vs. trg_validate_org_member_role).
Kanonischer Pfad: RPC public.admin_convert_org_type(p_org_id, p_new_type) mit PERFORM public.assert_is_admin(); als erste Zeile. Aktualisiert in einer Transaktion u. a.:
organizations.typeorganization_members.role(employee↔booker;ownerbleibt)profiles.rolealler Mitglieder (client↔agentwo passend)- bei Wechsel zu Agency:
agencies-Zeile falls nötig,organizations.agency_idsetzen; bei Wechsel zu Client:organizations.agency_idaufNULL
VERBOTEN in dieser RPC (PostgREST / Supabase):
ALTER TABLE public.organization_members DISABLE TRIGGER trg_validate_org_member_role;
ALTER TABLE … DISABLE TRIGGER ist im RPC-Kontext über PostgREST nicht zuverlässig (Rechte / Kontext) und gehört nicht in den kanonischen Pfad.
RICHTIG — Reihenfolge ohne Trigger-Disable:
UPDATE organizations SET type = …ZUERST — der Validierungs-Trigger auforganization_membersfeuert nur bei Änderungen an Mitgliedszeilen, nicht beim bloßen Org-Typ-Update.UPDATE organization_members SET role = …danach — der Trigger sieht bereits den neuen Org-Typ und akzeptiert z. B.bookerbeitype = agencybzw.employeebeitype = client.
Referenz-Migrationen: 20260414_admin_convert_org_type.sql (historisch), 20260414_admin_convert_org_type_v2.sql (ohne DISABLE TRIGGER).
Frontend: Wrapper adminConvertOrgType in src/services/adminSupabase.ts; UI in AdminDashboard.tsx. Bei RPC-Fehlern den konkreten Fehlertext loggen/anzeigen (nicht nur generisches „failed“).
Regel 10: Admin Dashboard — „GHOST ORG“-Badge (Heuristik, keine Wahrheit)
Zweck: Hinweis auf mögliche Bootstrap-Zombie-Org (ein User, Org-Name = persönlicher display_name), kein DB-Status.
Heuristik (bindend für neue Änderungen):
- Badge nur wenn
organizations.type === 'client'UNDmember_count <= 1UND Org-Name (trim, case-insensitive) =profiles.display_namedes Owners. type === 'agency'niemals als Ghost markieren — sonst False Positives (z. B. Agency-Name = Owner-display_name„Agency 1“ nach legitimer Konvertierung).
VERBOTEN: Ghost-Badge an organizations.is_ghost o. Ä. koppeln ohne explizites Schema-Produkt — aktuell reine UI-Heuristik in AdminDashboard.tsx.
Web: Destruktive Bestätigungen (Alert.alert + Promise) sind auf Web unzuverlässig — für Org-Konvertierung window.confirm nutzen wo Platform.OS === 'web' (Pattern bereits im Convert-Handler).
Was NIEMALS passieren darf
| Verboten | Grund |
|---|---|
bootstrapThenLoadProfile im selben try wie Nebeneffekte |
Admin wird ausgesperrt wenn Nebeneffekt wirft |
| Nur 1 Admin-Check (RPC ohne Fallback) | Transiente Fehler sperren Admin aus |
effectiveRole für Admin-Routing nutzen |
role='admin' gibt null → AuthScreen |
| UUID oder Email aus DB-Funktionen entfernen | Impersonation wird möglich |
handle_new_user Allowlist erweitern ohne Review |
Signup-Injection |
Neuen Admin-RPC ohne assert_is_admin() |
Schwächeres Guard |
is_admin oder role direkt per API setzen |
Blockiert durch Trigger |
| ALL-Policy auf Tabelle im profiles/models-Pfad | 42P17 Rekursion → Admin ausgesperrt |
SECURITY DEFINER ohne row_security TO off in RLS-Kontext |
Latente Rekursion in PG15+ |
Zweites Profil mit role='admin' |
Blockiert durch one_admin_only Index + Trigger |
ALTER TABLE … DISABLE/ENABLE TRIGGER in per PostgREST aufgerufenen Admin-RPCs |
Bricht oder ist unzuverlässig; stattdessen Regel 9 (Reihenfolge) |
Ghost-Org-Badge für organizations.type = agency |
False Positive; nur Client-Bootstrap-Heuristik (Regel 10) |
Gesamtarchitektur (Referenz)
Login → signIn() Step 1: bootstrapThenLoadProfile() [isoliert]
└─→ loadProfile()
├─ Ebene 1: get_own_admin_flags() [UUID+email] → is_admin=true ✅
├─ Ebene 2: is_current_user_admin() [UUID+email] → true ✅ (wenn Ebene 1 tot)
└─ Ebene 3: role==='admin' [Trigger] → true ✅ (wenn beide RPCs tot)
└─→ profile.is_admin = true → App.tsx → AdminDashboard ✅
DB-Guard (jede admin RPC):
assert_is_admin() → is_current_user_admin()
→ UUID=ADMIN_UUID ∧ email=ADMIN_EMAIL ∧ is_admin=true
→ Fehler → log_failed_admin_attempt() → Exception
Schutz gegen Eskalation:
3× BEFORE UPDATE Trigger → is_admin/role unveränderbar via API
handle_new_user Allowlist → role='admin' nie bei Signup möglich
Admin-Profil RLS → für andere User komplett unsichtbar
one_admin_only Index → nur 1 Profil mit role='admin' möglich
RLS-Rekursionsschutz (KRITISCH für Login):
profiles SELECT → models SELECT → model_agency_territories SELECT
Alle Tabellen im Pfad: KEINE ALL-Policies mit Rückverweis
Alle SECURITY DEFINER in Policies: SET row_security TO off
Prüfpflicht: Jede neue RLS-Policy auf Rekursionszyklus testen