Initial curated Unraid-AppOps-Gateway source from 3bd6f6fee310125bfbb4d4650389a817f498724d
This commit is contained in:
commit
1dfbca08a1
528 files changed
+131054
No files matched your search
@@ -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': []}
|
||||
Reference in new issue
Block a user