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
@@ -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