"""User identities, persistent sessions and server-enforced capabilities.""" from __future__ import annotations import hashlib import hmac import secrets from contextvars import ContextVar from time import time from urllib.parse import urlsplit from fastapi import HTTPException, Request from sqlalchemy import delete from sqlalchemy.exc import IntegrityError from sqlmodel import Session from ..core.config import settings from ..core.time import utc_now from ..db import session as database from ..db.models import BootstrapGrant, GatewayUser, UserSession USER_COOKIE = "appops_user_session" SESSION_TTL = 8 * 60 * 60 actor_identity: ContextVar[str | None] = ContextVar("appops_user", default=None) PERMISSIONS = { "read": "Status, inventaris en audit bekijken", "actions.execute": "Operationele acties uitvoeren en aanvragen", "inventory.manage": "Inventaris ontdekken en mappings beheren", "approvals.manage": "Goedkeuringen behandelen", "connectors.manage": "Connectorconfiguratie beheren", "policies.manage": "Policies en playbooks beheren", "settings.manage": "Systeeminstellingen, exports en backups beheren", "notifications.manage": "Meldingen en meldingsregels beheren", "voice.use": "Voice-opdrachten testen en aanvragen", "users.manage": "Gebruikers en rechten beheren", } ROLES = {"admin", "operator", "read_only", "custom"} OPERATOR_PERMISSIONS = {"read", "actions.execute", "inventory.manage", "voice.use", "notifications.manage"} def digest(value: str) -> str: return hashlib.sha256(value.encode()).hexdigest() def effective_permissions(user: GatewayUser) -> set[str]: if not user.enabled: return set() if user.role == "admin": return set(PERMISSIONS) if user.role == "operator": return set(OPERATOR_PERMISSIONS) if user.role == "custom": return {"read"} | (set(user.permissions) & (set(PERMISSIONS) - {"users.manage"})) return {"read"} def user_view(user: GatewayUser) -> dict: return { "id": user.id, "provider": user.provider, "subject": user.subject, "email": user.email, "displayName": user.display_name, "role": user.role, "enabled": user.enabled, "permissions": sorted(effective_permissions(user)), } def provision_user(db: Session, provider: str, issuer: str, claims: dict) -> GatewayUser: subject = str(claims.get("oid") if provider == "entra" else claims.get("sub") or "") if not subject or subject == "None": raise HTTPException(401, "Identity provider subject missing") if provider == "entra": subject = subject.casefold() user_id = digest(f"{provider}\n{issuer}\n{subject}") user = db.get(GatewayUser, user_id) email_claim = claims.get("email") verified_email = email_claim if isinstance(email_claim, str) else "" email = str(verified_email or claims.get("preferred_username") or "")[:320] if user is None: user = GatewayUser(id=user_id, provider=provider, issuer=issuer, subject=subject) # An explicit bootstrap can also recover an initial login made before its # provider configuration was complete. Consumed grants never restore roles. grant_key = None admin_subjects = {item.casefold() if item.startswith("entra:") else item for item in settings.auth_admin_subjects or []} if f"{provider}:{subject}" in admin_subjects: grant_key = digest(f"subject:{provider}:{issuer}:{subject}") elif provider == "authentik" and verified_email and claims.get("email_verified") is True and verified_email.casefold() in { item.casefold() for item in settings.auth_admin_emails or [] }: grant_key = digest(f"email:{provider}:{issuer}:{verified_email.casefold()}") user_grant_key = digest(f"user-bootstrap:{user_id}") if grant_key and db.get(BootstrapGrant, grant_key) is None and db.get(BootstrapGrant, user_grant_key) is None: db.add(BootstrapGrant(id=grant_key, user_id=user_id)) db.add(BootstrapGrant(id=user_grant_key, user_id=user_id)) user.role = "admin" user.email = email user.display_name = str(claims.get("name") or claims.get("preferred_username") or email or subject)[:200] user.last_login = utc_now() db.add(user) try: db.commit() except IntegrityError: # Another request consumed this bootstrap grant or provisioned the same # identity. Re-evaluate against committed state, never grant twice. db.rollback() return provision_user(db, provider, issuer, claims) db.refresh(user) return user def issue_session(db: Session, user: GatewayUser) -> str: if not user.enabled: raise HTTPException(403, "Account disabled") opaque = secrets.token_urlsafe(48) db.exec(delete(UserSession).where(UserSession.expires_at <= int(time()))) db.add(UserSession(id=digest(opaque), user_id=user.id, expires_at=int(time()) + SESSION_TTL)) db.commit() return opaque def validate_csrf(request: Request) -> None: if request.method in {"GET", "HEAD", "OPTIONS"}: return if not hmac.compare_digest(request.headers.get("X-AppOps-Session-CSRF", ""), "1"): raise HTTPException(403, "Session CSRF validation failed") origin = request.headers.get("Origin") if origin: allowed = {settings.frontend_origin.rstrip("/")} if settings.auth_public_url: parts = urlsplit(settings.auth_public_url) allowed.add(f"{parts.scheme}://{parts.netloc}") if origin.rstrip("/") not in allowed: raise HTTPException(403, "Session origin validation failed") def current_user(request: Request) -> GatewayUser: cached = getattr(request.state, "gateway_user", None) if cached is not None: return cached with Session(database.engine) as db: cookie = request.cookies.get(USER_COOKIE, "") record = db.get(UserSession, digest(cookie)) if cookie else None user = db.get(GatewayUser, record.user_id) if record and record.expires_at > time() else None if user is None: # Header identities are accepted only from explicitly trusted # Authentik outposts. A stable uid is required; groups grant no role. from ..core.security import _request_from_trusted_operator_proxy if _request_from_trusted_operator_proxy(request): uid = request.headers.get("X-authentik-uid", "").strip() if uid: user = provision_user(db, "authentik", settings.authentik_issuer or "authentik-proxy", { "sub": uid, "email": request.headers.get("X-authentik-email", ""), "name": request.headers.get("X-authentik-name") or request.headers.get("X-authentik-username", ""), }) if user is None or not user.enabled: raise HTTPException(401, "Sign in with Authentik or Microsoft Entra") validate_csrf(request) db.expunge(user) request.state.gateway_user = user return user def required_permission(path: str, method: str) -> str: if path.startswith("/api/auth/users") or path == "/api/auth/permissions": return "users.manage" if path.startswith(("/api/admin/", "/api/runtime/", "/api/settings/secrets")): return "settings.manage" if method in {"GET", "HEAD", "OPTIONS"}: return "read" if path in {"/api/ai/chat", "/api/ai/summarize/logs"} or path.startswith("/api/ai/analyze/incident/"): return "read" if path.startswith("/api/ai/memory"): return "settings.manage" if path.startswith(("/api/settings/connectors", "/api/connectors", "/api/setup/")): return "connectors.manage" if path.startswith(("/api/settings/", "/api/system/")) or path in { "/api/ai/rag/reindex", "/api/knowledge/reindex", "/api/enrollments/trust", "/api/enrollments/trust/storage-root", }: return "settings.manage" if path.startswith("/api/apps/") and path.endswith(("/metadata", "/container-mapping")): return "inventory.manage" if path.startswith(("/api/policies", "/api/playbooks")): return "policies.manage" if path.startswith("/api/approvals"): return "actions.execute" if path.rstrip('/') == "/api/approvals" and method == "POST" else "approvals.manage" if path.startswith("/api/inventory"): return "inventory.manage" if path.startswith("/api/notifications"): return "notifications.manage" if path.startswith(("/api/voice/", "/api/operator/voice/")): return "voice.use" return "actions.execute" def authorize_request(request: Request) -> GatewayUser: user = current_user(request) permission = required_permission(request.url.path, request.method) if permission not in effective_permissions(user): raise HTTPException(403, "Your account does not have permission for this operation") executes_approval = request.url.path.startswith("/api/approvals/") and request.url.path.endswith("/execute") executes_voice = request.url.path.startswith("/api/voice/actions/") or ( request.url.path.startswith("/api/voice/commands/") and request.url.path.endswith("/request-approval") ) if request.method not in {"GET", "HEAD", "OPTIONS"} and (executes_approval or executes_voice) and "actions.execute" not in effective_permissions(user): raise HTTPException(403, "Your account does not have permission to execute or request this action") return user def session_status(request: Request) -> dict: try: user = current_user(request) except HTTPException as exc: if exc.status_code != 401: raise return {"authenticated": False, "user": None, "permissions": []} return {"authenticated": True, "user": user_view(user), "permissions": sorted(effective_permissions(user))}