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
@@ -33,6 +33,11 @@ from ..services.repopulse_adoption_journal import (
|
||||
get_repopulse_adoption_status,
|
||||
)
|
||||
from ..services.user_access import current_user
|
||||
from ..connectors.authentik import AuthentikConnector
|
||||
from ..services.setup_service import apply_stored_connector_settings
|
||||
from ..services.enrollment_oidc_settings import (
|
||||
OIDC_TEMPLATE_SETTING_KEY, EnrollmentOidcProfileV1, load_profile, trusted_issuer_origin,
|
||||
)
|
||||
|
||||
|
||||
def _require_operator(request: Request) -> str:
|
||||
@@ -225,6 +230,110 @@ def update_enrollment_storage_root(
|
||||
return EnrollmentPlane.trust_status(session)
|
||||
|
||||
|
||||
def _oidc_profile_status(session: Session):
|
||||
apply_stored_connector_settings(session, {"authentik"})
|
||||
profile = load_profile(session)
|
||||
trust = EnrollmentPlane.get_trust_config(session)
|
||||
matches = bool(profile and trust and profile.issuer == trusted_issuer_origin()
|
||||
and AuthentikConnector.deployment_oidc_origin_matches(profile.issuer) and (
|
||||
trust.oidc_issuer == profile.issuer
|
||||
and trust.administrator_group == profile.administrator_group
|
||||
and trust.support_agent_group == profile.support_agent_group
|
||||
and trust.read_only_group == profile.read_only_group
|
||||
))
|
||||
return {"configured": matches, "issuerOrigin": trusted_issuer_origin(), "profile": profile.model_dump(mode="json") if profile else None}
|
||||
|
||||
|
||||
@router.get("/trust/oidc-profile")
|
||||
def enrollment_oidc_profile_status(session: EnrollmentSession, request: Request):
|
||||
if current_user(request).role != "admin":
|
||||
raise HTTPException(status_code=403, detail="Administrator role is required.")
|
||||
return _oidc_profile_status(session)
|
||||
|
||||
|
||||
@router.patch("/trust/oidc-profile")
|
||||
def update_enrollment_oidc_profile(
|
||||
payload: EnrollmentOidcProfileV1, session: EnrollmentSession, request: Request,
|
||||
):
|
||||
if current_user(request).role != "admin":
|
||||
raise HTTPException(status_code=403, detail="Administrator role is required.")
|
||||
if payload.issuer != trusted_issuer_origin():
|
||||
raise HTTPException(status_code=409, detail="oidc_issuer_mismatch")
|
||||
trust_row = session.get(AppSetting, TRUST_SETTING_KEY)
|
||||
binding_row = session.get(AppSetting, OIDC_TEMPLATE_SETTING_KEY)
|
||||
if trust_row is None or trust_row.is_secret:
|
||||
raise HTTPException(status_code=409, detail="enrollment_trust_not_configured")
|
||||
original = deepcopy(trust_row.value_json)
|
||||
trust_updated_at = trust_row.updated_at
|
||||
binding_original = deepcopy(binding_row.value_json) if binding_row else None
|
||||
binding_updated_at = binding_row.updated_at if binding_row else None
|
||||
if binding_row and binding_row.is_secret:
|
||||
raise HTTPException(status_code=409, detail="oidc_profile_binding_invalid")
|
||||
try:
|
||||
updated = EnrollmentTrustConfigV1.model_validate({
|
||||
**original,
|
||||
"oidc_issuer": payload.issuer, "oidc_role_claim": "roles",
|
||||
"administrator_group": payload.administrator_group,
|
||||
"support_agent_group": payload.support_agent_group,
|
||||
"read_only_group": payload.read_only_group,
|
||||
})
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=409, detail="enrollment_trust_changed") from None
|
||||
apply_stored_connector_settings(session, {"authentik"})
|
||||
connector = AuthentikConnector()
|
||||
if not connector.deployment_oidc_origin_matches(payload.issuer):
|
||||
raise HTTPException(status_code=409, detail="oidc_connector_issuer_mismatch")
|
||||
connector.configure_deployment_oidc_template(payload.template_config(), issuer_origin=payload.issuer)
|
||||
template = connector.validate_deployment_oidc_template(
|
||||
role_claim="roles",
|
||||
role_groups=(payload.administrator_group, payload.support_agent_group, payload.read_only_group),
|
||||
)
|
||||
if template.get("status") != "ready" or not connector.is_configured():
|
||||
raise HTTPException(status_code=409, detail="oidc_profile_not_verified")
|
||||
groups = connector.groups()
|
||||
names = {str(g.get("name") or "") for g in groups.get("items", []) if isinstance(g, dict)}
|
||||
if groups.get("success") is not True or not {payload.administrator_group, payload.support_agent_group, payload.read_only_group}.issubset(names):
|
||||
raise HTTPException(status_code=409, detail="oidc_profile_not_verified")
|
||||
value = payload.model_dump(mode="json")
|
||||
try:
|
||||
changed = session.execute(sql_update(AppSetting)
|
||||
.where(AppSetting.key == TRUST_SETTING_KEY)
|
||||
.where(AppSetting.updated_at == trust_updated_at)
|
||||
.where(AppSetting.value_json == original)
|
||||
.values(value_json=updated.model_dump(mode="json"), updated_at=utc_now())
|
||||
.execution_options(synchronize_session=False))
|
||||
if changed.rowcount != 1:
|
||||
raise HTTPException(status_code=409, detail="enrollment_trust_changed")
|
||||
if binding_row is None:
|
||||
session.execute(sql_insert(AppSetting).values(
|
||||
key=OIDC_TEMPLATE_SETTING_KEY, value_json=value, is_secret=False, updated_at=utc_now(),
|
||||
))
|
||||
else:
|
||||
changed = session.execute(sql_update(AppSetting)
|
||||
.where(AppSetting.key == OIDC_TEMPLATE_SETTING_KEY)
|
||||
.where(AppSetting.updated_at == binding_updated_at)
|
||||
.where(AppSetting.value_json == binding_original)
|
||||
.values(value_json=value, updated_at=utc_now())
|
||||
.execution_options(synchronize_session=False))
|
||||
if changed.rowcount != 1:
|
||||
raise HTTPException(status_code=409, detail="oidc_profile_changed")
|
||||
log_event(session, source="settings", identity="manual",
|
||||
action_id="enrollment.trust.oidc_profile.update", target="tower/deployment-controller",
|
||||
risk="high", policy_decision="allowed", result="updated", target_type="controller",
|
||||
reason="Administrator bound verified existing generic OIDC templates and three empty-or-existing role groups.",
|
||||
before_state={"configured": binding_original is not None},
|
||||
after_state={"configured": True, "issuer": payload.issuer},
|
||||
)
|
||||
except HTTPException:
|
||||
session.rollback()
|
||||
raise
|
||||
except SQLAlchemyError:
|
||||
session.rollback()
|
||||
raise HTTPException(status_code=409, detail="oidc_profile_changed") from None
|
||||
session.expire_all()
|
||||
return _oidc_profile_status(session)
|
||||
|
||||
|
||||
@router.post("")
|
||||
def request_enrollment(
|
||||
payload: EnrollmentRequestV1,
|
||||
|
||||
@@ -2,11 +2,12 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from urllib.parse import quote, urljoin, urlparse
|
||||
from urllib.parse import quote, urljoin, urlparse, urlsplit
|
||||
from uuid import RFC_4122, UUID
|
||||
|
||||
import httpx
|
||||
@@ -35,6 +36,10 @@ from ..schemas.forge_oidc import (
|
||||
from .base import BaseConnector
|
||||
|
||||
_FORGE_OIDC_AUTHENTIK_URL = 'https://auth.example-host.com'
|
||||
DEPLOYMENT_OIDC_PROFILE_MAPPING_EXPRESSION = '''return delete_none_values({
|
||||
"name": request.user.name,
|
||||
"preferred_username": request.user.username,
|
||||
})'''
|
||||
_FORGE_OIDC_SCOPE_MAPPING_IDS = (
|
||||
'AUTHENTIK_FORGE_OIDC_OPENID_CLAIM_MAPPING',
|
||||
'AUTHENTIK_FORGE_OIDC_EMAIL_CLAIM_MAPPING',
|
||||
@@ -248,6 +253,39 @@ class AuthentikConnector(BaseConnector):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._read_cache: dict[str, dict[str, Any]] = {}
|
||||
self._deployment_template_config: dict[str, str] | None = None
|
||||
self._deployment_template_issuer: str | None = None
|
||||
|
||||
def configure_deployment_oidc_template(self, config: dict[str, str] | None, *, issuer_origin: str | None = None) -> None:
|
||||
self._deployment_template_config = dict(config) if config is not None else None
|
||||
self._deployment_template_issuer = issuer_origin
|
||||
self._read_cache.clear()
|
||||
|
||||
@staticmethod
|
||||
def deployment_oidc_origin_matches(issuer_origin: str) -> bool:
|
||||
"""Root templates use the established tenant over verified HTTPS."""
|
||||
apis = dict(settings.apis)
|
||||
return AuthentikConnector._deployment_oidc_endpoint_matches(issuer_origin, apis)
|
||||
|
||||
@staticmethod
|
||||
def _deployment_oidc_endpoint_matches(issuer_origin: str, apis: dict[str, Any]) -> bool:
|
||||
try:
|
||||
endpoint = urlsplit(str(apis.get('AUTHENTIK_URL') or ''))
|
||||
origin = f'{endpoint.scheme}://{endpoint.netloc.lower()}'
|
||||
return bool(
|
||||
endpoint.scheme == 'https' and endpoint.hostname
|
||||
and not endpoint.username and not endpoint.password
|
||||
and endpoint.path in {'', '/'} and not endpoint.query and not endpoint.fragment
|
||||
and origin == issuer_origin
|
||||
and _as_bool(apis.get('AUTHENTIK_VERIFY_TLS'), False)
|
||||
)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
def _deployment_oidc_config(self) -> dict[str, Any]:
|
||||
if self._deployment_template_issuer and not self.deployment_oidc_origin_matches(self._deployment_template_issuer):
|
||||
return {}
|
||||
return self._deployment_template_config if self._deployment_template_config is not None else settings.apis
|
||||
|
||||
def _memo(self, key: str, factory: Any) -> dict[str, Any]:
|
||||
if key not in self._read_cache:
|
||||
@@ -258,15 +296,18 @@ class AuthentikConnector(BaseConnector):
|
||||
return bool(settings.apis.get('AUTHENTIK_URL') and settings.apis.get('AUTHENTIK_TOKEN'))
|
||||
|
||||
def _config(self) -> _AuthentikConfig | None:
|
||||
if not self.is_configured():
|
||||
apis = dict(settings.apis)
|
||||
if not (apis.get('AUTHENTIK_URL') and apis.get('AUTHENTIK_TOKEN')):
|
||||
return None
|
||||
if self._deployment_template_issuer and not self._deployment_oidc_endpoint_matches(self._deployment_template_issuer, apis):
|
||||
return None
|
||||
return _AuthentikConfig(
|
||||
url=str(settings.apis['AUTHENTIK_URL']).rstrip('/'),
|
||||
token=_token_value(settings.apis['AUTHENTIK_TOKEN']),
|
||||
verify_tls=_as_bool(settings.apis.get('AUTHENTIK_VERIFY_TLS'), False),
|
||||
timeout_seconds=_as_float(settings.apis.get('AUTHENTIK_TIMEOUT_SECONDS'), 10.0),
|
||||
outpost_url=str(settings.apis.get('AUTHENTIK_OUTPOST_URL') or '').strip().rstrip('/') or None,
|
||||
expect_embedded_outpost=_as_bool(settings.apis.get('AUTHENTIK_EXPECT_EMBEDDED_OUTPOST'), False),
|
||||
url=str(apis['AUTHENTIK_URL']).rstrip('/'),
|
||||
token=_token_value(apis['AUTHENTIK_TOKEN']),
|
||||
verify_tls=_as_bool(apis.get('AUTHENTIK_VERIFY_TLS'), False),
|
||||
timeout_seconds=_as_float(apis.get('AUTHENTIK_TIMEOUT_SECONDS'), 10.0),
|
||||
outpost_url=str(apis.get('AUTHENTIK_OUTPOST_URL') or '').strip().rstrip('/') or None,
|
||||
expect_embedded_outpost=_as_bool(apis.get('AUTHENTIK_EXPECT_EMBEDDED_OUTPOST'), False),
|
||||
)
|
||||
|
||||
def _request(self, path: str, params: dict[str, Any] | None = None) -> Any:
|
||||
@@ -448,15 +489,20 @@ class AuthentikConnector(BaseConnector):
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def deployment_oidc_template_status(*, role_claim: str = 'roles') -> dict[str, Any]:
|
||||
def deployment_oidc_template_status(*, role_claim: str = 'roles', template_config: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""Report only whether the server-owned OIDC template is configured."""
|
||||
config = settings.apis if template_config is None else template_config
|
||||
required = (
|
||||
'AUTHENTIK_OIDC_AUTHORIZATION_FLOW',
|
||||
'AUTHENTIK_OIDC_INVALIDATION_FLOW',
|
||||
'AUTHENTIK_OIDC_GROUP_CLAIM_MAPPING',
|
||||
'AUTHENTIK_OIDC_OPENID_CLAIM_MAPPING',
|
||||
'AUTHENTIK_OIDC_EMAIL_CLAIM_MAPPING',
|
||||
'AUTHENTIK_OIDC_PROFILE_CLAIM_MAPPING',
|
||||
'AUTHENTIK_OIDC_SIGNING_KEY_ID',
|
||||
)
|
||||
missing = [name for name in required if not str(settings.apis.get(name) or '').strip()]
|
||||
configured_claim = str(settings.apis.get('AUTHENTIK_OIDC_ROLE_CLAIM') or '').strip()
|
||||
missing = [name for name in required if not str(config.get(name) or '').strip()]
|
||||
configured_claim = str(config.get('AUTHENTIK_OIDC_ROLE_CLAIM') or '').strip()
|
||||
if configured_claim != role_claim:
|
||||
missing.append('AUTHENTIK_OIDC_ROLE_CLAIM')
|
||||
return {
|
||||
@@ -464,6 +510,68 @@ class AuthentikConnector(BaseConnector):
|
||||
'missing': missing,
|
||||
}
|
||||
|
||||
def validate_deployment_oidc_template(
|
||||
self, *, role_claim: str, role_groups: tuple[str, ...],
|
||||
) -> dict[str, Any]:
|
||||
"""Read the selected template; never return remote bodies or expressions."""
|
||||
config = self._deployment_oidc_config()
|
||||
configured = self.deployment_oidc_template_status(role_claim=role_claim, template_config=config)
|
||||
if configured['status'] != 'ready':
|
||||
return configured
|
||||
if role_claim != 'roles' or len(set(role_groups)) != 3 or any(not group for group in role_groups):
|
||||
return {'status': 'unavailable', 'reason': 'oidc_role_model_invalid'}
|
||||
authorization_id = str(config['AUTHENTIK_OIDC_AUTHORIZATION_FLOW'])
|
||||
invalidation_id = str(config['AUTHENTIK_OIDC_INVALIDATION_FLOW'])
|
||||
mapping_id = str(config['AUTHENTIK_OIDC_GROUP_CLAIM_MAPPING'])
|
||||
expression = (
|
||||
'return {"roles": sorted({group.name for group in request.user.ak_groups.all() '
|
||||
'if group.name in ' + json.dumps(sorted(role_groups)) + '})}'
|
||||
)
|
||||
try:
|
||||
# Flow detail is keyed by slug; OAuth providers reference UUIDs.
|
||||
flows = self._paged('/flows/instances/')
|
||||
for identifier, designation in (
|
||||
(authorization_id, 'authorization'), (invalidation_id, 'invalidation'),
|
||||
):
|
||||
matching_flows = [flow for flow in flows if isinstance(flow, dict) and _authentik_id(flow) == identifier]
|
||||
if (len(matching_flows) != 1
|
||||
or _authentik_enum(matching_flows[0].get('designation')) != designation):
|
||||
return {'status': 'unavailable', 'reason': 'oidc_flow_template_invalid'}
|
||||
mapping = self._request(f'/propertymappings/provider/scope/{quote(mapping_id, safe="")}/')
|
||||
if (not isinstance(mapping, dict) or str(mapping.get('pk')) != mapping_id
|
||||
or mapping.get('scope_name') != role_claim
|
||||
or str(mapping.get('expression') or '').strip() != expression):
|
||||
return {'status': 'unavailable', 'reason': 'oidc_role_mapping_invalid'}
|
||||
for scope in ('openid', 'email', 'profile'):
|
||||
identifier = str(config[f'AUTHENTIK_OIDC_{scope.upper()}_CLAIM_MAPPING'])
|
||||
mapping = self._request(f'/propertymappings/provider/scope/{quote(identifier, safe="")}/')
|
||||
if (not isinstance(mapping, dict) or _authentik_id(mapping) != identifier
|
||||
or mapping.get('scope_name') != scope):
|
||||
return {'status': 'unavailable', 'reason': 'oidc_identity_scope_invalid'}
|
||||
# Every attached mapping can contribute token claims. Scope
|
||||
# names alone cannot prevent an alternate administrator claim.
|
||||
if scope in {'openid', 'email'}:
|
||||
identity_matches = _forge_oidc_scope_mapping_matches(
|
||||
mapping, mapping_id=identifier, scope=scope,
|
||||
)
|
||||
else:
|
||||
identity_matches = (
|
||||
not mapping.get('managed')
|
||||
and str(mapping.get('expression') or '').replace('\r\n', '\n').strip()
|
||||
== DEPLOYMENT_OIDC_PROFILE_MAPPING_EXPRESSION
|
||||
)
|
||||
if not identity_matches:
|
||||
return {'status': 'unavailable', 'reason': 'oidc_identity_scope_invalid'}
|
||||
key_id = str(config['AUTHENTIK_OIDC_SIGNING_KEY_ID'])
|
||||
keys = self._paged('/crypto/certificatekeypairs/', {'has_key': True})
|
||||
matching = [key for key in keys if _authentik_id(key) == key_id]
|
||||
if (len(matching) != 1 or _authentik_enum(matching[0].get('key_type')) != 'rsa'
|
||||
or matching[0].get('private_key_available') is not True):
|
||||
return {'status': 'unavailable', 'reason': 'oidc_signing_key_unavailable'}
|
||||
except RuntimeError:
|
||||
return {'status': 'unavailable', 'reason': 'oidc_template_unreachable'}
|
||||
return {'status': 'ready', 'missing': []}
|
||||
|
||||
def ensure_deployment_oidc_application(
|
||||
self,
|
||||
*,
|
||||
@@ -489,11 +597,17 @@ class AuthentikConnector(BaseConnector):
|
||||
return {'success': False, 'status': 'invalid', 'message': 'invalid_launch_url'}
|
||||
if not redirect_uri.startswith(f'{launch_url}/') or '?' in redirect_uri or '#' in redirect_uri:
|
||||
return {'success': False, 'status': 'invalid', 'message': 'invalid_redirect_uri'}
|
||||
authorization_flow = str(settings.apis.get('AUTHENTIK_OIDC_AUTHORIZATION_FLOW') or '').strip()
|
||||
invalidation_flow = str(settings.apis.get('AUTHENTIK_OIDC_INVALIDATION_FLOW') or '').strip()
|
||||
group_claim_mapping = str(settings.apis.get('AUTHENTIK_OIDC_GROUP_CLAIM_MAPPING') or '').strip()
|
||||
if not authorization_flow or not invalidation_flow or not group_claim_mapping:
|
||||
config = self._deployment_oidc_config()
|
||||
authorization_flow = str(config.get('AUTHENTIK_OIDC_AUTHORIZATION_FLOW') or '').strip()
|
||||
invalidation_flow = str(config.get('AUTHENTIK_OIDC_INVALIDATION_FLOW') or '').strip()
|
||||
group_claim_mapping = str(config.get('AUTHENTIK_OIDC_GROUP_CLAIM_MAPPING') or '').strip()
|
||||
identity_mappings = [str(config.get(f'AUTHENTIK_OIDC_{scope}_CLAIM_MAPPING') or '').strip()
|
||||
for scope in ('OPENID', 'EMAIL', 'PROFILE')]
|
||||
signing_key = str(config.get('AUTHENTIK_OIDC_SIGNING_KEY_ID') or '').strip()
|
||||
if not authorization_flow or not invalidation_flow or not group_claim_mapping or not all(identity_mappings) or not signing_key:
|
||||
return {'success': False, 'status': 'blocked', 'message': 'oidc_template_not_configured'}
|
||||
expected_mappings = [*identity_mappings, group_claim_mapping]
|
||||
expected_redirects = [{'matching_mode': 'strict', 'url': redirect_uri}]
|
||||
try:
|
||||
groups = self._paged('/core/groups/')
|
||||
group_names = {str(item.get('name') or '') for item in groups}
|
||||
@@ -516,15 +630,17 @@ class AuthentikConnector(BaseConnector):
|
||||
None,
|
||||
)
|
||||
provider_redirects = provider.get('redirect_uris') if isinstance(provider, dict) else None
|
||||
if isinstance(provider_redirects, str):
|
||||
provider_redirects = [item for item in provider_redirects.splitlines() if item.strip()]
|
||||
provider_mappings = provider.get('property_mappings') if isinstance(provider, dict) else None
|
||||
if (
|
||||
not isinstance(provider, dict)
|
||||
or str(provider.get('client_id') or '') != application_slug
|
||||
or str(provider.get('client_type') or '') != 'confidential'
|
||||
or provider_redirects != [redirect_uri]
|
||||
or group_claim_mapping not in (provider_mappings or [])
|
||||
or provider_redirects != expected_redirects
|
||||
or not isinstance(provider_mappings, list)
|
||||
or not all(isinstance(item, str) for item in provider_mappings)
|
||||
or set(provider_mappings) != set(expected_mappings)
|
||||
or _authentik_id(provider.get('signing_key')) != signing_key
|
||||
or provider.get('issuer_mode') != 'per_provider'
|
||||
or str(provider.get('authorization_flow') or '') != authorization_flow
|
||||
or str(provider.get('invalidation_flow') or '') != invalidation_flow
|
||||
):
|
||||
@@ -547,8 +663,9 @@ class AuthentikConnector(BaseConnector):
|
||||
'client_type': 'confidential',
|
||||
'client_id': application_slug,
|
||||
'client_secret': client_secret,
|
||||
'redirect_uris': [redirect_uri],
|
||||
'property_mappings': [group_claim_mapping],
|
||||
'redirect_uris': expected_redirects,
|
||||
'property_mappings': expected_mappings,
|
||||
'signing_key': signing_key,
|
||||
'include_claims_in_id_token': True,
|
||||
'sub_mode': 'hashed_user_id',
|
||||
'issuer_mode': 'per_provider',
|
||||
|
||||
@@ -286,6 +286,10 @@ class Settings:
|
||||
"AUTHENTIK_OIDC_AUTHORIZATION_FLOW": _env_value("AUTHENTIK_OIDC_AUTHORIZATION_FLOW", None),
|
||||
"AUTHENTIK_OIDC_INVALIDATION_FLOW": _env_value("AUTHENTIK_OIDC_INVALIDATION_FLOW", None),
|
||||
"AUTHENTIK_OIDC_GROUP_CLAIM_MAPPING": _env_value("AUTHENTIK_OIDC_GROUP_CLAIM_MAPPING", None),
|
||||
"AUTHENTIK_OIDC_OPENID_CLAIM_MAPPING": _env_value("AUTHENTIK_OIDC_OPENID_CLAIM_MAPPING", None),
|
||||
"AUTHENTIK_OIDC_EMAIL_CLAIM_MAPPING": _env_value("AUTHENTIK_OIDC_EMAIL_CLAIM_MAPPING", None),
|
||||
"AUTHENTIK_OIDC_PROFILE_CLAIM_MAPPING": _env_value("AUTHENTIK_OIDC_PROFILE_CLAIM_MAPPING", None),
|
||||
"AUTHENTIK_OIDC_SIGNING_KEY_ID": _env_value("AUTHENTIK_OIDC_SIGNING_KEY_ID", None),
|
||||
# The reviewed group mapping must emit this exact claim. The
|
||||
# enrollment schema only accepts its bounded role contract.
|
||||
"AUTHENTIK_OIDC_ROLE_CLAIM": _env_value("AUTHENTIK_OIDC_ROLE_CLAIM", None),
|
||||
@@ -407,6 +411,10 @@ class Settings:
|
||||
"AUTHENTIK_OIDC_AUTHORIZATION_FLOW": bool(self.apis["AUTHENTIK_OIDC_AUTHORIZATION_FLOW"]),
|
||||
"AUTHENTIK_OIDC_INVALIDATION_FLOW": bool(self.apis["AUTHENTIK_OIDC_INVALIDATION_FLOW"]),
|
||||
"AUTHENTIK_OIDC_GROUP_CLAIM_MAPPING": bool(self.apis["AUTHENTIK_OIDC_GROUP_CLAIM_MAPPING"]),
|
||||
"AUTHENTIK_OIDC_OPENID_CLAIM_MAPPING": bool(self.apis["AUTHENTIK_OIDC_OPENID_CLAIM_MAPPING"]),
|
||||
"AUTHENTIK_OIDC_EMAIL_CLAIM_MAPPING": bool(self.apis["AUTHENTIK_OIDC_EMAIL_CLAIM_MAPPING"]),
|
||||
"AUTHENTIK_OIDC_PROFILE_CLAIM_MAPPING": bool(self.apis["AUTHENTIK_OIDC_PROFILE_CLAIM_MAPPING"]),
|
||||
"AUTHENTIK_OIDC_SIGNING_KEY_ID": bool(self.apis["AUTHENTIK_OIDC_SIGNING_KEY_ID"]),
|
||||
"AUTHENTIK_OIDC_ROLE_CLAIM": bool(self.apis["AUTHENTIK_OIDC_ROLE_CLAIM"]),
|
||||
"AUTHENTIK_FORGE_OIDC_SIGNING_KEY_ID": bool(self.apis["AUTHENTIK_FORGE_OIDC_SIGNING_KEY_ID"]),
|
||||
"AUTHENTIK_FORGE_OIDC_BINDING_VALID": bool(self.apis["AUTHENTIK_FORGE_OIDC_BINDING_VALID"]),
|
||||
|
||||
@@ -12,6 +12,8 @@ from uuid import uuid4
|
||||
from fastapi import Depends, FastAPI, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.exception_handlers import request_validation_exception_handler
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from sqlmodel import Session
|
||||
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
||||
@@ -361,6 +363,15 @@ app.include_router(setup_router)
|
||||
app.include_router(runtime_router)
|
||||
app.include_router(deployments_router)
|
||||
app.include_router(enrollments_router)
|
||||
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_error_response(request, exc):
|
||||
# Root template input accepts no credentials. Never echo an accidentally
|
||||
# submitted secret through Pydantic's validation-error input field.
|
||||
if request.url.path == "/api/enrollments/trust/oidc-profile":
|
||||
return JSONResponse(status_code=422, content={"detail": "invalid_oidc_profile"})
|
||||
return await request_validation_exception_handler(request, exc)
|
||||
app.include_router(integrations_router)
|
||||
app.include_router(n8n_router)
|
||||
app.include_router(knowledge_router)
|
||||
|
||||
@@ -22,9 +22,10 @@ from ..connectors.portainer import PortainerConnector
|
||||
from ..connectors.unraid import UnraidConnector
|
||||
from ..core.time import utc_now
|
||||
from ..db.models import DeploymentJob, Policy
|
||||
from ..schemas.deployment import DeploymentPlanV1
|
||||
from ..schemas.deployment import DeploymentPlanV1, OidcProvisioningV1
|
||||
from .audit_logger import log_event
|
||||
from .deployment_broker import DeploymentBrokerClient
|
||||
from .enrollment_oidc_settings import bind_template, load_profile
|
||||
from .policy_engine import evaluate_policy
|
||||
from .secret_store import get_secret, has_secret, secret_storage_status
|
||||
|
||||
@@ -76,6 +77,29 @@ class DeploymentControlPlane:
|
||||
self.authentik = authentik or AuthentikConnector()
|
||||
self.unraid = unraid or UnraidConnector()
|
||||
|
||||
def _oidc_template_ready(self, session: Session, oidc: OidcProvisioningV1) -> bool:
|
||||
bind_template(session, self.authentik)
|
||||
groups = (
|
||||
oidc.roles.administrator_group,
|
||||
oidc.roles.support_agent_group,
|
||||
oidc.roles.read_only_group,
|
||||
)
|
||||
profile = load_profile(session)
|
||||
if profile and groups != (
|
||||
profile.administrator_group,
|
||||
profile.support_agent_group,
|
||||
profile.read_only_group,
|
||||
):
|
||||
return False
|
||||
template = self.authentik.validate_deployment_oidc_template(
|
||||
role_claim=oidc.role_claim, role_groups=groups,
|
||||
)
|
||||
if template.get("status") != "ready":
|
||||
return False
|
||||
inventory = self.authentik.groups()
|
||||
names = {str(item.get("name") or "") for item in inventory.get("items", []) if isinstance(item, dict)}
|
||||
return inventory.get("success") is True and set(groups).issubset(names)
|
||||
|
||||
def preflight(
|
||||
self,
|
||||
session: Session,
|
||||
@@ -139,6 +163,12 @@ class DeploymentControlPlane:
|
||||
checks.append(_check("proxy_hostname", "blocked", "Requested hostname is owned by a different NPM target."))
|
||||
|
||||
if plan.oidc:
|
||||
ready = self._oidc_template_ready(session, plan.oidc)
|
||||
checks.append(_check(
|
||||
"oidc_template", "passed" if ready else "blocked",
|
||||
"Requested OIDC claim, groups and root template are verified." if ready
|
||||
else "Requested OIDC claim, groups or root template cannot be verified.",
|
||||
))
|
||||
auth_status = self.authentik.test_connection()
|
||||
if auth_status.get("status") != "connected":
|
||||
checks.append(_check("oidc_provider", "blocked", "Authentik is unavailable for the requested OIDC plan."))
|
||||
@@ -254,6 +284,8 @@ class DeploymentControlPlane:
|
||||
"""
|
||||
evidence: dict[str, Any] = {}
|
||||
if plan.oidc:
|
||||
if not self._oidc_template_ready(session, plan.oidc):
|
||||
return False, evidence, "Requested OIDC claim, groups or root template cannot be verified after deployment."
|
||||
client_secret = get_secret(session, plan.oidc.client_secret_ref, touch=False)
|
||||
if not client_secret:
|
||||
return False, evidence, "Referenced OIDC client secret is unavailable after deployment."
|
||||
|
||||
@@ -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 uuid
|
||||
from typing import Any, Literal
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlmodel import Session, select
|
||||
@@ -30,6 +31,7 @@ from ..schemas.enrollment import (
|
||||
EnrollmentTrustConfigV1,
|
||||
)
|
||||
from .audit_logger import log_event
|
||||
from .enrollment_oidc_settings import bind_template
|
||||
from .deployment_broker import DeploymentBrokerClient
|
||||
from .enrollment_profiles import PROFILE_VERSION, profile_contract, profile_summary
|
||||
from .secret_store import (
|
||||
@@ -155,6 +157,22 @@ def _enrollment_secret_store_ready(storage: dict[str, Any]) -> bool:
|
||||
return bool(storage.get("productionReady"))
|
||||
|
||||
|
||||
def _application_oidc_issuer(issuer: str | None, application_id: str | None = None) -> str | None:
|
||||
"""Bind Authentik's per-provider issuer to this application's own client."""
|
||||
if not issuer:
|
||||
return None
|
||||
parsed = urlsplit(issuer)
|
||||
path = parsed.path.rstrip('/')
|
||||
if application_id is None:
|
||||
if path not in {'', '/application/o'} and not re.fullmatch(r'/application/o/[a-z][a-z0-9-]{1,62}', path):
|
||||
return None
|
||||
return urlunsplit((parsed.scheme, parsed.netloc, path + '/', '', ''))
|
||||
expected_path = f"/application/o/{application_id}"
|
||||
if parsed.path.rstrip("/") not in {"", "/application/o", expected_path}:
|
||||
return None
|
||||
return urlunsplit((parsed.scheme, parsed.netloc, expected_path + "/", "", ""))
|
||||
|
||||
|
||||
class EnrollmentPlane:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -222,6 +240,8 @@ class EnrollmentPlane:
|
||||
|
||||
def capabilities(self, session: Session) -> dict[str, Any]:
|
||||
trust = self.get_trust_config(session)
|
||||
if isinstance(self.authentik, AuthentikConnector):
|
||||
bind_template(session, self.authentik)
|
||||
storage = secret_storage_status()
|
||||
configured = trust is not None
|
||||
bootstrap = bool(trust and has_secret(session, trust.bootstrap_secret_ref))
|
||||
@@ -335,11 +355,30 @@ class EnrollmentPlane:
|
||||
oidc_configured = bool(
|
||||
trust
|
||||
and trust.oidc_issuer
|
||||
and _application_oidc_issuer(trust.oidc_issuer)
|
||||
and trust.administrator_group
|
||||
and trust.support_agent_group
|
||||
and trust.read_only_group
|
||||
and self.authentik.is_configured()
|
||||
)
|
||||
if oidc_configured and trust is not None:
|
||||
template = self.authentik.validate_deployment_oidc_template(
|
||||
role_claim=trust.oidc_role_claim,
|
||||
role_groups=(trust.administrator_group, trust.support_agent_group, trust.read_only_group),
|
||||
)
|
||||
groups = self.authentik.groups()
|
||||
found_groups = {
|
||||
str(group.get("name") or "")
|
||||
for group in groups.get("items", [])
|
||||
if isinstance(group, dict)
|
||||
}
|
||||
oidc_configured = bool(
|
||||
template.get("status") == "ready"
|
||||
and groups.get("success") is True
|
||||
and {
|
||||
trust.administrator_group, trust.support_agent_group, trust.read_only_group,
|
||||
}.issubset(found_groups)
|
||||
)
|
||||
certificate_ready = bool(npm_ready)
|
||||
|
||||
def item(ready: bool, code: str) -> dict[str, str]:
|
||||
@@ -464,6 +503,8 @@ class EnrollmentPlane:
|
||||
def preflight(self, session: Session, plan: EnrollmentPlanV1) -> dict[str, Any]:
|
||||
checks: list[dict[str, str]] = []
|
||||
trust = self.get_trust_config(session)
|
||||
if isinstance(self.authentik, AuthentikConnector):
|
||||
bind_template(session, self.authentik)
|
||||
profile = profile_contract(plan.profile)
|
||||
if trust is None:
|
||||
checks.append(_check("enrollment_trust_not_configured", "blocked", "Root enrollment trust boundaries are not configured."))
|
||||
@@ -586,6 +627,13 @@ class EnrollmentPlane:
|
||||
checks.append(_check("oidc_profile_unavailable", "blocked", "Configured OIDC profile or Authentik role groups are unavailable."))
|
||||
else:
|
||||
assert trust is not None and plan.authentication.roles is not None
|
||||
issuer_matches = _application_oidc_issuer(trust.oidc_issuer, plan.application_id)
|
||||
checks.append(_check(
|
||||
"oidc_issuer" if issuer_matches else "oidc_issuer_mismatch",
|
||||
"passed" if issuer_matches else "blocked",
|
||||
"Authentik issuer is bound to this application's own provider."
|
||||
if issuer_matches else "Configured issuer belongs to a different application or unsupported issuer path.",
|
||||
))
|
||||
expected_roles = {
|
||||
"administrator": trust.administrator_group,
|
||||
"support_agent": trust.support_agent_group,
|
||||
@@ -603,8 +651,9 @@ class EnrollmentPlane:
|
||||
if auth_status.get("status") != "connected":
|
||||
checks.append(_check("oidc_provider", "blocked", "Authentik is unavailable."))
|
||||
else:
|
||||
template = self.authentik.deployment_oidc_template_status(
|
||||
role_claim=trust.oidc_role_claim
|
||||
template = self.authentik.validate_deployment_oidc_template(
|
||||
role_claim=trust.oidc_role_claim,
|
||||
role_groups=(trust.administrator_group, trust.support_agent_group, trust.read_only_group),
|
||||
)
|
||||
if template.get("status") != "ready":
|
||||
checks.append(_check("oidc_profile_unavailable", "blocked", "The server-owned Authentik OIDC template is incomplete."))
|
||||
@@ -978,6 +1027,8 @@ class EnrollmentPlane:
|
||||
raise EnrollmentError("application identity or repository is already enrolled")
|
||||
|
||||
trust = self.get_trust_config(session)
|
||||
if isinstance(self.authentik, AuthentikConnector):
|
||||
bind_template(session, self.authentik)
|
||||
if trust is None:
|
||||
raise EnrollmentError("enrollment trust boundaries are not configured")
|
||||
enrollment = self._create_enrollment(session, plan, plan_digest, trust)
|
||||
@@ -1145,7 +1196,7 @@ class EnrollmentPlane:
|
||||
runtime_values.update({
|
||||
"OIDC_CLIENT_ID": plan.application_id,
|
||||
"OIDC_CLIENT_SECRET": oidc_secret,
|
||||
"OIDC_ISSUER": trust.oidc_issuer or "",
|
||||
"OIDC_ISSUER": _application_oidc_issuer(trust.oidc_issuer, plan.application_id) or "",
|
||||
"OIDC_ROLE_CLAIM": trust.oidc_role_claim,
|
||||
})
|
||||
if plan.profile == "repopulse-authenticated":
|
||||
|
||||
@@ -175,6 +175,7 @@ def required_permission(path: str, method: str) -> str:
|
||||
"/api/knowledge/reindex",
|
||||
"/api/enrollments/trust",
|
||||
"/api/enrollments/trust/storage-root",
|
||||
"/api/enrollments/trust/oidc-profile",
|
||||
}:
|
||||
return "settings.manage"
|
||||
if path.startswith("/api/apps/") and path.endswith(("/metadata", "/container-mapping")):
|
||||
|
||||
Reference in new issue
Block a user