# Slice 2 — Authentik directory client + endpoint (worker output) ## Files changed | File | Status | Lines | |------|--------|-------| | `backend/src/media_library_viewer_api/clients/authentik.py` | new | 133 | | `backend/src/media_library_viewer_api/routers/authentik_users.py` | new | 88 | | `backend/src/media_library_viewer_api/main.py` | modified | +4 / -1 | | `backend/tests/test_authentik_client.py` | new | 175 | **Total: ~400 changed lines** (400 insertions, 1 deletion). At the 400-line budget. ## What was implemented ### 2.1 — AuthentikClient (`clients/authentik.py`) - `AuthentikClient(base_url, api_token, timeout=10.0)` — mirrors the JellyseerrClient pattern. - `requests.Session()` with `Authorization: Bearer ` header + `Accept: application/json`. - base_url normalization: rstrip "/" and strip trailing `/api/v3` suffix. - `get(path, **params)` helper — same error-logging pattern as JellyseerrClient (raise_for_status with detail text on HTTPError). - `users(search, page, page_size)` — calls `GET /api/v3/core/users/` with query params `search`, `page`, `page_size`. Normalizes the Authentik `{pagination: {count}, results: [...]}` response shape into `{items, total, page, page_size}`. Handles empty results and non-dict payloads defensively. - `ValueError` on empty base_url or api_token. - Module-level logger. ### 2.2 — Directory endpoint (`routers/authentik_users.py`) - `GET /api/services/authentik/{service_id}/users` — resolves the service record, builds an AuthentikClient from config + decrypted `api_token` secret, calls `users()`. - Query params: `search: str | None = None`, `page: int = 1`, `page_size: int = 50`. - Graceful error handling matching monitoring.py's pattern: - Service not configured → `{"items": [], "total": 0, ..., "error": "Authentik service not configured"}` with 200. - Request failure → `{"items": [], ..., "error": "Authentik is unreachable"}` with 200, logs the exception. - `_resolve_service_record` helper copied into the new router (type-specific to `authentik`; the monitoring.py one is generic but takes `service_type` as a param — copying keeps the new router self-contained without restructuring monitoring.py). - Router registered in `main.py`. ### Authentik API endpoint shape ``` GET /api/services/authentik/{service_id}/users?search=ali&page=1&page_size=50 Response (success): { "items": [{"pk": 1, "username": "alice", "email": "...", "avatar": "...", ...}], "total": 42, "page": 1, "page_size": 50 } Response (not configured / unreachable): { "items": [], "total": 0, "page": 1, "page_size": 50, "error": "Authentik service not configured" | "Authentik is unreachable" } ``` ## Validation ``` cd backend && .venv/bin/ruff check src/ tests/ → All checks passed! cd backend && .venv/bin/python -m pytest tests/ → 268 passed, 2 warnings (pre-existing) ``` New tests: 12 (8 client unit tests + 3 endpoint integration tests + 1 get URL/params assertion). ## Deviations from design 1. **`_resolve_service_record` copied rather than imported.** The monitoring.py helper takes `(store, service_type, service_id)` and is tightly coupled to monitoring's imports. Copying the ~15 lines into the new router (hardcoding `service_type="authentik"`) keeps the new router self-contained. A follow-up refactor could extract a shared `resolve_service_record` utility. 2. **`timeout` config parsing is guarded.** Added a `try/except (TypeError, ValueError)` around `float(config.get("timeout_seconds") or 10)` to handle a malformed config value gracefully (falls back to 10.0). Minor defensive addition not named in the design. ## skill_resolution `none` — no project/user SKILL.md paths were injected, and no `.atl/skill-registry.md` was found. ## Residual risks - The Authentik directory API field coverage (`avatar`, `is_active`, `attributes`, groups, etc.) is not pinned — the client returns raw user dicts and the frontend (Slice 8 UsersTab) will pick fields. Some fields the old compose flow used (Jellyfin activity state, Jellyseerr enrichment) will not be available from Authentik. - `_resolve_service_record` is duplicated across `monitoring.py` and the new `authentik_users.py`. A shared utility extraction is a follow-up. ## Acceptance ```acceptance-report { "criteriaSatisfied": [ { "id": "criterion-1", "status": "satisfied", "evidence": "Slice 2 implements AuthentikClient + directory endpoint + tests without widening scope (only authentik.py, authentik_users.py, main.py, test file). Mirrors JellyseerrClient + monitoring.py patterns. 268 backend tests pass; ruff clean." } ], "changedFiles": [ "backend/src/media_library_viewer_api/clients/authentik.py", "backend/src/media_library_viewer_api/routers/authentik_users.py", "backend/src/media_library_viewer_api/main.py", "backend/tests/test_authentik_client.py" ], "testsAddedOrUpdated": [ "backend/tests/test_authentik_client.py" ], "commandsRun": [ { "command": "cd backend && .venv/bin/ruff check src/ tests/", "result": "passed", "summary": "All checks passed (after --fix import sorting)" }, { "command": "cd backend && .venv/bin/python -m pytest tests/test_authentik_client.py -v", "result": "passed", "summary": "12 passed (8 client + 4 endpoint)" }, { "command": "cd backend && .venv/bin/python -m pytest tests/ -q", "result": "passed", "summary": "268 passed, 2 warnings (pre-existing deprecation warnings)" }, { "command": "git diff --cached --stat", "result": "passed", "summary": "Empty — no staged files" } ], "validationOutput": [ "AuthentikClient mirrors JellyseerrClient: Session, Bearer header, base_url normalization, get() helper with raise_for_status + detail logging.", "users() normalizes Authentik {pagination, results} into {items, total, page, page_size}; handles empty + non-dict payloads.", "GET /api/services/authentik/{id}/users resolves service record, builds client from decrypted secret, returns graceful error dict on not-configured/unreachable (200, matching monitoring.py).", "Router registered in main.py alongside existing routers.", "268 backend tests pass (+12 new); ruff clean." ], "residualRisks": [ "Authentik directory API field coverage not pinned (frontend UsersTab will pick fields in Slice 8).", "_resolve_service_record duplicated across monitoring.py and authentik_users.py (shared utility extraction is a follow-up)." ], "noStagedFiles": true, "diffSummary": "Adds AuthentikClient (clients/authentik.py, 133 lines) with Bearer-auth session + users() pagination normalization, a directory endpoint (routers/authentik_users.py, 88 lines) at GET /api/services/authentik/{id}/users with graceful error handling, main.py router registration (+4 lines), and 12 new tests (175 lines). 400 lines total, at budget.", "reviewFindings": [ "no blockers" ], "manualNotes": "git status confirms nothing is staged; all changes are unstaged/untracked, ready for the parent to review and commit. The _resolve_service_record helper was copied (not imported) to keep the new router self-contained; monitoring.py was not modified." } ```