Publish curated Unraid-AppOps-Gateway source from 68878c3167dacf2b424c16780e60e0d60815e477

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

No files matched your search

@@ -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,
)
+54 -3
View File
@@ -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":
+1
View File
@@ -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")):