Files
Unraid-AppOps-Gateway-Public/backend/app/services/user_access.py
T

217 lines
9.7 KiB
Python

"""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))}