feat: add Authentik access widgets

This commit is contained in:
Developer
2026-07-14 21:41:24 +00:00
parent 4562a9dfca
commit 17976eab80
21 changed files with 1082 additions and 214 deletions
@@ -1,10 +1,7 @@
"""Authentik directory + messaging router.
"""Read-only Authentik directory, access metadata, and messaging router.
Resolves an ``authentik`` service instance from the registry, builds an
:class:`AuthentikClient` from its config + decrypted ``api_token`` secret, and
proxies paginated directory queries plus message-compose (email enqueue).
Graceful "not configured" / "unreachable" payloads (matching the monitoring
router's pattern) so the UI always renders.
Directory data is service-scoped and fails gracefully so the service page can
render a useful empty/error state when Authentik is unavailable.
"""
from __future__ import annotations
@@ -12,7 +9,7 @@ from __future__ import annotations
import logging
from typing import Any
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
from media_library_viewer_api.clients.authentik import AuthentikClient
@@ -30,7 +27,7 @@ router = APIRouter(prefix="/api/services/authentik", tags=["authentik"])
class MessageRequest(BaseModel):
"""Compose-request body for the Authentik messaging endpoint."""
"""Compose-request body for the existing Authentik messaging endpoint."""
recipient_emails: list[str]
subject: str
@@ -38,39 +35,99 @@ class MessageRequest(BaseModel):
def _build_client(service: ServiceRecord) -> AuthentikClient:
base_url = str(service.config.get("base_url") or "").rstrip("/")
api_token = str(service.secrets.get("api_token") or "")
try:
timeout = float(service.config.get("timeout_seconds") or 10)
except (TypeError, ValueError):
timeout = 10.0
return AuthentikClient(base_url=base_url, api_token=api_token, timeout=timeout)
return AuthentikClient(
base_url=str(service.config.get("base_url") or "").rstrip("/"),
api_token=str(service.secrets.get("api_token") or ""),
timeout=timeout,
)
def _empty(error: str) -> dict[str, Any]:
def _empty_directory(error: str) -> dict[str, Any]:
return {"items": [], "total": 0, "page": 1, "page_size": 50, "error": error}
def _empty_collection(error: str) -> dict[str, Any]:
return {"items": [], "total": 0, "error": error}
def _service_or_error(store: SettingsStore, service_id: str) -> ServiceRecord | None:
return resolve_service_record(store, "authentik", service_id)
@router.get("/{service_id}/users")
def get_authentik_users(
service_id: str,
search: str | None = None,
page: int = 1,
page_size: int = 50,
page: int = Query(default=1, ge=1),
page_size: int = Query(default=50, ge=1, le=200),
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]:
"""Paginated Authentik user directory for a specific service instance."""
service = resolve_service_record(store, "authentik", service_id)
"""Paginated raw directory users for the existing messaging surface."""
service = _service_or_error(store, service_id)
if service is None:
logger.info("Authentik users requested but no enabled authentik service for id=%s", service_id)
return _empty("Authentik service not configured")
return _empty_directory("Authentik service not configured")
try:
client = _build_client(service)
return client.users(search=search, page=page, page_size=page_size)
return _build_client(service).users(search=search, page=page, page_size=page_size)
except Exception:
logger.exception("Authentik users query failed for service %s", service_id)
return _empty("Authentik is unreachable")
return _empty_directory("Authentik is unreachable")
@router.get("/{service_id}/access-summary")
def get_authentik_access_summary(
service_id: str,
search: str | None = None,
page: int = Query(default=1, ge=1),
page_size: int = Query(default=50, ge=1, le=200),
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]:
"""User groups plus explicit staff/superuser flags, not effective permissions."""
service = _service_or_error(store, service_id)
if service is None:
return _empty_directory("Authentik service not configured")
try:
return _build_client(service).access_summaries(search=search, page=page, page_size=page_size)
except Exception:
logger.exception("Authentik access summary query failed for service %s", service_id)
return _empty_directory("Authentik is unreachable")
@router.get("/{service_id}/groups")
def get_authentik_groups(
service_id: str,
limit: int = Query(default=100, ge=1, le=200),
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]:
"""Display-safe, service-scoped Authentik group list."""
service = _service_or_error(store, service_id)
if service is None:
return _empty_collection("Authentik service not configured")
try:
return _build_client(service).groups(limit=limit)
except Exception:
logger.exception("Authentik groups query failed for service %s", service_id)
return _empty_collection("Authentik is unreachable")
@router.get("/{service_id}/applications")
def get_authentik_applications(
service_id: str,
limit: int = Query(default=100, ge=1, le=200),
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]:
"""Display-safe Authentik applications without provider or policy details."""
service = _service_or_error(store, service_id)
if service is None:
return _empty_collection("Authentik service not configured")
try:
return _build_client(service).applications(limit=limit)
except Exception:
logger.exception("Authentik applications query failed for service %s", service_id)
return _empty_collection("Authentik is unreachable")
@router.get("/{service_id}/message/status")
@@ -80,8 +137,7 @@ def get_authentik_message_status(
mail_queue: MailQueue = Depends(get_mail_queue),
) -> dict[str, Any]:
"""Mail-queue status snapshot for the Authentik messaging tab."""
service = resolve_service_record(store, "authentik", service_id)
if service is None:
if _service_or_error(store, service_id) is None:
return {"state": "stopped", "worker_running": False, "error": "Authentik service not configured"}
return mail_queue.status()
@@ -94,20 +150,16 @@ def post_authentik_message(
mail_queue: MailQueue = Depends(get_mail_queue),
) -> dict[str, Any]:
"""Enqueue an email to Authentik-sourced recipients via the mail queue."""
service = resolve_service_record(store, "authentik", service_id)
if service is None:
if _service_or_error(store, service_id) is None:
return {"status": "error", "error": "Authentik service not configured"}
recipients = [r.strip() for r in body.recipient_emails if r.strip()]
recipients = [recipient.strip() for recipient in body.recipient_emails if recipient.strip()]
if not recipients:
return {"status": "error", "error": "No recipients with valid email addresses."}
settings = get_settings()
try:
validate_smtp_settings(settings)
except ValueError as exc:
return {"status": "error", "error": f"SMTP settings invalid: {exc}"}
request_id = mail_queue.enqueue(
settings=settings,
recipients=recipients,
@@ -115,8 +167,4 @@ def post_authentik_message(
html_body=body.html_body,
)
logger.info("Authentik message enqueued for service %s (%d recipients)", service_id, len(recipients))
return {
"status": "queued",
"request_id": request_id,
"recipient_count": len(recipients),
}
return {"status": "queued", "request_id": request_id, "recipient_count": len(recipients)}