efihub/docs/superpowers/specs/2026-04-27-auth-permissions-design.md

5.3 KiB

Design Spec: Auth — Öffentliche Routen & Permission-System

Datum: 2026-04-27 Status: Genehmigt


Ziel

Öffentliche und geschützte Routen unterscheiden. Geschützte Routen sind durch eine Dependency einfach zu kennzeichnen. Die Landing Page ist ohne Login erreichbar, zeigt aber dem eingeloggten User seine persönlichen Kacheln. Kacheln werden nur angezeigt, wenn der aktuelle User (oder Anonymous) berechtigt ist.


Permission-Modell

permission auf einem Modul ist eine list[str]. Zugriff wird gewährt wenn der User mindestens einen Wert erfüllt (OR-Logik).

Wert Bedingung
"public" immer True (auch anonyme User)
"authenticated" User ist eingeloggt
"admin" user.is_admin == True
"<gruppenname>" User ist Mitglied dieser Gruppe (Part 2)

Modul-Metadaten (slug, icon, name, description, status) bleiben als Code-Artefakt im MODULES-Dict. Berechtigungen wandern erst in die DB wenn Gruppen in Part 2 implementiert werden — check_permission ist der einzige Ort der dann angepasst werden muss.


Neue/geänderte Komponenten

app/modules/auth/dependencies.py

Drei Ergänzungen:

get_current_user_optional — gibt User | None zurück, wirft nie eine Exception. Für öffentliche Routen die optional Userinfo brauchen.

async def get_current_user_optional(
    request: Request, db: Session = Depends(get_db)
) -> User | None:
    token = get_token_from_request(request)
    if not token:
        return None
    payload = decode_token(token)
    if not payload:
        return None
    return get_user(db, payload.get("sub", ""))

check_permission — reine Funktion, kein FastAPI-Kontext:

def check_permission(user: User | None, permissions: list[str]) -> bool:
    if "public" in permissions:
        return True
    if user is None:
        return False
    if "authenticated" in permissions:
        return True
    if "admin" in permissions and user.is_admin:
        return True
    return False  # group check: Part 2

require_permission — Dependency-Factory:

def require_permission(permissions: list[str]):
    async def _dep(
        request: Request, db: Session = Depends(get_db)
    ) -> User | None:
        user = await get_current_user_optional(request, db)
        if not check_permission(user, permissions):
            raise RequiresLoginException()
        return user
    return _dep

Bestehende get_current_user und require_admin bleiben für Kompatibilität erhalten, delegieren aber intern an require_permission:

  • get_current_user = require_permission(["authenticated"])
  • require_admin = require_permission(["admin"])

app/main.py — MODULES-Registry

MODULES bekommt slug und permission:

MODULES = [
    {
        "slug": "rss",
        "icon": "📡",
        "name": "RSS-Feed Server",
        "description": "Aggregiert und verteilt Neuigkeiten der Fakultät als standardkonformen RSS 2.0 Feed.",
        "status": "active",
        "permission": ["authenticated"],
    },
    {
        "slug": "kalender",
        "icon": "📅",
        "name": "Kalender",
        "description": "Veranstaltungen, Prüfungstermine und Fristen im iCal-Format.",
        "status": "planned",
        "permission": ["authenticated"],
    },
    # ...
]

Aktive Module (status == "active") werden beim App-Start automatisch registriert:

import importlib

for module in MODULES:
    if module["status"] == "active":
        mod = importlib.import_module(f"app.modules.{module['slug']}.router")
        app.include_router(mod.router, prefix=f"/{module['slug']}")

GET / wird öffentlich — get_current_user_optional statt get_current_user:

@app.get("/", response_class=HTMLResponse)
async def root(request: Request, current_user=Depends(get_current_user_optional)):
    visible = [m for m in MODULES if check_permission(current_user, m["permission"])]
    return templates.TemplateResponse(request, "index.html", {
        "nav_items": NAV_ITEMS,
        "modules": visible,
        "db_mode": _db_mode(),
        "app_version": "0.1.0",
        "current_user": current_user,
    })

app/templates/base.html — Navbar

Navbar zeigt conditional:

  • Eingeloggt: Username + Logout-Button
  • Nicht eingeloggt: Anmelden-Button (→ /auth/login)
{% if current_user %}
  <span class="text-white/85 text-sm">{{ current_user.username }}</span>
  <a href="/auth/logout" class="...">Abmelden</a>
{% else %}
  <a href="/auth/login" class="...">Anmelden</a>
{% endif %}

Neue Modul-Konvention

Ein Entwickler der ein neues Modul anlegt:

  1. Verzeichnis app/modules/<slug>/ mit __init__.py und router.py
  2. router.py schützt alle Routen über den Router:
    router = APIRouter(dependencies=[Depends(require_permission(["authenticated"]))])
    
    Für feinere Kontrolle einzelner Routen:
    @router.get("/secret", dependencies=[Depends(require_permission(["admin"]))])
    
  3. Eintrag in MODULES in main.py mit status: "active" und passendem permission

Out of Scope

  • Gruppen-Tabelle und Gruppen-Verwaltung (→ Part 2)
  • Modul-Permissions in der DB (→ Part 2, zusammen mit Gruppen)
  • Admin-UI für Berechtigungsvergabe
  • Per-Objekt-Permissions (Row-Level Security)