docs: add auth permissions design spec (public routes, permission system)
This commit is contained in:
parent
89e060f9d2
commit
e85a1eb5fa
174
docs/superpowers/specs/2026-04-27-auth-permissions-design.md
Normal file
174
docs/superpowers/specs/2026-04-27-auth-permissions-design.md
Normal file
@ -0,0 +1,174 @@
|
||||
# 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)
|
||||
Loading…
x
Reference in New Issue
Block a user