Alle PlaybooksNext.js + Flutter Product Monorepo

Eine Admin-Seite hinzufügen

Der Admin-App eine Seite hinzufügen, die Daten aller Nutzer liest, autorisiert auf dem Server und durch eine RLS-Policy nur für Admins.

Wann du es brauchst

Admins müssen Daten über ihre eigenen hinaus sehen oder verwalten, zum Beispiel die Liste der Nutzer mit Rolle und Billing-Status. Die Schritte verwenden eine Seite /users als Beispiel.

Regeln

  • Die Admin-App ist für sich keine Sicherheitsgrenze: Jede Seite und jeder Route Handler ruft getCurrentAdmin() auf und bricht ohne Admin ab. Eine Prüfung nur im Layout reicht nicht, denn Layouts werden bei der Navigation nicht neu gerendert und halten die Seite nicht vom Rendern ab.
  • Die Rolle wird auf dem Server über RLS aus profiles gelesen, nie aus einem Cookie, einem Query-Parameter oder einem Flag des Clients.
  • Admins lesen die Zeilen anderer Nutzer über RLS-Policies, mit dem Anon-Key und der Sitzung. Verwende in apps/admin keinen Service-Role-Key: Er umgeht RLS, ein Fehler in einer Seite würde also alle Zeilen offenlegen.
  • Keine Policy darf Nutzern erlauben, profiles.role zu ändern. Ernenne Admins per SQL im Supabase-Dashboard oder mit einer Migration.

Schritte

  1. Lege eine Migration in supabase/migrations/ an (append-only) mit einer Hilfsfunktion, die sagt, ob der Aufrufer Admin ist, und den Policies, die die Seite braucht. Eine Policy auf profiles kann profiles nicht direkt abfragen (Postgres meldet eine unendliche Rekursion), deshalb ist die Hilfsfunktion security definer:

    create function public.is_admin()
    returns boolean
    language sql
    stable
    security definer set search_path = ''
    as $$
      select exists (
        select 1 from public.profiles where id = (select auth.uid()) and role = 'admin'
      );
    $$;
    
    revoke execute on function public.is_admin() from public, anon;
    grant execute on function public.is_admin() to authenticated;
    
    create policy "admins read all profiles"
      on public.profiles for select to authenticated
      using ((select public.is_admin()));
    create policy "admins read all billing accounts"
      on public.billing_accounts for select to authenticated
      using ((select public.is_admin()));
    

    Die Hilfsfunktion antwortet nur über den Aufrufer selbst und darf daher über die API aufgerufen werden. Lege nur select-Policies an, außer die Seite ändert wirklich Daten.

  2. Verschiebe in apps/admin/lib/current_admin.ts das Erzeugen des Supabase-Clients in eine exportierte Funktion createAdminSupabaseClient() und nutze sie in getCurrentAdmin(), damit Seiten mit demselben Client pro Request abfragen.

  3. Lege apps/admin/app/users/page.tsx als Server Component an. Rufe zuerst getCurrentAdmin() auf und zeige ohne Admin vor jeder Abfrage dasselbe "Access denied" wie apps/admin/app/page.tsx. Lies dann mit createAdminSupabaseClient(), zum Beispiel from('profiles').select('id, email, role, billing_accounts(status)'), und rendere nur die Felder, die die Seite zeigt.

  4. Ändert die Seite Daten, verwende eine Server Action oder einen Route Handler, der erneut getCurrentAdmin() aufruft, und lege für diese Operation eine RLS-Policy an, die ebenfalls public.is_admin() nutzt.

  5. Wende die Migration an und prüfe mit zwei Konten: Ein member sieht auf /users "Access denied", und ein select auf profiles mit seiner Sitzung liefert weiterhin nur seine eigene Zeile; ein admin sieht alle Nutzer.

  6. Beschreibe die neuen Policies in DATABASE.md und was Admins tun können in PRODUCT.md.

Fertig, wenn

  • die Prüfbefehle durchlaufen;
  • die Seite und jede Aktion darauf getCurrentAdmin() auf dem Server aufrufen;
  • ein member weder über die API noch über die Datenbank zusätzliche Zeilen erhält, geprüft wie in Schritt 5;
  • DATABASE.md und PRODUCT.md die Änderung beschreiben.