"""Read-only Authentik directory, access metadata, and messaging router. 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 import logging from typing import Any from fastapi import APIRouter, Depends, Query from pydantic import BaseModel from media_library_viewer_api.clients.authentik import AuthentikClient from media_library_viewer_api.config import get_settings from media_library_viewer_api.dependencies import get_mail_queue, get_settings_store from media_library_viewer_api.services.mail_queue import MailQueue from media_library_viewer_api.services.mailer import validate_smtp_settings from media_library_viewer_api.services.service_resolution import resolve_service_record from media_library_viewer_api.services.settings_store import SettingsStore from media_library_viewer_api.widgets.sources import ServiceRecord logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/services/authentik", tags=["authentik"]) class MessageRequest(BaseModel): """Compose-request body for the existing Authentik messaging endpoint.""" recipient_emails: list[str] subject: str html_body: str def _build_client(service: ServiceRecord) -> AuthentikClient: try: timeout = float(service.config.get("timeout_seconds") or 10) except (TypeError, ValueError): timeout = 10.0 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_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 = 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 raw directory users for the existing messaging surface.""" service = _service_or_error(store, service_id) if service is None: return _empty_directory("Authentik service not configured") try: 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_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") def get_authentik_message_status( service_id: str, store: SettingsStore = Depends(get_settings_store), mail_queue: MailQueue = Depends(get_mail_queue), ) -> dict[str, Any]: """Mail-queue status snapshot for the Authentik messaging tab.""" if _service_or_error(store, service_id) is None: return {"state": "stopped", "worker_running": False, "error": "Authentik service not configured"} return mail_queue.status() @router.post("/{service_id}/message") def post_authentik_message( service_id: str, body: MessageRequest, store: SettingsStore = Depends(get_settings_store), mail_queue: MailQueue = Depends(get_mail_queue), ) -> dict[str, Any]: """Enqueue an email to Authentik-sourced recipients via the mail queue.""" if _service_or_error(store, service_id) is None: return {"status": "error", "error": "Authentik service not configured"} 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, subject=body.subject, 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)}