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': []}