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,9 +1,7 @@
"""Authentik directory API client. """Read-only Authentik directory client.
Authentik is the user-directory source (replacing the Jellyfin-backed Users The client normalizes the subset of Authentik core data that Manage displays.
page). This client wraps the Authentik REST API for browsing the user directory It deliberately does not fetch individual users or expose policy/provider data.
with pagination and search. OIDC authentication is unchanged — this client is
for the directory, not SSO.
""" """
from __future__ import annotations from __future__ import annotations
@@ -17,9 +15,34 @@ from media_library_viewer_api.clients.http_timeout import DEFAULT_READ_TIMEOUT,
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_MAX_COLLECTION_ITEMS = 10_000
_PAGE_SIZE = 100
def _text(value: Any) -> str:
return str(value).strip() if value is not None else ""
def _identifier(item: dict[str, Any]) -> str:
for key in ("pk", "id", "uuid"):
value = _text(item.get(key))
if value:
return value
return ""
def _page_total(payload: dict[str, Any], fallback: int) -> int:
pagination = payload.get("pagination")
if isinstance(pagination, dict):
try:
return max(0, int(pagination.get("count") or fallback))
except (TypeError, ValueError):
pass
return fallback
class AuthentikClient: class AuthentikClient:
"""Small wrapper around the Authentik core directory API.""" """Small wrapper around Authentik's read-only core API."""
def __init__(self, base_url: str, api_token: str, timeout: float = DEFAULT_READ_TIMEOUT): def __init__(self, base_url: str, api_token: str, timeout: float = DEFAULT_READ_TIMEOUT):
if not base_url: if not base_url:
@@ -31,88 +54,139 @@ class AuthentikClient:
if self.base_url.endswith("/api/v3"): if self.base_url.endswith("/api/v3"):
self.base_url = self.base_url[:-7] self.base_url = self.base_url[:-7]
self.api_token = api_token self.api_token = api_token
# timeout is the per-response READ timeout (seconds); connect timeout is fixed at 5s.
self.timeout = http_timeout(timeout) self.timeout = http_timeout(timeout)
self.session = requests.Session() self.session = requests.Session()
self.session.headers.update( self.session.headers.update({"Authorization": f"Bearer {api_token}", "Accept": "application/json"})
{
"Authorization": f"Bearer {api_token}",
"Accept": "application/json",
}
)
def get(self, path: str, **params: Any) -> Any: def get(self, path: str, **params: Any) -> Any:
"""GET an Authentik endpoint and include useful response text on errors.""" """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 != ""} clean_params = {key: value for key, value in params.items() if value is not None and value != ""}
logger.debug("Authentik GET %s params=%s", path, sorted(clean_params.keys())) logger.debug("Authentik GET %s params=%s", path, sorted(clean_params.keys()))
response = self.session.get( response = self.session.get(f"{self.base_url}/api/v3{path}", params=clean_params, timeout=self.timeout)
f"{self.base_url}/api/v3{path}",
params=clean_params,
timeout=self.timeout,
)
try: try:
response.raise_for_status() response.raise_for_status()
except requests.HTTPError as exc: except requests.HTTPError as exc:
detail = response.text[:500] detail = response.text[:500]
logger.warning( logger.warning("Authentik GET %s failed status=%s url=%s", path, response.status_code, response.url)
"Authentik GET %s failed status=%s url=%s", raise requests.HTTPError(f"{response.status_code} for {response.url}: {detail}", response=response) from exc
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() return response.json()
def users( def users(self, search: str | None = None, page: int = 1, page_size: int = 50) -> dict[str, Any]:
"""Return one raw user page for the directory and messaging surfaces."""
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 = [item for item in results if isinstance(item, dict)] if isinstance(results, list) else []
return {"items": items, "total": _page_total(payload, len(items)), "page": page, "page_size": page_size}
def _collection(self, path: str, limit: int = _MAX_COLLECTION_ITEMS) -> dict[str, Any]:
"""Read a paginated core collection with a hard cap and loop protection."""
try:
requested = max(1, min(int(limit), _MAX_COLLECTION_ITEMS))
except (TypeError, ValueError):
requested = _MAX_COLLECTION_ITEMS
items: list[dict[str, Any]] = []
page = 1
total = 0
while len(items) < requested:
payload = self.get(path, page=page, page_size=min(_PAGE_SIZE, requested - len(items)))
if not isinstance(payload, dict):
logger.warning("Authentik %s payload was not a dict: %s", path, type(payload).__name__)
break
results = payload.get("results")
page_items = [item for item in results if isinstance(item, dict)] if isinstance(results, list) else []
total = _page_total(payload, len(items) + len(page_items))
items.extend(page_items[: requested - len(items)])
if not page_items or len(items) >= total:
break
page += 1
if page > 100: # defensive limit for malformed pagination responses
logger.warning("Authentik %s pagination stopped after 100 pages", path)
break
return {"items": items, "total": total or len(items)}
@staticmethod
def _normalize_group(item: dict[str, Any]) -> dict[str, str] | None:
group_id = _identifier(item)
if not group_id:
return None
name = _text(item.get("name") or item.get("display_name") or item.get("slug"))
return {"id": group_id, "name": name or f"Unnamed group ({group_id})"}
@staticmethod
def _normalize_application(item: dict[str, Any]) -> dict[str, str]:
app_id = _identifier(item)
return {
"id": app_id,
"name": _text(item.get("name") or item.get("slug") or item.get("meta_name")) or "Unnamed application",
"slug": _text(item.get("slug")),
"launch_url": _text(item.get("launch_url") or item.get("meta_launch_url")),
}
def groups(self, limit: int = _MAX_COLLECTION_ITEMS) -> dict[str, Any]:
"""Return normalized groups; only display-safe identifiers and names are retained."""
raw = self._collection("/core/groups/", limit)
items = [normalized for item in raw["items"] if (normalized := self._normalize_group(item)) is not None]
return {"items": items, "total": raw["total"]}
def applications(self, limit: int = _MAX_COLLECTION_ITEMS) -> dict[str, Any]:
"""Return normalized applications without provider, policy, or secret fields."""
raw = self._collection("/core/applications/", limit)
return {"items": [self._normalize_application(item) for item in raw["items"]], "total": raw["total"]}
@staticmethod
def _group_references(user: dict[str, Any]) -> list[str]:
"""Extract group ids from release-dependent user reference shapes."""
raw = user.get("groups", user.get("group", []))
if not isinstance(raw, list):
raw = [raw] if raw is not None else []
ids: list[str] = []
for reference in raw:
if isinstance(reference, dict):
group_id = _identifier(reference)
else:
group_id = _text(reference)
if group_id and group_id not in ids:
ids.append(group_id)
return ids
def access_summaries(
self, self,
search: str | None = None, search: str | None = None,
page: int = 1, page: int = 1,
page_size: int = 50, page_size: int = 50,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Return a normalized page of Authentik users. """Summarize user group references and privileged flags without N+1 user reads.
Calls ``GET /api/v3/core/users/`` and normalizes the paginated This is directory metadata only: group membership plus the explicit
Authentik response into ``{items, total, page, page_size}``. Each item ``is_superuser`` and ``is_staff`` fields. It does not evaluate policies
is the raw Authentik user dict (pk, username, name, email, avatar, …) or claim to calculate effective authorization.
so the frontend can pick the fields it needs.
""" """
payload = self.get( users = self.users(search=search, page=page, page_size=page_size)
"/core/users/", groups = self.groups()
search=search, group_names = {group["id"]: group["name"] for group in groups["items"]}
page=page, summaries: list[dict[str, Any]] = []
page_size=page_size, for user in users["items"]:
) group_ids = self._group_references(user)
if not isinstance(payload, dict): summaries.append(
logger.warning("Authentik users payload was not a dict: %s", type(payload).__name__) {
return {"items": [], "total": 0, "page": page, "page_size": page_size} "id": _identifier(user),
"username": _text(user.get("username")),
results = payload.get("results") "name": _text(user.get("name")),
items: list[dict[str, Any]] = ( "email": _text(user.get("email")),
[item for item in results if isinstance(item, dict)] if isinstance(results, list) else [] "is_active": bool(user.get("is_active", True)),
) "is_superuser": bool(user.get("is_superuser", False)),
"is_staff": bool(user.get("is_staff", False)),
pagination = payload.get("pagination") or {} "groups": [
total = 0 {
if isinstance(pagination, dict): "id": group_id,
try: "name": group_names.get(group_id, f"Unknown group ({group_id})"),
total = int(pagination.get("count") or 0) "known": group_id in group_names,
except (TypeError, ValueError): }
total = 0 for group_id in group_ids
],
logger.info( }
"Authentik users page=%s page_size=%s -> %s items (total=%s)", )
page, return {"items": summaries, "total": users["total"], "page": users["page"], "page_size": users["page_size"]}
page_size,
len(items),
total,
)
return {
"items": items,
"total": total,
"page": page,
"page_size": page_size,
}
@@ -1,15 +1,11 @@
"""Authentik service definition. """Authentik service definition for read-only directory and access metadata."""
Authentik is the user-directory source (replacing the Jellyfin-backed Users
page). Its directory API is queried via :class:`AuthentikClient` and surfaced
on the Authentik service page (Users + Messaging tabs). OIDC authentication
is unchanged -- this service type is for the directory, not SSO.
"""
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from pydantic import Field
from media_library_viewer_api.clients.authentik import AuthentikClient from media_library_viewer_api.clients.authentik import AuthentikClient
from media_library_viewer_api.integrations.base import ( from media_library_viewer_api.integrations.base import (
SecretField, SecretField,
@@ -17,27 +13,25 @@ from media_library_viewer_api.integrations.base import (
ServiceConfigBase, ServiceConfigBase,
ServiceDefinition, ServiceDefinition,
TestResult, TestResult,
WidgetConfigBase,
translate_connection_error, translate_connection_error,
widget_kind,
) )
if TYPE_CHECKING: if TYPE_CHECKING:
from media_library_viewer_api.services.settings_store import SettingsStore from media_library_viewer_api.services.settings_store import SettingsStore
def test_connection( def test_connection(config: dict[str, Any], secrets: dict[str, str], store: SettingsStore) -> TestResult:
config: dict[str, Any], """Probe the least-expensive Authentik directory endpoint."""
secrets: dict[str, str],
store: SettingsStore,
) -> TestResult:
"""Probe AuthentikClient.users(page=1, page_size=1) — lightest directory call."""
try: try:
base_url = str(config.get("base_url") or "").rstrip("/") client = AuthentikClient(
api_token = str(secrets.get("api_token") or "") base_url=str(config.get("base_url") or "").rstrip("/"),
timeout = float(config.get("timeout_seconds") or 60) api_token=str(secrets.get("api_token") or ""),
client = AuthentikClient(base_url=base_url, api_token=api_token, timeout=timeout) timeout=float(config.get("timeout_seconds") or 60),
)
result = client.users(page=1, page_size=1) result = client.users(page=1, page_size=1)
total = result.get("total", 0) if isinstance(result, dict) else 0 return TestResult(ok=True, detail="Connected to Authentik.", evidence=f"{result.get('total', 0)} users")
return TestResult(ok=True, detail="Connected to Authentik.", evidence=f"{total} users")
except Exception as exc: except Exception as exc:
return translate_connection_error(exc, context="Authentik") return translate_connection_error(exc, context="Authentik")
@@ -46,17 +40,46 @@ class AuthentikConfig(ServiceConfigBase):
"""Non-secret Authentik connection config.""" """Non-secret Authentik connection config."""
base_url: ServiceBaseUrl base_url: ServiceBaseUrl
timeout_seconds: int = 60 timeout_seconds: int = Field(default=60, ge=1, le=300)
class AuthentikListWidgetConfig(WidgetConfigBase):
"""Bounded display count for read-only Authentik list widgets."""
limit: int = Field(default=10, ge=1, le=50)
DEFINITION = ServiceDefinition( DEFINITION = ServiceDefinition(
service_type="authentik", service_type="authentik",
name="Authentik", name="Authentik",
description="User directory and identity provider integration.", description="Read-only user directory, groups, and application access metadata.",
config_model=AuthentikConfig, config_model=AuthentikConfig,
secret_fields=[ secret_fields=[SecretField(key="api_token", label="API token", required=True)],
SecretField(key="api_token", label="API token", required=True), widget_kinds=[
widget_kind(
kind="access_summary",
name="User access summary",
description="User group memberships and explicit staff/superuser status; not effective authorization.",
model_cls=AuthentikListWidgetConfig,
default_config={"limit": 10},
refresh_interval_ms=60_000,
),
widget_kind(
kind="groups",
name="Groups",
description="Read-only Authentik group list.",
model_cls=AuthentikListWidgetConfig,
default_config={"limit": 10},
refresh_interval_ms=60_000,
),
widget_kind(
kind="applications",
name="Applications",
description="Read-only Authentik application list.",
model_cls=AuthentikListWidgetConfig,
default_config={"limit": 10},
refresh_interval_ms=60_000,
),
], ],
widget_kinds=[],
test_callable=test_connection, test_callable=test_connection,
) )
@@ -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 Directory data is service-scoped and fails gracefully so the service page can
:class:`AuthentikClient` from its config + decrypted ``api_token`` secret, and render a useful empty/error state when Authentik is unavailable.
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.
""" """
from __future__ import annotations from __future__ import annotations
@@ -12,7 +9,7 @@ from __future__ import annotations
import logging import logging
from typing import Any from typing import Any
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel from pydantic import BaseModel
from media_library_viewer_api.clients.authentik import AuthentikClient from media_library_viewer_api.clients.authentik import AuthentikClient
@@ -30,7 +27,7 @@ router = APIRouter(prefix="/api/services/authentik", tags=["authentik"])
class MessageRequest(BaseModel): class MessageRequest(BaseModel):
"""Compose-request body for the Authentik messaging endpoint.""" """Compose-request body for the existing Authentik messaging endpoint."""
recipient_emails: list[str] recipient_emails: list[str]
subject: str subject: str
@@ -38,39 +35,99 @@ class MessageRequest(BaseModel):
def _build_client(service: ServiceRecord) -> AuthentikClient: 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: try:
timeout = float(service.config.get("timeout_seconds") or 10) timeout = float(service.config.get("timeout_seconds") or 10)
except (TypeError, ValueError): except (TypeError, ValueError):
timeout = 10.0 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} 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") @router.get("/{service_id}/users")
def get_authentik_users( def get_authentik_users(
service_id: str, service_id: str,
search: str | None = None, search: str | None = None,
page: int = 1, page: int = Query(default=1, ge=1),
page_size: int = 50, page_size: int = Query(default=50, ge=1, le=200),
store: SettingsStore = Depends(get_settings_store), store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Paginated Authentik user directory for a specific service instance.""" """Paginated raw directory users for the existing messaging surface."""
service = resolve_service_record(store, "authentik", service_id) service = _service_or_error(store, service_id)
if service is None: if service is None:
logger.info("Authentik users requested but no enabled authentik service for id=%s", service_id) return _empty_directory("Authentik service not configured")
return _empty("Authentik service not configured")
try: try:
client = _build_client(service) return _build_client(service).users(search=search, page=page, page_size=page_size)
return client.users(search=search, page=page, page_size=page_size)
except Exception: except Exception:
logger.exception("Authentik users query failed for service %s", service_id) 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") @router.get("/{service_id}/message/status")
@@ -80,8 +137,7 @@ def get_authentik_message_status(
mail_queue: MailQueue = Depends(get_mail_queue), mail_queue: MailQueue = Depends(get_mail_queue),
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Mail-queue status snapshot for the Authentik messaging tab.""" """Mail-queue status snapshot for the Authentik messaging tab."""
service = resolve_service_record(store, "authentik", service_id) if _service_or_error(store, service_id) is None:
if service is None:
return {"state": "stopped", "worker_running": False, "error": "Authentik service not configured"} return {"state": "stopped", "worker_running": False, "error": "Authentik service not configured"}
return mail_queue.status() return mail_queue.status()
@@ -94,20 +150,16 @@ def post_authentik_message(
mail_queue: MailQueue = Depends(get_mail_queue), mail_queue: MailQueue = Depends(get_mail_queue),
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Enqueue an email to Authentik-sourced recipients via the mail queue.""" """Enqueue an email to Authentik-sourced recipients via the mail queue."""
service = resolve_service_record(store, "authentik", service_id) if _service_or_error(store, service_id) is None:
if service is None:
return {"status": "error", "error": "Authentik service not configured"} return {"status": "error", "error": "Authentik service not configured"}
recipients = [recipient.strip() for recipient in body.recipient_emails if recipient.strip()]
recipients = [r.strip() for r in body.recipient_emails if r.strip()]
if not recipients: if not recipients:
return {"status": "error", "error": "No recipients with valid email addresses."} return {"status": "error", "error": "No recipients with valid email addresses."}
settings = get_settings() settings = get_settings()
try: try:
validate_smtp_settings(settings) validate_smtp_settings(settings)
except ValueError as exc: except ValueError as exc:
return {"status": "error", "error": f"SMTP settings invalid: {exc}"} return {"status": "error", "error": f"SMTP settings invalid: {exc}"}
request_id = mail_queue.enqueue( request_id = mail_queue.enqueue(
settings=settings, settings=settings,
recipients=recipients, recipients=recipients,
@@ -115,8 +167,4 @@ def post_authentik_message(
html_body=body.html_body, html_body=body.html_body,
) )
logger.info("Authentik message enqueued for service %s (%d recipients)", service_id, len(recipients)) logger.info("Authentik message enqueued for service %s (%d recipients)", service_id, len(recipients))
return { return {"status": "queued", "request_id": request_id, "recipient_count": len(recipients)}
"status": "queued",
"request_id": request_id,
"recipient_count": len(recipients),
}
@@ -19,6 +19,7 @@ from typing import Any, Protocol
import requests import requests
from media_library_viewer_api.clients.authentik import AuthentikClient
from media_library_viewer_api.clients.jellyfin import JellyfinClient from media_library_viewer_api.clients.jellyfin import JellyfinClient
from media_library_viewer_api.clients.qbittorrent import QbittorrentClient from media_library_viewer_api.clients.qbittorrent import QbittorrentClient
from media_library_viewer_api.domain.dashboard import ( from media_library_viewer_api.domain.dashboard import (
@@ -316,6 +317,36 @@ class AlertmanagerWidgetSource:
return {"error": f"Alertmanager query failed: {exc}"} return {"error": f"Alertmanager query failed: {exc}"}
class AuthentikWidgetSource:
"""Fetch bounded, display-safe Authentik directory metadata."""
async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
try:
if service is None:
return {"error": "Authentik widget is missing its service"}
base_url = str(service.config.get("base_url") or "")
api_token = str(service.secrets.get("api_token") or "")
timeout = _safe_int(service.config.get("timeout_seconds") or 60, 60)
limit = max(1, min(_safe_int(config.get("limit") or 10, 10), 50))
client = await asyncio.wait_for(
asyncio.to_thread(AuthentikClient, base_url, api_token, timeout), timeout=timeout
)
if widget_kind == "access_summary":
return await asyncio.wait_for(
asyncio.to_thread(client.access_summaries, page=1, page_size=limit), timeout=timeout
)
if widget_kind == "groups":
return await asyncio.wait_for(asyncio.to_thread(client.groups, limit=limit), timeout=timeout)
if widget_kind == "applications":
return await asyncio.wait_for(asyncio.to_thread(client.applications, limit=limit), timeout=timeout)
return {"error": f"Unknown Authentik widget kind: {widget_kind}"}
except asyncio.TimeoutError as _timeout_error:
return {"error": "Authentik data fetch timed out"}
except Exception as exc:
logger.exception("authentik adapter failed")
return {"error": f"Authentik data fetch failed: {exc}"}
class JellyfinWidgetSource: class JellyfinWidgetSource:
"""Fetch Jellyfin sessions and map them to activity rows.""" """Fetch Jellyfin sessions and map them to activity rows."""
@@ -531,6 +562,7 @@ SERVICE_ADAPTERS: dict[str, WidgetSource] = {
"qbittorrent": QbittorrentWidgetSource(), "qbittorrent": QbittorrentWidgetSource(),
"alertmanager": AlertmanagerWidgetSource(), "alertmanager": AlertmanagerWidgetSource(),
"jellyfin": JellyfinWidgetSource(), "jellyfin": JellyfinWidgetSource(),
"authentik": AuthentikWidgetSource(),
"remote_machine": SshTaskWidgetSource(), "remote_machine": SshTaskWidgetSource(),
} }
+103 -2
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Generator
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
@@ -19,7 +20,7 @@ TEST_KEY = Fernet.generate_key().decode()
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def _encryption_key(monkeypatch: pytest.MonkeyPatch) -> None: def _encryption_key(monkeypatch: pytest.MonkeyPatch) -> Generator[None, None, None]:
"""Provide a stable MANAGE_ENCRYPTION_KEY for every test.""" """Provide a stable MANAGE_ENCRYPTION_KEY for every test."""
monkeypatch.setenv("MANAGE_ENCRYPTION_KEY", TEST_KEY) monkeypatch.setenv("MANAGE_ENCRYPTION_KEY", TEST_KEY)
reset_encryption_key_cache() reset_encryption_key_cache()
@@ -28,7 +29,7 @@ def _encryption_key(monkeypatch: pytest.MonkeyPatch) -> None:
@pytest.fixture() @pytest.fixture()
def store(tmp_path: Path) -> SettingsStore: def store(tmp_path: Path) -> Generator[SettingsStore, None, None]:
s = SettingsStore(tmp_path / "settings.sqlite") s = SettingsStore(tmp_path / "settings.sqlite")
s.ensure_defaults() s.ensure_defaults()
app.dependency_overrides[get_settings_store] = lambda: s app.dependency_overrides[get_settings_store] = lambda: s
@@ -181,3 +182,103 @@ class TestAuthentikUsersEndpoint:
data = response.json() data = response.json()
assert data["items"] == [] assert data["items"] == []
assert "error" in data assert "error" in data
class TestAuthentikAccessMetadata:
@patch.object(AuthentikClient, "get")
def test_groups_and_applications_paginate_and_whitelist_fields(self, mock_get: MagicMock) -> None:
def payload(path: str, **params: object) -> dict[str, object]:
if path == "/core/groups/":
if params["page"] == 1:
return {"pagination": {"count": 2}, "results": [{"pk": 1, "name": "Admins"}]}
return {"pagination": {"count": 2}, "results": [{"id": "g2", "display_name": "Readers"}]}
return {
"pagination": {"count": 1},
"results": [
{
"pk": 3,
"name": "Portal",
"slug": "portal",
"meta_launch_url": "https://portal.example.com",
"provider": {"client_secret": "must-not-leak"},
"policy_engine_mode": "any",
}
],
}
mock_get.side_effect = payload
auth = AuthentikClient(base_url="https://auth.example.com", api_token="t")
assert auth.groups(limit=2)["items"] == [{"id": "1", "name": "Admins"}, {"id": "g2", "name": "Readers"}]
application = auth.applications(limit=1)["items"][0]
assert application == {
"id": "3",
"name": "Portal",
"slug": "portal",
"launch_url": "https://portal.example.com",
}
assert "provider" not in application
@patch.object(AuthentikClient, "get")
def test_access_summary_uses_group_references_without_user_detail_calls(self, mock_get: MagicMock) -> None:
def payload(path: str, **params: object) -> dict[str, object]:
if path == "/core/users/":
return {
"pagination": {"count": 1},
"results": [
{
"pk": 7,
"username": "alice",
"name": "Alice",
"groups": [1, {"id": "missing"}],
"is_superuser": True,
"is_staff": False,
}
],
}
assert path == "/core/groups/"
return {"pagination": {"count": 1}, "results": [{"pk": 1, "name": "Admins"}]}
mock_get.side_effect = payload
result = AuthentikClient(base_url="https://auth.example.com", api_token="t").access_summaries()
assert result["items"][0]["groups"] == [
{"id": "1", "name": "Admins", "known": True},
{"id": "missing", "name": "Unknown group (missing)", "known": False},
]
assert bool(result["items"][0]["is_superuser"])
assert all(call.args[0] in {"/core/users/", "/core/groups/"} for call in mock_get.call_args_list)
class TestAuthentikAccessEndpoints:
def test_not_configured_access_collections_return_empty_envelopes(self, store: SettingsStore) -> None:
client = TestClient(app)
for path in ("access-summary", "groups", "applications"):
response = client.get(f"/api/services/authentik/missing/{path}")
assert response.status_code == 200
assert response.json()["items"] == []
assert response.json()["error"] == "Authentik service not configured"
@patch("media_library_viewer_api.routers.authentik_users.AuthentikClient")
def test_access_summary_endpoint_returns_normalized_data(
self, mock_client_cls: MagicMock, store: SettingsStore
) -> None:
mock_client = MagicMock()
mock_client.access_summaries.return_value = {
"items": [{"id": "1", "groups": []}],
"total": 1,
"page": 1,
"page_size": 25,
}
mock_client_cls.return_value = mock_client
service = store.upsert_service(
{
"service_type": "authentik",
"name": "Main",
"config": {"base_url": "https://auth.example.com"},
"enabled": True,
},
secret_values={"api_token": "secret-token"},
)
response = TestClient(app).get(f"/api/services/authentik/{service['id']}/access-summary?page_size=25")
assert response.status_code == 200
assert response.json()["items"] == [{"id": "1", "groups": []}]
mock_client.access_summaries.assert_called_once_with(search=None, page=1, page_size=25)
+50 -28
View File
@@ -28,6 +28,13 @@ from media_library_viewer_api.services.secrets import (
) )
from media_library_viewer_api.services.settings_store import SettingsStore from media_library_viewer_api.services.settings_store import SettingsStore
def _definition(service_type: str):
definition = get_service_definition(service_type)
assert definition
return definition
TEST_KEY = Fernet.generate_key().decode() TEST_KEY = Fernet.generate_key().decode()
@@ -73,7 +80,7 @@ def test_registry_contains_eight_service_types():
def test_jellyseerr_absorbed_into_jellyfin(): def test_jellyseerr_absorbed_into_jellyfin():
"""Jellyseerr is no longer its own service type (absorbed into Jellyfin).""" """Jellyseerr is no longer its own service type (absorbed into Jellyfin)."""
assert "jellyseerr" not in SERVICE_DEFINITIONS assert "jellyseerr" not in SERVICE_DEFINITIONS
jellyfin = get_service_definition("jellyfin") jellyfin = _definition("jellyfin")
jellyfin_config = jellyfin.config_schema["properties"] jellyfin_config = jellyfin.config_schema["properties"]
assert "jellyseerr_url" in jellyfin_config assert "jellyseerr_url" in jellyfin_config
# jellyseerr_api_key moved from config to a secret field. # jellyseerr_api_key moved from config to a secret field.
@@ -82,8 +89,8 @@ def test_jellyseerr_absorbed_into_jellyfin():
def test_backups_service_definition(): def test_backups_service_definition():
definition = get_service_definition("backups") definition = _definition("backups")
assert definition is not None assert definition
assert definition.secret_fields == [] assert definition.secret_fields == []
assert {wk.kind for wk in definition.widget_kinds} == {"summary"} assert {wk.kind for wk in definition.widget_kinds} == {"summary"}
schema = definition.config_schema schema = definition.config_schema
@@ -91,35 +98,43 @@ def test_backups_service_definition():
def test_authentik_service_definition(): def test_authentik_service_definition():
definition = get_service_definition("authentik") definition = _definition("authentik")
assert definition is not None assert definition
assert {sf.key for sf in definition.secret_fields} == {"api_token"} assert {sf.key for sf in definition.secret_fields} == {"api_token"}
assert definition.secret_fields[0].required is True assert definition.secret_fields[0].required
assert definition.widget_kinds == [] assert {widget.kind for widget in definition.widget_kinds} == {
"access_summary",
"groups",
"applications",
}
schema = definition.config_schema schema = definition.config_schema
assert "base_url" in schema["properties"] assert "base_url" in schema["properties"]
assert "timeout_seconds" in schema["properties"] assert "timeout_seconds" in schema["properties"]
def test_definitions_declare_widget_kinds(): def test_definitions_declare_widget_kinds():
assert {wk.kind for wk in get_service_definition("prometheus").widget_kinds} == {"metric", "chart", "gauge", "mean"} assert {wk.kind for wk in _definition("prometheus").widget_kinds} == {"metric", "chart", "gauge", "mean"}
assert {wk.kind for wk in get_service_definition("alertmanager").widget_kinds} == {"active_alerts"} assert {wk.kind for wk in _definition("alertmanager").widget_kinds} == {"active_alerts"}
assert {wk.kind for wk in get_service_definition("jellyfin").widget_kinds} == { assert {wk.kind for wk in _definition("jellyfin").widget_kinds} == {
"activity", "activity",
"now_playing", "now_playing",
"stat", "stat",
"stats_overview", "stats_overview",
} }
assert get_service_definition("nextcloud").widget_kinds == [] assert _definition("nextcloud").widget_kinds == []
assert get_service_definition("authentik").widget_kinds == [] assert {widget.kind for widget in _definition("authentik").widget_kinds} == {
assert {wk.kind for wk in get_service_definition("backups").widget_kinds} == {"summary"} "access_summary",
assert {wk.kind for wk in get_service_definition("remote_machine").widget_kinds} == {"task_output"} "groups",
"applications",
}
assert {wk.kind for wk in _definition("backups").widget_kinds} == {"summary"}
assert {wk.kind for wk in _definition("remote_machine").widget_kinds} == {"task_output"}
def test_widget_kind_lookup(): def test_widget_kind_lookup():
assert get_widget_kind("prometheus", "metric") is not None assert get_widget_kind("prometheus", "metric")
assert get_widget_kind("prometheus", "missing") is None assert not get_widget_kind("prometheus", "missing")
assert get_widget_kind("unknown", "metric") is None assert not get_widget_kind("unknown", "metric")
def test_chart_widget_kinds_expose_unit_and_scale_options(): def test_chart_widget_kinds_expose_unit_and_scale_options():
@@ -128,25 +143,28 @@ def test_chart_widget_kinds_expose_unit_and_scale_options():
scales = ["auto", "k", "m", "g", "t"] scales = ["auto", "k", "m", "g", "t"]
prom_chart = get_widget_kind("prometheus", "chart") prom_chart = get_widget_kind("prometheus", "chart")
assert prom_chart is not None assert prom_chart
prom_props = prom_chart.config_schema["properties"] prom_props = prom_chart.config_schema["properties"]
assert prom_props["unit"]["enum"] == units assert prom_props["unit"]["enum"] == units
assert prom_props["scale"]["enum"] == scales assert prom_props["scale"]["enum"] == scales
qbit_speed = get_widget_kind("qbittorrent", "speed") qbit_speed = get_widget_kind("qbittorrent", "speed")
assert qbit_speed is not None assert qbit_speed
qbit_props = qbit_speed.config_schema["properties"] qbit_props = qbit_speed.config_schema["properties"]
assert qbit_props["unit"]["enum"] == units assert qbit_props["unit"]["enum"] == units
assert qbit_props["scale"]["enum"] == scales assert qbit_props["scale"]["enum"] == scales
# qBittorrent speed data is bytes/sec by default. # qBittorrent speed data is bytes/sec by default.
assert qbit_speed.default_config["unit"] == "bytes_per_sec" assert qbit_speed.default_config["unit"] == "bytes_per_sec"
# totals/active are not graphs and stay option-less. # totals/active are not graphs and stay option-less.
assert "unit" not in get_widget_kind("qbittorrent", "totals").config_schema["properties"] qbit_totals = get_widget_kind("qbittorrent", "totals")
assert "unit" not in get_widget_kind("qbittorrent", "active").config_schema["properties"] qbit_active = get_widget_kind("qbittorrent", "active")
assert qbit_totals and qbit_active
assert "unit" not in qbit_totals.config_schema["properties"]
assert "unit" not in qbit_active.config_schema["properties"]
def test_service_config_schema_is_json_schema(): def test_service_config_schema_is_json_schema():
schema = get_service_definition("prometheus").config_schema schema = _definition("prometheus").config_schema
assert schema["type"] == "object" assert schema["type"] == "object"
assert "grafana_url" in schema["properties"] assert "grafana_url" in schema["properties"]
@@ -321,7 +339,7 @@ def test_service_test_uses_stored_secrets_when_not_reentered(client):
}, },
) )
assert res.status_code == 200 assert res.status_code == 200
assert res.json()["ok"] is True assert res.json()["ok"]
# The stored grafana_api_key was used for the request (not empty). # The stored grafana_api_key was used for the request (not empty).
headers = mock_post.call_args.kwargs["headers"] headers = mock_post.call_args.kwargs["headers"]
assert headers["Authorization"] == "Bearer secret-token" assert headers["Authorization"] == "Bearer secret-token"
@@ -354,16 +372,16 @@ def test_invalid_config_rejected(client):
) )
def test_service_base_url_requires_http_schema(bad_url): def test_service_base_url_requires_http_schema(bad_url):
"""Every service base_url must include an http:// or https:// schema.""" """Every service base_url must include an http:// or https:// schema."""
model = get_service_definition("prometheus").config_model model = _definition("prometheus").config_model
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
model.model_validate({"grafana_url": bad_url, "timeout_seconds": 5}) model.model_validate({"grafana_url": bad_url, "timeout_seconds": 5})
@pytest.mark.parametrize("service_type", ["alertmanager", "jellyfin", "authentik", "nextcloud"]) @pytest.mark.parametrize("service_type", ["alertmanager", "jellyfin", "authentik", "nextcloud"])
def test_service_base_url_accepts_absolute_urls(service_type): def test_service_base_url_accepts_absolute_urls(service_type):
model = get_service_definition(service_type).config_model model = _definition(service_type).config_model
instance = model.model_validate({"base_url": "https://example.com"}) instance = model.model_validate({"base_url": "https://example.com"})
assert instance.base_url == "https://example.com" assert getattr(instance, "base_url") == "https://example.com"
def test_unknown_secret_field_rejected(client): def test_unknown_secret_field_rejected(client):
@@ -451,13 +469,14 @@ def test_delete_service_cascades_to_widgets(client, tmp_path):
) )
store.delete_service(service["id"]) store.delete_service(service["id"])
assert store.get_service(service["id"]) is None assert not store.get_service(service["id"])
with store.connect() as conn: with store.connect() as conn:
remaining = conn.execute( remaining = conn.execute(
"SELECT COUNT(*) FROM dashboard_widgets WHERE service_id = ?", "SELECT COUNT(*) FROM dashboard_widgets WHERE service_id = ?",
(service["id"],), (service["id"],),
).fetchone() ).fetchone()
assert int(remaining[0]) == 0 assert remaining is not None
assert remaining[0] == 0
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -593,6 +612,7 @@ def test_jellyseerr_migrates_into_single_jellyfin(tmp_path):
# Jellyfin config gained jellyseerr_url; the api key is now a secret. # Jellyfin config gained jellyseerr_url; the api key is now a secret.
migrated = store.get_service(jellyfin["id"]) migrated = store.get_service(jellyfin["id"])
assert migrated
assert migrated["config"]["jellyseerr_url"] == "https://jellyseerr.example.com" assert migrated["config"]["jellyseerr_url"] == "https://jellyseerr.example.com"
assert "jellyseerr_api_key" not in migrated["config"] assert "jellyseerr_api_key" not in migrated["config"]
assert decrypt_value(migrated["secrets"]["jellyseerr_api_key"]) == "js-key" assert decrypt_value(migrated["secrets"]["jellyseerr_api_key"]) == "js-key"
@@ -621,11 +641,13 @@ def test_jellyseerr_api_key_migrates_from_config_to_secret(tmp_path):
store.ensure_defaults() # runs the config->secret migration store.ensure_defaults() # runs the config->secret migration
migrated = store.get_service(jellyfin["id"]) migrated = store.get_service(jellyfin["id"])
assert migrated
assert "jellyseerr_api_key" not in migrated["config"] assert "jellyseerr_api_key" not in migrated["config"]
assert decrypt_value(migrated["secrets"]["jellyseerr_api_key"]) == "plaintext-key" assert decrypt_value(migrated["secrets"]["jellyseerr_api_key"]) == "plaintext-key"
# Idempotent: a second run keeps it in secrets, doesn't wipe it. # Idempotent: a second run keeps it in secrets, doesn't wipe it.
store.ensure_defaults() store.ensure_defaults()
migrated = store.get_service(jellyfin["id"]) migrated = store.get_service(jellyfin["id"])
assert migrated
assert decrypt_value(migrated["secrets"]["jellyseerr_api_key"]) == "plaintext-key" assert decrypt_value(migrated["secrets"]["jellyseerr_api_key"]) == "plaintext-key"
+27 -1
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import time import time
from types import SimpleNamespace from types import SimpleNamespace
from typing import Any from typing import Any
from unittest.mock import patch from unittest.mock import MagicMock, patch
import pytest import pytest
from cryptography.fernet import Fernet from cryptography.fernet import Fernet
@@ -16,6 +16,7 @@ from media_library_viewer_api.main import app
from media_library_viewer_api.services.settings_store import SettingsStore from media_library_viewer_api.services.settings_store import SettingsStore
from media_library_viewer_api.widgets.sources import ( from media_library_viewer_api.widgets.sources import (
AlertmanagerWidgetSource, AlertmanagerWidgetSource,
AuthentikWidgetSource,
BackupsWidgetSource, BackupsWidgetSource,
JellyfinWidgetSource, JellyfinWidgetSource,
ServiceRecord, ServiceRecord,
@@ -416,6 +417,31 @@ async def test_static_adapter():
assert result == {"text": "hi"} assert result == {"text": "hi"}
@pytest.mark.asyncio
async def test_authentik_adapter_returns_bounded_access_summaries():
client = MagicMock()
client.access_summaries.return_value = {"items": [{"id": "u1", "groups": []}], "total": 1}
service = ServiceRecord(
id="auth",
service_type="authentik",
name="Auth",
config={"base_url": "https://auth.example.com", "timeout_seconds": 5},
secrets={"api_token": "token"},
)
with patch("media_library_viewer_api.widgets.sources.AuthentikClient", return_value=client):
result = await AuthentikWidgetSource().fetch(service, "access_summary", {"limit": 100})
assert result["items"] == [{"id": "u1", "groups": []}]
client.access_summaries.assert_called_once_with(page=1, page_size=50)
def test_authentik_definition_declares_read_only_widget_kinds():
from media_library_viewer_api.integrations.registry import get_service_definition
definition = get_service_definition("authentik")
assert definition is not None
assert {kind.kind for kind in definition.widget_kinds} == {"access_summary", "groups", "applications"}
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_backups_adapter(client): async def test_backups_adapter(client):
store = app.dependency_overrides[get_settings_store]() store = app.dependency_overrides[get_settings_store]()
+10
View File
@@ -134,6 +134,16 @@ fully removed (web-ui-rework; see decision log 2026-06-17).
- The File Browser should persist its current directory and selected file across reloads and tab switches. - The File Browser should persist its current directory and selected file across reloads and tab switches.
- Backend startup should log a secret-safe configuration summary and request/activity diagnostics so configuration issues can be debugged without exposing API keys. - Backend startup should log a secret-safe configuration summary and request/activity diagnostics so configuration issues can be debugged without exposing API keys.
### Authentik Directory and Access Metadata
- Authentik is the read-only identity-directory source for its service page and dashboard widgets.
- Provide read-only user access summaries showing group membership and explicit staff/superuser status.
- Label the summary as **access metadata**, not complete effective authorization: conditional or expression-based Authentik policies are not evaluated by Manage.
- Provide read-only groups and applications lists. Application entries may include safe display metadata such as name, slug, launch URL, and policy-engine mode, but must never expose provider configuration, tokens, or raw policy data.
- The configured Authentik API token must have read access to users, groups, and applications.
- Authentik data reads must remain service-instance scoped and tolerate unavailable upstream services with an empty/error state.
- Authentik widgets are read-only and support bounded display limits for access summaries, groups, and applications.
### Remote Filesystem over SSH ### Remote Filesystem over SSH
- Connect to a remote media server via SSH. - Connect to a remote media server via SSH.
+78 -1
View File
@@ -1,4 +1,4 @@
/** API client for the Authentik service (directory + messaging). */ /** API client for Authentik directory, access metadata, and messaging. */
import { get, post } from "./shared"; import { get, post } from "./shared";
export interface AuthentikUser { export interface AuthentikUser {
@@ -19,6 +19,49 @@ export interface AuthentikUsersResponse {
error?: string; error?: string;
} }
export interface AuthentikGroupReference {
id: string;
name: string;
known: boolean;
}
export interface AuthentikAccessSummary {
id: string;
username: string;
name: string;
email: string;
is_active: boolean;
is_superuser: boolean;
is_staff: boolean;
groups: AuthentikGroupReference[];
}
export interface AuthentikAccessSummaryResponse {
items: AuthentikAccessSummary[];
total: number;
page: number;
page_size: number;
error?: string;
}
export interface AuthentikGroup {
id: string;
name: string;
}
export interface AuthentikApplication {
id: string;
name: string;
slug: string;
launch_url: string;
}
export interface AuthentikCollectionResponse<T> {
items: T[];
total: number;
error?: string;
}
export async function fetchAuthentikUsers( export async function fetchAuthentikUsers(
serviceId: string, serviceId: string,
params: { search?: string; page?: number; page_size?: number }, params: { search?: string; page?: number; page_size?: number },
@@ -33,6 +76,40 @@ export async function fetchAuthentikUsers(
); );
} }
export async function fetchAuthentikAccessSummary(
serviceId: string,
params: { search?: string; page?: number; page_size?: number },
): Promise<AuthentikAccessSummaryResponse> {
return get<AuthentikAccessSummaryResponse>(
`/api/services/authentik/${serviceId}/access-summary`,
{
search: params.search ?? "",
page: String(params.page ?? 1),
page_size: String(params.page_size ?? 50),
},
);
}
export async function fetchAuthentikGroups(
serviceId: string,
limit = 100,
): Promise<AuthentikCollectionResponse<AuthentikGroup>> {
return get<AuthentikCollectionResponse<AuthentikGroup>>(
`/api/services/authentik/${serviceId}/groups`,
{ limit: String(limit) },
);
}
export async function fetchAuthentikApplications(
serviceId: string,
limit = 100,
): Promise<AuthentikCollectionResponse<AuthentikApplication>> {
return get<AuthentikCollectionResponse<AuthentikApplication>>(
`/api/services/authentik/${serviceId}/applications`,
{ limit: String(limit) },
);
}
export interface AuthentikMessageInput { export interface AuthentikMessageInput {
recipient_emails: string[]; recipient_emails: string[];
subject: string; subject: string;
+31 -1
View File
@@ -1,6 +1,9 @@
/** Hooks for the Authentik directory + messaging tabs. */ /** Hooks for Authentik directory, access metadata, and messaging tabs. */
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
fetchAuthentikAccessSummary,
fetchAuthentikApplications,
fetchAuthentikGroups,
fetchAuthentikMessageStatus, fetchAuthentikMessageStatus,
fetchAuthentikUsers, fetchAuthentikUsers,
sendAuthentikMessage, sendAuthentikMessage,
@@ -17,6 +20,33 @@ export function useAuthentikUsers(
}); });
} }
export function useAuthentikAccessSummary(
serviceId: string,
params: { search?: string; page?: number; page_size?: number },
) {
return useQuery({
queryKey: ["authentik", "access-summary", serviceId, params],
queryFn: () => fetchAuthentikAccessSummary(serviceId, params),
staleTime: 10_000,
});
}
export function useAuthentikGroups(serviceId: string, limit = 100) {
return useQuery({
queryKey: ["authentik", "groups", serviceId, limit],
queryFn: () => fetchAuthentikGroups(serviceId, limit),
staleTime: 30_000,
});
}
export function useAuthentikApplications(serviceId: string, limit = 100) {
return useQuery({
queryKey: ["authentik", "applications", serviceId, limit],
queryFn: () => fetchAuthentikApplications(serviceId, limit),
staleTime: 30_000,
});
}
export function useSendAuthentikMessage(serviceId: string) { export function useSendAuthentikMessage(serviceId: string) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
+17 -4
View File
@@ -12,6 +12,7 @@ describe("service registry", () => {
it("registers the backend service types", () => { it("registers the backend service types", () => {
expect(Object.keys(SERVICE_REGISTRY).sort()).toEqual([ expect(Object.keys(SERVICE_REGISTRY).sort()).toEqual([
"alertmanager", "alertmanager",
"authentik",
"jellyfin", "jellyfin",
"nextcloud", "nextcloud",
"prometheus", "prometheus",
@@ -30,6 +31,11 @@ describe("service registry", () => {
expect(SERVICE_REGISTRY.alertmanager.widgets.map((w) => w.kind)).toEqual([ expect(SERVICE_REGISTRY.alertmanager.widgets.map((w) => w.kind)).toEqual([
"active_alerts", "active_alerts",
]); ]);
expect(SERVICE_REGISTRY.authentik.widgets.map((w) => w.kind)).toEqual([
"access_summary",
"groups",
"applications",
]);
expect(SERVICE_REGISTRY.remote_machine.widgets.map((w) => w.kind)).toEqual([ expect(SERVICE_REGISTRY.remote_machine.widgets.map((w) => w.kind)).toEqual([
"task_output", "task_output",
]); ]);
@@ -41,10 +47,17 @@ describe("service registry", () => {
}); });
it("exposes unit/scale options on graph widget kinds", () => { it("exposes unit/scale options on graph widget kinds", () => {
const propsOf = (binding: { configSchema: Record<string, unknown> } | undefined) => const propsOf = (
(binding?.configSchema as { properties?: Record<string, { enum?: string[] }> } | undefined) binding: { configSchema: Record<string, unknown> } | undefined,
?.properties ?? {}; ) =>
const chart = getServiceBinding("prometheus")?.widgets.find((w) => w.kind === "chart"); (
binding?.configSchema as
| { properties?: Record<string, { enum?: string[] }> }
| undefined
)?.properties ?? {};
const chart = getServiceBinding("prometheus")?.widgets.find(
(w) => w.kind === "chart",
);
const speed = getServiceBinding("qbittorrent")?.widgets.find( const speed = getServiceBinding("qbittorrent")?.widgets.find(
(w) => w.kind === "speed", (w) => w.kind === "speed",
); );
+50
View File
@@ -1,5 +1,8 @@
import type { ComponentType } from "react"; import type { ComponentType } from "react";
import { AlertmanagerAlertsWidget } from "../widgets/AlertmanagerAlertsWidget"; import { AlertmanagerAlertsWidget } from "../widgets/AlertmanagerAlertsWidget";
import { AuthentikAccessSummaryWidget } from "../widgets/AuthentikAccessSummaryWidget";
import { AuthentikApplicationsWidget } from "../widgets/AuthentikApplicationsWidget";
import { AuthentikGroupsWidget } from "../widgets/AuthentikGroupsWidget";
import { BackupsWidget } from "../widgets/BackupsWidget"; import { BackupsWidget } from "../widgets/BackupsWidget";
import { MetricChartWidget } from "../widgets/MetricChartWidget"; import { MetricChartWidget } from "../widgets/MetricChartWidget";
import { MetricGaugeWidget } from "../widgets/MetricGaugeWidget"; import { MetricGaugeWidget } from "../widgets/MetricGaugeWidget";
@@ -76,6 +79,53 @@ const AXIS_FORMAT_PROPERTIES = {
}; };
export const SERVICE_REGISTRY: Record<string, ServiceBinding> = { export const SERVICE_REGISTRY: Record<string, ServiceBinding> = {
authentik: {
serviceType: "authentik",
name: "Authentik",
description: "Read-only user directory, group, and application metadata.",
widgets: [
{
kind: "access_summary",
name: "User access summary",
description:
"Group membership and explicit privileged flags; not effective authorization.",
refreshIntervalMs: 60_000,
defaultConfig: { limit: 10 },
configSchema: {
type: "object",
properties: { limit: { type: "integer", minimum: 1, maximum: 50 } },
required: [],
},
component: AuthentikAccessSummaryWidget,
},
{
kind: "groups",
name: "Groups",
description: "Read-only Authentik group list.",
refreshIntervalMs: 60_000,
defaultConfig: { limit: 10 },
configSchema: {
type: "object",
properties: { limit: { type: "integer", minimum: 1, maximum: 50 } },
required: [],
},
component: AuthentikGroupsWidget,
},
{
kind: "applications",
name: "Applications",
description: "Read-only Authentik application list.",
refreshIntervalMs: 60_000,
defaultConfig: { limit: 10 },
configSchema: {
type: "object",
properties: { limit: { type: "integer", minimum: 1, maximum: 50 } },
required: [],
},
component: AuthentikApplicationsWidget,
},
],
},
alertmanager: { alertmanager: {
serviceType: "alertmanager", serviceType: "alertmanager",
name: "Alertmanager", name: "Alertmanager",
@@ -0,0 +1,80 @@
/** ApplicationsTab — read-only Authentik application directory. */
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useAuthentikApplications } from "../../hooks/useAuthentik";
import type { ServiceInstance } from "../../types";
export function ApplicationsTab({ instance }: { instance: ServiceInstance }) {
const { data, isLoading } = useAuthentikApplications(instance.id);
const applications = data?.items ?? [];
return (
<div className="flex flex-col gap-3">
<Alert>
<AlertDescription>
Application metadata only; providers, outposts, policies, and
effective access evaluation are not shown.
</AlertDescription>
</Alert>
{data?.error ? (
<Alert variant="destructive">
<AlertDescription>{data.error}</AlertDescription>
</Alert>
) : null}
<div className="rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Application</TableHead>
<TableHead>Slug</TableHead>
<TableHead>Launch URL</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading && applications.length === 0 ? (
<TableRow>
<TableCell colSpan={3}>
<Skeleton className="h-5 w-full" />
</TableCell>
</TableRow>
) : null}
{!isLoading && applications.length === 0 ? (
<TableRow>
<TableCell colSpan={3} className="text-muted-foreground">
No applications found.
</TableCell>
</TableRow>
) : null}
{applications.map((application) => (
<TableRow
key={application.id || application.slug || application.name}
>
<TableCell className="font-medium">
{application.name}
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{application.slug || "—"}
</TableCell>
<TableCell className="max-w-sm truncate text-muted-foreground">
{application.launch_url || "—"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{data && data.total > applications.length ? (
<p className="text-sm text-muted-foreground">
Showing the first {applications.length} of {data.total} applications.
</p>
) : null}
</div>
);
}
@@ -0,0 +1,66 @@
/** GroupsTab — read-only Authentik group directory. */
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useAuthentikGroups } from "../../hooks/useAuthentik";
import type { ServiceInstance } from "../../types";
export function GroupsTab({ instance }: { instance: ServiceInstance }) {
const { data, isLoading } = useAuthentikGroups(instance.id);
const groups = data?.items ?? [];
return (
<div className="flex flex-col gap-3">
{data?.error ? (
<Alert variant="destructive">
<AlertDescription>{data.error}</AlertDescription>
</Alert>
) : null}
<div className="rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Group</TableHead>
<TableHead>ID</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading && groups.length === 0 ? (
<TableRow>
<TableCell colSpan={2}>
<Skeleton className="h-5 w-full" />
</TableCell>
</TableRow>
) : null}
{!isLoading && groups.length === 0 ? (
<TableRow>
<TableCell colSpan={2} className="text-muted-foreground">
No groups found.
</TableCell>
</TableRow>
) : null}
{groups.map((group) => (
<TableRow key={group.id}>
<TableCell className="font-medium">{group.name}</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{group.id}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{data && data.total > groups.length ? (
<p className="text-sm text-muted-foreground">
Showing the first {groups.length} of {data.total} groups.
</p>
) : null}
</div>
);
}
+67 -39
View File
@@ -1,4 +1,4 @@
/** UsersTab — Authentik user directory for the Authentik service page. */ /** UsersTab — Authentik access metadata, not an effective-permissions calculation. */
import { useState } from "react"; import { useState } from "react";
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -13,7 +13,7 @@ import {
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import type { ServiceInstance } from "../../types"; import type { ServiceInstance } from "../../types";
import { useAuthentikUsers } from "../../hooks/useAuthentik"; import { useAuthentikAccessSummary } from "../../hooks/useAuthentik";
const PAGE_SIZE = 25; const PAGE_SIZE = 25;
@@ -21,14 +21,11 @@ export function UsersTab({ instance }: { instance: ServiceInstance }) {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [committedSearch, setCommittedSearch] = useState(""); const [committedSearch, setCommittedSearch] = useState("");
const { data, isLoading } = useAuthentikAccessSummary(instance.id, {
const { data, isLoading } = useAuthentikUsers(instance.id, {
search: committedSearch, search: committedSearch,
page, page,
page_size: PAGE_SIZE, page_size: PAGE_SIZE,
}); });
const error = data?.error;
const users = data?.items ?? []; const users = data?.items ?? [];
const total = data?.total ?? 0; const total = data?.total ?? 0;
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
@@ -40,19 +37,25 @@ export function UsersTab({ instance }: { instance: ServiceInstance }) {
return ( return (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{error ? ( <Alert>
<AlertDescription>
Shows Authentik group membership and explicit staff/superuser flags.
This is access metadata, not a complete effective-authorization
calculation.
</AlertDescription>
</Alert>
{data?.error ? (
<Alert variant="destructive"> <Alert variant="destructive">
<AlertDescription>{error}</AlertDescription> <AlertDescription>{data.error}</AlertDescription>
</Alert> </Alert>
) : null} ) : null}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Input <Input
placeholder="Search users…" placeholder="Search users…"
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(event) => setSearch(event.target.value)}
onKeyDown={(e) => { onKeyDown={(event) => {
if (e.key === "Enter") handleSearch(); if (event.key === "Enter") handleSearch();
}} }}
className="max-w-xs" className="max-w-xs"
/> />
@@ -60,52 +63,75 @@ export function UsersTab({ instance }: { instance: ServiceInstance }) {
Search Search
</Button> </Button>
</div> </div>
<div className="rounded-lg border"> <div className="rounded-lg border">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Name</TableHead> <TableHead>Name</TableHead>
<TableHead>Username</TableHead> <TableHead>Username</TableHead>
<TableHead>Email</TableHead> <TableHead>Groups</TableHead>
<TableHead className="w-24">Status</TableHead> <TableHead>Privileges</TableHead>
<TableHead>Status</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{isLoading && users.length === 0 ? ( {isLoading && users.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={4} className="text-muted-foreground"> <TableCell colSpan={5} className="text-muted-foreground">
Loading Loading
</TableCell> </TableCell>
</TableRow> </TableRow>
) : users.length === 0 ? ( ) : null}
{!isLoading && users.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={4} className="text-muted-foreground"> <TableCell colSpan={5} className="text-muted-foreground">
No users found. No users found.
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : null}
users.map((user) => ( {users.map((user) => (
<TableRow key={user.pk}> <TableRow key={user.id || user.username}>
<TableCell className="font-medium"> <TableCell className="font-medium">
{user.name || "—"} {user.name || "—"}
</TableCell> </TableCell>
<TableCell>{user.username}</TableCell> <TableCell>{user.username || "—"}</TableCell>
<TableCell className="text-muted-foreground"> <TableCell>
{user.email || "—"} <div className="flex max-w-sm flex-wrap gap-1">
</TableCell> {user.groups.length ? (
<TableCell> user.groups.map((group) => (
<Badge variant={user.is_active ? "default" : "secondary"}> <Badge
{user.is_active ? "Active" : "Inactive"} key={group.id}
</Badge> variant={group.known ? "secondary" : "destructive"}
</TableCell> >
</TableRow> {group.name}
)) </Badge>
)} ))
) : (
<span className="text-muted-foreground">None</span>
)}
</div>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{user.is_superuser ? (
<Badge variant="destructive">Superuser</Badge>
) : null}
{user.is_staff ? <Badge>Staff</Badge> : null}
{!user.is_superuser && !user.is_staff ? (
<span className="text-muted-foreground">None</span>
) : null}
</div>
</TableCell>
<TableCell>
<Badge variant={user.is_active ? "default" : "secondary"}>
{user.is_active ? "Active" : "Inactive"}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
{total > 0 ? ( {total > 0 ? (
<div className="flex items-center justify-between text-sm text-muted-foreground"> <div className="flex items-center justify-between text-sm text-muted-foreground">
<span> <span>
@@ -115,7 +141,7 @@ export function UsersTab({ instance }: { instance: ServiceInstance }) {
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => setPage((p) => Math.max(1, p - 1))} onClick={() => setPage((current) => Math.max(1, current - 1))}
disabled={page <= 1} disabled={page <= 1}
> >
Previous Previous
@@ -123,7 +149,9 @@ export function UsersTab({ instance }: { instance: ServiceInstance }) {
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))} onClick={() =>
setPage((current) => Math.min(totalPages, current + 1))
}
disabled={page >= totalPages} disabled={page >= totalPages}
> >
Next Next
@@ -15,22 +15,28 @@ const instance: ServiceInstance = {
}; };
vi.mock("../../../hooks/useAuthentik", () => ({ vi.mock("../../../hooks/useAuthentik", () => ({
useAuthentikUsers: vi.fn(() => ({ useAuthentikAccessSummary: vi.fn(() => ({
data: { data: {
items: [ items: [
{ {
pk: 1, id: "1",
username: "alice", username: "alice",
name: "Alice", name: "Alice",
email: "alice@example.com", email: "alice@example.com",
is_active: true, is_active: true,
is_superuser: true,
is_staff: false,
groups: [{ id: "admins", name: "Admins", known: true }],
}, },
{ {
pk: 2, id: "2",
username: "bob", username: "bob",
name: "Bob", name: "Bob",
email: "bob@example.com", email: "bob@example.com",
is_active: false, is_active: false,
is_superuser: false,
is_staff: true,
groups: [{ id: "gone", name: "Unknown group (gone)", known: false }],
}, },
], ],
total: 2, total: 2,
@@ -42,13 +48,17 @@ vi.mock("../../../hooks/useAuthentik", () => ({
})); }));
describe("UsersTab", () => { describe("UsersTab", () => {
it("renders the directory table with users", () => { it("renders group membership and explicit privilege metadata", () => {
render(<UsersTab instance={instance} />); render(<UsersTab instance={instance} />);
expect(screen.getByText("Alice")).toBeInTheDocument(); expect(screen.getByText("Alice")).toBeInTheDocument();
expect(screen.getByText("bob")).toBeInTheDocument(); expect(screen.getByText("bob")).toBeInTheDocument();
expect(screen.getByText("alice@example.com")).toBeInTheDocument(); expect(screen.getByText("Admins")).toBeInTheDocument();
expect(screen.getByText("Active")).toBeInTheDocument(); expect(screen.getByText("Unknown group (gone)")).toBeInTheDocument();
expect(screen.getByText("Inactive")).toBeInTheDocument(); expect(screen.getByText("Superuser")).toBeInTheDocument();
expect(screen.getByText("Staff")).toBeInTheDocument();
expect(
screen.getByText(/not a complete effective-authorization calculation/i),
).toBeInTheDocument();
}); });
it("renders search input and pagination", () => { it("renders search input and pagination", () => {
+4
View File
@@ -15,6 +15,8 @@ import { FilesTab } from "./FilesTab";
import { ActionsTab } from "./ActionsTab"; import { ActionsTab } from "./ActionsTab";
import { JobsTab } from "./JobsTab"; import { JobsTab } from "./JobsTab";
import { UsersTab } from "./UsersTab"; import { UsersTab } from "./UsersTab";
import { GroupsTab } from "./GroupsTab";
import { ApplicationsTab } from "./ApplicationsTab";
import { MessagingTab } from "./MessagingTab"; import { MessagingTab } from "./MessagingTab";
import { QbittorrentTab } from "./QbittorrentTab"; import { QbittorrentTab } from "./QbittorrentTab";
@@ -52,6 +54,8 @@ export function serviceContentTabs(serviceType: string): ContentTab[] {
case "authentik": case "authentik":
return [ return [
{ label: "Users", Component: UsersTab }, { label: "Users", Component: UsersTab },
{ label: "Groups", Component: GroupsTab },
{ label: "Applications", Component: ApplicationsTab },
{ label: "Messaging", Component: MessagingTab }, { label: "Messaging", Component: MessagingTab },
]; ];
case "alertmanager": case "alertmanager":
@@ -0,0 +1,77 @@
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { SectionCard } from "../components/SectionCard";
import { useWidgetData } from "../hooks/useWidgets";
import type { AuthentikAccessSummary } from "../api/authentik";
import type { WidgetInstance } from "../types";
interface Props {
widget: WidgetInstance;
refreshIntervalMs: number;
description?: string;
}
export function AuthentikAccessSummaryWidget({
widget,
refreshIntervalMs,
description,
}: Props) {
const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
const payload = data?.data as
| { items?: AuthentikAccessSummary[] }
| undefined;
const users = payload?.items ?? [];
return (
<SectionCard title={widget.title} description={description}>
{isLoading && !data ? (
<Skeleton className="h-16 w-full" />
) : data?.error ? (
<Alert variant="destructive">
<AlertDescription>{data.error}</AlertDescription>
</Alert>
) : users.length ? (
<ul className="space-y-2">
{users.map((user) => (
<li
key={user.id || user.username}
className="rounded-md border px-3 py-2 text-sm"
>
<div className="flex items-center justify-between gap-2">
<span className="font-medium">
{user.name || user.username || "Unknown user"}
</span>
<span className="flex gap-1">
{user.is_superuser ? (
<Badge variant="destructive">Superuser</Badge>
) : null}
{user.is_staff ? <Badge>Staff</Badge> : null}
</span>
</div>
<div className="mt-1 flex flex-wrap gap-1">
{user.groups.length ? (
user.groups.map((group) => (
<Badge
key={group.id}
variant={group.known ? "secondary" : "destructive"}
>
{group.name}
</Badge>
))
) : (
<span className="text-xs text-muted-foreground">
No group references
</span>
)}
</div>
</li>
))}
</ul>
) : (
<p className="text-sm text-muted-foreground">
No user access metadata found.
</p>
)}
</SectionCard>
);
}
@@ -0,0 +1,51 @@
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Skeleton } from "@/components/ui/skeleton";
import { SectionCard } from "../components/SectionCard";
import { useWidgetData } from "../hooks/useWidgets";
import type { AuthentikApplication } from "../api/authentik";
import type { WidgetInstance } from "../types";
interface Props {
widget: WidgetInstance;
refreshIntervalMs: number;
description?: string;
}
export function AuthentikApplicationsWidget({
widget,
refreshIntervalMs,
description,
}: Props) {
const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
const payload = data?.data as { items?: AuthentikApplication[] } | undefined;
const applications = payload?.items ?? [];
return (
<SectionCard title={widget.title} description={description}>
{isLoading && !data ? (
<Skeleton className="h-16 w-full" />
) : data?.error ? (
<Alert variant="destructive">
<AlertDescription>{data.error}</AlertDescription>
</Alert>
) : applications.length ? (
<ul className="space-y-1">
{applications.map((application) => (
<li
key={application.id || application.slug || application.name}
className="rounded-md border px-2 py-1 text-sm"
>
<span className="font-medium">{application.name}</span>
{application.slug ? (
<span className="ml-2 text-xs text-muted-foreground">
{application.slug}
</span>
) : null}
</li>
))}
</ul>
) : (
<p className="text-sm text-muted-foreground">No applications found.</p>
)}
</SectionCard>
);
}
@@ -0,0 +1,43 @@
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Skeleton } from "@/components/ui/skeleton";
import { SectionCard } from "../components/SectionCard";
import { useWidgetData } from "../hooks/useWidgets";
import type { AuthentikGroup } from "../api/authentik";
import type { WidgetInstance } from "../types";
interface Props {
widget: WidgetInstance;
refreshIntervalMs: number;
description?: string;
}
export function AuthentikGroupsWidget({
widget,
refreshIntervalMs,
description,
}: Props) {
const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
const payload = data?.data as { items?: AuthentikGroup[] } | undefined;
const groups = payload?.items ?? [];
return (
<SectionCard title={widget.title} description={description}>
{isLoading && !data ? (
<Skeleton className="h-16 w-full" />
) : data?.error ? (
<Alert variant="destructive">
<AlertDescription>{data.error}</AlertDescription>
</Alert>
) : groups.length ? (
<ul className="space-y-1">
{groups.map((group) => (
<li key={group.id} className="rounded-md border px-2 py-1 text-sm">
{group.name}
</li>
))}
</ul>
) : (
<p className="text-sm text-muted-foreground">No groups found.</p>
)}
</SectionCard>
);
}
+3
View File
@@ -1,4 +1,7 @@
export { AlertmanagerAlertsWidget } from "./AlertmanagerAlertsWidget"; export { AlertmanagerAlertsWidget } from "./AlertmanagerAlertsWidget";
export { AuthentikAccessSummaryWidget } from "./AuthentikAccessSummaryWidget";
export { AuthentikApplicationsWidget } from "./AuthentikApplicationsWidget";
export { AuthentikGroupsWidget } from "./AuthentikGroupsWidget";
export { BackupsWidget } from "./BackupsWidget"; export { BackupsWidget } from "./BackupsWidget";
export { MetricChartWidget } from "./MetricChartWidget"; export { MetricChartWidget } from "./MetricChartWidget";
export { MetricGaugeWidget } from "./MetricGaugeWidget"; export { MetricGaugeWidget } from "./MetricGaugeWidget";