Publish curated Unraid-AppOps-Gateway source from 68878c3167dacf2b424c16780e60e0d60815e477

This commit is contained in:
Public Source Publisher committed 2026-10-08 18:33:13 +00:00
1 parent 837c263cd8
commit 551de41e9f
18 files changed
+1183 -58

No files matched your search

+1 -1
View File
@@ -1,3 +1,3 @@
# Public source export # 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.
+42 -27
View File
@@ -1,6 +1,6 @@
{ {
"schemaVersion": 1, "schemaVersion": 1,
"sourceRevision": "aaf7d11821a86be65c91fead97a6a4758c4fbacd", "sourceRevision": "68878c3167dacf2b424c16780e60e0d60815e477",
"files": [ "files": [
{ {
"path": ".dockerignore", "path": ".dockerignore",
@@ -129,8 +129,8 @@
}, },
{ {
"path": "backend/app/api/routes_enrollments.py", "path": "backend/app/api/routes_enrollments.py",
"sha256": "5da9f18add09638f8a2d8dddc7c92c4790a94f76e9ead4639dfb35a9b8f247cb", "sha256": "ea1c582f0b2349109cea43181793a39ffa37c9b8c740d417236e4b0db24fcac9",
"bytes": 9878 "bytes": 15897
}, },
{ {
"path": "backend/app/api/routes_exposure_settings.py", "path": "backend/app/api/routes_exposure_settings.py",
@@ -279,8 +279,8 @@
}, },
{ {
"path": "backend/app/connectors/authentik.py", "path": "backend/app/connectors/authentik.py",
"sha256": "2bac45ebbee05c3878ec6eb03e92145d971ae9ae875ed7d90ba609e1329e38b2", "sha256": "c6ded7643ddf956f0731ac38a5c031082474517456836d87325217a85220df7d",
"bytes": 92086 "bytes": 99247
}, },
{ {
"path": "backend/app/connectors/base.py", "path": "backend/app/connectors/base.py",
@@ -354,8 +354,8 @@
}, },
{ {
"path": "backend/app/core/config.py", "path": "backend/app/core/config.py",
"sha256": "56ea46b05cad49d1d7f19b6af06ce80036723c0f75317a33af42a50b9521a289", "sha256": "4ac7b1c6c546866073cb8479692ad2c521f854a0a133ba08b6bcc716a8562f20",
"bytes": 27330 "bytes": 28166
}, },
{ {
"path": "backend/app/core/network_security.py", "path": "backend/app/core/network_security.py",
@@ -399,8 +399,8 @@
}, },
{ {
"path": "backend/app/main.py", "path": "backend/app/main.py",
"sha256": "17aa07c08bbe9eecbc0c100a6b573ef7db4b682e9bd7c6e1cb5982f72966d853", "sha256": "9d93cb8c2dc6d64635fad88f25d3bdbcd2d38dde017449b59e58d99e16fbb101",
"bytes": 18104 "bytes": 18706
}, },
{ {
"path": "backend/app/mcp_server.py", "path": "backend/app/mcp_server.py",
@@ -544,8 +544,8 @@
}, },
{ {
"path": "backend/app/services/deployment_control_plane.py", "path": "backend/app/services/deployment_control_plane.py",
"sha256": "4443d9a1581cf24c1cc3eccaaa1e0e67527228e28a873924ab0c3591fd67da99", "sha256": "d49434d6c1e928a4394e1fa32c0302e54eaafc8b2e5fb817df91c99c4347fdad",
"bytes": 25070 "bytes": 26675
}, },
{ {
"path": "backend/app/services/deployment_target_registry.py", "path": "backend/app/services/deployment_target_registry.py",
@@ -557,10 +557,15 @@
"sha256": "cc2afc7e8c216a1683a90c2f8776844c48ea766a2597907a701868eda1cac2ea", "sha256": "cc2afc7e8c216a1683a90c2f8776844c48ea766a2597907a701868eda1cac2ea",
"bytes": 13517 "bytes": 13517
}, },
{
"path": "backend/app/services/enrollment_oidc_settings.py",
"sha256": "a8d1fbb197834920f22498b9e4f13731336fcd3970d544c6c8ac6e91482e9810",
"bytes": 4081
},
{ {
"path": "backend/app/services/enrollment_plane.py", "path": "backend/app/services/enrollment_plane.py",
"sha256": "d04fd7a4399c698705719e9748130e450702f541c3e69b003480b4e5621a256c", "sha256": "66ac8ffbc3015e76c76eb62313184933fe4696a3238fda97529f9009698c0c6c",
"bytes": 63491 "bytes": 66255
}, },
{ {
"path": "backend/app/services/enrollment_profiles.py", "path": "backend/app/services/enrollment_profiles.py",
@@ -829,8 +834,8 @@
}, },
{ {
"path": "backend/app/services/user_access.py", "path": "backend/app/services/user_access.py",
"sha256": "61c994dc09c413f04838015f8ca0ea6f75d45aec1b63fa21716e3be69adc7c3f", "sha256": "7801c9775889daecea422ac0b06f36f33268f2a4472d19e11bc9053f15e93b21",
"bytes": 9918 "bytes": 9965
}, },
{ {
"path": "backend/app/services/voice_catalog.py", "path": "backend/app/services/voice_catalog.py",
@@ -914,8 +919,8 @@
}, },
{ {
"path": "backend/tests/test_authentik_connector.py", "path": "backend/tests/test_authentik_connector.py",
"sha256": "ac1056893dfa791e074f9962fb9752cd65944af16c41437a8d1824c1b144dd85", "sha256": "54b24a90e7238aaed22d0dfbae70d9772006d20cdb9dbd43d781af48892dcd94",
"bytes": 28251 "bytes": 33759
}, },
{ {
"path": "backend/tests/test_ci_job_docker.py", "path": "backend/tests/test_ci_job_docker.py",
@@ -1009,8 +1014,8 @@
}, },
{ {
"path": "backend/tests/test_deployment_control_plane.py", "path": "backend/tests/test_deployment_control_plane.py",
"sha256": "bf4cb458db21e33ee95fdf601e7eee011fb8e5e9557a9389a269e4fb784bbe44", "sha256": "732e7a029a2f720702589279a2163864a969323979c1ab539c997fef13ba77f8",
"bytes": 11508 "bytes": 18941
}, },
{ {
"path": "backend/tests/test_deployment_hygiene.py", "path": "backend/tests/test_deployment_hygiene.py",
@@ -1027,10 +1032,15 @@
"sha256": "4c60a43935f668c87f76e506dde319f0632308855ec508e81af85840b694edfb", "sha256": "4c60a43935f668c87f76e506dde319f0632308855ec508e81af85840b694edfb",
"bytes": 1175 "bytes": 1175
}, },
{
"path": "backend/tests/test_enrollment_oidc_settings.py",
"sha256": "233b38d6c87af71fd50056908fe88e7addf1228773e5e99a3fa258083e5f2407",
"bytes": 13131
},
{ {
"path": "backend/tests/test_enrollment_plane.py", "path": "backend/tests/test_enrollment_plane.py",
"sha256": "d886402e194b2e76d1eac67761e9eb801af525580261d16cca79ed6273bf01d9", "sha256": "3dcee27db49b04d5d567a22596b3c9447adbb812a20922885d393eab5ce00c31",
"bytes": 41160 "bytes": 44712
}, },
{ {
"path": "backend/tests/test_existing_registered_target_reconciliation.py", "path": "backend/tests/test_existing_registered_target_reconciliation.py",
@@ -1819,8 +1829,8 @@
}, },
{ {
"path": "src/api/liveClient.ts", "path": "src/api/liveClient.ts",
"sha256": "f2cf782e3bbdcbe737dee523d47ba6f0d655048a8253a117525872486f8f1553", "sha256": "0e0d5d74648b475eff227798bf87fa8b39512afb08ffc6a88e5980268f3401fd",
"bytes": 62680 "bytes": 63128
}, },
{ {
"path": "src/api/userSession.test.ts", "path": "src/api/userSession.test.ts",
@@ -1862,6 +1872,11 @@
"sha256": "7cb7bba856f0e32e1443171ac504c27b690ecfaa46fb17a8030127bb420b5180", "sha256": "7cb7bba856f0e32e1443171ac504c27b690ecfaa46fb17a8030127bb420b5180",
"bytes": 1676 "bytes": 1676
}, },
{
"path": "src/components/EnrollmentOidcSettings.tsx",
"sha256": "79e22a529d0f737ab032f3363151fbf7063f703d8b7fea52b22bb98bf6064392",
"bytes": 5097
},
{ {
"path": "src/components/ExposureSettings.tsx", "path": "src/components/ExposureSettings.tsx",
"sha256": "48e9660fb9358b5af4558406c5072675728be4830c1b4dc2ebfd47d9743f1351", "sha256": "48e9660fb9358b5af4558406c5072675728be4830c1b4dc2ebfd47d9743f1351",
@@ -3139,8 +3154,8 @@
}, },
{ {
"path": "src/pages/Settings.tsx", "path": "src/pages/Settings.tsx",
"sha256": "14dd631e2cdbeb39e34d3a587106a434a281f71b9d5c86b5aefcac12d8674ad0", "sha256": "e012f66a6019b0118e28c7064331cceb0c93fe8b8ad0c62588650730d4de4325",
"bytes": 28242 "bytes": 28357
}, },
{ {
"path": "src/pages/Setup.tsx", "path": "src/pages/Setup.tsx",
@@ -3214,8 +3229,8 @@
}, },
{ {
"path": "src/types/appops.ts", "path": "src/types/appops.ts",
"sha256": "75e999b241d6196950fbf0b9decc82339e044fd6ced75513d462b9187a85ab24", "sha256": "21c5cac6b55c7fd402228de18619eb9066d811fd0238840b2b52a809103b43da",
"bytes": 74346 "bytes": 74847
}, },
{ {
"path": "src/utils/toast.ts", "path": "src/utils/toast.ts",
+109
View File
@@ -33,6 +33,11 @@ from ..services.repopulse_adoption_journal import (
get_repopulse_adoption_status, get_repopulse_adoption_status,
) )
from ..services.user_access import current_user 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: def _require_operator(request: Request) -> str:
@@ -225,6 +230,110 @@ def update_enrollment_storage_root(
return EnrollmentPlane.trust_status(session) 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("") @router.post("")
def request_enrollment( def request_enrollment(
payload: EnrollmentRequestV1, payload: EnrollmentRequestV1,
+138 -21
View File
@@ -2,11 +2,12 @@ from __future__ import annotations
import hashlib import hashlib
import hmac import hmac
import json
import re import re
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any 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 from uuid import RFC_4122, UUID
import httpx import httpx
@@ -35,6 +36,10 @@ from ..schemas.forge_oidc import (
from .base import BaseConnector from .base import BaseConnector
_FORGE_OIDC_AUTHENTIK_URL = 'https://auth.example-host.com' _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 = ( _FORGE_OIDC_SCOPE_MAPPING_IDS = (
'AUTHENTIK_FORGE_OIDC_OPENID_CLAIM_MAPPING', 'AUTHENTIK_FORGE_OIDC_OPENID_CLAIM_MAPPING',
'AUTHENTIK_FORGE_OIDC_EMAIL_CLAIM_MAPPING', 'AUTHENTIK_FORGE_OIDC_EMAIL_CLAIM_MAPPING',
@@ -248,6 +253,39 @@ class AuthentikConnector(BaseConnector):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self._read_cache: dict[str, dict[str, Any]] = {} 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]: def _memo(self, key: str, factory: Any) -> dict[str, Any]:
if key not in self._read_cache: 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')) return bool(settings.apis.get('AUTHENTIK_URL') and settings.apis.get('AUTHENTIK_TOKEN'))
def _config(self) -> _AuthentikConfig | None: 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 None
return _AuthentikConfig( return _AuthentikConfig(
url=str(settings.apis['AUTHENTIK_URL']).rstrip('/'), url=str(apis['AUTHENTIK_URL']).rstrip('/'),
token=_token_value(settings.apis['AUTHENTIK_TOKEN']), token=_token_value(apis['AUTHENTIK_TOKEN']),
verify_tls=_as_bool(settings.apis.get('AUTHENTIK_VERIFY_TLS'), False), verify_tls=_as_bool(apis.get('AUTHENTIK_VERIFY_TLS'), False),
timeout_seconds=_as_float(settings.apis.get('AUTHENTIK_TIMEOUT_SECONDS'), 10.0), timeout_seconds=_as_float(apis.get('AUTHENTIK_TIMEOUT_SECONDS'), 10.0),
outpost_url=str(settings.apis.get('AUTHENTIK_OUTPOST_URL') or '').strip().rstrip('/') or None, outpost_url=str(apis.get('AUTHENTIK_OUTPOST_URL') or '').strip().rstrip('/') or None,
expect_embedded_outpost=_as_bool(settings.apis.get('AUTHENTIK_EXPECT_EMBEDDED_OUTPOST'), False), expect_embedded_outpost=_as_bool(apis.get('AUTHENTIK_EXPECT_EMBEDDED_OUTPOST'), False),
) )
def _request(self, path: str, params: dict[str, Any] | None = None) -> Any: def _request(self, path: str, params: dict[str, Any] | None = None) -> Any:
@@ -448,15 +489,20 @@ class AuthentikConnector(BaseConnector):
) )
@staticmethod @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.""" """Report only whether the server-owned OIDC template is configured."""
config = settings.apis if template_config is None else template_config
required = ( required = (
'AUTHENTIK_OIDC_AUTHORIZATION_FLOW', 'AUTHENTIK_OIDC_AUTHORIZATION_FLOW',
'AUTHENTIK_OIDC_INVALIDATION_FLOW', 'AUTHENTIK_OIDC_INVALIDATION_FLOW',
'AUTHENTIK_OIDC_GROUP_CLAIM_MAPPING', '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()] missing = [name for name in required if not str(config.get(name) or '').strip()]
configured_claim = str(settings.apis.get('AUTHENTIK_OIDC_ROLE_CLAIM') or '').strip() configured_claim = str(config.get('AUTHENTIK_OIDC_ROLE_CLAIM') or '').strip()
if configured_claim != role_claim: if configured_claim != role_claim:
missing.append('AUTHENTIK_OIDC_ROLE_CLAIM') missing.append('AUTHENTIK_OIDC_ROLE_CLAIM')
return { return {
@@ -464,6 +510,68 @@ class AuthentikConnector(BaseConnector):
'missing': missing, '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( def ensure_deployment_oidc_application(
self, self,
*, *,
@@ -489,11 +597,17 @@ class AuthentikConnector(BaseConnector):
return {'success': False, 'status': 'invalid', 'message': 'invalid_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: 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'} return {'success': False, 'status': 'invalid', 'message': 'invalid_redirect_uri'}
authorization_flow = str(settings.apis.get('AUTHENTIK_OIDC_AUTHORIZATION_FLOW') or '').strip() config = self._deployment_oidc_config()
invalidation_flow = str(settings.apis.get('AUTHENTIK_OIDC_INVALIDATION_FLOW') or '').strip() authorization_flow = str(config.get('AUTHENTIK_OIDC_AUTHORIZATION_FLOW') or '').strip()
group_claim_mapping = str(settings.apis.get('AUTHENTIK_OIDC_GROUP_CLAIM_MAPPING') or '').strip() invalidation_flow = str(config.get('AUTHENTIK_OIDC_INVALIDATION_FLOW') or '').strip()
if not authorization_flow or not invalidation_flow or not group_claim_mapping: 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'} 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: try:
groups = self._paged('/core/groups/') groups = self._paged('/core/groups/')
group_names = {str(item.get('name') or '') for item in groups} group_names = {str(item.get('name') or '') for item in groups}
@@ -516,15 +630,17 @@ class AuthentikConnector(BaseConnector):
None, None,
) )
provider_redirects = provider.get('redirect_uris') if isinstance(provider, dict) else 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 provider_mappings = provider.get('property_mappings') if isinstance(provider, dict) else None
if ( if (
not isinstance(provider, dict) not isinstance(provider, dict)
or str(provider.get('client_id') or '') != application_slug or str(provider.get('client_id') or '') != application_slug
or str(provider.get('client_type') or '') != 'confidential' or str(provider.get('client_type') or '') != 'confidential'
or provider_redirects != [redirect_uri] or provider_redirects != expected_redirects
or group_claim_mapping not in (provider_mappings or []) 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('authorization_flow') or '') != authorization_flow
or str(provider.get('invalidation_flow') or '') != invalidation_flow or str(provider.get('invalidation_flow') or '') != invalidation_flow
): ):
@@ -547,8 +663,9 @@ class AuthentikConnector(BaseConnector):
'client_type': 'confidential', 'client_type': 'confidential',
'client_id': application_slug, 'client_id': application_slug,
'client_secret': client_secret, 'client_secret': client_secret,
'redirect_uris': [redirect_uri], 'redirect_uris': expected_redirects,
'property_mappings': [group_claim_mapping], 'property_mappings': expected_mappings,
'signing_key': signing_key,
'include_claims_in_id_token': True, 'include_claims_in_id_token': True,
'sub_mode': 'hashed_user_id', 'sub_mode': 'hashed_user_id',
'issuer_mode': 'per_provider', 'issuer_mode': 'per_provider',
+8
View File
@@ -286,6 +286,10 @@ class Settings:
"AUTHENTIK_OIDC_AUTHORIZATION_FLOW": _env_value("AUTHENTIK_OIDC_AUTHORIZATION_FLOW", None), "AUTHENTIK_OIDC_AUTHORIZATION_FLOW": _env_value("AUTHENTIK_OIDC_AUTHORIZATION_FLOW", None),
"AUTHENTIK_OIDC_INVALIDATION_FLOW": _env_value("AUTHENTIK_OIDC_INVALIDATION_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_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 # The reviewed group mapping must emit this exact claim. The
# enrollment schema only accepts its bounded role contract. # enrollment schema only accepts its bounded role contract.
"AUTHENTIK_OIDC_ROLE_CLAIM": _env_value("AUTHENTIK_OIDC_ROLE_CLAIM", None), "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_AUTHORIZATION_FLOW": bool(self.apis["AUTHENTIK_OIDC_AUTHORIZATION_FLOW"]),
"AUTHENTIK_OIDC_INVALIDATION_FLOW": bool(self.apis["AUTHENTIK_OIDC_INVALIDATION_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_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_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_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"]), "AUTHENTIK_FORGE_OIDC_BINDING_VALID": bool(self.apis["AUTHENTIK_FORGE_OIDC_BINDING_VALID"]),
+11
View File
@@ -12,6 +12,8 @@ from uuid import uuid4
from fastapi import Depends, FastAPI, HTTPException from fastapi import Depends, FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse 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 fastapi.staticfiles import StaticFiles
from sqlmodel import Session from sqlmodel import Session
from starlette.types import ASGIApp, Message, Receive, Scope, Send 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(runtime_router)
app.include_router(deployments_router) app.include_router(deployments_router)
app.include_router(enrollments_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(integrations_router)
app.include_router(n8n_router) app.include_router(n8n_router)
app.include_router(knowledge_router) app.include_router(knowledge_router)
@@ -22,9 +22,10 @@ from ..connectors.portainer import PortainerConnector
from ..connectors.unraid import UnraidConnector from ..connectors.unraid import UnraidConnector
from ..core.time import utc_now from ..core.time import utc_now
from ..db.models import DeploymentJob, Policy from ..db.models import DeploymentJob, Policy
from ..schemas.deployment import DeploymentPlanV1 from ..schemas.deployment import DeploymentPlanV1, OidcProvisioningV1
from .audit_logger import log_event from .audit_logger import log_event
from .deployment_broker import DeploymentBrokerClient from .deployment_broker import DeploymentBrokerClient
from .enrollment_oidc_settings import bind_template, load_profile
from .policy_engine import evaluate_policy from .policy_engine import evaluate_policy
from .secret_store import get_secret, has_secret, secret_storage_status from .secret_store import get_secret, has_secret, secret_storage_status
@@ -76,6 +77,29 @@ class DeploymentControlPlane:
self.authentik = authentik or AuthentikConnector() self.authentik = authentik or AuthentikConnector()
self.unraid = unraid or UnraidConnector() 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( def preflight(
self, self,
session: Session, session: Session,
@@ -139,6 +163,12 @@ class DeploymentControlPlane:
checks.append(_check("proxy_hostname", "blocked", "Requested hostname is owned by a different NPM target.")) checks.append(_check("proxy_hostname", "blocked", "Requested hostname is owned by a different NPM target."))
if plan.oidc: 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() auth_status = self.authentik.test_connection()
if auth_status.get("status") != "connected": if auth_status.get("status") != "connected":
checks.append(_check("oidc_provider", "blocked", "Authentik is unavailable for the requested OIDC plan.")) checks.append(_check("oidc_provider", "blocked", "Authentik is unavailable for the requested OIDC plan."))
@@ -254,6 +284,8 @@ class DeploymentControlPlane:
""" """
evidence: dict[str, Any] = {} evidence: dict[str, Any] = {}
if plan.oidc: 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) client_secret = get_secret(session, plan.oidc.client_secret_ref, touch=False)
if not client_secret: if not client_secret:
return False, evidence, "Referenced OIDC client secret is unavailable after deployment." return False, evidence, "Referenced OIDC client secret is unavailable after deployment."
@@ -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,
)
+54 -3
View File
@@ -14,6 +14,7 @@ import re
import secrets import secrets
import uuid import uuid
from typing import Any, Literal from typing import Any, Literal
from urllib.parse import urlsplit, urlunsplit
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlmodel import Session, select from sqlmodel import Session, select
@@ -30,6 +31,7 @@ from ..schemas.enrollment import (
EnrollmentTrustConfigV1, EnrollmentTrustConfigV1,
) )
from .audit_logger import log_event from .audit_logger import log_event
from .enrollment_oidc_settings import bind_template
from .deployment_broker import DeploymentBrokerClient from .deployment_broker import DeploymentBrokerClient
from .enrollment_profiles import PROFILE_VERSION, profile_contract, profile_summary from .enrollment_profiles import PROFILE_VERSION, profile_contract, profile_summary
from .secret_store import ( from .secret_store import (
@@ -155,6 +157,22 @@ def _enrollment_secret_store_ready(storage: dict[str, Any]) -> bool:
return bool(storage.get("productionReady")) 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: class EnrollmentPlane:
def __init__( def __init__(
self, self,
@@ -222,6 +240,8 @@ class EnrollmentPlane:
def capabilities(self, session: Session) -> dict[str, Any]: def capabilities(self, session: Session) -> dict[str, Any]:
trust = self.get_trust_config(session) trust = self.get_trust_config(session)
if isinstance(self.authentik, AuthentikConnector):
bind_template(session, self.authentik)
storage = secret_storage_status() storage = secret_storage_status()
configured = trust is not None configured = trust is not None
bootstrap = bool(trust and has_secret(session, trust.bootstrap_secret_ref)) bootstrap = bool(trust and has_secret(session, trust.bootstrap_secret_ref))
@@ -335,11 +355,30 @@ class EnrollmentPlane:
oidc_configured = bool( oidc_configured = bool(
trust trust
and trust.oidc_issuer and trust.oidc_issuer
and _application_oidc_issuer(trust.oidc_issuer)
and trust.administrator_group and trust.administrator_group
and trust.support_agent_group and trust.support_agent_group
and trust.read_only_group and trust.read_only_group
and self.authentik.is_configured() 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) certificate_ready = bool(npm_ready)
def item(ready: bool, code: str) -> dict[str, str]: 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]: def preflight(self, session: Session, plan: EnrollmentPlanV1) -> dict[str, Any]:
checks: list[dict[str, str]] = [] checks: list[dict[str, str]] = []
trust = self.get_trust_config(session) trust = self.get_trust_config(session)
if isinstance(self.authentik, AuthentikConnector):
bind_template(session, self.authentik)
profile = profile_contract(plan.profile) profile = profile_contract(plan.profile)
if trust is None: if trust is None:
checks.append(_check("enrollment_trust_not_configured", "blocked", "Root enrollment trust boundaries are not configured.")) 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.")) checks.append(_check("oidc_profile_unavailable", "blocked", "Configured OIDC profile or Authentik role groups are unavailable."))
else: else:
assert trust is not None and plan.authentication.roles is not None 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 = { expected_roles = {
"administrator": trust.administrator_group, "administrator": trust.administrator_group,
"support_agent": trust.support_agent_group, "support_agent": trust.support_agent_group,
@@ -603,8 +651,9 @@ class EnrollmentPlane:
if auth_status.get("status") != "connected": if auth_status.get("status") != "connected":
checks.append(_check("oidc_provider", "blocked", "Authentik is unavailable.")) checks.append(_check("oidc_provider", "blocked", "Authentik is unavailable."))
else: else:
template = self.authentik.deployment_oidc_template_status( template = self.authentik.validate_deployment_oidc_template(
role_claim=trust.oidc_role_claim role_claim=trust.oidc_role_claim,
role_groups=(trust.administrator_group, trust.support_agent_group, trust.read_only_group),
) )
if template.get("status") != "ready": if template.get("status") != "ready":
checks.append(_check("oidc_profile_unavailable", "blocked", "The server-owned Authentik OIDC template is incomplete.")) 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") raise EnrollmentError("application identity or repository is already enrolled")
trust = self.get_trust_config(session) trust = self.get_trust_config(session)
if isinstance(self.authentik, AuthentikConnector):
bind_template(session, self.authentik)
if trust is None: if trust is None:
raise EnrollmentError("enrollment trust boundaries are not configured") raise EnrollmentError("enrollment trust boundaries are not configured")
enrollment = self._create_enrollment(session, plan, plan_digest, trust) enrollment = self._create_enrollment(session, plan, plan_digest, trust)
@@ -1145,7 +1196,7 @@ class EnrollmentPlane:
runtime_values.update({ runtime_values.update({
"OIDC_CLIENT_ID": plan.application_id, "OIDC_CLIENT_ID": plan.application_id,
"OIDC_CLIENT_SECRET": oidc_secret, "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, "OIDC_ROLE_CLAIM": trust.oidc_role_claim,
}) })
if plan.profile == "repopulse-authenticated": if plan.profile == "repopulse-authenticated":
+1
View File
@@ -175,6 +175,7 @@ def required_permission(path: str, method: str) -> str:
"/api/knowledge/reindex", "/api/knowledge/reindex",
"/api/enrollments/trust", "/api/enrollments/trust",
"/api/enrollments/trust/storage-root", "/api/enrollments/trust/storage-root",
"/api/enrollments/trust/oidc-profile",
}: }:
return "settings.manage" return "settings.manage"
if path.startswith("/api/apps/") and path.endswith(("/metadata", "/container-mapping")): if path.startswith("/api/apps/") and path.endswith(("/metadata", "/container-mapping")):
+111 -4
View File
@@ -1,5 +1,7 @@
from pathlib import Path from pathlib import Path
import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from sqlmodel import SQLModel, create_engine 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 from backend.app.core.config import settings
previous = _set_authentik_config() previous = _set_authentik_config()
_set_deployment_identity_template()
settings.apis['AUTHENTIK_OIDC_AUTHORIZATION_FLOW'] = 'flow-default-auth' settings.apis['AUTHENTIK_OIDC_AUTHORIZATION_FLOW'] = 'flow-default-auth'
settings.apis['AUTHENTIK_OIDC_INVALIDATION_FLOW'] = 'flow-default-invalidation' settings.apis['AUTHENTIK_OIDC_INVALIDATION_FLOW'] = 'flow-default-invalidation'
settings.apis['AUTHENTIK_OIDC_GROUP_CLAIM_MAPPING'] = 'mapping-groups' 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) _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): def test_typed_oidc_provisioning_is_bounded_and_never_returns_client_secret(monkeypatch):
from backend.app.connectors.authentik import AuthentikConnector from backend.app.connectors.authentik import AuthentikConnector
from backend.app.core.config import settings from backend.app.core.config import settings
previous = _set_authentik_config() previous = _set_authentik_config()
_set_deployment_identity_template()
settings.apis['AUTHENTIK_OIDC_AUTHORIZATION_FLOW'] = 'flow-default-auth' settings.apis['AUTHENTIK_OIDC_AUTHORIZATION_FLOW'] = 'flow-default-auth'
settings.apis['AUTHENTIK_OIDC_INVALIDATION_FLOW'] = 'flow-default-invalidation' settings.apis['AUTHENTIK_OIDC_INVALIDATION_FLOW'] = 'flow-default-invalidation'
settings.apis['AUTHENTIK_OIDC_GROUP_CLAIM_MAPPING'] = 'mapping-groups' 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][0:2] == ('POST', '/providers/oauth2/')
assert writes[0][2]['client_secret'] == secret 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) assert secret not in str(result)
_restore_config(previous) _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 from backend.app.core.config import settings
previous = _set_authentik_config() previous = _set_authentik_config()
_set_deployment_identity_template()
settings.apis['AUTHENTIK_OIDC_AUTHORIZATION_FLOW'] = 'flow-default-auth' settings.apis['AUTHENTIK_OIDC_AUTHORIZATION_FLOW'] = 'flow-default-auth'
settings.apis['AUTHENTIK_OIDC_INVALIDATION_FLOW'] = 'flow-default-invalidation' settings.apis['AUTHENTIK_OIDC_INVALIDATION_FLOW'] = 'flow-default-invalidation'
settings.apis['AUTHENTIK_OIDC_GROUP_CLAIM_MAPPING'] = 'mapping-groups' 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) _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.connectors.authentik import AuthentikConnector
from backend.app.core.config import settings from backend.app.core.config import settings
previous = _set_authentik_config() previous = _set_authentik_config()
_set_deployment_identity_template()
settings.apis['AUTHENTIK_OIDC_AUTHORIZATION_FLOW'] = 'flow-default-auth' settings.apis['AUTHENTIK_OIDC_AUTHORIZATION_FLOW'] = 'flow-default-auth'
settings.apis['AUTHENTIK_OIDC_INVALIDATION_FLOW'] = 'flow-default-invalidation' settings.apis['AUTHENTIK_OIDC_INVALIDATION_FLOW'] = 'flow-default-invalidation'
settings.apis['AUTHENTIK_OIDC_GROUP_CLAIM_MAPPING'] = 'mapping-groups' 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( monkeypatch.setattr(
AuthentikConnector, AuthentikConnector,
'_paged', '_paged',
lambda self, path, params=None: { lambda self, path, params=None: {
'/core/groups/': [{'name': 'Administrator'}, {'name': 'SupportAgent'}, {'name': 'ReadOnly'}], '/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'}], '/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], }[path],
raising=True, raising=True,
) )
@@ -279,11 +380,17 @@ def test_typed_oidc_existing_application_requires_matching_provider_contract(mon
finally: finally:
_restore_config(previous) _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, 'success': False,
'status': 'conflict', 'status': 'conflict',
'message': 'existing_oidc_provider_does_not_match_managed_contract', 'message': 'existing_oidc_provider_does_not_match_managed_contract',
} })
def test_authentik_health_success_and_auth_failure_redact_token(monkeypatch): def test_authentik_health_success_and_auth_failure_redact_token(monkeypatch):
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import pytest
from sqlmodel import Session, SQLModel, create_engine from sqlmodel import Session, SQLModel, create_engine
from backend.app.db.models import Policy from backend.app.db.models import Policy
@@ -28,6 +29,7 @@ def _plan(**overrides):
"proxy": {"hostname": "support.example.com"}, "proxy": {"hostname": "support.example.com"},
"oidc": { "oidc": {
"application_slug": "wsbc-portal", "application_slug": "wsbc-portal",
"role_claim": "roles",
"client_secret_ref": "WSBC_PORTAL_OIDC_CLIENT_SECRET", "client_secret_ref": "WSBC_PORTAL_OIDC_CLIENT_SECRET",
"roles": { "roles": {
"administrator_group": "Administrator", "administrator_group": "Administrator",
@@ -72,6 +74,18 @@ class _Npm:
class _Authentik: 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): def test_connection(self):
return {"status": "connected"} 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 "broker-secret" not in str(result)
assert "npm-secret" not in str(result) assert "npm-secret" not in str(result)
assert "oidc-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 == {}
@@ -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/')
+76
View File
@@ -202,6 +202,9 @@ class _Authentik:
assert role_claim == "roles" assert role_claim == "roles"
return {"status": "ready", "missing": []} 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): def ensure_deployment_oidc_application(self, **kwargs):
assert kwargs["application_slug"] == "wsbc-portal" assert kwargs["application_slug"] == "wsbc-portal"
assert kwargs["client_secret"] assert kwargs["client_secret"]
@@ -434,6 +437,79 @@ def test_capabilities_require_a_live_bootstrap_controller_contract(tmp_path):
assert result["controller"]["status"] == "ready" 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): def test_capabilities_block_when_controller_root_differs_from_operator_trust(tmp_path):
class AlternateRootBroker(_Broker): class AlternateRootBroker(_Broker):
appdata_root = "/var/lib/docker/volumes/itworx-managed-appdata/_data" appdata_root = "/var/lib/docker/volumes/itworx-managed-appdata/_data"
+5 -1
View File
@@ -4,7 +4,7 @@ import { AISummary, AIRecommendation, AIChatMessage, AIAutopilotRule, AITool } f
import { toIsoString } from '@/lib/backendDateTime'; 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 { 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 { 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'; import type { ExposurePreferences, ManagedAiConfig, ManagedAiConfigUpdate, ManagedAiStatus, ManagedAiInference } from '../types/appops';
type HealthStatus = { type HealthStatus = {
@@ -386,6 +386,10 @@ export const createLiveClient = () => ({
}, },
async getEnrollmentCapabilities(): Promise<EnrollmentCapabilities> { return get<EnrollmentCapabilities>('/api/enrollments/capabilities', defaultConfig); }, async getEnrollmentCapabilities(): Promise<EnrollmentCapabilities> { return get<EnrollmentCapabilities>('/api/enrollments/capabilities', defaultConfig); },
async getEnrollmentTrustStatus(): Promise<EnrollmentTrustStatus> { return get<EnrollmentTrustStatus>('/api/enrollments/trust', defaultConfig); }, async getEnrollmentTrustStatus(): Promise<EnrollmentTrustStatus> { return get<EnrollmentTrustStatus>('/api/enrollments/trust', defaultConfig); },
async getEnrollmentOidcProfile(): Promise<EnrollmentOidcProfileStatus> { return get<EnrollmentOidcProfileStatus>('/api/enrollments/trust/oidc-profile', defaultConfig); },
async updateEnrollmentOidcProfile(payload: EnrollmentOidcProfile): Promise<EnrollmentOidcProfileStatus> {
return patch<EnrollmentOidcProfileStatus>('/api/enrollments/trust/oidc-profile', payload, defaultConfig);
},
async updateEnrollmentStorageRoot(payload: EnrollmentStorageRootSelection): Promise<EnrollmentTrustStatus> { async updateEnrollmentStorageRoot(payload: EnrollmentStorageRootSelection): Promise<EnrollmentTrustStatus> {
return patch<EnrollmentTrustStatus>('/api/enrollments/trust/storage-root', payload, defaultConfig); return patch<EnrollmentTrustStatus>('/api/enrollments/trust/storage-root', payload, defaultConfig);
}, },
+82
View File
@@ -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<EnrollmentOidcProfile>(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 <Card className="mb-5 border-slate-700 bg-slate-900/50">
<CardHeader><CardTitle className="flex items-center gap-2 text-sm"><KeyRound className="h-4 w-4" /> Authenticatie voor nieuwe deploymentdoelen</CardTitle></CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-slate-400">Koppel bestaande Authentik-templates voor native OIDC. Rollen: Administrator, SupportAgent en ReadOnly. Nieuwe applicaties krijgen bij enrollment een eigen client en secret.</p>
<p className="text-sm">Rootprofiel: {configured ? 'gekoppeld' : 'niet gekoppeld'}. De actuele deployment-readiness controleert de tenant opnieuw.</p>
{error && <p role="alert" className="text-sm text-red-300">{error}</p>}
{notice && <p role="status" className="text-sm text-emerald-300">{notice}</p>}
<div className="grid gap-4 md:grid-cols-2">{fields.map(([field, label]) => <div key={field} className="space-y-2">
<Label htmlFor={`enrollment-oidc-${field}`}>{label}</Label>
<Input id={`enrollment-oidc-${field}`} value={draft[field]} disabled={busy || !loaded}
onChange={event => setDraft(current => ({ ...current, [field]: event.target.value.trim() }))} />
</div>)}</div>
<div className="flex gap-2">
<Button type="button" variant="outline" disabled={busy} onClick={() => void load()}><RefreshCw className="mr-2 h-4 w-4" />Vernieuwen</Button>
<Button type="button" disabled={busy || !loaded || fields.some(([field]) => !draft[field])} onClick={() => void save()}>
{busy && <LoaderCircle className="mr-2 h-4 w-4 animate-spin" />}Verifiëren en opslaan
</Button>
</div>
</CardContent>
</Card>;
};
+2
View File
@@ -16,6 +16,7 @@ import { UserAccessSettings } from "@/components/UserAccessSettings";
import { ExposureSettings } from "@/components/ExposureSettings"; import { ExposureSettings } from "@/components/ExposureSettings";
import { ManagedAiSettings } from "@/components/ManagedAiSettings"; import { ManagedAiSettings } from "@/components/ManagedAiSettings";
import { ManagedStorageSettings } from "@/components/ManagedStorageSettings"; import { ManagedStorageSettings } from "@/components/ManagedStorageSettings";
import { EnrollmentOidcSettings } from "@/components/EnrollmentOidcSettings";
const settingsTabs = ["general", "security", "ai", "notifications", "audit", "voice", "maintenance", "backups"] as const; const settingsTabs = ["general", "security", "ai", "notifications", "audit", "voice", "maintenance", "backups"] as const;
type SettingsTab = typeof settingsTabs[number]; type SettingsTab = typeof settingsTabs[number];
@@ -196,6 +197,7 @@ const Settings = () => {
<TabsContent value="general"> <TabsContent value="general">
<ManagedStorageSettings /> <ManagedStorageSettings />
<EnrollmentOidcSettings />
<Card className="signal-rail border-slate-700 bg-slate-900/50"> <Card className="signal-rail border-slate-700 bg-slate-900/50">
<CardHeader> <CardHeader>
<CardTitle className="text-sm font-bold uppercase tracking-wider text-slate-400">Runtime Profiel</CardTitle> <CardTitle className="text-sm font-bold uppercase tracking-wider text-slate-400">Runtime Profiel</CardTitle>
+19
View File
@@ -164,6 +164,25 @@ export interface EnrollmentStorageRootSelection {
managed_appdata_backup_acknowledged: boolean; 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 { export interface Enrollment {
id: string; id: string;
applicationId: string; applicationId: string;