Publish curated Unraid-AppOps-Gateway source from 034c9f844d2455850ba37b31379b7edaa786583e
This commit is contained in:
1 parent
c61fbfd87f
commit
1a54836efc
101 files changed
+4973
-1298
No files matched your search
@@ -0,0 +1,211 @@
|
||||
"""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))}
|
||||
Reference in new issue
Block a user