add admin stuff

This commit is contained in:
2026-08-20 10:43:19 +02:00
parent 7108d446d8
commit 91758a2701
19 changed files with 2514 additions and 85 deletions

View File

@ -1,12 +1,15 @@
"""FastAPI-Dependencies fuer Authentifizierung und Autorisierung."""
from __future__ import annotations
import json
from dataclasses import dataclass
from datetime import datetime, timezone
from fastapi import Cookie, Depends, HTTPException, Request, Response, WebSocket, status
from fastapi import Cookie, Depends, Header, HTTPException, Request, Response, WebSocket, status
from app.db import get_db
from app.rbac import user_has_role, user_has_role_for_host
from app.security.api_tokens import TOKEN_PREFIX, hash_token, token_has_scope
from app.security.sessions import (
SESSION_COOKIE_NAME,
decode_session_token,
@ -120,3 +123,77 @@ def require_host_role(role_name: str):
return user
return _dep
# --- API-Tokens (siehe app/security/api_tokens.py, Migration 0005) -------------
#
# Tokens sind bewusst NUR fuer die Admin-/Verwaltungs-API gedacht (Hosts,
# User, Rollen, ...), NICHT fuer SSH/RDP/SFTP-Sitzungen selbst -- die bleiben
# ausschliesslich an eine eingeloggte, TOTP-verifizierte Browser-Session
# gebunden. Ein Token ist nie maechtiger als sein explizit vergebener Scope,
# auch wenn der zugehoerige User Admin ist (kein automatisches is_admin).
# Die Tokenverwaltung selbst (/admin/tokens/*) laeuft ausschliesslich ueber
# require_global_admin (Session), niemals ueber ein Token -- ein geleaktes
# Token darf sich damit nicht selbst weitere/staerkere Tokens ausstellen.
async def _validate_api_token(token: str, *, resource: str, action: str) -> CurrentUser | None:
if not token.startswith(TOKEN_PREFIX):
return None
conn = get_db()
token_hash = hash_token(token)
cursor = await conn.execute(
"SELECT at.id, at.user_id, at.scopes_json, at.expires_at, at.revoked_at, "
"u.username, u.is_active "
"FROM api_tokens at JOIN users u ON u.id = at.user_id WHERE at.token_hash = ?",
(token_hash,),
)
row = await cursor.fetchone()
if row is None:
return None
token_id, user_id, scopes_json, expires_at, revoked_at, username, is_active = row
if revoked_at is not None or not is_active:
return None
now_str = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
if expires_at is not None and expires_at <= now_str:
return None
scopes = json.loads(scopes_json)
if not token_has_scope(scopes, resource, action):
return None
await conn.execute(
"UPDATE api_tokens SET last_used_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
(token_id,),
)
await conn.commit()
return CurrentUser(id=user_id, username=username, is_admin=False)
def require_admin_or_scope(resource: str, action: str):
"""Dependency-Factory fuer die Admin-API: erlaubt Zugriff entweder ueber
eine eingeloggte Admin-Session (Cookie, wie bisher) ODER ueber ein
API-Token mit passendem Scope (Header 'Authorization: Bearer <token>').
'write' schliesst 'read' auf derselben Ressource ein (siehe
token_has_scope)."""
async def _dep(
response: Response,
jh_session: str | None = Cookie(default=None, alias=SESSION_COOKIE_NAME),
authorization: str | None = Header(default=None),
) -> CurrentUser:
if authorization and authorization.startswith("Bearer "):
token = authorization.removeprefix("Bearer ").strip()
principal = await _validate_api_token(token, resource=resource, action=action)
if principal is None:
raise HTTPException(
status.HTTP_401_UNAUTHORIZED,
"Ungueltiges, abgelaufenes oder nicht ausreichend berechtigtes Token",
)
return principal
user = await get_current_user(response, jh_session)
if not user.is_admin:
raise HTTPException(status.HTTP_403_FORBIDDEN, "Admin-Rechte erforderlich")
return user
return _dep