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).
This commit is contained in:
@@ -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,
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user