diff --git a/PUBLIC_SOURCE_EXPORT.md b/PUBLIC_SOURCE_EXPORT.md index 84c087a..716748e 100644 --- a/PUBLIC_SOURCE_EXPORT.md +++ b/PUBLIC_SOURCE_EXPORT.md @@ -1,3 +1,3 @@ # Public source export -This source snapshot was generated from the private canonical repository at revision `aaf7d11821a86be65c91fead97a6a4758c4fbacd`. It contains no private Git history or operational evidence. Changes are published from the canonical repository. +This source snapshot was generated from the private canonical repository at revision `68878c3167dacf2b424c16780e60e0d60815e477`. It contains no private Git history or operational evidence. Changes are published from the canonical repository. diff --git a/PUBLIC_SOURCE_MANIFEST.json b/PUBLIC_SOURCE_MANIFEST.json index be7a24d..e2cd8bc 100644 --- a/PUBLIC_SOURCE_MANIFEST.json +++ b/PUBLIC_SOURCE_MANIFEST.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "sourceRevision": "aaf7d11821a86be65c91fead97a6a4758c4fbacd", + "sourceRevision": "68878c3167dacf2b424c16780e60e0d60815e477", "files": [ { "path": ".dockerignore", @@ -129,8 +129,8 @@ }, { "path": "backend/app/api/routes_enrollments.py", - "sha256": "5da9f18add09638f8a2d8dddc7c92c4790a94f76e9ead4639dfb35a9b8f247cb", - "bytes": 9878 + "sha256": "ea1c582f0b2349109cea43181793a39ffa37c9b8c740d417236e4b0db24fcac9", + "bytes": 15897 }, { "path": "backend/app/api/routes_exposure_settings.py", @@ -279,8 +279,8 @@ }, { "path": "backend/app/connectors/authentik.py", - "sha256": "2bac45ebbee05c3878ec6eb03e92145d971ae9ae875ed7d90ba609e1329e38b2", - "bytes": 92086 + "sha256": "c6ded7643ddf956f0731ac38a5c031082474517456836d87325217a85220df7d", + "bytes": 99247 }, { "path": "backend/app/connectors/base.py", @@ -354,8 +354,8 @@ }, { "path": "backend/app/core/config.py", - "sha256": "56ea46b05cad49d1d7f19b6af06ce80036723c0f75317a33af42a50b9521a289", - "bytes": 27330 + "sha256": "4ac7b1c6c546866073cb8479692ad2c521f854a0a133ba08b6bcc716a8562f20", + "bytes": 28166 }, { "path": "backend/app/core/network_security.py", @@ -399,8 +399,8 @@ }, { "path": "backend/app/main.py", - "sha256": "17aa07c08bbe9eecbc0c100a6b573ef7db4b682e9bd7c6e1cb5982f72966d853", - "bytes": 18104 + "sha256": "9d93cb8c2dc6d64635fad88f25d3bdbcd2d38dde017449b59e58d99e16fbb101", + "bytes": 18706 }, { "path": "backend/app/mcp_server.py", @@ -544,8 +544,8 @@ }, { "path": "backend/app/services/deployment_control_plane.py", - "sha256": "4443d9a1581cf24c1cc3eccaaa1e0e67527228e28a873924ab0c3591fd67da99", - "bytes": 25070 + "sha256": "d49434d6c1e928a4394e1fa32c0302e54eaafc8b2e5fb817df91c99c4347fdad", + "bytes": 26675 }, { "path": "backend/app/services/deployment_target_registry.py", @@ -557,10 +557,15 @@ "sha256": "cc2afc7e8c216a1683a90c2f8776844c48ea766a2597907a701868eda1cac2ea", "bytes": 13517 }, + { + "path": "backend/app/services/enrollment_oidc_settings.py", + "sha256": "a8d1fbb197834920f22498b9e4f13731336fcd3970d544c6c8ac6e91482e9810", + "bytes": 4081 + }, { "path": "backend/app/services/enrollment_plane.py", - "sha256": "d04fd7a4399c698705719e9748130e450702f541c3e69b003480b4e5621a256c", - "bytes": 63491 + "sha256": "66ac8ffbc3015e76c76eb62313184933fe4696a3238fda97529f9009698c0c6c", + "bytes": 66255 }, { "path": "backend/app/services/enrollment_profiles.py", @@ -829,8 +834,8 @@ }, { "path": "backend/app/services/user_access.py", - "sha256": "61c994dc09c413f04838015f8ca0ea6f75d45aec1b63fa21716e3be69adc7c3f", - "bytes": 9918 + "sha256": "7801c9775889daecea422ac0b06f36f33268f2a4472d19e11bc9053f15e93b21", + "bytes": 9965 }, { "path": "backend/app/services/voice_catalog.py", @@ -914,8 +919,8 @@ }, { "path": "backend/tests/test_authentik_connector.py", - "sha256": "ac1056893dfa791e074f9962fb9752cd65944af16c41437a8d1824c1b144dd85", - "bytes": 28251 + "sha256": "54b24a90e7238aaed22d0dfbae70d9772006d20cdb9dbd43d781af48892dcd94", + "bytes": 33759 }, { "path": "backend/tests/test_ci_job_docker.py", @@ -1009,8 +1014,8 @@ }, { "path": "backend/tests/test_deployment_control_plane.py", - "sha256": "bf4cb458db21e33ee95fdf601e7eee011fb8e5e9557a9389a269e4fb784bbe44", - "bytes": 11508 + "sha256": "732e7a029a2f720702589279a2163864a969323979c1ab539c997fef13ba77f8", + "bytes": 18941 }, { "path": "backend/tests/test_deployment_hygiene.py", @@ -1027,10 +1032,15 @@ "sha256": "4c60a43935f668c87f76e506dde319f0632308855ec508e81af85840b694edfb", "bytes": 1175 }, + { + "path": "backend/tests/test_enrollment_oidc_settings.py", + "sha256": "233b38d6c87af71fd50056908fe88e7addf1228773e5e99a3fa258083e5f2407", + "bytes": 13131 + }, { "path": "backend/tests/test_enrollment_plane.py", - "sha256": "d886402e194b2e76d1eac67761e9eb801af525580261d16cca79ed6273bf01d9", - "bytes": 41160 + "sha256": "3dcee27db49b04d5d567a22596b3c9447adbb812a20922885d393eab5ce00c31", + "bytes": 44712 }, { "path": "backend/tests/test_existing_registered_target_reconciliation.py", @@ -1819,8 +1829,8 @@ }, { "path": "src/api/liveClient.ts", - "sha256": "f2cf782e3bbdcbe737dee523d47ba6f0d655048a8253a117525872486f8f1553", - "bytes": 62680 + "sha256": "0e0d5d74648b475eff227798bf87fa8b39512afb08ffc6a88e5980268f3401fd", + "bytes": 63128 }, { "path": "src/api/userSession.test.ts", @@ -1862,6 +1872,11 @@ "sha256": "7cb7bba856f0e32e1443171ac504c27b690ecfaa46fb17a8030127bb420b5180", "bytes": 1676 }, + { + "path": "src/components/EnrollmentOidcSettings.tsx", + "sha256": "79e22a529d0f737ab032f3363151fbf7063f703d8b7fea52b22bb98bf6064392", + "bytes": 5097 + }, { "path": "src/components/ExposureSettings.tsx", "sha256": "48e9660fb9358b5af4558406c5072675728be4830c1b4dc2ebfd47d9743f1351", @@ -3139,8 +3154,8 @@ }, { "path": "src/pages/Settings.tsx", - "sha256": "14dd631e2cdbeb39e34d3a587106a434a281f71b9d5c86b5aefcac12d8674ad0", - "bytes": 28242 + "sha256": "e012f66a6019b0118e28c7064331cceb0c93fe8b8ad0c62588650730d4de4325", + "bytes": 28357 }, { "path": "src/pages/Setup.tsx", @@ -3214,8 +3229,8 @@ }, { "path": "src/types/appops.ts", - "sha256": "75e999b241d6196950fbf0b9decc82339e044fd6ced75513d462b9187a85ab24", - "bytes": 74346 + "sha256": "21c5cac6b55c7fd402228de18619eb9066d811fd0238840b2b52a809103b43da", + "bytes": 74847 }, { "path": "src/utils/toast.ts", diff --git a/backend/app/api/routes_enrollments.py b/backend/app/api/routes_enrollments.py index 112528f..11c7030 100644 --- a/backend/app/api/routes_enrollments.py +++ b/backend/app/api/routes_enrollments.py @@ -33,6 +33,11 @@ from ..services.repopulse_adoption_journal import ( get_repopulse_adoption_status, ) from ..services.user_access import current_user +from ..connectors.authentik import AuthentikConnector +from ..services.setup_service import apply_stored_connector_settings +from ..services.enrollment_oidc_settings import ( + OIDC_TEMPLATE_SETTING_KEY, EnrollmentOidcProfileV1, load_profile, trusted_issuer_origin, +) def _require_operator(request: Request) -> str: @@ -225,6 +230,110 @@ def update_enrollment_storage_root( return EnrollmentPlane.trust_status(session) +def _oidc_profile_status(session: Session): + apply_stored_connector_settings(session, {"authentik"}) + profile = load_profile(session) + trust = EnrollmentPlane.get_trust_config(session) + matches = bool(profile and trust and profile.issuer == trusted_issuer_origin() + and AuthentikConnector.deployment_oidc_origin_matches(profile.issuer) and ( + trust.oidc_issuer == profile.issuer + and trust.administrator_group == profile.administrator_group + and trust.support_agent_group == profile.support_agent_group + and trust.read_only_group == profile.read_only_group + )) + return {"configured": matches, "issuerOrigin": trusted_issuer_origin(), "profile": profile.model_dump(mode="json") if profile else None} + + +@router.get("/trust/oidc-profile") +def enrollment_oidc_profile_status(session: EnrollmentSession, request: Request): + if current_user(request).role != "admin": + raise HTTPException(status_code=403, detail="Administrator role is required.") + return _oidc_profile_status(session) + + +@router.patch("/trust/oidc-profile") +def update_enrollment_oidc_profile( + payload: EnrollmentOidcProfileV1, session: EnrollmentSession, request: Request, +): + if current_user(request).role != "admin": + raise HTTPException(status_code=403, detail="Administrator role is required.") + if payload.issuer != trusted_issuer_origin(): + raise HTTPException(status_code=409, detail="oidc_issuer_mismatch") + trust_row = session.get(AppSetting, TRUST_SETTING_KEY) + binding_row = session.get(AppSetting, OIDC_TEMPLATE_SETTING_KEY) + if trust_row is None or trust_row.is_secret: + raise HTTPException(status_code=409, detail="enrollment_trust_not_configured") + original = deepcopy(trust_row.value_json) + trust_updated_at = trust_row.updated_at + binding_original = deepcopy(binding_row.value_json) if binding_row else None + binding_updated_at = binding_row.updated_at if binding_row else None + if binding_row and binding_row.is_secret: + raise HTTPException(status_code=409, detail="oidc_profile_binding_invalid") + try: + updated = EnrollmentTrustConfigV1.model_validate({ + **original, + "oidc_issuer": payload.issuer, "oidc_role_claim": "roles", + "administrator_group": payload.administrator_group, + "support_agent_group": payload.support_agent_group, + "read_only_group": payload.read_only_group, + }) + except ValueError: + raise HTTPException(status_code=409, detail="enrollment_trust_changed") from None + apply_stored_connector_settings(session, {"authentik"}) + connector = AuthentikConnector() + if not connector.deployment_oidc_origin_matches(payload.issuer): + raise HTTPException(status_code=409, detail="oidc_connector_issuer_mismatch") + connector.configure_deployment_oidc_template(payload.template_config(), issuer_origin=payload.issuer) + template = connector.validate_deployment_oidc_template( + role_claim="roles", + role_groups=(payload.administrator_group, payload.support_agent_group, payload.read_only_group), + ) + if template.get("status") != "ready" or not connector.is_configured(): + raise HTTPException(status_code=409, detail="oidc_profile_not_verified") + groups = connector.groups() + names = {str(g.get("name") or "") for g in groups.get("items", []) if isinstance(g, dict)} + if groups.get("success") is not True or not {payload.administrator_group, payload.support_agent_group, payload.read_only_group}.issubset(names): + raise HTTPException(status_code=409, detail="oidc_profile_not_verified") + value = payload.model_dump(mode="json") + try: + changed = session.execute(sql_update(AppSetting) + .where(AppSetting.key == TRUST_SETTING_KEY) + .where(AppSetting.updated_at == trust_updated_at) + .where(AppSetting.value_json == original) + .values(value_json=updated.model_dump(mode="json"), updated_at=utc_now()) + .execution_options(synchronize_session=False)) + if changed.rowcount != 1: + raise HTTPException(status_code=409, detail="enrollment_trust_changed") + if binding_row is None: + session.execute(sql_insert(AppSetting).values( + key=OIDC_TEMPLATE_SETTING_KEY, value_json=value, is_secret=False, updated_at=utc_now(), + )) + else: + changed = session.execute(sql_update(AppSetting) + .where(AppSetting.key == OIDC_TEMPLATE_SETTING_KEY) + .where(AppSetting.updated_at == binding_updated_at) + .where(AppSetting.value_json == binding_original) + .values(value_json=value, updated_at=utc_now()) + .execution_options(synchronize_session=False)) + if changed.rowcount != 1: + raise HTTPException(status_code=409, detail="oidc_profile_changed") + log_event(session, source="settings", identity="manual", + action_id="enrollment.trust.oidc_profile.update", target="tower/deployment-controller", + risk="high", policy_decision="allowed", result="updated", target_type="controller", + reason="Administrator bound verified existing generic OIDC templates and three empty-or-existing role groups.", + before_state={"configured": binding_original is not None}, + after_state={"configured": True, "issuer": payload.issuer}, + ) + except HTTPException: + session.rollback() + raise + except SQLAlchemyError: + session.rollback() + raise HTTPException(status_code=409, detail="oidc_profile_changed") from None + session.expire_all() + return _oidc_profile_status(session) + + @router.post("") def request_enrollment( payload: EnrollmentRequestV1, diff --git a/backend/app/connectors/authentik.py b/backend/app/connectors/authentik.py index 3fe0332..fbb6342 100644 --- a/backend/app/connectors/authentik.py +++ b/backend/app/connectors/authentik.py @@ -2,11 +2,12 @@ from __future__ import annotations import hashlib import hmac +import json import re from collections.abc import Callable from dataclasses import dataclass from typing import Any -from urllib.parse import quote, urljoin, urlparse +from urllib.parse import quote, urljoin, urlparse, urlsplit from uuid import RFC_4122, UUID import httpx @@ -35,6 +36,10 @@ from ..schemas.forge_oidc import ( from .base import BaseConnector _FORGE_OIDC_AUTHENTIK_URL = 'https://auth.example-host.com' +DEPLOYMENT_OIDC_PROFILE_MAPPING_EXPRESSION = '''return delete_none_values({ + "name": request.user.name, + "preferred_username": request.user.username, +})''' _FORGE_OIDC_SCOPE_MAPPING_IDS = ( 'AUTHENTIK_FORGE_OIDC_OPENID_CLAIM_MAPPING', 'AUTHENTIK_FORGE_OIDC_EMAIL_CLAIM_MAPPING', @@ -248,6 +253,39 @@ class AuthentikConnector(BaseConnector): def __init__(self): super().__init__() self._read_cache: dict[str, dict[str, Any]] = {} + self._deployment_template_config: dict[str, str] | None = None + self._deployment_template_issuer: str | None = None + + def configure_deployment_oidc_template(self, config: dict[str, str] | None, *, issuer_origin: str | None = None) -> None: + self._deployment_template_config = dict(config) if config is not None else None + self._deployment_template_issuer = issuer_origin + self._read_cache.clear() + + @staticmethod + def deployment_oidc_origin_matches(issuer_origin: str) -> bool: + """Root templates use the established tenant over verified HTTPS.""" + apis = dict(settings.apis) + return AuthentikConnector._deployment_oidc_endpoint_matches(issuer_origin, apis) + + @staticmethod + def _deployment_oidc_endpoint_matches(issuer_origin: str, apis: dict[str, Any]) -> bool: + try: + endpoint = urlsplit(str(apis.get('AUTHENTIK_URL') or '')) + origin = f'{endpoint.scheme}://{endpoint.netloc.lower()}' + return bool( + endpoint.scheme == 'https' and endpoint.hostname + and not endpoint.username and not endpoint.password + and endpoint.path in {'', '/'} and not endpoint.query and not endpoint.fragment + and origin == issuer_origin + and _as_bool(apis.get('AUTHENTIK_VERIFY_TLS'), False) + ) + except ValueError: + return False + + def _deployment_oidc_config(self) -> dict[str, Any]: + if self._deployment_template_issuer and not self.deployment_oidc_origin_matches(self._deployment_template_issuer): + return {} + return self._deployment_template_config if self._deployment_template_config is not None else settings.apis def _memo(self, key: str, factory: Any) -> dict[str, Any]: if key not in self._read_cache: @@ -258,15 +296,18 @@ class AuthentikConnector(BaseConnector): return bool(settings.apis.get('AUTHENTIK_URL') and settings.apis.get('AUTHENTIK_TOKEN')) def _config(self) -> _AuthentikConfig | None: - if not self.is_configured(): + apis = dict(settings.apis) + if not (apis.get('AUTHENTIK_URL') and apis.get('AUTHENTIK_TOKEN')): + return None + if self._deployment_template_issuer and not self._deployment_oidc_endpoint_matches(self._deployment_template_issuer, apis): 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), + url=str(apis['AUTHENTIK_URL']).rstrip('/'), + token=_token_value(apis['AUTHENTIK_TOKEN']), + verify_tls=_as_bool(apis.get('AUTHENTIK_VERIFY_TLS'), False), + timeout_seconds=_as_float(apis.get('AUTHENTIK_TIMEOUT_SECONDS'), 10.0), + outpost_url=str(apis.get('AUTHENTIK_OUTPOST_URL') or '').strip().rstrip('/') or None, + expect_embedded_outpost=_as_bool(apis.get('AUTHENTIK_EXPECT_EMBEDDED_OUTPOST'), False), ) def _request(self, path: str, params: dict[str, Any] | None = None) -> Any: @@ -448,15 +489,20 @@ class AuthentikConnector(BaseConnector): ) @staticmethod - def deployment_oidc_template_status(*, role_claim: str = 'roles') -> dict[str, Any]: + def deployment_oidc_template_status(*, role_claim: str = 'roles', template_config: dict[str, Any] | None = None) -> dict[str, Any]: """Report only whether the server-owned OIDC template is configured.""" + config = settings.apis if template_config is None else template_config required = ( 'AUTHENTIK_OIDC_AUTHORIZATION_FLOW', 'AUTHENTIK_OIDC_INVALIDATION_FLOW', 'AUTHENTIK_OIDC_GROUP_CLAIM_MAPPING', + 'AUTHENTIK_OIDC_OPENID_CLAIM_MAPPING', + 'AUTHENTIK_OIDC_EMAIL_CLAIM_MAPPING', + 'AUTHENTIK_OIDC_PROFILE_CLAIM_MAPPING', + 'AUTHENTIK_OIDC_SIGNING_KEY_ID', ) - 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() + missing = [name for name in required if not str(config.get(name) or '').strip()] + configured_claim = str(config.get('AUTHENTIK_OIDC_ROLE_CLAIM') or '').strip() if configured_claim != role_claim: missing.append('AUTHENTIK_OIDC_ROLE_CLAIM') return { @@ -464,6 +510,68 @@ class AuthentikConnector(BaseConnector): 'missing': missing, } + def validate_deployment_oidc_template( + self, *, role_claim: str, role_groups: tuple[str, ...], + ) -> dict[str, Any]: + """Read the selected template; never return remote bodies or expressions.""" + config = self._deployment_oidc_config() + configured = self.deployment_oidc_template_status(role_claim=role_claim, template_config=config) + if configured['status'] != 'ready': + return configured + if role_claim != 'roles' or len(set(role_groups)) != 3 or any(not group for group in role_groups): + return {'status': 'unavailable', 'reason': 'oidc_role_model_invalid'} + authorization_id = str(config['AUTHENTIK_OIDC_AUTHORIZATION_FLOW']) + invalidation_id = str(config['AUTHENTIK_OIDC_INVALIDATION_FLOW']) + mapping_id = str(config['AUTHENTIK_OIDC_GROUP_CLAIM_MAPPING']) + expression = ( + 'return {"roles": sorted({group.name for group in request.user.ak_groups.all() ' + 'if group.name in ' + json.dumps(sorted(role_groups)) + '})}' + ) + try: + # Flow detail is keyed by slug; OAuth providers reference UUIDs. + flows = self._paged('/flows/instances/') + for identifier, designation in ( + (authorization_id, 'authorization'), (invalidation_id, 'invalidation'), + ): + matching_flows = [flow for flow in flows if isinstance(flow, dict) and _authentik_id(flow) == identifier] + if (len(matching_flows) != 1 + or _authentik_enum(matching_flows[0].get('designation')) != designation): + return {'status': 'unavailable', 'reason': 'oidc_flow_template_invalid'} + mapping = self._request(f'/propertymappings/provider/scope/{quote(mapping_id, safe="")}/') + if (not isinstance(mapping, dict) or str(mapping.get('pk')) != mapping_id + or mapping.get('scope_name') != role_claim + or str(mapping.get('expression') or '').strip() != expression): + return {'status': 'unavailable', 'reason': 'oidc_role_mapping_invalid'} + for scope in ('openid', 'email', 'profile'): + identifier = str(config[f'AUTHENTIK_OIDC_{scope.upper()}_CLAIM_MAPPING']) + mapping = self._request(f'/propertymappings/provider/scope/{quote(identifier, safe="")}/') + if (not isinstance(mapping, dict) or _authentik_id(mapping) != identifier + or mapping.get('scope_name') != scope): + return {'status': 'unavailable', 'reason': 'oidc_identity_scope_invalid'} + # Every attached mapping can contribute token claims. Scope + # names alone cannot prevent an alternate administrator claim. + if scope in {'openid', 'email'}: + identity_matches = _forge_oidc_scope_mapping_matches( + mapping, mapping_id=identifier, scope=scope, + ) + else: + identity_matches = ( + not mapping.get('managed') + and str(mapping.get('expression') or '').replace('\r\n', '\n').strip() + == DEPLOYMENT_OIDC_PROFILE_MAPPING_EXPRESSION + ) + if not identity_matches: + return {'status': 'unavailable', 'reason': 'oidc_identity_scope_invalid'} + key_id = str(config['AUTHENTIK_OIDC_SIGNING_KEY_ID']) + keys = self._paged('/crypto/certificatekeypairs/', {'has_key': True}) + matching = [key for key in keys if _authentik_id(key) == key_id] + if (len(matching) != 1 or _authentik_enum(matching[0].get('key_type')) != 'rsa' + or matching[0].get('private_key_available') is not True): + return {'status': 'unavailable', 'reason': 'oidc_signing_key_unavailable'} + except RuntimeError: + return {'status': 'unavailable', 'reason': 'oidc_template_unreachable'} + return {'status': 'ready', 'missing': []} + def ensure_deployment_oidc_application( self, *, @@ -489,11 +597,17 @@ class AuthentikConnector(BaseConnector): 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: + config = self._deployment_oidc_config() + authorization_flow = str(config.get('AUTHENTIK_OIDC_AUTHORIZATION_FLOW') or '').strip() + invalidation_flow = str(config.get('AUTHENTIK_OIDC_INVALIDATION_FLOW') or '').strip() + group_claim_mapping = str(config.get('AUTHENTIK_OIDC_GROUP_CLAIM_MAPPING') or '').strip() + identity_mappings = [str(config.get(f'AUTHENTIK_OIDC_{scope}_CLAIM_MAPPING') or '').strip() + for scope in ('OPENID', 'EMAIL', 'PROFILE')] + signing_key = str(config.get('AUTHENTIK_OIDC_SIGNING_KEY_ID') or '').strip() + if not authorization_flow or not invalidation_flow or not group_claim_mapping or not all(identity_mappings) or not signing_key: return {'success': False, 'status': 'blocked', 'message': 'oidc_template_not_configured'} + expected_mappings = [*identity_mappings, group_claim_mapping] + expected_redirects = [{'matching_mode': 'strict', 'url': redirect_uri}] try: groups = self._paged('/core/groups/') group_names = {str(item.get('name') or '') for item in groups} @@ -516,15 +630,17 @@ class AuthentikConnector(BaseConnector): 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 provider_redirects != expected_redirects + or not isinstance(provider_mappings, list) + or not all(isinstance(item, str) for item in provider_mappings) + or set(provider_mappings) != set(expected_mappings) + or _authentik_id(provider.get('signing_key')) != signing_key + or provider.get('issuer_mode') != 'per_provider' or str(provider.get('authorization_flow') or '') != authorization_flow or str(provider.get('invalidation_flow') or '') != invalidation_flow ): @@ -547,8 +663,9 @@ class AuthentikConnector(BaseConnector): 'client_type': 'confidential', 'client_id': application_slug, 'client_secret': client_secret, - 'redirect_uris': [redirect_uri], - 'property_mappings': [group_claim_mapping], + 'redirect_uris': expected_redirects, + 'property_mappings': expected_mappings, + 'signing_key': signing_key, 'include_claims_in_id_token': True, 'sub_mode': 'hashed_user_id', 'issuer_mode': 'per_provider', diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 0025dbf..c61106b 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -286,6 +286,10 @@ class Settings: "AUTHENTIK_OIDC_AUTHORIZATION_FLOW": _env_value("AUTHENTIK_OIDC_AUTHORIZATION_FLOW", None), "AUTHENTIK_OIDC_INVALIDATION_FLOW": _env_value("AUTHENTIK_OIDC_INVALIDATION_FLOW", None), "AUTHENTIK_OIDC_GROUP_CLAIM_MAPPING": _env_value("AUTHENTIK_OIDC_GROUP_CLAIM_MAPPING", None), + "AUTHENTIK_OIDC_OPENID_CLAIM_MAPPING": _env_value("AUTHENTIK_OIDC_OPENID_CLAIM_MAPPING", None), + "AUTHENTIK_OIDC_EMAIL_CLAIM_MAPPING": _env_value("AUTHENTIK_OIDC_EMAIL_CLAIM_MAPPING", None), + "AUTHENTIK_OIDC_PROFILE_CLAIM_MAPPING": _env_value("AUTHENTIK_OIDC_PROFILE_CLAIM_MAPPING", None), + "AUTHENTIK_OIDC_SIGNING_KEY_ID": _env_value("AUTHENTIK_OIDC_SIGNING_KEY_ID", None), # The reviewed group mapping must emit this exact claim. The # enrollment schema only accepts its bounded role contract. "AUTHENTIK_OIDC_ROLE_CLAIM": _env_value("AUTHENTIK_OIDC_ROLE_CLAIM", None), @@ -407,6 +411,10 @@ class Settings: "AUTHENTIK_OIDC_AUTHORIZATION_FLOW": bool(self.apis["AUTHENTIK_OIDC_AUTHORIZATION_FLOW"]), "AUTHENTIK_OIDC_INVALIDATION_FLOW": bool(self.apis["AUTHENTIK_OIDC_INVALIDATION_FLOW"]), "AUTHENTIK_OIDC_GROUP_CLAIM_MAPPING": bool(self.apis["AUTHENTIK_OIDC_GROUP_CLAIM_MAPPING"]), + "AUTHENTIK_OIDC_OPENID_CLAIM_MAPPING": bool(self.apis["AUTHENTIK_OIDC_OPENID_CLAIM_MAPPING"]), + "AUTHENTIK_OIDC_EMAIL_CLAIM_MAPPING": bool(self.apis["AUTHENTIK_OIDC_EMAIL_CLAIM_MAPPING"]), + "AUTHENTIK_OIDC_PROFILE_CLAIM_MAPPING": bool(self.apis["AUTHENTIK_OIDC_PROFILE_CLAIM_MAPPING"]), + "AUTHENTIK_OIDC_SIGNING_KEY_ID": bool(self.apis["AUTHENTIK_OIDC_SIGNING_KEY_ID"]), "AUTHENTIK_OIDC_ROLE_CLAIM": bool(self.apis["AUTHENTIK_OIDC_ROLE_CLAIM"]), "AUTHENTIK_FORGE_OIDC_SIGNING_KEY_ID": bool(self.apis["AUTHENTIK_FORGE_OIDC_SIGNING_KEY_ID"]), "AUTHENTIK_FORGE_OIDC_BINDING_VALID": bool(self.apis["AUTHENTIK_FORGE_OIDC_BINDING_VALID"]), diff --git a/backend/app/main.py b/backend/app/main.py index 6a67478..203175a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -12,6 +12,8 @@ from uuid import uuid4 from fastapi import Depends, FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, JSONResponse +from fastapi.exceptions import RequestValidationError +from fastapi.exception_handlers import request_validation_exception_handler from fastapi.staticfiles import StaticFiles from sqlmodel import Session from starlette.types import ASGIApp, Message, Receive, Scope, Send @@ -361,6 +363,15 @@ app.include_router(setup_router) app.include_router(runtime_router) app.include_router(deployments_router) app.include_router(enrollments_router) + + +@app.exception_handler(RequestValidationError) +async def validation_error_response(request, exc): + # Root template input accepts no credentials. Never echo an accidentally + # submitted secret through Pydantic's validation-error input field. + if request.url.path == "/api/enrollments/trust/oidc-profile": + return JSONResponse(status_code=422, content={"detail": "invalid_oidc_profile"}) + return await request_validation_exception_handler(request, exc) app.include_router(integrations_router) app.include_router(n8n_router) app.include_router(knowledge_router) diff --git a/backend/app/services/deployment_control_plane.py b/backend/app/services/deployment_control_plane.py index 5a2faed..ab10885 100644 --- a/backend/app/services/deployment_control_plane.py +++ b/backend/app/services/deployment_control_plane.py @@ -22,9 +22,10 @@ from ..connectors.portainer import PortainerConnector from ..connectors.unraid import UnraidConnector from ..core.time import utc_now from ..db.models import DeploymentJob, Policy -from ..schemas.deployment import DeploymentPlanV1 +from ..schemas.deployment import DeploymentPlanV1, OidcProvisioningV1 from .audit_logger import log_event from .deployment_broker import DeploymentBrokerClient +from .enrollment_oidc_settings import bind_template, load_profile from .policy_engine import evaluate_policy from .secret_store import get_secret, has_secret, secret_storage_status @@ -76,6 +77,29 @@ class DeploymentControlPlane: self.authentik = authentik or AuthentikConnector() self.unraid = unraid or UnraidConnector() + def _oidc_template_ready(self, session: Session, oidc: OidcProvisioningV1) -> bool: + bind_template(session, self.authentik) + groups = ( + oidc.roles.administrator_group, + oidc.roles.support_agent_group, + oidc.roles.read_only_group, + ) + profile = load_profile(session) + if profile and groups != ( + profile.administrator_group, + profile.support_agent_group, + profile.read_only_group, + ): + return False + template = self.authentik.validate_deployment_oidc_template( + role_claim=oidc.role_claim, role_groups=groups, + ) + if template.get("status") != "ready": + return False + inventory = self.authentik.groups() + names = {str(item.get("name") or "") for item in inventory.get("items", []) if isinstance(item, dict)} + return inventory.get("success") is True and set(groups).issubset(names) + def preflight( self, session: Session, @@ -139,6 +163,12 @@ class DeploymentControlPlane: checks.append(_check("proxy_hostname", "blocked", "Requested hostname is owned by a different NPM target.")) if plan.oidc: + ready = self._oidc_template_ready(session, plan.oidc) + checks.append(_check( + "oidc_template", "passed" if ready else "blocked", + "Requested OIDC claim, groups and root template are verified." if ready + else "Requested OIDC claim, groups or root template cannot be verified.", + )) auth_status = self.authentik.test_connection() if auth_status.get("status") != "connected": checks.append(_check("oidc_provider", "blocked", "Authentik is unavailable for the requested OIDC plan.")) @@ -254,6 +284,8 @@ class DeploymentControlPlane: """ evidence: dict[str, Any] = {} if plan.oidc: + if not self._oidc_template_ready(session, plan.oidc): + return False, evidence, "Requested OIDC claim, groups or root template cannot be verified after deployment." client_secret = get_secret(session, plan.oidc.client_secret_ref, touch=False) if not client_secret: return False, evidence, "Referenced OIDC client secret is unavailable after deployment." diff --git a/backend/app/services/enrollment_oidc_settings.py b/backend/app/services/enrollment_oidc_settings.py new file mode 100644 index 0000000..fdf6eb4 --- /dev/null +++ b/backend/app/services/enrollment_oidc_settings.py @@ -0,0 +1,103 @@ +"""Root-owned generic enrollment template, separate from Forge and user SSO.""" +from __future__ import annotations + +import re +from typing import Literal +from uuid import UUID +from urllib.parse import urlsplit + +from pydantic import BaseModel, ConfigDict, field_validator, model_validator +from sqlmodel import Session + +from ..connectors.authentik import AuthentikConnector +from ..db.models import AppSetting +from ..schemas.enrollment import EnrollmentTrustConfigV1 +from ..core.config import settings + +OIDC_TEMPLATE_SETTING_KEY = 'deployment_enrollment:oidc_template' +TEMPLATE_FIELDS = { + 'authorization_flow': 'AUTHENTIK_OIDC_AUTHORIZATION_FLOW', + 'invalidation_flow': 'AUTHENTIK_OIDC_INVALIDATION_FLOW', + 'openid_mapping': 'AUTHENTIK_OIDC_OPENID_CLAIM_MAPPING', + 'email_mapping': 'AUTHENTIK_OIDC_EMAIL_CLAIM_MAPPING', + 'profile_mapping': 'AUTHENTIK_OIDC_PROFILE_CLAIM_MAPPING', + 'roles_mapping': 'AUTHENTIK_OIDC_GROUP_CLAIM_MAPPING', + 'signing_key': 'AUTHENTIK_OIDC_SIGNING_KEY_ID', +} + + +def trusted_issuer_origin() -> str | None: + """Use AppOps' established native Authentik SSO trust; never caller URLs.""" + try: + value = urlsplit(settings.authentik_issuer or '') + except ValueError: + return None + if value.scheme != 'https' or not value.netloc or value.username or value.password: + return None + return f'https://{value.netloc.lower()}' + + +class EnrollmentOidcProfileV1(BaseModel): + model_config = ConfigDict(extra='forbid', frozen=True) + + issuer: str + authorization_flow: UUID + invalidation_flow: UUID + openid_mapping: UUID + email_mapping: UUID + profile_mapping: UUID + roles_mapping: UUID + signing_key: UUID + administrator_group: Literal['Administrator'] = 'Administrator' + support_agent_group: Literal['SupportAgent'] = 'SupportAgent' + read_only_group: Literal['ReadOnly'] = 'ReadOnly' + + @field_validator('issuer') + @classmethod + def issuer_origin(cls, value: str) -> str: + if not re.fullmatch(r'https://[a-z0-9]+(?:[.-][a-z0-9]+)*(?:/)?', value): + raise ValueError('An HTTPS Authentik origin without credentials or application path is required') + return value.rstrip('/') + + @model_validator(mode='after') + def separate_mappings(self): + if len({self.openid_mapping, self.email_mapping, self.profile_mapping, self.roles_mapping}) != 4: + raise ValueError('Four distinct scope mappings are required') + return self + + def template_config(self) -> dict[str, str]: + return {**{env: str(getattr(self, field)) for field, env in TEMPLATE_FIELDS.items()}, 'AUTHENTIK_OIDC_ROLE_CLAIM': 'roles'} + + +def load_profile(session: Session) -> EnrollmentOidcProfileV1 | None: + row = session.get(AppSetting, OIDC_TEMPLATE_SETTING_KEY) + if row is None or row.is_secret: + return None + try: + return EnrollmentOidcProfileV1.model_validate(row.value_json) + except ValueError: + return None + + +def bind_template(session: Session, connector: AuthentikConnector) -> None: + """A corrupt stored binding blocks fallback; never mutate global/Forge IDs.""" + row = session.get(AppSetting, OIDC_TEMPLATE_SETTING_KEY) + if row is None: + connector.configure_deployment_oidc_template(None) + return + profile = load_profile(session) + trust_row = session.get(AppSetting, 'deployment_enrollment:trust') + try: + trust = EnrollmentTrustConfigV1.model_validate(trust_row.value_json) if trust_row and not trust_row.is_secret else None + except ValueError: + trust = None + if not trust or not profile or profile.issuer != trusted_issuer_origin() or ( + trust.oidc_issuer != profile.issuer + or trust.administrator_group != profile.administrator_group + or trust.support_agent_group != profile.support_agent_group + or trust.read_only_group != profile.read_only_group + ): + profile = None + connector.configure_deployment_oidc_template( + profile.template_config() if profile else {}, issuer_origin=profile.issuer if profile else None, + ) diff --git a/backend/app/services/enrollment_plane.py b/backend/app/services/enrollment_plane.py index 49b6b41..f93b812 100644 --- a/backend/app/services/enrollment_plane.py +++ b/backend/app/services/enrollment_plane.py @@ -14,6 +14,7 @@ import re import secrets import uuid from typing import Any, Literal +from urllib.parse import urlsplit, urlunsplit from sqlalchemy.exc import IntegrityError from sqlmodel import Session, select @@ -30,6 +31,7 @@ from ..schemas.enrollment import ( EnrollmentTrustConfigV1, ) from .audit_logger import log_event +from .enrollment_oidc_settings import bind_template from .deployment_broker import DeploymentBrokerClient from .enrollment_profiles import PROFILE_VERSION, profile_contract, profile_summary from .secret_store import ( @@ -155,6 +157,22 @@ def _enrollment_secret_store_ready(storage: dict[str, Any]) -> bool: return bool(storage.get("productionReady")) +def _application_oidc_issuer(issuer: str | None, application_id: str | None = None) -> str | None: + """Bind Authentik's per-provider issuer to this application's own client.""" + if not issuer: + return None + parsed = urlsplit(issuer) + path = parsed.path.rstrip('/') + if application_id is None: + if path not in {'', '/application/o'} and not re.fullmatch(r'/application/o/[a-z][a-z0-9-]{1,62}', path): + return None + return urlunsplit((parsed.scheme, parsed.netloc, path + '/', '', '')) + expected_path = f"/application/o/{application_id}" + if parsed.path.rstrip("/") not in {"", "/application/o", expected_path}: + return None + return urlunsplit((parsed.scheme, parsed.netloc, expected_path + "/", "", "")) + + class EnrollmentPlane: def __init__( self, @@ -222,6 +240,8 @@ class EnrollmentPlane: def capabilities(self, session: Session) -> dict[str, Any]: trust = self.get_trust_config(session) + if isinstance(self.authentik, AuthentikConnector): + bind_template(session, self.authentik) storage = secret_storage_status() configured = trust is not None bootstrap = bool(trust and has_secret(session, trust.bootstrap_secret_ref)) @@ -335,11 +355,30 @@ class EnrollmentPlane: oidc_configured = bool( trust and trust.oidc_issuer + and _application_oidc_issuer(trust.oidc_issuer) and trust.administrator_group and trust.support_agent_group and trust.read_only_group and self.authentik.is_configured() ) + if oidc_configured and trust is not None: + template = self.authentik.validate_deployment_oidc_template( + role_claim=trust.oidc_role_claim, + role_groups=(trust.administrator_group, trust.support_agent_group, trust.read_only_group), + ) + groups = self.authentik.groups() + found_groups = { + str(group.get("name") or "") + for group in groups.get("items", []) + if isinstance(group, dict) + } + oidc_configured = bool( + template.get("status") == "ready" + and groups.get("success") is True + and { + trust.administrator_group, trust.support_agent_group, trust.read_only_group, + }.issubset(found_groups) + ) certificate_ready = bool(npm_ready) def item(ready: bool, code: str) -> dict[str, str]: @@ -464,6 +503,8 @@ class EnrollmentPlane: def preflight(self, session: Session, plan: EnrollmentPlanV1) -> dict[str, Any]: checks: list[dict[str, str]] = [] trust = self.get_trust_config(session) + if isinstance(self.authentik, AuthentikConnector): + bind_template(session, self.authentik) profile = profile_contract(plan.profile) if trust is None: checks.append(_check("enrollment_trust_not_configured", "blocked", "Root enrollment trust boundaries are not configured.")) @@ -586,6 +627,13 @@ class EnrollmentPlane: checks.append(_check("oidc_profile_unavailable", "blocked", "Configured OIDC profile or Authentik role groups are unavailable.")) else: assert trust is not None and plan.authentication.roles is not None + issuer_matches = _application_oidc_issuer(trust.oidc_issuer, plan.application_id) + checks.append(_check( + "oidc_issuer" if issuer_matches else "oidc_issuer_mismatch", + "passed" if issuer_matches else "blocked", + "Authentik issuer is bound to this application's own provider." + if issuer_matches else "Configured issuer belongs to a different application or unsupported issuer path.", + )) expected_roles = { "administrator": trust.administrator_group, "support_agent": trust.support_agent_group, @@ -603,8 +651,9 @@ class EnrollmentPlane: if auth_status.get("status") != "connected": checks.append(_check("oidc_provider", "blocked", "Authentik is unavailable.")) else: - template = self.authentik.deployment_oidc_template_status( - role_claim=trust.oidc_role_claim + template = self.authentik.validate_deployment_oidc_template( + role_claim=trust.oidc_role_claim, + role_groups=(trust.administrator_group, trust.support_agent_group, trust.read_only_group), ) if template.get("status") != "ready": checks.append(_check("oidc_profile_unavailable", "blocked", "The server-owned Authentik OIDC template is incomplete.")) @@ -978,6 +1027,8 @@ class EnrollmentPlane: raise EnrollmentError("application identity or repository is already enrolled") trust = self.get_trust_config(session) + if isinstance(self.authentik, AuthentikConnector): + bind_template(session, self.authentik) if trust is None: raise EnrollmentError("enrollment trust boundaries are not configured") enrollment = self._create_enrollment(session, plan, plan_digest, trust) @@ -1145,7 +1196,7 @@ class EnrollmentPlane: runtime_values.update({ "OIDC_CLIENT_ID": plan.application_id, "OIDC_CLIENT_SECRET": oidc_secret, - "OIDC_ISSUER": trust.oidc_issuer or "", + "OIDC_ISSUER": _application_oidc_issuer(trust.oidc_issuer, plan.application_id) or "", "OIDC_ROLE_CLAIM": trust.oidc_role_claim, }) if plan.profile == "repopulse-authenticated": diff --git a/backend/app/services/user_access.py b/backend/app/services/user_access.py index 143396a..a4400bc 100644 --- a/backend/app/services/user_access.py +++ b/backend/app/services/user_access.py @@ -175,6 +175,7 @@ def required_permission(path: str, method: str) -> str: "/api/knowledge/reindex", "/api/enrollments/trust", "/api/enrollments/trust/storage-root", + "/api/enrollments/trust/oidc-profile", }: return "settings.manage" if path.startswith("/api/apps/") and path.endswith(("/metadata", "/container-mapping")): diff --git a/backend/tests/test_authentik_connector.py b/backend/tests/test_authentik_connector.py index f7e6153..4b0cd82 100644 --- a/backend/tests/test_authentik_connector.py +++ b/backend/tests/test_authentik_connector.py @@ -1,5 +1,7 @@ from pathlib import Path +import pytest + from fastapi.testclient import TestClient from sqlmodel import SQLModel, create_engine @@ -133,6 +135,7 @@ def test_oidc_template_requires_the_exact_enrolled_role_claim(): from backend.app.core.config import settings previous = _set_authentik_config() + _set_deployment_identity_template() settings.apis['AUTHENTIK_OIDC_AUTHORIZATION_FLOW'] = 'flow-default-auth' settings.apis['AUTHENTIK_OIDC_INVALIDATION_FLOW'] = 'flow-default-invalidation' settings.apis['AUTHENTIK_OIDC_GROUP_CLAIM_MAPPING'] = 'mapping-groups' @@ -151,11 +154,86 @@ def test_oidc_template_requires_the_exact_enrolled_role_claim(): _restore_config(previous) +def _set_deployment_identity_template(): + from backend.app.core.config import settings + + settings.apis.update({ + 'AUTHENTIK_OIDC_OPENID_CLAIM_MAPPING': 'openid-id', + 'AUTHENTIK_OIDC_EMAIL_CLAIM_MAPPING': 'email-id', + 'AUTHENTIK_OIDC_PROFILE_CLAIM_MAPPING': 'profile-id', + 'AUTHENTIK_OIDC_SIGNING_KEY_ID': 'rsa-key-id', + }) + + +@pytest.mark.parametrize("failure", [None, "flow", "flow_missing", "flow_duplicate", "mapping", "scope", "signing", "unreachable", "openid_roles", "email_roles", "profile_roles"]) +def test_enrollment_template_verifies_remote_flows_and_filtered_role_claim(monkeypatch, failure): + from backend.app.connectors.authentik import AuthentikConnector + from backend.app.core.config import settings + + previous = _set_authentik_config() + _set_deployment_identity_template() + settings.apis.update({ + 'AUTHENTIK_OIDC_AUTHORIZATION_FLOW': 'authorization-id', + 'AUTHENTIK_OIDC_INVALIDATION_FLOW': 'invalidation-id', + 'AUTHENTIK_OIDC_GROUP_CLAIM_MAPPING': 'roles-id', + 'AUTHENTIK_OIDC_ROLE_CLAIM': 'roles', + }) + expression = 'return {"roles": sorted({group.name for group in request.user.ak_groups.all() if group.name in ["Administrator", "ReadOnly", "SupportAgent"]})}' + + def request(self, path, params=None): + if failure == 'unreachable': + raise RuntimeError('SECRET_SENTINEL') + for scope in ('openid', 'email', 'profile'): + if path == f'/propertymappings/provider/scope/{scope}-id/': + expressions = { + 'openid': '# This scope is required by the OpenID-spec, and must as such exist in authentik.\n# The scope by itself does not grant any information\nreturn {}', + 'email': 'return {\n "email": request.user.email,\n "email_verified": False\n}', + 'profile': 'return delete_none_values({\n "name": request.user.name,\n "preferred_username": request.user.username,\n})', + } + return { + 'pk': f'{scope}-id', 'scope_name': 'wrong' if failure == 'scope' else scope, + 'managed': f'goauthentik.io/providers/oauth2/scope-{scope}' if scope != 'profile' else None, + 'expression': 'return {"roles": ["Administrator"]}' if failure == f'{scope}_roles' else expressions[scope], + } + if path.startswith('/flows/instances/'): + raise RuntimeError('HTTP 404: flow detail requires slug, not UUID') + return { + '/propertymappings/provider/scope/roles-id/': { + 'pk': 'roles-id', 'scope_name': 'roles', + 'expression': 'return {"roles": ["Administrator"]}' if failure == 'mapping' else expression, + }, + }[path] + + monkeypatch.setattr(AuthentikConnector, '_request', request) + def paged(self, path, params=None): + if path == '/flows/instances/': + authorization = {'pk': 'authorization-id', 'slug': 'default-provider-authorization-implicit-consent', 'designation': 'authentication' if failure == 'flow' else 'authorization'} + flows = [{'pk': 'invalidation-id', 'slug': 'default-provider-invalidation-flow', 'designation': 'invalidation'}] + if failure != 'flow_missing': + flows.append(authorization) + if failure == 'flow_duplicate': + flows.append(authorization) + return flows + assert path == '/crypto/certificatekeypairs/' + return [{'pk': 'rsa-key-id', 'key_type': 'ec' if failure == 'signing' else 'rsa', 'private_key_available': True}] + monkeypatch.setattr(AuthentikConnector, '_paged', paged) + try: + result = AuthentikConnector().validate_deployment_oidc_template( + role_claim='roles', role_groups=('Administrator', 'SupportAgent', 'ReadOnly'), + ) + assert (result['status'] == 'ready') is (failure is None) + assert 'SECRET_SENTINEL' not in str(result) + assert 'expression' not in result + finally: + _restore_config(previous) + + def test_typed_oidc_provisioning_is_bounded_and_never_returns_client_secret(monkeypatch): from backend.app.connectors.authentik import AuthentikConnector from backend.app.core.config import settings previous = _set_authentik_config() + _set_deployment_identity_template() settings.apis['AUTHENTIK_OIDC_AUTHORIZATION_FLOW'] = 'flow-default-auth' settings.apis['AUTHENTIK_OIDC_INVALIDATION_FLOW'] = 'flow-default-invalidation' settings.apis['AUTHENTIK_OIDC_GROUP_CLAIM_MAPPING'] = 'mapping-groups' @@ -195,6 +273,9 @@ def test_typed_oidc_provisioning_is_bounded_and_never_returns_client_secret(monk } assert writes[0][0:2] == ('POST', '/providers/oauth2/') assert writes[0][2]['client_secret'] == secret + assert writes[0][2]['redirect_uris'] == [{'matching_mode': 'strict', 'url': 'https://support.itworx.test/signin-oidc'}] + assert writes[0][2]['signing_key'] == 'rsa-key-id' + assert set(writes[0][2]['property_mappings']) == {'openid-id', 'email-id', 'profile-id', 'mapping-groups'} assert secret not in str(result) _restore_config(previous) @@ -204,6 +285,7 @@ def test_typed_oidc_provisioning_removes_new_provider_when_application_create_fa from backend.app.core.config import settings previous = _set_authentik_config() + _set_deployment_identity_template() settings.apis['AUTHENTIK_OIDC_AUTHORIZATION_FLOW'] = 'flow-default-auth' settings.apis['AUTHENTIK_OIDC_INVALIDATION_FLOW'] = 'flow-default-invalidation' settings.apis['AUTHENTIK_OIDC_GROUP_CLAIM_MAPPING'] = 'mapping-groups' @@ -248,21 +330,40 @@ def test_typed_oidc_provisioning_removes_new_provider_when_application_create_fa _restore_config(previous) -def test_typed_oidc_existing_application_requires_matching_provider_contract(monkeypatch): +@pytest.mark.parametrize('damage', [None, 'client', 'regex', 'extra_mapping', 'signing_key', 'issuer']) +def test_typed_oidc_existing_application_requires_matching_provider_contract(monkeypatch, damage): from backend.app.connectors.authentik import AuthentikConnector from backend.app.core.config import settings previous = _set_authentik_config() + _set_deployment_identity_template() settings.apis['AUTHENTIK_OIDC_AUTHORIZATION_FLOW'] = 'flow-default-auth' settings.apis['AUTHENTIK_OIDC_INVALIDATION_FLOW'] = 'flow-default-invalidation' settings.apis['AUTHENTIK_OIDC_GROUP_CLAIM_MAPPING'] = 'mapping-groups' + provider = { + 'pk': 'provider-1', 'client_id': 'wsbc-portal', 'client_type': 'confidential', + 'redirect_uris': [{'matching_mode': 'strict', 'url': 'https://support.itworx.test/signin-oidc'}], + 'property_mappings': ['openid-id', 'email-id', 'profile-id', 'mapping-groups'], + 'authorization_flow': 'flow-default-auth', 'invalidation_flow': 'flow-default-invalidation', + 'signing_key': 'rsa-key-id', 'issuer_mode': 'per_provider', + } + if damage == 'client': + provider['client_id'] = 'unexpected-client' + elif damage == 'regex': + provider['redirect_uris'][0]['matching_mode'] = 'regex' + elif damage == 'extra_mapping': + provider['property_mappings'].append('other-roles') + elif damage == 'signing_key': + provider['signing_key'] = None + elif damage == 'issuer': + provider['issuer_mode'] = 'global' monkeypatch.setattr( AuthentikConnector, '_paged', lambda self, path, params=None: { '/core/groups/': [{'name': 'Administrator'}, {'name': 'SupportAgent'}, {'name': 'ReadOnly'}], '/core/applications/': [{'pk': 'application-1', 'slug': 'wsbc-portal', 'meta_launch_url': 'https://support.itworx.test', 'provider': 'provider-1'}], - '/providers/oauth2/': [{'pk': 'provider-1', 'client_id': 'unexpected-client', 'client_type': 'confidential', 'redirect_uris': ['https://support.itworx.test/signin-oidc'], 'property_mappings': ['mapping-groups'], 'authorization_flow': 'flow-default-auth', 'invalidation_flow': 'flow-default-invalidation'}], + '/providers/oauth2/': [provider], }[path], raising=True, ) @@ -279,11 +380,17 @@ def test_typed_oidc_existing_application_requires_matching_provider_contract(mon finally: _restore_config(previous) - assert result == { + assert result == ({ + 'success': True, + 'status': 'already_managed', + 'message': 'oidc_application_already_matches_typed_plan', + 'applicationId': 'application-1', + 'clientSecretCreated': False, + } if damage is None else { 'success': False, 'status': 'conflict', 'message': 'existing_oidc_provider_does_not_match_managed_contract', - } + }) def test_authentik_health_success_and_auth_failure_redact_token(monkeypatch): diff --git a/backend/tests/test_deployment_control_plane.py b/backend/tests/test_deployment_control_plane.py index 35a907e..32f7c10 100644 --- a/backend/tests/test_deployment_control_plane.py +++ b/backend/tests/test_deployment_control_plane.py @@ -1,5 +1,6 @@ from __future__ import annotations +import pytest from sqlmodel import Session, SQLModel, create_engine from backend.app.db.models import Policy @@ -28,6 +29,7 @@ def _plan(**overrides): "proxy": {"hostname": "support.example.com"}, "oidc": { "application_slug": "wsbc-portal", + "role_claim": "roles", "client_secret_ref": "WSBC_PORTAL_OIDC_CLIENT_SECRET", "roles": { "administrator_group": "Administrator", @@ -72,6 +74,18 @@ class _Npm: class _Authentik: + def configure_deployment_oidc_template(self, config, *, issuer_origin=None): + self.template_config = config + self.template_issuer = issuer_origin + + def validate_deployment_oidc_template(self, *, role_claim, role_groups): + assert role_claim == "roles" + assert role_groups == ("Administrator", "SupportAgent", "ReadOnly") + return {"status": "ready"} + + def groups(self): + return {"success": True, "items": [{"name": name} for name in ("Administrator", "SupportAgent", "ReadOnly")]} + def test_connection(self): return {"status": "connected"} @@ -343,3 +357,138 @@ def test_deployment_evidence_uses_allowlists_and_never_persists_connector_secret assert "broker-secret" not in str(result) assert "npm-secret" not in str(result) assert "oidc-secret" not in str(result) + + +@pytest.mark.parametrize("swapped_roles", [False, True]) +def test_saved_root_template_is_used_by_existing_deployment_plan(tmp_path, monkeypatch, swapped_roles): + from uuid import uuid4 + from backend.app.core.config import settings + from backend.app.db.models import AppSetting + from backend.app.schemas.enrollment import EnrollmentTrustConfigV1 + from backend.app.services.enrollment_oidc_settings import EnrollmentOidcProfileV1, TEMPLATE_FIELDS + + monkeypatch.setattr(settings, "authentik_issuer", "https://auth.example.com/application/o/appops-users/") + profile = EnrollmentOidcProfileV1(issuer="https://auth.example.com", **{field: uuid4() for field in TEMPLATE_FIELDS}) + + class SavedTemplateAuthentik(_Authentik): + def validate_deployment_oidc_template(self, **kwargs): + assert self.template_config == profile.template_config() + # Authentik emits the same filtered set for any permutation. The + # orchestrator must separately preserve named root role meanings. + assert kwargs["role_claim"] == "roles" + assert set(kwargs["role_groups"]) == {"Administrator", "SupportAgent", "ReadOnly"} + return {"status": "ready"} + + def ensure_deployment_oidc_application(self, **kwargs): + assert self.template_config == profile.template_config() + return super().ensure_deployment_oidc_application(**kwargs) + + with _session(tmp_path) as session: + trust = EnrollmentTrustConfigV1( + controller_identity="unraid-controller", bootstrap_secret_ref="ENROLLMENT_BOOTSTRAP", + approved_networks=("itworx-npm",), allowed_domain_suffixes=("example.com",), + oidc_issuer=profile.issuer, administrator_group="Administrator", + support_agent_group="SupportAgent", read_only_group="ReadOnly", + ) + session.add(AppSetting(key="deployment_enrollment:trust", value_json=trust.model_dump(mode="json"))) + session.add(AppSetting(key="deployment_enrollment:oidc_template", value_json=profile.model_dump(mode="json"))) + session.commit() + plane = _plane() + plane.authentik = SavedTemplateAuthentik() + plan = _plan() + if swapped_roles: + oidc = plan.oidc.model_dump() + oidc["roles"].update(administrator_group="ReadOnly", read_only_group="Administrator") + plan = _plan(oidc=oidc) + # Exercise both consumers; the integration can also run on an async + # controller completion where this instance never performed preflight. + preflight = plane.preflight(session, plan) + if swapped_roles: + assert "oidc_template" in preflight["blockers"] + plane.authentik = SavedTemplateAuthentik() + set_secret(session, "WSBC_PORTAL_OIDC_CLIENT_SECRET", "b" * 32) + success, evidence, _ = plane._complete_external_integrations(session, plan) + assert success is not swapped_roles + if swapped_roles: + assert evidence == {} + else: + assert evidence["oidc"]["status"] == "created" + + +def test_invalid_oidc_template_prevents_controller_apply(tmp_path): + class UnavailableAuthentik(_Authentik): + def validate_deployment_oidc_template(self, **kwargs): + return {"status": "unavailable", "reason": "remote-secret-sentinel"} + + class NoApplyBroker(_Broker): + def apply(self, *args, **kwargs): + raise AssertionError("Invalid template must fail before controller apply") + + with _session(tmp_path) as session: + set_secret(session, "WSBC_PORTAL_DEPLOYMENT_BROKER", "a" * 32) + set_secret(session, "WSBC_PORTAL_OIDC_CLIENT_SECRET", "b" * 32) + session.add(Policy(id="allow-invalid-template-test", action="deployment.apply", target="wsbc-portal", manual="allowed", voice="blocked", risk="high", reason="Test")) + session.commit() + plane = _plane() + plane.authentik = UnavailableAuthentik() + plane.broker = NoApplyBroker() + result = plane.execute(session, plan=_plan(), idempotency_key="invalid-template-0001", operation="apply") + assert result["status"] == "preflight_failed" + assert "oidc_template" in result["preflight"]["blockers"] + assert "remote-secret-sentinel" not in str(result) + + +def test_oidc_template_drift_after_preflight_prevents_provider_write(tmp_path): + class ChangedAuthentik(_Authentik): + def validate_deployment_oidc_template(self, **kwargs): + return {"status": "unavailable", "reason": "secret-sentinel"} + + def ensure_deployment_oidc_application(self, **kwargs): + raise AssertionError("Unverified template must never create a provider") + + with _session(tmp_path) as session: + set_secret(session, "WSBC_PORTAL_OIDC_CLIENT_SECRET", "b" * 32) + plane = _plane() + plane.authentik = ChangedAuthentik() + success, evidence, message = plane._complete_external_integrations(session, _plan()) + assert not success + assert evidence == {} + assert "secret-sentinel" not in message + + +@pytest.mark.parametrize("claim,administrator", [("groups", "Administrator"), ("roles", "OtherAdmin")]) +def test_preflight_verifies_requested_claim_and_groups(tmp_path, claim, administrator): + class RequestedContractAuthentik(_Authentik): + def validate_deployment_oidc_template(self, *, role_claim, role_groups): + assert role_claim == claim + assert role_groups == (administrator, "SupportAgent", "ReadOnly") + return {"status": "unavailable", "reason": "private-template-diagnostic"} + + oidc = _plan().oidc.model_dump() + oidc["role_claim"] = claim + oidc["roles"]["administrator_group"] = administrator + with _session(tmp_path) as session: + plane = _plane() + plane.authentik = RequestedContractAuthentik() + result = plane.preflight(session, _plan(oidc=oidc)) + assert "oidc_template" in result["blockers"] + assert "private-template-diagnostic" not in str(result) + + +def test_missing_role_group_blocks_provider_write(tmp_path): + class MissingGroupAuthentik(_Authentik): + def groups(self): + return {"success": True, "items": [{"name": "Administrator"}, {"name": "SupportAgent"}]} + + def ensure_deployment_oidc_application(self, **kwargs): + raise AssertionError("Missing group must fail before provider write") + + with _session(tmp_path) as session: + set_secret(session, "WSBC_PORTAL_OIDC_CLIENT_SECRET", "b" * 32) + plane = _plane() + plane.authentik = MissingGroupAuthentik() + result = plane.preflight(session, _plan()) + assert "oidc_template" in result["blockers"] + success, evidence, _ = plane._complete_external_integrations(session, _plan()) + assert not success + assert evidence == {} diff --git a/backend/tests/test_enrollment_oidc_settings.py b/backend/tests/test_enrollment_oidc_settings.py new file mode 100644 index 0000000..731a24f --- /dev/null +++ b/backend/tests/test_enrollment_oidc_settings.py @@ -0,0 +1,239 @@ +from uuid import uuid4 + +import pytest +from sqlmodel import Session + +from backend.tests.test_user_auth import identity_client, sign_in + + +@pytest.fixture(autouse=True) +def native_authentik_issuer(monkeypatch): + from backend.app.core.config import settings + monkeypatch.setattr(settings, 'authentik_issuer', 'https://auth.example.com/application/o/appops-users/') + monkeypatch.setitem(settings.apis, 'AUTHENTIK_URL', 'https://auth.example.com') + monkeypatch.setitem(settings.apis, 'AUTHENTIK_VERIFY_TLS', True) + + +def payload(): + return { + 'issuer': 'https://auth.example.com', + 'authorization_flow': str(uuid4()), 'invalidation_flow': str(uuid4()), + 'openid_mapping': str(uuid4()), 'email_mapping': str(uuid4()), + 'profile_mapping': str(uuid4()), 'roles_mapping': str(uuid4()), + 'signing_key': str(uuid4()), + 'administrator_group': 'Administrator', 'support_agent_group': 'SupportAgent', + 'read_only_group': 'ReadOnly', + } + + +def seed_trust(): + from backend.app.db import session as database + from backend.app.schemas.enrollment import EnrollmentTrustConfigV1 + from backend.app.services.enrollment_plane import EnrollmentPlane + with Session(database.engine) as db: + EnrollmentPlane.set_trust_config(db, EnrollmentTrustConfigV1( + controller_identity='unraid-controller', bootstrap_secret_ref='ENROLLMENT_BOOTSTRAP', + approved_networks=('itworx-mcp-hub-npm',), allowed_domain_suffixes=('example.com',), + autonomous_profiles=('repopulse-authenticated',), repopulse_proxy_access_list_id=1, + )) + + +def test_oidc_profile_requires_admin_csrf_and_settings_permission(identity_client): + client = identity_client + path = '/api/enrollments/trust/oidc-profile' + assert client.patch(path, json=payload()).status_code == 401 + sign_in(client, 'reader') + assert client.patch(path, json=payload(), headers={'X-AppOps-Session-CSRF': '1'}).status_code == 403 + sign_in(client, 'owner') + assert client.patch(path, json=payload()).status_code == 403 + assert client.patch(path, json=payload(), headers={'X-AppOps-Session-CSRF': '1', 'Origin': 'https://evil.example'}).status_code == 403 + + +def test_oidc_profile_rejects_unverified_remote_template_without_writes(identity_client, monkeypatch): + from backend.app.connectors.authentik import AuthentikConnector + from backend.app.db import session as database + from backend.app.db.models import AppSetting + sign_in(identity_client, 'owner') + seed_trust() + monkeypatch.setattr(AuthentikConnector, 'validate_deployment_oidc_template', lambda *a, **k: {'status': 'unavailable', 'reason': 'remote-secret-must-not-leak'}) + result = identity_client.patch('/api/enrollments/trust/oidc-profile', json=payload(), headers={'X-AppOps-Session-CSRF': '1'}) + assert result.status_code == 409 + assert result.json() == {'detail': 'oidc_profile_not_verified'} + with Session(database.engine) as db: + assert db.get(AppSetting, 'deployment_enrollment:oidc_template') is None + assert db.get(AppSetting, 'deployment_enrollment:trust').value_json['oidc_issuer'] is None + + +def test_oidc_profile_persists_ids_and_preserves_other_trust_boundaries(identity_client, monkeypatch): + from backend.app.connectors.authentik import AuthentikConnector + from backend.app.db import session as database + from backend.app.db.models import AppSetting + sign_in(identity_client, 'owner') + seed_trust() + monkeypatch.setattr(AuthentikConnector, 'is_configured', lambda _: True) + monkeypatch.setattr(AuthentikConnector, 'validate_deployment_oidc_template', lambda *a, **k: {'status': 'ready'}) + monkeypatch.setattr(AuthentikConnector, 'groups', lambda _: {'success': True, 'items': [{'name': n} for n in ['Administrator', 'SupportAgent', 'ReadOnly']]}) + value = payload() + result = identity_client.patch('/api/enrollments/trust/oidc-profile', json=value, headers={'X-AppOps-Session-CSRF': '1'}) + assert result.status_code == 200 + assert result.json()['configured'] is True + with Session(database.engine) as db: + trust = db.get(AppSetting, 'deployment_enrollment:trust').value_json + assert trust['oidc_issuer'] == value['issuer'] + assert trust['autonomous_profiles'] == ['repopulse-authenticated'] + assert trust['trusted_appdata_root'] == '/srv/example/appdata' + assert trust['repopulse_proxy_access_list_id'] == 1 + binding = db.get(AppSetting, 'deployment_enrollment:oidc_template') + assert binding.value_json == value + assert binding.is_secret is False + assert identity_client.get('/api/enrollments/trust/oidc-profile').json()['profile'] == value + + +@pytest.mark.parametrize('extra', [{'client_secret': 'never-accepted'}, {'authorization_flow': 'not-a-uuid'}, {'issuer': 'http://auth.example.com'}, {'administrator_group': 'authentik Admins'}]) +def test_oidc_profile_rejects_extra_secrets_unsafe_ids_and_roles(identity_client, extra): + sign_in(identity_client, 'owner') + result = identity_client.patch('/api/enrollments/trust/oidc-profile', json={**payload(), **extra}, headers={'X-AppOps-Session-CSRF': '1'}) + assert result.status_code == 422 + assert result.json() == {'detail': 'invalid_oidc_profile'} + + +def test_oidc_profile_concurrent_trust_change_preserves_newer_selection(identity_client, monkeypatch): + from backend.app.connectors.authentik import AuthentikConnector + from backend.app.db import session as database + from backend.app.db.models import AppSetting + from backend.app.core.time import utc_now + sign_in(identity_client, 'owner') + seed_trust() + monkeypatch.setattr(AuthentikConnector, 'is_configured', lambda _: True) + def concurrent_edit(*args, **kwargs): + with Session(database.engine) as db: + row = db.get(AppSetting, 'deployment_enrollment:trust') + row.value_json = {**row.value_json, 'repopulse_proxy_access_list_id': 2} + row.updated_at = utc_now() + db.add(row) + db.commit() + return {'status': 'ready'} + monkeypatch.setattr(AuthentikConnector, 'validate_deployment_oidc_template', concurrent_edit) + monkeypatch.setattr(AuthentikConnector, 'groups', lambda _: {'success': True, 'items': [{'name': n} for n in ['Administrator', 'SupportAgent', 'ReadOnly']]}) + result = identity_client.patch('/api/enrollments/trust/oidc-profile', json=payload(), headers={'X-AppOps-Session-CSRF': '1'}) + assert result.status_code == 409 + with Session(database.engine) as db: + assert db.get(AppSetting, 'deployment_enrollment:trust').value_json['repopulse_proxy_access_list_id'] == 2 + assert db.get(AppSetting, 'deployment_enrollment:oidc_template') is None + + +def test_stored_generic_binding_is_local_and_corruption_never_falls_back(identity_client, monkeypatch): + from backend.app.connectors.authentik import AuthentikConnector + from backend.app.core.config import settings + from backend.app.db import session as database + from backend.app.db.models import AppSetting + from backend.app.services.enrollment_oidc_settings import bind_template + seed_trust() + original = dict(settings.apis) + first = AuthentikConnector() + second = AuthentikConnector() + with Session(database.engine) as db: + db.add(AppSetting(key='deployment_enrollment:oidc_template', value_json={'corrupt': True})) + db.commit() + bind_template(db, first) + assert first._deployment_oidc_config() == {} + assert second._deployment_oidc_config() == original + assert settings.apis == original + + +def test_custom_settings_operator_cannot_bind_root_profile(identity_client): + from backend.app.db import session as database + from backend.app.db.models import GatewayUser + user_id = sign_in(identity_client, 'settings-operator') + with Session(database.engine) as db: + user = db.get(GatewayUser, user_id) + user.role = 'custom' + user.permissions = ['settings.manage'] + db.add(user) + db.commit() + result = identity_client.patch('/api/enrollments/trust/oidc-profile', json=payload(), headers={'X-AppOps-Session-CSRF': '1'}) + assert result.status_code == 403 + + +@pytest.mark.parametrize('origin', ['https://wrong.example', 'https://auth.example']) +def test_root_profile_rejects_foreign_issuer_before_connector_reads(identity_client, monkeypatch, origin): + from backend.app.connectors.authentik import AuthentikConnector + from backend.app.db import session as database + from backend.app.db.models import AppSetting + sign_in(identity_client, 'owner') + seed_trust() + def unexpected_read(*args, **kwargs): + pytest.fail('No remote request is permitted for a foreign issuer') + monkeypatch.setattr(AuthentikConnector, 'validate_deployment_oidc_template', unexpected_read) + result = identity_client.patch('/api/enrollments/trust/oidc-profile', json={**payload(), 'issuer': origin}, headers={'X-AppOps-Session-CSRF': '1'}) + assert result.status_code == 409 + assert result.json() == {'detail': 'oidc_issuer_mismatch'} + with Session(database.engine) as db: + assert db.get(AppSetting, 'deployment_enrollment:oidc_template') is None + + +@pytest.mark.parametrize('native_issuer', [None, 'https://[invalid', 'http://auth.example.com']) +def test_root_profile_requires_established_native_issuer(identity_client, monkeypatch, native_issuer): + from backend.app.core.config import settings + sign_in(identity_client, 'owner') + seed_trust() + monkeypatch.setattr(settings, 'authentik_issuer', native_issuer) + assert identity_client.get('/api/enrollments/trust/oidc-profile').json()['configured'] is False + result = identity_client.patch('/api/enrollments/trust/oidc-profile', json=payload(), headers={'X-AppOps-Session-CSRF': '1'}) + assert result.status_code == 409 + assert result.json() == {'detail': 'oidc_issuer_mismatch'} + + +@pytest.mark.parametrize('url,verify_tls', [ + ('https://other.example', True), + ('http://auth.example.com', True), + ('https://auth.example.com', False), + ('https://user:secret@auth.example.com', True), +]) +def test_root_profile_rejects_unbound_connector_before_remote_reads(identity_client, monkeypatch, url, verify_tls): + from backend.app.core.config import settings + from backend.app.connectors.authentik import AuthentikConnector + from backend.app.db import session as database + from backend.app.db.models import AppSetting + sign_in(identity_client, 'owner') + seed_trust() + monkeypatch.setitem(settings.apis, 'AUTHENTIK_URL', url) + monkeypatch.setitem(settings.apis, 'AUTHENTIK_VERIFY_TLS', verify_tls) + def unexpected_read(*args, **kwargs): + pytest.fail('Unbound connector must not receive template reads') + monkeypatch.setattr(AuthentikConnector, 'validate_deployment_oidc_template', unexpected_read) + result = identity_client.patch('/api/enrollments/trust/oidc-profile', json=payload(), headers={'X-AppOps-Session-CSRF': '1'}) + assert result.status_code == 409 + assert result.json() == {'detail': 'oidc_connector_issuer_mismatch'} + with Session(database.engine) as db: + assert db.get(AppSetting, 'deployment_enrollment:oidc_template') is None + + +def test_connector_drift_invalidates_bound_template_before_remote_reads(monkeypatch): + from backend.app.core.config import settings + from backend.app.connectors.authentik import AuthentikConnector + from backend.app.services.enrollment_oidc_settings import EnrollmentOidcProfileV1 + connector = AuthentikConnector() + profile = EnrollmentOidcProfileV1.model_validate(payload()) + connector.configure_deployment_oidc_template(profile.template_config(), issuer_origin=profile.issuer) + assert connector._deployment_oidc_config() == profile.template_config() + monkeypatch.setitem(settings.apis, 'AUTHENTIK_URL', 'https://other.example') + assert connector._deployment_oidc_config() == {} + assert connector.validate_deployment_oidc_template(role_claim='roles', role_groups=('Administrator', 'SupportAgent', 'ReadOnly'))['status'] == 'missing_config' + + +def test_connector_drift_blocks_each_subsequent_http_request(monkeypatch): + from backend.app.core.config import settings + from backend.app.connectors.authentik import AuthentikConnector + from backend.app.services.enrollment_oidc_settings import EnrollmentOidcProfileV1 + profile = EnrollmentOidcProfileV1.model_validate(payload()) + monkeypatch.setitem(settings.apis, 'AUTHENTIK_TOKEN', 'fixture-token') + connector = AuthentikConnector() + connector.configure_deployment_oidc_template(profile.template_config(), issuer_origin=profile.issuer) + assert connector._config().url == profile.issuer + monkeypatch.setitem(settings.apis, 'AUTHENTIK_URL', 'https://other.example') + assert connector._config() is None + with pytest.raises(RuntimeError, match='missing_config'): + connector._write_request('POST', '/providers/oauth2/', {'client_secret': 'fixture-secret'}) + with pytest.raises(RuntimeError, match='missing_config'): + connector._request('/core/applications/') diff --git a/backend/tests/test_enrollment_plane.py b/backend/tests/test_enrollment_plane.py index 01a3b53..1f0cc47 100644 --- a/backend/tests/test_enrollment_plane.py +++ b/backend/tests/test_enrollment_plane.py @@ -202,6 +202,9 @@ class _Authentik: assert role_claim == "roles" return {"status": "ready", "missing": []} + def validate_deployment_oidc_template(self, *, role_claim, role_groups): + return self.deployment_oidc_template_status(role_claim=role_claim) + def ensure_deployment_oidc_application(self, **kwargs): assert kwargs["application_slug"] == "wsbc-portal" assert kwargs["client_secret"] @@ -434,6 +437,79 @@ def test_capabilities_require_a_live_bootstrap_controller_contract(tmp_path): assert result["controller"]["status"] == "ready" +@pytest.mark.parametrize("failure", ["template", "groups", "unreachable"]) +def test_capabilities_do_not_advertise_unusable_oidc_profile(tmp_path, failure): + class UnavailableAuthentik(_Authentik): + def deployment_oidc_template_status(self, *, role_claim): + return {"status": "missing_config" if failure == "template" else "ready"} + + def groups(self): + if failure == "groups": + return {"success": True, "items": [{"name": "Administrator"}]} + if failure == "unreachable": + return {"success": False, "message": "SECRET_SENTINEL"} + return super().groups() + + with _session(tmp_path) as session: + _configure(session) + result = _plane(authentik=UnavailableAuthentik()).capabilities(session) + assert result["oidcProvisioning"] == { + "status": "unavailable", "blockerCode": "oidc_profile_unavailable", + } + assert "SECRET_SENTINEL" not in str(result) + assert result["targetEnrollment"]["status"] == "ready" + assert session.exec(select(Enrollment)).all() == [] + assert session.exec(select(Policy)).all() == [] + + +def test_capabilities_advertise_oidc_only_with_template_and_all_role_groups(tmp_path): + with _session(tmp_path) as session: + _configure(session) + assert _plane().capabilities(session)["oidcProvisioning"]["status"] == "ready" + + +@pytest.mark.parametrize('issuer', ['https://auth.example.com/unsupported-path/', 'https://auth.example.com/application/o/bad/path/']) +def test_capabilities_refuse_unusable_issuer_paths(tmp_path, issuer): + with _session(tmp_path) as session: + _configure(session) + EnrollmentPlane.set_trust_config(session, _trust().model_copy(update={'oidc_issuer': issuer})) + assert _plane().capabilities(session)['oidcProvisioning']['status'] == 'unavailable' + + +def test_enrollment_refuses_another_applications_oidc_issuer(tmp_path): + with _session(tmp_path) as session: + _configure(session) + EnrollmentPlane.set_trust_config( + session, _trust().model_copy(update={ + "oidc_issuer": "https://auth.example.com/application/o/another-project/", + }), + ) + result = _plane().preflight(session, _plan()) + assert result["status"] == "blocked" + assert "oidc_issuer_mismatch" in result["blockers"] + assert session.exec(select(Enrollment)).all() == [] + + +@pytest.mark.parametrize("configured", [ + "https://auth.example.com", + "https://auth.example.com/application/o/", + "https://auth.example.com/application/o/wsbc-portal", +]) +def test_enrollment_seals_its_own_per_provider_issuer(tmp_path, configured): + class RuntimeBroker(_Broker): + def configure_enrollment_runtime(self, runtime, *, secret): + self.issuer = runtime["values"]["OIDC_ISSUER"] + return super().configure_enrollment_runtime(runtime, secret=secret) + + broker = RuntimeBroker() + with _session(tmp_path) as session: + _configure(session) + EnrollmentPlane.set_trust_config(session, _trust().model_copy(update={"oidc_issuer": configured})) + result = _plane(broker=broker).enroll(session, _plan(), idempotency_key="own-issuer-test-0001") + assert result["enrollment"]["status"] == "ready" + assert broker.issuer == "https://auth.example.com/application/o/wsbc-portal/" + + def test_capabilities_block_when_controller_root_differs_from_operator_trust(tmp_path): class AlternateRootBroker(_Broker): appdata_root = "/var/lib/docker/volumes/itworx-managed-appdata/_data" diff --git a/src/api/liveClient.ts b/src/api/liveClient.ts index 90c0d20..f512b40 100644 --- a/src/api/liveClient.ts +++ b/src/api/liveClient.ts @@ -4,7 +4,7 @@ import { AISummary, AIRecommendation, AIChatMessage, AIAutopilotRule, AITool } f import { toIsoString } from '@/lib/backendDateTime'; import type { AiSecurityFinding, AiSecurityRecommendation, AiSecurityReviewResult, AiSecurityScope, AiSecuritySummary, NotificationChannel, NotificationEvaluationResult, NotificationEvent, NotificationRule, NotificationSummary, RemediationApprovalProposal, RemediationDryRunResult, RemediationPlan, RemediationPlanCollection, SafetyDashboard, SafetyFinding, SafetyRecommendation, SafetyScoreBreakdown, SafetySection, SafetySummary, UnraidDisksResponse, UnraidDockerResponse, UnraidSharesResponse, UnraidVmCapabilitiesResponse, UnraidVmsResponse } from '../types/appops'; import type { GiteaActionsStatus, IntegrationStatus, GatewayUser, IdentityProvider, PermissionDefinition, UserAccessUpdate, UserAuthSession, RagQueryResult, RagStatus, RunnerFleetStatus } from '../types/appops'; -import type { DeliveryStatus, DeploymentJob, Enrollment, EnrollmentCapabilities, EnrollmentStorageRootSelection, EnrollmentTrustStatus, ForgeRouteStageResult, WorkMeshPromotion } from '../types/appops'; +import type { DeliveryStatus, DeploymentJob, Enrollment, EnrollmentCapabilities, EnrollmentStorageRootSelection, EnrollmentTrustStatus, EnrollmentOidcProfile, EnrollmentOidcProfileStatus, ForgeRouteStageResult, WorkMeshPromotion } from '../types/appops'; import type { ExposurePreferences, ManagedAiConfig, ManagedAiConfigUpdate, ManagedAiStatus, ManagedAiInference } from '../types/appops'; type HealthStatus = { @@ -386,6 +386,10 @@ export const createLiveClient = () => ({ }, async getEnrollmentCapabilities(): Promise { return get('/api/enrollments/capabilities', defaultConfig); }, async getEnrollmentTrustStatus(): Promise { return get('/api/enrollments/trust', defaultConfig); }, + async getEnrollmentOidcProfile(): Promise { return get('/api/enrollments/trust/oidc-profile', defaultConfig); }, + async updateEnrollmentOidcProfile(payload: EnrollmentOidcProfile): Promise { + return patch('/api/enrollments/trust/oidc-profile', payload, defaultConfig); + }, async updateEnrollmentStorageRoot(payload: EnrollmentStorageRootSelection): Promise { return patch('/api/enrollments/trust/storage-root', payload, defaultConfig); }, diff --git a/src/components/EnrollmentOidcSettings.tsx b/src/components/EnrollmentOidcSettings.tsx new file mode 100644 index 0000000..5c13a1a --- /dev/null +++ b/src/components/EnrollmentOidcSettings.tsx @@ -0,0 +1,82 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { KeyRound, LoaderCircle, RefreshCw } from 'lucide-react'; +import { appopsClient } from '@/api/appopsClient'; +import { useUserSession } from '@/hooks/useUserSession'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Button } from '@/components/ui/button'; +import { formatOperatorError } from '@/lib/operatorError'; +import type { EnrollmentOidcProfile } from '@/types/appops'; + +const emptyProfile: EnrollmentOidcProfile = { + issuer: '', authorization_flow: '', invalidation_flow: '', openid_mapping: '', + email_mapping: '', profile_mapping: '', roles_mapping: '', signing_key: '', + administrator_group: 'Administrator', support_agent_group: 'SupportAgent', read_only_group: 'ReadOnly', +}; +const fields = [ + ['issuer', 'Authentik HTTPS-origin'], ['authorization_flow', 'Authorization flow ID'], + ['invalidation_flow', 'Invalidation flow ID'], ['openid_mapping', 'OpenID mapping ID'], + ['email_mapping', 'Email mapping ID'], ['profile_mapping', 'Identity-only profile mapping ID'], + ['roles_mapping', 'Begrensde roles mapping ID'], ['signing_key', 'RSA signing key ID'], +] as const; + +export const EnrollmentOidcSettings = () => { + const session = useUserSession(); + const canManage = session.ready && session.authenticated && session.user?.role === 'admin' && session.permissions.includes('settings.manage'); + const [draft, setDraft] = useState(emptyProfile); + const [configured, setConfigured] = useState(false); + const [loaded, setLoaded] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + const [notice, setNotice] = useState(''); + const sequence = useRef(0); + const load = useCallback(async () => { + if (!canManage) return; + const request = ++sequence.current; + setBusy(true); setError(''); setLoaded(false); setNotice(''); + try { + const status = await appopsClient.getEnrollmentOidcProfile(); + if (request !== sequence.current) return; + setDraft(status.profile ?? { ...emptyProfile, issuer: status.issuerOrigin ?? '' }); setConfigured(status.configured); setLoaded(true); + } catch (failure) { + if (request === sequence.current) setError(formatOperatorError(failure, 'OIDC-rootprofiel laden mislukt.')); + } finally { if (request === sequence.current) setBusy(false); } + }, [canManage]); + const invalidate = useCallback(() => { sequence.current++; }, []); + useEffect(() => { void load(); return invalidate; }, [load, invalidate]); + const save = async () => { + if (!canManage || !loaded || busy) return; + const request = ++sequence.current; + setBusy(true); setError(''); setNotice(''); + try { + const status = await appopsClient.updateEnrollmentOidcProfile(draft); + if (request !== sequence.current) return; + setDraft(status.profile ?? emptyProfile); setConfigured(status.configured); + setNotice('Bestaande Authentik-templates geverifieerd en het rootprofiel opgeslagen.'); + } catch (failure) { + if (request === sequence.current) setError(formatOperatorError(failure, 'OIDC-rootprofiel opslaan mislukt.')); + } finally { if (request === sequence.current) setBusy(false); } + }; + if (!canManage) return null; + return + Authenticatie voor nieuwe deploymentdoelen + +

