Initial curated Unraid-AppOps-Gateway source from 3bd6f6fee310125bfbb4d4650389a817f498724d

This commit is contained in:
Public Source Publisher committed 2026-10-01 01:35:57 +02:00
commit 1dfbca08a1
528 files changed
+131054

No files matched your search

+817
View File
@@ -0,0 +1,817 @@
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import Any
from urllib.parse import urljoin, urlparse
import httpx
from ..core.config import settings
from ..core.redaction import redact_message
from .base import BaseConnector
@dataclass
class _AuthentikConfig:
url: str
token: str
verify_tls: bool
timeout_seconds: float
outpost_url: str | None
expect_embedded_outpost: bool
def _as_bool(value: Any, default: bool = False) -> bool:
if isinstance(value, bool):
return value
if value is None:
return default
return str(value).strip().lower() in {'1', 'true', 'yes', 'on'}
def _as_float(value: Any, default: float) -> float:
try:
return float(value)
except (TypeError, ValueError):
return default
def _as_int(value: Any, default: int = 0) -> int:
try:
return int(float(value))
except (TypeError, ValueError):
return default
def _token_value(value: Any) -> str:
token = str(value or '').strip()
if token.lower().startswith('bearer '):
return token[7:].strip()
return token
def _as_list(value: Any) -> list[Any]:
if isinstance(value, list):
return value
if value in {None, ''}:
return []
return [value]
def _safe_url(value: Any = None) -> str:
raw = str(value or settings.apis.get('AUTHENTIK_URL') or '').strip().rstrip('/')
if not raw:
return ''
parsed = urlparse(raw)
if not parsed.username and not parsed.password:
return raw
host = parsed.hostname or ''
port = f':{parsed.port}' if parsed.port else ''
return f'{parsed.scheme}://{host}{port}{parsed.path}'.rstrip('/')
def _domain(value: Any) -> str:
text = str(value or '').strip()
if not text:
return ''
parsed = urlparse(text if '://' in text else f'https://{text}')
return (parsed.hostname or text).lower().strip()
def _response_items(payload: Any) -> list[dict[str, Any]]:
if isinstance(payload, list):
return [item for item in payload if isinstance(item, dict)]
if isinstance(payload, dict):
data = payload.get('data')
if isinstance(data, list):
return [item for item in data if isinstance(item, dict)]
if isinstance(payload.get('results'), list):
return [item for item in payload['results'] if isinstance(item, dict)]
if isinstance(payload.get('items'), list):
return [item for item in payload['items'] if isinstance(item, dict)]
return []
def _pagination_next(payload: Any) -> str | None:
if not isinstance(payload, dict):
return None
pagination = payload.get('pagination')
if isinstance(pagination, dict) and pagination.get('next'):
return str(pagination['next'])
if payload.get('next'):
return str(payload['next'])
return None
class AuthentikConnector(BaseConnector):
name = 'Authentik'
def __init__(self):
super().__init__()
self._read_cache: dict[str, dict[str, Any]] = {}
def _memo(self, key: str, factory: Any) -> dict[str, Any]:
if key not in self._read_cache:
self._read_cache[key] = factory()
return self._read_cache[key]
def is_configured(self) -> bool:
return bool(settings.apis.get('AUTHENTIK_URL') and settings.apis.get('AUTHENTIK_TOKEN'))
def _config(self) -> _AuthentikConfig | None:
if not self.is_configured():
return None
return _AuthentikConfig(
url=str(settings.apis['AUTHENTIK_URL']).rstrip('/'),
token=_token_value(settings.apis['AUTHENTIK_TOKEN']),
verify_tls=_as_bool(settings.apis.get('AUTHENTIK_VERIFY_TLS'), False),
timeout_seconds=_as_float(settings.apis.get('AUTHENTIK_TIMEOUT_SECONDS'), 10.0),
outpost_url=str(settings.apis.get('AUTHENTIK_OUTPOST_URL') or '').strip().rstrip('/') or None,
expect_embedded_outpost=_as_bool(settings.apis.get('AUTHENTIK_EXPECT_EMBEDDED_OUTPOST'), False),
)
def _request(self, path: str, params: dict[str, Any] | None = None) -> Any:
config = self._config()
if not config:
raise RuntimeError('missing_config')
url = path if path.startswith(('http://', 'https://')) else f'{config.url}/api/v3{path}'
try:
self.clear_error()
with httpx.Client(timeout=config.timeout_seconds, verify=config.verify_tls) as client:
response = client.get(
url,
params=params,
headers={'Accept': 'application/json', 'Authorization': f'Bearer {config.token}'},
)
except httpx.RequestError as exc:
message = redact_message(str(exc))
self.set_error(message)
raise RuntimeError(message)
if response.status_code in {401, 403}:
self.set_error('auth_failed')
raise RuntimeError('auth_failed')
if response.status_code >= 400:
message = redact_message(f'Authentik API error: HTTP {response.status_code} {(response.text or "")[:256]}')
self.set_error(message)
raise RuntimeError(message)
try:
return response.json()
except ValueError as exc:
message = redact_message(str(exc))
self.set_error(message)
raise RuntimeError(message)
def _write_request(self, method: str, path: str, payload: dict[str, Any] | None = None) -> Any:
"""Use the existing Authentik API identity for one typed mutation.
Callers in this repository pass only a DeploymentPlan-derived payload;
this method is private so routes cannot turn it into a generic Authentik
proxy.
"""
config = self._config()
if not config:
raise RuntimeError('missing_config')
try:
self.clear_error()
with httpx.Client(timeout=config.timeout_seconds, verify=config.verify_tls) as client:
request_kwargs: dict[str, Any] = {
'headers': {'Accept': 'application/json', 'Authorization': f'Bearer {config.token}'},
}
if payload is not None:
request_kwargs['json'] = payload
response = client.request(method, f'{config.url}/api/v3{path}', **request_kwargs)
except httpx.RequestError as exc:
message = redact_message(str(exc))
self.set_error(message)
raise RuntimeError(message)
if response.status_code in {401, 403}:
self.set_error('auth_failed')
raise RuntimeError('auth_failed')
if response.status_code >= 400:
message = redact_message(f'Authentik API error: HTTP {response.status_code} {(response.text or "")[:256]}')
self.set_error(message)
raise RuntimeError(message)
if not response.content:
return {}
try:
return response.json()
except ValueError as exc:
message = redact_message(str(exc))
self.set_error(message)
raise RuntimeError(message)
def _paged(self, path: str, params: dict[str, Any] | None = None) -> list[dict[str, Any]]:
config = self._config()
if not config:
raise RuntimeError('missing_config')
items: list[dict[str, Any]] = []
base_path = path
base_params = dict(params or {})
next_path: str | None = path
next_params = params
for _ in range(20):
if not next_path:
break
payload = self._request(next_path, next_params)
items.extend(_response_items(payload))
next_value = _pagination_next(payload)
if not next_value:
break
if next_value.isdigit():
next_path = base_path
next_params = {**base_params, 'page': int(next_value)}
continue
if next_value.startswith(('http://', 'https://')):
parsed_base = urlparse(config.url)
parsed_next = urlparse(next_value)
if parsed_next.netloc and parsed_next.netloc != parsed_base.netloc:
break
next_path = next_value
else:
next_path = urljoin('/api/v3/', next_value).replace('/api/v3', '', 1)
next_params = None
return items
def test_connection(self) -> dict[str, Any]:
if not self.is_configured():
return {'status': 'missing_config', 'reason': 'missing_config'}
try:
root = self._request('/root/config/')
except RuntimeError as exc:
reason = redact_message(str(exc))
if reason.startswith('auth_failed'):
return {'status': 'auth_failed', 'reason': reason}
if reason == 'missing_config':
return {'status': 'missing_config', 'reason': reason}
return {'status': 'unreachable', 'reason': reason}
return {'status': 'connected', 'version': self._version(root)}
def health(self) -> dict[str, Any]:
if not self.is_configured():
return self._missing(applicationCount=0, outpostAvailable=False)
try:
result = self.test_connection()
if result.get('status') != 'connected':
return self._error_payload(RuntimeError(str(result.get('reason', result.get('status')))), applicationCount=0, outpostAvailable=False)
apps = self.applications()
outpost = self.outpost_health()
except RuntimeError as exc:
return self._error_payload(exc, applicationCount=0, outpostAvailable=False)
return {
'success': True,
'status': 'connected',
'message': 'Authentik connector ready.',
'url': _safe_url(),
'version': str(result.get('version') or 'unknown'),
'applicationCount': len(apps.get('items', [])),
'outpostAvailable': bool(outpost.get('available')),
'data': {},
}
def system_status(self) -> dict[str, Any]:
if not self.is_configured():
return self._missing()
try:
root = self._request('/root/config/')
except RuntimeError as exc:
return self._error_payload(exc)
data = {
'version': self._version(root),
'brand': root.get('brand') if isinstance(root, dict) else None,
'url': _safe_url(),
}
return {'success': True, 'status': 'ok', 'message': 'system_status_ok', **data, 'data': data}
def applications(self) -> dict[str, Any]:
return self._memo('applications', lambda: self._collection('/core/applications/', self._map_applications, 'applications_ok'))
def providers(self) -> dict[str, Any]:
return self._memo('providers', lambda: self._collection('/core/providers/', self._map_providers, 'providers_ok'))
def proxy_providers(self) -> dict[str, Any]:
return self._memo('proxy_providers', lambda: self._collection('/providers/proxy/', self._map_proxy_providers, 'proxy_providers_ok'))
def oauth2_providers(self) -> dict[str, Any]:
return self._memo('oauth2_providers', lambda: self._collection('/providers/oauth2/', self._map_oauth2_providers, 'oauth2_providers_ok'))
def groups(self) -> dict[str, Any]:
"""Read role-group identities for enrollment takeover-safe preflight."""
return self._memo(
'groups',
lambda: self._collection(
'/core/groups/',
lambda items: [
{'id': item.get('pk') or item.get('id'), 'name': str(item.get('name') or '')}
for item in items
],
'groups_ok',
),
)
@staticmethod
def deployment_oidc_template_status(*, role_claim: str = 'roles') -> dict[str, Any]:
"""Report only whether the server-owned OIDC template is configured."""
required = (
'AUTHENTIK_OIDC_AUTHORIZATION_FLOW',
'AUTHENTIK_OIDC_INVALIDATION_FLOW',
'AUTHENTIK_OIDC_GROUP_CLAIM_MAPPING',
)
missing = [name for name in required if not str(settings.apis.get(name) or '').strip()]
configured_claim = str(settings.apis.get('AUTHENTIK_OIDC_ROLE_CLAIM') or '').strip()
if configured_claim != role_claim:
missing.append('AUTHENTIK_OIDC_ROLE_CLAIM')
return {
'status': 'ready' if not missing else 'missing_config',
'missing': missing,
}
def ensure_deployment_oidc_application(
self,
*,
application_slug: str,
launch_url: str,
redirect_uri: str,
client_secret: str,
administrator_group: str,
support_agent_group: str,
read_only_group: str,
) -> dict[str, Any]:
"""Provision one Authentik OIDC app from the reviewed deployment template.
The method refuses tenant-wide group/flow creation and never returns the
supplied/generated client secret. Existing applications are accepted
only when their slug and launch URL already identify this deployment.
"""
if not self.is_configured():
return self._missing(reason='missing_config')
if not re.fullmatch(r'[a-z][a-z0-9-]{1,62}', application_slug):
return {'success': False, 'status': 'invalid', 'message': 'invalid_application_slug'}
if not re.fullmatch(r'https://[a-z0-9.-]+(?:/[^?#]*)?', launch_url):
return {'success': False, 'status': 'invalid', 'message': 'invalid_launch_url'}
if not redirect_uri.startswith(f'{launch_url}/') or '?' in redirect_uri or '#' in redirect_uri:
return {'success': False, 'status': 'invalid', 'message': 'invalid_redirect_uri'}
authorization_flow = str(settings.apis.get('AUTHENTIK_OIDC_AUTHORIZATION_FLOW') or '').strip()
invalidation_flow = str(settings.apis.get('AUTHENTIK_OIDC_INVALIDATION_FLOW') or '').strip()
group_claim_mapping = str(settings.apis.get('AUTHENTIK_OIDC_GROUP_CLAIM_MAPPING') or '').strip()
if not authorization_flow or not invalidation_flow or not group_claim_mapping:
return {'success': False, 'status': 'blocked', 'message': 'oidc_template_not_configured'}
try:
groups = self._paged('/core/groups/')
group_names = {str(item.get('name') or '') for item in groups}
required_groups = {administrator_group, support_agent_group, read_only_group}
if not required_groups.issubset(group_names):
return {'success': False, 'status': 'blocked', 'message': 'required_role_groups_are_missing'}
applications = self._paged('/core/applications/')
matches = [item for item in applications if str(item.get('slug') or '') == application_slug]
if len(matches) > 1:
return {'success': False, 'status': 'conflict', 'message': 'multiple_authentik_applications_with_slug'}
if matches:
existing = matches[0]
existing_url = str(existing.get('meta_launch_url') or existing.get('launch_url') or '')
if existing_url.rstrip('/') != launch_url.rstrip('/'):
return {'success': False, 'status': 'conflict', 'message': 'application_slug_owned_by_different_launch_url'}
provider_id = existing.get('provider') or existing.get('provider_id')
providers = self._paged('/providers/oauth2/')
provider = next(
(item for item in providers if str(item.get('pk') or item.get('id')) == str(provider_id)),
None,
)
provider_redirects = provider.get('redirect_uris') if isinstance(provider, dict) else None
if isinstance(provider_redirects, str):
provider_redirects = [item for item in provider_redirects.splitlines() if item.strip()]
provider_mappings = provider.get('property_mappings') if isinstance(provider, dict) else None
if (
not isinstance(provider, dict)
or str(provider.get('client_id') or '') != application_slug
or str(provider.get('client_type') or '') != 'confidential'
or provider_redirects != [redirect_uri]
or group_claim_mapping not in (provider_mappings or [])
or str(provider.get('authorization_flow') or '') != authorization_flow
or str(provider.get('invalidation_flow') or '') != invalidation_flow
):
return {'success': False, 'status': 'conflict', 'message': 'existing_oidc_provider_does_not_match_managed_contract'}
return {
'success': True,
'status': 'already_managed',
'message': 'oidc_application_already_matches_typed_plan',
'applicationId': existing.get('pk') or existing.get('id'),
'clientSecretCreated': False,
}
provider = self._write_request(
'POST',
'/providers/oauth2/',
{
'name': f'{application_slug} OIDC',
'authorization_flow': authorization_flow,
'invalidation_flow': invalidation_flow,
'client_type': 'confidential',
'client_id': application_slug,
'client_secret': client_secret,
'redirect_uris': [redirect_uri],
'property_mappings': [group_claim_mapping],
'include_claims_in_id_token': True,
'sub_mode': 'hashed_user_id',
'issuer_mode': 'per_provider',
},
)
provider_id = provider.get('pk') or provider.get('id') if isinstance(provider, dict) else None
if not provider_id:
return {'success': False, 'status': 'failed', 'message': 'oidc_provider_response_missing_identifier'}
try:
application = self._write_request(
'POST',
'/core/applications/',
{
'name': application_slug,
'slug': application_slug,
'provider': provider_id,
'meta_launch_url': launch_url,
},
)
except RuntimeError:
# A generated client secret must never appear in an API error,
# audit record or browser response. Compensate only the provider
# this method just created, and fail closed if that cleanup is
# not acknowledged by Authentik.
try:
self._write_request('DELETE', f'/providers/oauth2/{provider_id}/')
except RuntimeError:
return {'success': False, 'status': 'partial_failure', 'message': 'oidc_application_failed_provider_cleanup_failed'}
return {'success': False, 'status': 'failed', 'message': 'oidc_application_creation_failed_provider_removed'}
except RuntimeError:
# The provider request carries the confidential client secret. A
# remote error body is not a safe channel for returning it.
return {'success': False, 'status': 'failed', 'message': 'oidc_provider_creation_failed'}
application_id = application.get('pk') or application.get('id') if isinstance(application, dict) else None
if not application_id:
try:
self._write_request('DELETE', f'/providers/oauth2/{provider_id}/')
except RuntimeError:
return {'success': False, 'status': 'partial_failure', 'message': 'oidc_application_response_missing_identifier_provider_cleanup_failed'}
return {'success': False, 'status': 'failed', 'message': 'oidc_application_response_missing_identifier_provider_removed'}
return {
'success': True,
'status': 'created',
'message': 'oidc_application_created_from_typed_template',
'applicationId': application_id,
'providerId': provider_id,
'clientSecretCreated': True,
}
def remove_deployment_oidc_application(
self,
*,
application_id: str | int,
provider_id: str | int,
application_slug: str,
launch_url: str,
) -> dict[str, Any]:
"""Compensate only an OIDC pair created during this enrollment call.
This is intentionally private to the enrollment orchestrator. It first
re-reads both resources and refuses to delete if the identity is no
longer exactly the bounded application/provider pair it created.
"""
if not self.is_configured():
return self._missing(reason='missing_config')
if not re.fullmatch(r'[a-z][a-z0-9-]{1,62}', application_slug):
return {'success': False, 'status': 'invalid', 'message': 'invalid_application_slug'}
if not re.fullmatch(r'https://[a-z0-9.-]+(?:/[^?#]*)?', launch_url):
return {'success': False, 'status': 'invalid', 'message': 'invalid_launch_url'}
try:
applications = self._paged('/core/applications/')
matches = [
item for item in applications
if str(item.get('pk') or item.get('id')) == str(application_id)
]
if len(matches) != 1:
return {'success': False, 'status': 'not_found', 'message': 'managed_oidc_application_not_found'}
application = matches[0]
if (
application.get('slug') != application_slug
or str(application.get('meta_launch_url') or application.get('launch_url') or '').rstrip('/') != launch_url.rstrip('/')
or str(application.get('provider') or application.get('provider_id') or '') != str(provider_id)
):
return {'success': False, 'status': 'conflict', 'message': 'oidc_application_is_not_the_created_managed_pair'}
provider = self._write_request('GET', f'/providers/oauth2/{provider_id}/')
if not isinstance(provider, dict) or str(provider.get('client_id') or '') != application_slug:
return {'success': False, 'status': 'conflict', 'message': 'oidc_provider_is_not_the_created_managed_pair'}
self._write_request('DELETE', f'/core/applications/{application_id}/')
self._write_request('DELETE', f'/providers/oauth2/{provider_id}/')
except RuntimeError as exc:
return self._error_payload(exc)
return {
'success': True,
'status': 'removed',
'applicationId': application_id,
'providerId': provider_id,
}
def outposts(self) -> dict[str, Any]:
return self._memo('outposts', lambda: self._collection('/outposts/instances/', self._map_outposts, 'outposts_ok'))
def outpost_health(self) -> dict[str, Any]:
return self._memo('outpost_health', self._outpost_health_uncached)
def _outpost_health_uncached(self) -> dict[str, Any]:
if not self.is_configured():
return self._missing(items=[], total=0, available=False, embeddedExpected=False)
outposts = self.outposts()
if not outposts.get('success'):
return {**outposts, 'available': False}
items = outposts.get('items', [])
available = any(str(item.get('health') or '').lower() in {'healthy', 'ok', 'connected'} for item in items)
config = self._config()
embedded_expected = bool(config and config.expect_embedded_outpost)
reachability = {'status': 'reported_healthy', 'reachable': True, 'statusCode': None} if available else self._probe_outpost(config) if config and config.outpost_url else {
'status': 'not_configured',
'reachable': None,
'statusCode': None,
}
reachable = reachability.get('reachable')
status = 'ok' if available else ('reachable_unverified' if reachable else ('warning' if embedded_expected else 'unknown'))
if available:
message = 'outpost_health_ok'
elif reachable:
message = 'outpost_reachable_health_unverified'
else:
message = 'outpost_health_unknown'
data = {
'items': items,
'available': available,
'embeddedExpected': embedded_expected,
'outpostUrl': _safe_url(config.outpost_url) if config and config.outpost_url else None,
'reachable': reachable,
'reachabilityStatus': reachability.get('status'),
'reachabilityStatusCode': reachability.get('statusCode'),
'telemetryStatus': 'reported' if available else 'unverified',
}
return {'success': True, 'status': status, 'message': message, 'items': items, 'total': len(items), **data, 'data': data}
@staticmethod
def _probe_outpost(config: _AuthentikConfig) -> dict[str, Any]:
if not config.outpost_url:
return {'status': 'not_configured', 'reachable': None, 'statusCode': None}
try:
# Never forward the Authentik API bearer token to a separately
# configured outpost origin. This probe verifies reachability only.
with httpx.Client(
timeout=min(config.timeout_seconds, 8.0),
verify=config.verify_tls,
follow_redirects=False,
) as client:
response = client.get(
config.outpost_url,
headers={'Accept': 'text/html,application/json;q=0.9,*/*;q=0.8'},
)
except httpx.RequestError:
return {'status': 'unreachable', 'reachable': False, 'statusCode': None}
reachable = response.status_code < 500
return {
'status': 'reachable' if reachable else 'degraded',
'reachable': reachable,
'statusCode': response.status_code,
}
def flows_summary(self) -> dict[str, Any]:
if not self.is_configured():
return self._missing(items=[], total=0, byDesignation={})
try:
items = self._paged('/flows/instances/')
except RuntimeError as exc:
return self._error_payload(exc, items=[], total=0, byDesignation={})
mapped = [{'id': item.get('pk') or item.get('id'), 'name': str(item.get('name') or item.get('slug') or ''), 'designation': str(item.get('designation') or 'unknown')} for item in items]
by_designation: dict[str, int] = {}
for item in mapped:
by_designation[item['designation']] = by_designation.get(item['designation'], 0) + 1
return {'success': True, 'status': 'ok', 'message': 'flows_summary_ok', 'total': len(mapped), 'items': mapped, 'byDesignation': by_designation, 'data': {'items': mapped, 'byDesignation': by_designation}}
def policies_summary(self) -> dict[str, Any]:
if not self.is_configured():
return self._missing(items=[], total=0, enabled=0, disabled=0)
try:
items = self._paged('/policies/all/')
except RuntimeError as exc:
return self._error_payload(exc, items=[], total=0, enabled=0, disabled=0)
mapped = [{'id': item.get('pk') or item.get('id'), 'name': str(item.get('name') or ''), 'enabled': bool(item.get('enabled', True))} for item in items]
enabled = sum(1 for item in mapped if item['enabled'])
data = {'items': mapped, 'enabled': enabled, 'disabled': len(mapped) - enabled}
return {'success': True, 'status': 'ok', 'message': 'policies_summary_ok', 'total': len(mapped), **data, 'data': data}
def events(self, limit: int = 25) -> dict[str, Any]:
cache_key = f'events:{max(1, min(limit, 100))}'
return self._memo(cache_key, lambda: self._events_uncached(limit))
def _events_uncached(self, limit: int = 25) -> dict[str, Any]:
if not self.is_configured():
return self._missing(items=[], total=0)
try:
raw = self._paged('/events/events/', {'page_size': max(1, min(limit, 100))})
except RuntimeError as exc:
return self._error_payload(exc, items=[], total=0)
items = [self._map_event(item) for item in raw[:limit]]
return {'success': True, 'status': 'ok', 'message': 'events_ok', 'total': len(items), 'items': items, 'data': {'items': items}}
def auth_coverage(self) -> dict[str, Any]:
return self._memo('auth_coverage', self._auth_coverage_uncached)
def _auth_coverage_uncached(self) -> dict[str, Any]:
apps = self.applications()
providers = self.providers()
proxy = self.proxy_providers()
if not apps.get('success'):
return {**apps, 'summary': self._empty_coverage_summary(), 'publicHosts': [], 'data': {'publicHosts': [], 'summary': self._empty_coverage_summary()}}
npm_hosts = self._npm_hosts()
hosts = npm_hosts.get('items', []) if npm_hosts.get('success') else []
proxy_domains = {_domain(item.get('externalHost')): item for item in proxy.get('items', []) if _domain(item.get('externalHost'))} if proxy.get('success') else {}
app_domains = {_domain(item.get('launchUrl')): item for item in apps.get('items', []) if _domain(item.get('launchUrl'))}
public_hosts: list[dict[str, Any]] = []
for host in hosts:
if not host.get('enabled', True):
continue
domains = [str(item) for item in _as_list(host.get('domainNames'))]
matches = [domain for domain in domains if _domain(domain) in proxy_domains or _domain(domain) in app_domains]
status = 'protected' if matches else ('likely_protected' if host.get('accessListId') else 'unprotected')
public_hosts.append({
'hostId': host.get('id'),
'domains': domains,
'target': f"{host.get('forwardScheme', 'http')}://{host.get('forwardHost', '')}:{host.get('forwardPort', '')}",
'coverageStatus': status,
'authentikMatches': matches,
'accessListId': host.get('accessListId'),
})
summary = {
'totalApplications': len(apps.get('items', [])),
'totalProviders': len(providers.get('items', [])) if providers.get('success') else 0,
'totalProxyProviders': len(proxy.get('items', [])) if proxy.get('success') else 0,
'totalPublicHosts': len(public_hosts),
'protectedHosts': sum(1 for item in public_hosts if item['coverageStatus'] == 'protected'),
'likelyProtectedHosts': sum(1 for item in public_hosts if item['coverageStatus'] == 'likely_protected'),
'unprotectedHosts': sum(1 for item in public_hosts if item['coverageStatus'] == 'unprotected'),
'npmStatus': npm_hosts.get('status', 'unknown'),
}
data = {'summary': summary, 'publicHosts': public_hosts}
return {'success': True, 'status': 'ok', 'message': 'auth_coverage_ok', 'summary': summary, 'publicHosts': public_hosts, 'data': data}
def security_warnings(self) -> dict[str, Any]:
return self._memo('security_warnings', self._security_warnings_uncached)
def _security_warnings_uncached(self) -> dict[str, Any]:
warnings: list[dict[str, Any]] = []
if not self.is_configured():
npm_hosts = self._npm_hosts()
if npm_hosts.get('success') and npm_hosts.get('items'):
warnings.append({'type': 'authentik_missing_config_with_public_hosts', 'severity': 'high', 'title': 'Authentik connector is missing while NPM has public hosts', 'evidence': [str(len(npm_hosts.get('items', [])))], 'suggestedAction': 'configure_authentik_connector'})
return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'items': warnings, 'total': len(warnings), 'data': {'items': warnings}}
coverage = self.auth_coverage()
outpost = self.outpost_health()
apps = self.applications()
proxy = self.proxy_providers()
oauth = self.oauth2_providers()
events = self.events()
if not outpost.get('items'):
warnings.append({'type': 'no_outposts', 'severity': 'high', 'title': 'No Authentik outposts reported', 'evidence': [], 'suggestedAction': 'review_outpost_health'})
if outpost.get('embeddedExpected') and not outpost.get('available'):
outpost_status = str(outpost.get('status') or 'unknown')
if outpost.get('reachable'):
warnings.append({
'type': 'embedded_outpost_health_unverified',
'severity': 'info',
'title': 'Embedded outpost is reachable but health telemetry is not reported',
'evidence': [outpost_status, str(outpost.get('reachabilityStatusCode') or '')],
'suggestedAction': 'review_embedded_outpost_telemetry',
})
else:
warnings.append({'type': 'embedded_outpost_unavailable', 'severity': 'warning', 'title': 'Expected embedded outpost is unavailable or unknown', 'evidence': [outpost_status], 'suggestedAction': 'review_embedded_outpost'})
for host in coverage.get('publicHosts', []):
if host.get('coverageStatus') == 'unprotected':
warnings.append({'type': 'public_host_without_authentik', 'severity': 'high', 'title': f"{', '.join(host.get('domains') or [])} has no Authentik mapping", 'evidence': host.get('domains') or [], 'suggestedAction': 'map_host_to_authentik'})
app_slugs = {str(item.get('slug')) for item in apps.get('items', []) if item.get('slug')}
provider_app_slugs = {str(item.get('assignedApplicationSlug')) for item in proxy.get('items', []) if item.get('assignedApplicationSlug')}
for provider in proxy.get('items', []):
if not provider.get('assignedApplicationSlug'):
warnings.append({'type': 'proxy_provider_without_app', 'severity': 'warning', 'title': f"{provider.get('name')} is not assigned to an application", 'evidence': [str(provider.get('externalHost') or provider.get('id'))], 'suggestedAction': 'assign_proxy_provider'})
for app in apps.get('items', []):
slug = str(app.get('slug') or '')
if slug and slug not in provider_app_slugs and not app.get('providerId'):
warnings.append({'type': 'application_without_provider', 'severity': 'warning', 'title': f"{app.get('name')} has no provider mapping", 'evidence': [slug], 'suggestedAction': 'review_application_provider'})
for provider in oauth.get('items', []):
if _as_int(provider.get('redirectUriCount')) >= 4:
warnings.append({'type': 'oauth_many_redirect_uris', 'severity': 'warning', 'title': f"{provider.get('name')} has many redirect URIs", 'evidence': [str(provider.get('redirectUriCount'))], 'suggestedAction': 'review_oauth_redirect_uris'})
if not events.get('items'):
warnings.append({'type': 'no_recent_events', 'severity': 'info', 'title': 'No recent Authentik events returned', 'evidence': [], 'suggestedAction': 'review_event_retention_or_permissions'})
warnings = [item for item in warnings if item.get('evidence') is not app_slugs]
severity_order = {'critical': 3, 'high': 2, 'warning': 1, 'info': 0}
items = sorted(warnings, key=lambda item: severity_order.get(str(item.get('severity')), 0), reverse=True)
status = 'warning' if items else 'ok'
return {'success': True, 'status': status, 'message': 'security_warnings_detected' if items else 'security_warnings_ok', 'items': items, 'total': len(items), 'data': {'items': items}}
def _collection(self, path: str, mapper: Any, message: str) -> dict[str, Any]:
if not self.is_configured():
return self._missing(items=[], total=0)
try:
items = mapper(self._paged(path))
except RuntimeError as exc:
return self._error_payload(exc, items=[], total=0)
return {'success': True, 'status': 'ok', 'message': message, 'total': len(items), 'items': items, 'data': {'items': items}}
@staticmethod
def _version(payload: Any) -> str:
if not isinstance(payload, dict):
return 'unknown'
return str(payload.get('version') or payload.get('build') or payload.get('authentik_version') or 'unknown')
@staticmethod
def _map_applications(raw: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [{
'id': item.get('pk') or item.get('id'),
'name': str(item.get('name') or ''),
'slug': str(item.get('slug') or ''),
'launchUrl': str(item.get('launch_url') or item.get('launchUrl') or item.get('meta_launch_url') or ''),
'providerId': item.get('provider') or item.get('providerId'),
} for item in raw]
@staticmethod
def _map_providers(raw: list[dict[str, Any]]) -> list[dict[str, Any]]:
result = []
for item in raw:
type_text = str(item.get('type') or item.get('component') or '').lower()
provider_type = 'proxy' if 'proxy' in type_text else ('oauth2' if 'oauth' in type_text or 'oidc' in type_text else 'unknown')
result.append({'id': item.get('pk') or item.get('id'), 'name': str(item.get('name') or ''), 'providerType': provider_type, 'assignedApplicationSlug': item.get('assigned_application_slug') or item.get('assignedApplicationSlug')})
return result
@staticmethod
def _map_proxy_providers(raw: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [{
'id': item.get('pk') or item.get('id'),
'name': str(item.get('name') or ''),
'externalHost': str(item.get('external_host') or item.get('externalHost') or ''),
'assignedApplicationSlug': item.get('assigned_application_slug') or item.get('assignedApplicationSlug'),
'mode': str(item.get('mode') or ''),
} for item in raw]
@staticmethod
def _map_oauth2_providers(raw: list[dict[str, Any]]) -> list[dict[str, Any]]:
mapped = []
for item in raw:
redirect_uris = item.get('redirect_uris') or item.get('redirectUris') or []
if isinstance(redirect_uris, str):
uris = [line for line in redirect_uris.splitlines() if line.strip()]
else:
uris = [str(uri) for uri in _as_list(redirect_uris)]
mapped.append({'id': item.get('pk') or item.get('id'), 'name': str(item.get('name') or ''), 'assignedApplicationSlug': item.get('assigned_application_slug') or item.get('assignedApplicationSlug'), 'redirectUriCount': len(uris), 'redirectUris': uris[:10]})
return mapped
@staticmethod
def _map_outposts(raw: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [{
'id': item.get('pk') or item.get('id'),
'name': str(item.get('name') or ''),
'type': str(item.get('type') or item.get('outpost_type') or 'unknown'),
'health': str(item.get('health') or item.get('health_status') or item.get('status') or 'unknown'),
'providerCount': len(_as_list(item.get('providers'))),
} for item in raw]
@staticmethod
def _map_event(raw: dict[str, Any]) -> dict[str, Any]:
user = raw.get('user') if isinstance(raw.get('user'), dict) else {}
return {
'id': raw.get('pk') or raw.get('id'),
'action': str(raw.get('action') or raw.get('event') or ''),
'created': raw.get('created') or raw.get('timestamp'),
'username': str(user.get('username') or raw.get('username') or ''),
'clientIp': AuthentikConnector._mask_ip(raw.get('client_ip') or raw.get('clientIp')),
}
@staticmethod
def _mask_ip(value: Any) -> str:
text = str(value or '')
parts = text.split('.')
if len(parts) == 4:
return '.'.join(parts[:3] + ['x'])
return 'masked' if text else ''
@staticmethod
def _empty_coverage_summary() -> dict[str, Any]:
return {'totalApplications': 0, 'totalProviders': 0, 'totalProxyProviders': 0, 'totalPublicHosts': 0, 'protectedHosts': 0, 'likelyProtectedHosts': 0, 'unprotectedHosts': 0, 'npmStatus': 'unknown'}
@staticmethod
def _missing(**extra: Any) -> dict[str, Any]:
return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'url': _safe_url(), 'required_env_vars': ['AUTHENTIK_URL', 'AUTHENTIK_TOKEN'], 'data': {}, **extra}
@staticmethod
def _error_payload(exc: Exception, **extra: Any) -> dict[str, Any]:
reason = redact_message(str(exc))
status = 'auth_failed' if reason.startswith('auth_failed') else ('missing_config' if reason == 'missing_config' else 'unreachable')
return {'success': False, 'status': status, 'message': reason, 'data': {}, **extra}
@staticmethod
def _npm_hosts() -> dict[str, Any]:
try:
from .nginx_proxy_manager import NginxProxyManagerConnector
return NginxProxyManagerConnector().proxy_hosts()
except Exception as exc:
return {'success': False, 'status': 'unavailable', 'message': redact_message(str(exc)), 'items': []}