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

175 lines
5.3 KiB
Markdown

# 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.
```python
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:
```python
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:
```python
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`:
```python
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:
```python
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`:
```python
@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`)
```html
{% 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:
```python
router = APIRouter(dependencies=[Depends(require_permission(["authenticated"]))])
```
Für feinere Kontrolle einzelner Routen:
```python
@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)