Koppel bestaande Authentik-templates voor native OIDC. Rollen: Administrator, SupportAgent en ReadOnly. Nieuwe applicaties krijgen bij enrollment een eigen client en secret.

+

Rootprofiel: {configured ? 'gekoppeld' : 'niet gekoppeld'}. De actuele deployment-readiness controleert de tenant opnieuw.

+ {error &&

{error}

} + {notice &&

{notice}

} +
{fields.map(([field, label]) =>
+ + setDraft(current => ({ ...current, [field]: event.target.value.trim() }))} /> +
)}
+
+ + +
+
+
; +}; diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index d5bd165..2b23e8c 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -16,6 +16,7 @@ import { UserAccessSettings } from "@/components/UserAccessSettings"; import { ExposureSettings } from "@/components/ExposureSettings"; import { ManagedAiSettings } from "@/components/ManagedAiSettings"; import { ManagedStorageSettings } from "@/components/ManagedStorageSettings"; +import { EnrollmentOidcSettings } from "@/components/EnrollmentOidcSettings"; const settingsTabs = ["general", "security", "ai", "notifications", "audit", "voice", "maintenance", "backups"] as const; type SettingsTab = typeof settingsTabs[number]; @@ -196,6 +197,7 @@ const Settings = () => { + Runtime Profiel diff --git a/src/types/appops.ts b/src/types/appops.ts index 4aea08e..d20d48d 100644 --- a/src/types/appops.ts +++ b/src/types/appops.ts @@ -164,6 +164,25 @@ export interface EnrollmentStorageRootSelection { managed_appdata_backup_acknowledged: boolean; } +export interface EnrollmentOidcProfile { + issuer: string; + authorization_flow: string; + invalidation_flow: string; + openid_mapping: string; + email_mapping: string; + profile_mapping: string; + roles_mapping: string; + signing_key: string; + administrator_group: 'Administrator'; + support_agent_group: 'SupportAgent'; + read_only_group: 'ReadOnly'; +} +export interface EnrollmentOidcProfileStatus { + configured: boolean; + issuerOrigin: string | null; + profile: EnrollmentOidcProfile | null; +} + export interface Enrollment { id: string; applicationId: string;