212 lines
9.6 KiB
Python
212 lines
9.6 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"}:
|
|
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))}
|