Publish curated Unraid-AppOps-Gateway source from 68878c3167dacf2b424c16780e60e0d60815e477
This commit is contained in:
1 parent
837c263cd8
commit
551de41e9f
18 files changed
+1183
-58
No files matched your search
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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"]),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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":
|
||||||
|
|||||||
@@ -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")):
|
||||||
|
|||||||
@@ -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/')
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in new issue
Block a user