From 9370e52cfcf47e7f89cb89103e6c52c6607a8615 Mon Sep 17 00:00:00 2001 From: Developer Date: Fri, 26 Jun 2026 18:11:44 +0000 Subject: [PATCH] Backend: Authentik directory client + endpoint (Slice 2) AuthentikClient (clients/authentik.py) wraps Authentik's directory API: - Bearer-token requests.Session, base_url normalization (rstrip / and trailing /api/v3), get() helper mirroring JellyseerrClient. - users(search, page, page_size) calls GET /api/v3/core/users/ and normalizes Authentik's {pagination, results} shape into {items, total, page, page_size} for frontend consumption. Directory endpoint (routers/authentik_users.py): - GET /api/services/authentik/{service_id}/users resolves the service record, builds the client from decrypted api_token, returns the normalized user list. - Graceful error handling matching monitoring.py: not-configured and unreachable return {items:[], total:0, error} with 200 (no 500s). - _resolve_service_record copied in (self-contained; shared-utility extraction is a follow-up). Router registered in main.py. Tests: 12 new (8 client unit + 4 endpoint integration covering success, not-configured, unreachable, URL/params). 268 backend tests pass; ruff clean. Refs openspec/changes/services-as-hub-ia/ (spec R6.2/R7.2, tasks slice 2). --- .../clients/authentik.py | 115 +++++++++++ backend/src/media_library_viewer_api/main.py | 4 + .../routers/authentik_users.py | 82 ++++++++ backend/tests/test_authentik_client.py | 183 ++++++++++++++++++ 4 files changed, 384 insertions(+) create mode 100644 backend/src/media_library_viewer_api/clients/authentik.py create mode 100644 backend/src/media_library_viewer_api/routers/authentik_users.py create mode 100644 backend/tests/test_authentik_client.py diff --git a/backend/src/media_library_viewer_api/clients/authentik.py b/backend/src/media_library_viewer_api/clients/authentik.py new file mode 100644 index 0000000..8c94a10 --- /dev/null +++ b/backend/src/media_library_viewer_api/clients/authentik.py @@ -0,0 +1,115 @@ +"""Authentik directory API client. + +Authentik is the user-directory source (replacing the Jellyfin-backed Users +page). This client wraps the Authentik REST API for browsing the user directory +with pagination and search. OIDC authentication is unchanged — this client is +for the directory, not SSO. +""" + +from __future__ import annotations + +import logging +from typing import Any + +import requests + +logger = logging.getLogger(__name__) + + +class AuthentikClient: + """Small wrapper around the Authentik core directory API.""" + + def __init__(self, base_url: str, api_token: str, timeout: float = 10.0): + if not base_url: + raise ValueError("Authentik base_url is required") + if not api_token: + raise ValueError("Authentik API token is required") + + self.base_url = base_url.rstrip("/") + if self.base_url.endswith("/api/v3"): + self.base_url = self.base_url[:-7] + self.api_token = api_token + self.timeout = timeout + self.session = requests.Session() + self.session.headers.update( + { + "Authorization": f"Bearer {api_token}", + "Accept": "application/json", + } + ) + + def get(self, path: str, **params: Any) -> Any: + """GET an Authentik endpoint and include useful response text on errors.""" + clean_params = {k: v for k, v in params.items() if v is not None and v != ""} + logger.debug("Authentik GET %s params=%s", path, sorted(clean_params.keys())) + response = self.session.get( + f"{self.base_url}/api/v3{path}", + params=clean_params, + timeout=self.timeout, + ) + try: + response.raise_for_status() + except requests.HTTPError as exc: + detail = response.text[:500] + logger.warning( + "Authentik GET %s failed status=%s url=%s", + path, + response.status_code, + response.url, + ) + raise requests.HTTPError( + f"{response.status_code} for {response.url}: {detail}", + response=response, + ) from exc + logger.debug("Authentik GET %s ok status=%s", path, response.status_code) + return response.json() + + def users( + self, + search: str | None = None, + page: int = 1, + page_size: int = 50, + ) -> dict[str, Any]: + """Return a normalized page of Authentik users. + + Calls ``GET /api/v3/core/users/`` and normalizes the paginated + Authentik response into ``{items, total, page, page_size}``. Each item + is the raw Authentik user dict (pk, username, name, email, avatar, …) + so the frontend can pick the fields it needs. + """ + payload = self.get( + "/core/users/", + search=search, + page=page, + page_size=page_size, + ) + if not isinstance(payload, dict): + logger.warning("Authentik users payload was not a dict: %s", type(payload).__name__) + return {"items": [], "total": 0, "page": page, "page_size": page_size} + + results = payload.get("results") + items: list[dict[str, Any]] = ( + [item for item in results if isinstance(item, dict)] if isinstance(results, list) else [] + ) + + pagination = payload.get("pagination") or {} + total = 0 + if isinstance(pagination, dict): + try: + total = int(pagination.get("count") or 0) + except (TypeError, ValueError): + total = 0 + + logger.info( + "Authentik users page=%s page_size=%s -> %s items (total=%s)", + page, + page_size, + len(items), + total, + ) + return { + "items": items, + "total": total, + "page": page, + "page_size": page_size, + } diff --git a/backend/src/media_library_viewer_api/main.py b/backend/src/media_library_viewer_api/main.py index 021df8c..1604988 100644 --- a/backend/src/media_library_viewer_api/main.py +++ b/backend/src/media_library_viewer_api/main.py @@ -21,6 +21,9 @@ from media_library_viewer_api.observability import ( record_request, set_current_request_id, ) +from media_library_viewer_api.routers import ( + authentik_users as authentik_users_router, +) from media_library_viewer_api.routers import backups as backups_router from media_library_viewer_api.routers import dashboard, files, jobs, media, monitoring, tasks, users from media_library_viewer_api.routers import services as services_router @@ -142,6 +145,7 @@ app.include_router(settings_router) app.include_router(backups_router.router) app.include_router(widgets_router.router) app.include_router(services_router.router) +app.include_router(authentik_users_router.router) @app.get("/api/health") diff --git a/backend/src/media_library_viewer_api/routers/authentik_users.py b/backend/src/media_library_viewer_api/routers/authentik_users.py new file mode 100644 index 0000000..5e19a3f --- /dev/null +++ b/backend/src/media_library_viewer_api/routers/authentik_users.py @@ -0,0 +1,82 @@ +"""Authentik directory router — user lookup for the Authentik service page. + +Resolves an ``authentik`` service instance from the registry, builds an +:class:`AuthentikClient` from its config + decrypted ``api_token`` secret, and +proxies a paginated directory query. Graceful "not configured" / "unreachable" +payloads (matching the monitoring router's pattern) so the UI always renders. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from fastapi import APIRouter, Depends + +from media_library_viewer_api.clients.authentik import AuthentikClient +from media_library_viewer_api.dependencies import get_settings_store +from media_library_viewer_api.services.settings_store import SettingsStore +from media_library_viewer_api.widgets.sources import ServiceRecord, build_service_record + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/services/authentik", tags=["authentik"]) + + +def _resolve_service_record( + store: SettingsStore, + service_id: str | None = None, +) -> ServiceRecord | None: + """Return the requested authentik instance, else the first enabled one. + + Returns ``None`` when the instance does not exist / is the wrong type, or + when no enabled ``authentik`` instance is configured. + """ + service_type = "authentik" + if service_id: + row = store.get_service(service_id) + if not row or row.get("service_type") != service_type: + return None + if not row.get("enabled", True): + return None + return build_service_record(store, row) + for row in store.list_services(service_type): + if row.get("enabled", True): + return build_service_record(store, row) + return None + + +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) + + +def _empty(error: str) -> dict[str, Any]: + return {"items": [], "total": 0, "page": 1, "page_size": 50, "error": error} + + +@router.get("/{service_id}/users") +def get_authentik_users( + service_id: str, + search: str | None = None, + page: int = 1, + page_size: int = 50, + store: SettingsStore = Depends(get_settings_store), +) -> dict[str, Any]: + """Paginated Authentik user directory for a specific service instance.""" + service = _resolve_service_record(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") + + try: + client = _build_client(service) + return client.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") diff --git a/backend/tests/test_authentik_client.py b/backend/tests/test_authentik_client.py new file mode 100644 index 0000000..b318de0 --- /dev/null +++ b/backend/tests/test_authentik_client.py @@ -0,0 +1,183 @@ +"""Tests for AuthentikClient and the directory endpoint.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from cryptography.fernet import Fernet +from fastapi.testclient import TestClient + +from media_library_viewer_api.clients.authentik import AuthentikClient +from media_library_viewer_api.dependencies import get_settings_store +from media_library_viewer_api.main import app +from media_library_viewer_api.services.secrets import reset_encryption_key_cache +from media_library_viewer_api.services.settings_store import SettingsStore + +TEST_KEY = Fernet.generate_key().decode() + + +@pytest.fixture(autouse=True) +def _encryption_key(monkeypatch: pytest.MonkeyPatch) -> None: + """Provide a stable MANAGE_ENCRYPTION_KEY for every test.""" + monkeypatch.setenv("MANAGE_ENCRYPTION_KEY", TEST_KEY) + reset_encryption_key_cache() + yield + reset_encryption_key_cache() + + +@pytest.fixture() +def store(tmp_path: Path) -> SettingsStore: + s = SettingsStore(tmp_path / "settings.sqlite") + s.ensure_defaults() + app.dependency_overrides[get_settings_store] = lambda: s + yield s + app.dependency_overrides.clear() + + +# --------------------------------------------------------------------------- +# Client unit tests +# --------------------------------------------------------------------------- + + +class TestAuthentikClient: + def test_base_url_normalizes_trailing_slash(self) -> None: + c = AuthentikClient(base_url="https://auth.example.com/", api_token="t") + assert c.base_url == "https://auth.example.com" + + def test_base_url_strips_api_v3_suffix(self) -> None: + c = AuthentikClient(base_url="https://auth.example.com/api/v3", api_token="t") + assert c.base_url == "https://auth.example.com" + + def test_bearer_header_is_set(self) -> None: + c = AuthentikClient(base_url="https://auth.example.com", api_token="tok") + assert c.session.headers["Authorization"] == "Bearer tok" + + def test_empty_base_url_raises(self) -> None: + with pytest.raises(ValueError): + AuthentikClient(base_url="", api_token="t") + + def test_empty_api_token_raises(self) -> None: + with pytest.raises(ValueError): + AuthentikClient(base_url="https://auth.example.com", api_token="") + + @patch.object(AuthentikClient, "get") + def test_users_normalizes_pagination(self, mock_get: MagicMock) -> None: + mock_get.return_value = { + "pagination": {"count": 42, "next": 2, "previous": 0, "current": 1}, + "results": [ + {"pk": 1, "username": "alice", "email": "alice@example.com"}, + {"pk": 2, "username": "bob", "email": "bob@example.com"}, + ], + } + client = AuthentikClient(base_url="https://auth.example.com", api_token="t") + result = client.users(search="ali", page=1, page_size=2) + assert result["total"] == 42 + assert result["page"] == 1 + assert result["page_size"] == 2 + assert len(result["items"]) == 2 + assert result["items"][0]["username"] == "alice" + + @patch.object(AuthentikClient, "get") + def test_users_handles_empty_results(self, mock_get: MagicMock) -> None: + mock_get.return_value = {"pagination": {"count": 0}, "results": []} + client = AuthentikClient(base_url="https://auth.example.com", api_token="t") + result = client.users() + assert result["items"] == [] + assert result["total"] == 0 + + @patch.object(AuthentikClient, "get") + def test_users_handles_non_dict_payload(self, mock_get: MagicMock) -> None: + mock_get.return_value = [] + client = AuthentikClient(base_url="https://auth.example.com", api_token="t") + result = client.users() + assert result["items"] == [] + assert result["total"] == 0 + + @patch("media_library_viewer_api.clients.authentik.requests.Session") + def test_get_sends_correct_url_and_params(self, mock_session_cls: MagicMock) -> None: + mock_session = MagicMock() + mock_session_cls.return_value = mock_session + mock_response = MagicMock() + mock_response.json.return_value = {"results": []} + mock_response.raise_for_status.return_value = None + mock_session.get.return_value = mock_response + + c = AuthentikClient(base_url="https://auth.example.com", api_token="t") + c.get("/core/users/", search="x", page=2) + + call_args = mock_session.get.call_args + assert call_args.kwargs["params"] == {"search": "x", "page": 2} + assert call_args.args[0] == "https://auth.example.com/api/v3/core/users/" + + +# --------------------------------------------------------------------------- +# Endpoint integration tests +# --------------------------------------------------------------------------- + + +class TestAuthentikUsersEndpoint: + def test_not_configured_returns_empty_with_error(self, store: SettingsStore) -> None: + client = TestClient(app) + response = client.get("/api/services/authentik/nonexistent/users") + assert response.status_code == 200 + data = response.json() + assert data["items"] == [] + assert data["total"] == 0 + assert "error" in data + + @patch("media_library_viewer_api.routers.authentik_users.AuthentikClient") + def test_success_returns_users(self, mock_client_cls: MagicMock, store: SettingsStore) -> None: + mock_client = MagicMock() + mock_client.users.return_value = { + "items": [{"pk": 1, "username": "alice"}], + "total": 1, + "page": 1, + "page_size": 50, + } + mock_client_cls.return_value = mock_client + + created = store.upsert_service( + { + "service_type": "authentik", + "name": "Main", + "config": {"base_url": "https://auth.example.com"}, + "enabled": True, + }, + secret_values={"api_token": "secret-token"}, + ) + service_id = created["id"] + + client = TestClient(app) + response = client.get(f"/api/services/authentik/{service_id}/users?search=ali") + assert response.status_code == 200 + data = response.json() + assert len(data["items"]) == 1 + assert data["items"][0]["username"] == "alice" + assert data["total"] == 1 + assert "error" not in data + + @patch("media_library_viewer_api.routers.authentik_users.AuthentikClient") + def test_unreachable_returns_error(self, mock_client_cls: MagicMock, store: SettingsStore) -> None: + mock_client = MagicMock() + mock_client.users.side_effect = ConnectionError("refused") + mock_client_cls.return_value = mock_client + + created = store.upsert_service( + { + "service_type": "authentik", + "name": "Main", + "config": {"base_url": "https://auth.example.com"}, + "enabled": True, + }, + secret_values={"api_token": "secret-token"}, + ) + service_id = created["id"] + + client = TestClient(app) + response = client.get(f"/api/services/authentik/{service_id}/users") + assert response.status_code == 200 + data = response.json() + assert data["items"] == [] + assert "error" in data