Files
Unraid-AppOps-Gateway-Public/backend/app/services/enrollment_plane.py
T

1106 lines
56 KiB
Python

"""Bounded enrollment orchestration for a new managed application identity.
The plane is intentionally not a generic infrastructure API. It creates a
single controller target from a trusted profile, generates write-only
credentials, and only then creates exact-target deployment policies. Every
resource identifier and error returned from this module is sanitized.
"""
from __future__ import annotations
import hashlib
import json
import re
import secrets
import uuid
from typing import Any, Literal
from sqlmodel import Session, select
from ..connectors.authentik import AuthentikConnector
from ..connectors.nginx_proxy_manager import NginxProxyManagerConnector
from ..connectors.portainer import PortainerConnector
from ..core.time import utc_now
from ..db.models import AppSetting, Enrollment, EnrollmentJob, Policy
from ..schemas.enrollment import (
LEGACY_APPDATA_ROOT,
MANAGED_DOCKER_APPDATA_ROOT,
EnrollmentPlanV1,
EnrollmentTrustConfigV1,
)
from .audit_logger import log_event
from .deployment_broker import DeploymentBrokerClient
from .enrollment_profiles import PROFILE_VERSION, profile_contract, profile_summary
from .secret_store import (
delete_secret,
get_secret,
has_secret,
secret_storage_status,
set_secret,
)
TRUST_SETTING_KEY = "deployment_enrollment:trust"
_IDEMPOTENCY_KEY = re.compile(r"^[A-Za-z0-9._:-]{8,128}$")
_TERMINAL = {"ready", "failed", "compensation_required", "preflight_failed"}
_CONTROLLER_EVIDENCE = {"status", "targetId", "repository", "applicationId", "appdataPath"}
_PROXY_EVIDENCE = {"success", "status", "hostId", "hostname"}
_OIDC_EVIDENCE = {"success", "status", "applicationId", "providerId", "clientSecretCreated"}
_APPDATA_ROOT_STATUSES = {"ready", "needs_initialization", "unavailable"}
def _trusted_appdata_roots_readiness(
controller_capability: dict[str, Any],
) -> list[dict[str, str]]:
roots = (LEGACY_APPDATA_ROOT, MANAGED_DOCKER_APPDATA_ROOT)
unavailable = {root: "unavailable" for root in roots}
reported = controller_capability.get("availableTrustedAppdataRoots")
if isinstance(reported, list):
if len(reported) != len(roots):
return [{"root": root, "status": unavailable[root]} for root in roots]
statuses: dict[str, str] = {}
for item in reported:
if not isinstance(item, dict):
return [{"root": root, "status": unavailable[root]} for root in roots]
root = item.get("root")
status = item.get("status")
if (
not isinstance(root, str)
or root not in unavailable
or root in statuses
or not isinstance(status, str)
or status not in _APPDATA_ROOT_STATUSES
):
return [{"root": expected, "status": unavailable[expected]} for expected in roots]
statuses[root] = status
if set(statuses) != set(roots):
return [{"root": root, "status": unavailable[root]} for root in roots]
return [{"root": root, "status": statuses[root]} for root in roots]
# A legacy V1 controller reports only its configured root. It cannot attest
# independent managed-volume readiness, so only its exact legacy root can
# be marked ready through this compatibility path.
if "availableTrustedAppdataRoots" not in controller_capability:
if (
controller_capability.get("status") == "ready"
and controller_capability.get("trustedAppdataRoot") == LEGACY_APPDATA_ROOT
):
unavailable[LEGACY_APPDATA_ROOT] = "ready"
return [{"root": root, "status": unavailable[root]} for root in roots]
class EnrollmentError(ValueError):
pass
def _canonical(value: EnrollmentPlanV1) -> tuple[dict[str, Any], str]:
payload = value.model_dump(mode="json")
digest = hashlib.sha256(json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
return payload, digest
def _idempotency_digest(operation: str, value: str) -> str:
if not _IDEMPOTENCY_KEY.fullmatch(value):
raise EnrollmentError("idempotency key must contain 8 to 128 safe characters")
return hashlib.sha256(f"{operation}:{value}".encode()).hexdigest()
def _check(
code: str,
status: Literal["passed", "blocked", "warning", "unknown"],
detail: str,
) -> dict[str, str]:
return {"code": code, "status": status, "detail": detail}
def _secret_ref(application_id: str, kind: str) -> str:
return f"ENROLL_{application_id.upper().replace('-', '_')}_{kind}"
def _credential() -> str:
# 64 URL-safe characters provide a high-entropy printable HMAC key while
# remaining suitable for the controller's mode-0600 credential file.
return secrets.token_urlsafe(48)
def _fingerprint(value: str) -> str:
return hashlib.sha256(value.encode("utf-8")).hexdigest()[:16]
def _enrollment_secret_store_ready(storage: dict[str, Any]) -> bool:
"""Enrollment always needs a configured production-grade key.
The general secret store intentionally supports a development fallback for
offline UI work. Enrollment generates controller and OIDC credentials, so
it must never accept that deterministic fallback as a write target.
"""
return bool(storage.get("productionReady"))
class EnrollmentPlane:
def __init__(
self,
*,
broker: DeploymentBrokerClient | None = None,
portainer: PortainerConnector | None = None,
npm: NginxProxyManagerConnector | None = None,
authentik: AuthentikConnector | None = None,
):
self.broker = broker or DeploymentBrokerClient()
self.portainer = portainer or PortainerConnector()
self.npm = npm or NginxProxyManagerConnector()
self.authentik = authentik or AuthentikConnector()
@staticmethod
def get_trust_config(session: Session) -> EnrollmentTrustConfigV1 | None:
row = session.get(AppSetting, TRUST_SETTING_KEY)
if not row or row.is_secret:
return None
try:
return EnrollmentTrustConfigV1.model_validate(row.value_json)
except ValueError:
return None
@staticmethod
def set_trust_config(session: Session, value: EnrollmentTrustConfigV1) -> dict[str, Any]:
row = session.get(AppSetting, TRUST_SETTING_KEY) or AppSetting(
key=TRUST_SETTING_KEY, is_secret=False
)
row.value_json = value.model_dump(mode="json")
row.is_secret = False
row.updated_at = utc_now()
session.add(row)
session.commit()
return EnrollmentPlane.trust_status(session)
@staticmethod
def trust_status(session: Session) -> dict[str, Any]:
trust = EnrollmentPlane.get_trust_config(session)
storage = secret_storage_status()
if trust is None:
return {
"status": "needs_setup",
"blockers": ["enrollment_trust_not_configured"],
"configured": False,
"trustedAppdataRoot": LEGACY_APPDATA_ROOT,
"managedAppdataBackupAcknowledged": False,
"secretStore": storage,
}
return {
"status": "ready" if _enrollment_secret_store_ready(storage) else "blocked",
"blockers": [] if _enrollment_secret_store_ready(storage) else ["secret_store_not_production_ready"],
"configured": True,
"controllerIdentity": trust.controller_identity,
"trustedAppdataRoot": trust.trusted_appdata_root,
"managedAppdataBackupAcknowledged": trust.managed_appdata_backup_acknowledged,
"approvedNetworks": list(trust.approved_networks),
"allowedDomainSuffixes": list(trust.allowed_domain_suffixes),
"autonomousProfiles": list(trust.autonomous_profiles),
"repopulseProxyAccessListId": trust.repopulse_proxy_access_list_id,
"oidcConfigured": bool(trust.oidc_issuer and trust.administrator_group),
"bootstrapSecretConfigured": has_secret(session, trust.bootstrap_secret_ref),
"secretStore": storage,
}
def capabilities(self, session: Session) -> dict[str, Any]:
trust = self.get_trust_config(session)
storage = secret_storage_status()
configured = trust is not None
bootstrap = bool(trust and has_secret(session, trust.bootstrap_secret_ref))
controller_capability: dict[str, Any] = {}
if bootstrap and self.broker.is_configured() and trust is not None:
bootstrap_secret = get_secret(session, trust.bootstrap_secret_ref, touch=False)
if bootstrap_secret:
controller_capability = self.broker.enrollment_capabilities(secret=bootstrap_secret)
available_roots = _trusted_appdata_roots_readiness(controller_capability)
if trust is not None:
selected_root = next(
(
item
for item in available_roots
if item["root"] == trust.trusted_appdata_root
),
None,
)
controller_root_matches = bool(selected_root and selected_root.get("status") == "ready")
else:
controller_root_matches = False
broker_ready = bool(
bootstrap
and self.broker.is_configured()
and controller_capability.get("status") == "ready"
and controller_root_matches
)
controller_managed_storage = controller_capability.get("managedStorage")
if not isinstance(controller_managed_storage, dict):
controller_managed_storage = {}
if trust is None or trust.trusted_appdata_root != MANAGED_DOCKER_APPDATA_ROOT:
managed_storage_status = "not_selected"
managed_storage_blocker = ""
elif not trust.managed_appdata_backup_acknowledged:
managed_storage_status = "blocked"
managed_storage_blocker = "managed_appdata_backup_acknowledgement_required"
elif controller_root_matches and controller_managed_storage.get("status") == "ready":
managed_storage_status = "ready"
managed_storage_blocker = ""
elif controller_managed_storage.get("initializationAvailable") is True:
managed_storage_status = "initialization_required"
managed_storage_blocker = "managed_appdata_initialization_required"
else:
managed_storage_status = "unavailable"
managed_storage_blocker = "managed_appdata_volume_unavailable"
npm_ready = self.npm.is_configured()
oidc_configured = bool(
trust
and trust.oidc_issuer
and trust.administrator_group
and trust.support_agent_group
and trust.read_only_group
and self.authentik.is_configured()
)
certificate_ready = bool(npm_ready)
def item(ready: bool, code: str) -> dict[str, str]:
return {"status": "ready" if ready else "unavailable", "blockerCode": "" if ready else code}
return {
"apiVersion": "itworx.enrollment-capabilities/v1",
"trust": item(configured, "enrollment_trust_not_configured"),
"targetEnrollment": item(broker_ready, "deployment_executor_unavailable"),
"secretBootstrap": item(_enrollment_secret_store_ready(storage), "secret_store_not_production_ready"),
"runtimeDeployment": item(broker_ready, "deployment_executor_unavailable"),
"reverseProxyProvisioning": item(npm_ready, "npm_credentials_missing"),
"tlsProvisioning": item(certificate_ready, "certificate_provisioning_unavailable"),
"oidcProvisioning": item(oidc_configured, "oidc_profile_unavailable"),
"policyProvisioning": item(configured, "policy_template_unavailable"),
"rollback": item(broker_ready, "deployment_executor_unavailable"),
"verification": item(broker_ready, "deployment_executor_unavailable"),
"availableTrustedAppdataRoots": available_roots,
"managedStorage": {
"status": managed_storage_status,
"blockerCode": managed_storage_blocker,
"root": MANAGED_DOCKER_APPDATA_ROOT,
"backupScope": "dockerRootDir",
"backupAcknowledged": bool(
trust and trust.managed_appdata_backup_acknowledged
),
},
"controller": {
"status": "ready" if broker_ready else "unavailable",
"reason": (
""
if broker_ready
else str(controller_capability.get("reason") or (
"trusted_appdata_root_mismatch"
if controller_capability.get("status") == "ready" and not controller_root_matches
else "deployment_executor_unavailable"
))
),
},
"profiles": [profile_summary(name) for name in ("internal-webapp", "internal-webapp-oidc", "internal-service", "repopulse-authenticated")],
}
@staticmethod
def _allowed_hostname(hostname: str, trust: EnrollmentTrustConfigV1) -> bool:
return any(hostname == suffix or hostname.endswith(f".{suffix}") for suffix in trust.allowed_domain_suffixes)
@staticmethod
def _existing(session: Session, application_id: str, repository: str) -> tuple[Enrollment | None, Enrollment | None]:
by_application = session.exec(
select(Enrollment).where(Enrollment.application_id == application_id)
).first()
by_repository = session.exec(
select(Enrollment).where(Enrollment.repository == repository)
).first()
return by_application, by_repository
def _controller_payload(
self,
plan: EnrollmentPlanV1,
*,
enrollment_id: str,
target_credential: str | None = None,
) -> dict[str, Any]:
profile = profile_contract(plan.profile)
assert profile is not None # validated before this method is called
payload: dict[str, Any] = {
"api_version": "itworx.controller-enrollment/v1",
"enrollment_id": enrollment_id,
"application_id": plan.application_id,
"repository": plan.repository,
"revision": plan.revision,
"profile": plan.profile,
"container_name": plan.target.container_name,
"compose_file": plan.target.compose_file,
"appdata_path": plan.appdata_path,
"trusted_appdata_root": plan.trusted_appdata_root,
"data_path": plan.data_path,
"runtime_uid": plan.storage.runtime_uid,
"runtime_gid": plan.storage.runtime_gid,
"network": plan.network.docker_network,
"internal_port": plan.network.internal_port,
"proxy_hostname": plan.exposure.hostname if plan.exposure else None,
"image_repository": plan.artifact.image_repository,
"image_tag": plan.artifact.image_tag,
"health_path": plan.artifact.health_path,
"runtime_environment_keys": list(profile["runtimeEnvironmentKeys"]),
}
if target_credential is not None:
payload["target_credential"] = target_credential
return payload
def preflight(self, session: Session, plan: EnrollmentPlanV1) -> dict[str, Any]:
checks: list[dict[str, str]] = []
trust = self.get_trust_config(session)
profile = profile_contract(plan.profile)
if trust is None:
checks.append(_check("enrollment_trust_not_configured", "blocked", "Root enrollment trust boundaries are not configured."))
else:
checks.append(_check("enrollment_trust", "passed", "Root-owned enrollment boundaries are configured."))
if plan.target.target_identity == trust.controller_identity:
checks.append(_check("controller_identity", "passed", "Requested controller matches the trusted enrollment target."))
else:
checks.append(_check("deployment_executor_unavailable", "blocked", "Requested controller is not the configured enrollment target."))
if plan.network.docker_network in trust.approved_networks:
checks.append(_check("network", "passed", "Requested Docker network is within the trusted profile boundary."))
else:
checks.append(_check("network_unavailable", "blocked", "Requested Docker network is not approved for enrollment."))
# RepoPulse is an established, fixed legacy-root exception; the
# operator's selected root applies to other new targets only.
expected_appdata_root = (
LEGACY_APPDATA_ROOT
if plan.profile == "repopulse-authenticated"
else trust.trusted_appdata_root
)
if plan.trusted_appdata_root == expected_appdata_root:
checks.append(_check("trusted_appdata_root", "passed", "Requested appdata root matches its fixed profile trust boundary."))
else:
checks.append(_check("trusted_appdata_root_mismatch", "blocked", "Requested appdata root does not match the explicit operator trust boundary."))
if plan.profile in trust.autonomous_profiles:
checks.append(_check("autonomous_profile", "passed", "Deployment profile is approved for autonomous enrollment."))
else:
checks.append(_check("policy_template_unavailable", "blocked", "Deployment profile is not approved for autonomous enrollment."))
if profile is None:
checks.append(_check("policy_template_unavailable", "blocked", "Requested server-side deployment profile is unavailable."))
else:
checks.append(_check("deployment_profile", "passed", "Server-side deployment profile is available."))
if plan.profile == "repopulse-authenticated":
gitea_token = get_secret(session, "REPOPULSE_GITEA_TOKEN", touch=False)
checks.append(_check(
"repopulse_gitea_token_unavailable",
"passed" if gitea_token else "blocked",
"The existing encrypted RepoPulse Gitea token is available by reference."
if gitea_token else "The existing encrypted RepoPulse Gitea token is unavailable.",
))
if has_secret(session, _secret_ref("repopulse", "MAINTENANCE_TOKEN")):
checks.append(_check(
"repopulse_maintenance_token_collision", "blocked",
"An existing write-only RepoPulse maintenance token cannot be adopted by a new enrollment.",
))
else:
checks.append(_check(
"repopulse_maintenance_token", "passed",
"A new write-only RepoPulse maintenance token can be generated for this enrollment.",
))
if trust is None or trust.repopulse_proxy_access_list_id is None:
checks.append(_check(
"repopulse_access_list_unavailable", "blocked",
"A positive root-owned RepoPulse NPM access-list ID is not configured.",
))
else:
verify_access_list = getattr(self.npm, "validate_managed_access_list", None)
access_list = verify_access_list(trust.repopulse_proxy_access_list_id) if callable(verify_access_list) else {}
verified = (
access_list.get("success") is True
and access_list.get("status") == "verified"
and access_list.get("accessListId") == trust.repopulse_proxy_access_list_id
)
checks.append(_check(
"repopulse_access_list_unavailable",
"passed" if verified else "blocked",
"The root-owned RepoPulse NPM access list has active authorization entries and requires all checks."
if verified else "The root-owned RepoPulse NPM access list could not be verified.",
))
by_application, by_repository = self._existing(session, plan.application_id, plan.repository)
if by_application:
status = "warning" if by_application.plan_sha256 == _canonical(plan)[1] else "blocked"
code = "enrollment_replay" if status == "warning" else "target_already_exists"
checks.append(_check(code, status, "This application identity is already enrolled."))
else:
checks.append(_check("application_identity", "passed", "Application identity is not yet enrolled."))
if by_repository and (not by_application or by_repository.id != by_application.id):
checks.append(_check("target_already_exists", "blocked", "Repository is already bound to a different managed application."))
containers = self.portainer.list_containers()
if containers.get("status") != "ok":
checks.append(_check("container_inventory_unavailable", "blocked", "Portainer inventory is unavailable for collision protection."))
else:
names = {str(item.get("name") or "").casefold() for item in containers.get("containers", [])}
if plan.target.container_name.casefold() in names:
checks.append(_check("unmanaged_resource_collision", "blocked", "Container identity already exists and cannot be claimed by enrollment."))
else:
checks.append(_check("container_identity", "passed", "Container identity is free."))
if plan.exposure:
if trust and self._allowed_hostname(plan.exposure.hostname, trust):
checks.append(_check("hostname_suffix", "passed", "Hostname is inside an allowed domain suffix."))
else:
checks.append(_check("hostname_not_allowed", "blocked", "Hostname is outside the configured allowed suffixes."))
hosts = self.npm.proxy_hosts()
if not hosts.get("success"):
checks.append(_check("npm_credentials_missing" if not self.npm.is_configured() else "npm_executor_unavailable", "blocked", "NPM inventory is unavailable for takeover protection."))
else:
existing = [item for item in hosts.get("items", []) if plan.exposure.hostname in {str(value).lower() for value in item.get("domainNames", [])}]
checks.append(_check("hostname_collision", "blocked" if existing else "passed", "Hostname is already owned and cannot be taken over." if existing else "Hostname is free in NPM."))
if plan.authentication.mode == "native-oidc":
groups_ready = bool(trust and trust.administrator_group and trust.support_agent_group and trust.read_only_group and trust.oidc_issuer)
if not groups_ready or not self.authentik.is_configured():
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
expected_roles = {
"administrator": trust.administrator_group,
"support_agent": trust.support_agent_group,
"read_only": trust.read_only_group,
}
requested_roles = plan.authentication.roles.model_dump(mode="json")
checks.append(_check(
"oidc_role_model",
"passed" if requested_roles == expected_roles else "blocked",
"Requested application roles match the root-owned Authentik role model."
if requested_roles == expected_roles
else "Requested application roles do not match the root-owned Authentik role model.",
))
auth_status = self.authentik.test_connection()
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
)
if template.get("status") != "ready":
checks.append(_check("oidc_profile_unavailable", "blocked", "The server-owned Authentik OIDC template is incomplete."))
applications = self.authentik.applications()
groups = self.authentik.groups()
if not applications.get("success") or not groups.get("success"):
checks.append(_check("oidc_provider", "blocked", "Authentik applications cannot be inspected for takeover protection."))
elif any(str(item.get("slug") or "") == plan.application_id for item in applications.get("items", [])):
checks.append(_check("unmanaged_resource_collision", "blocked", "An existing Authentik application cannot be claimed by a new enrollment."))
else:
checks.append(_check("oidc_provider", "passed", "Authentik is available and the application identity is free."))
required_groups = {
trust.administrator_group if trust else "",
trust.support_agent_group if trust else "",
trust.read_only_group if trust else "",
}
found_groups = {str(item.get("name") or "") for item in groups.get("items", [])}
checks.append(_check(
"oidc_role_groups",
"passed" if required_groups.issubset(found_groups) else "blocked",
"Configured Authentik role groups are available." if required_groups.issubset(found_groups) else "One or more configured Authentik role groups are missing.",
))
storage = secret_storage_status()
storage_ready = _enrollment_secret_store_ready(storage)
checks.append(_check("secret_store", "passed" if storage_ready else "blocked", "Production-grade write-only secret storage is ready." if storage_ready else "Enrollment requires APPOPS_SECRET_KEY from a protected environment variable or file; the development fallback is not permitted."))
if trust:
checks.append(_check("controller_bootstrap_secret", "passed" if has_secret(session, trust.bootstrap_secret_ref) else "blocked", "Controller bootstrap secret is available by reference." if has_secret(session, trust.bootstrap_secret_ref) else "Controller bootstrap secret reference is not configured."))
expected_appdata_root = (
LEGACY_APPDATA_ROOT
if plan.profile == "repopulse-authenticated"
else trust.trusted_appdata_root
)
root_matches_trust = plan.trusted_appdata_root == expected_appdata_root
if not root_matches_trust:
checks.append(_check("controller_target", "blocked", "Controller preflight was skipped because the requested appdata root is outside the profile trust boundary."))
elif has_secret(session, trust.bootstrap_secret_ref):
controller = self.broker.enrollment_preflight(
self._controller_payload(plan, enrollment_id="enr-00000000000000000000"),
secret=get_secret(session, trust.bootstrap_secret_ref, touch=False) or "",
)
code = str(controller.get("reason") or "deployment_executor_unavailable")
checks.append(_check("controller_target", "passed" if controller.get("status") in {"preflight_ok", "ready"} else "blocked", "Controller accepted the bounded enrollment preflight." if controller.get("status") in {"preflight_ok", "ready"} else f"Controller enrollment preflight is unavailable ({code})."))
else:
checks.append(_check("controller_target", "blocked", "Controller bootstrap credential is unavailable."))
blockers = [item["code"] for item in checks if item["status"] == "blocked"]
warnings = [item["code"] for item in checks if item["status"] == "warning"]
planned_resources = {
"applicationId": plan.application_id,
"controllerTarget": plan.target.target_identity,
"appdataPath": plan.appdata_path,
"dataPath": plan.data_path,
"network": plan.network.docker_network,
"hostname": plan.exposure.hostname if plan.exposure else None,
"oidc": plan.authentication.mode == "native-oidc",
}
return {
"status": "ready" if not blockers else "blocked",
"blockers": blockers,
"warnings": warnings,
"checks": checks,
"plannedResources": planned_resources,
"plannedMutations": [] if blockers else ["controller_target_register", "secret_generate", *( ["reverse_proxy_ensure"] if plan.exposure else []), *( ["oidc_application_ensure"] if plan.authentication.mode == "native-oidc" else []), *( ["runtime_environment_seal"] if profile and profile["runtimeEnvironmentKeys"] else []), "exact_policy_install"],
"plannedSecretRefs": [
_secret_ref(plan.application_id, "TARGET_HMAC"),
*( [_secret_ref(plan.application_id, "OIDC_CLIENT_SECRET")] if plan.authentication.mode == "native-oidc" else []),
*( ["REPOPULSE_GITEA_TOKEN", _secret_ref("repopulse", "MAINTENANCE_TOKEN")] if plan.profile == "repopulse-authenticated" else []),
],
"plannedPolicies": [f"enrollment-{plan.application_id}-deployment-apply", f"enrollment-{plan.application_id}-deployment-rollback"],
}
def _create_enrollment(self, session: Session, plan: EnrollmentPlanV1, plan_sha256: str, trust: EnrollmentTrustConfigV1) -> Enrollment:
enrollment = Enrollment(
id=f"enr-{uuid.uuid4().hex[:20]}",
application_id=plan.application_id,
repository=plan.repository,
revision=plan.revision,
profile=plan.profile,
controller_identity=trust.controller_identity,
plan_sha256=plan_sha256,
status="planned",
target_secret_ref=_secret_ref(plan.application_id, "TARGET_HMAC"),
oidc_secret_ref=_secret_ref(plan.application_id, "OIDC_CLIENT_SECRET") if plan.authentication.mode == "native-oidc" else None,
plan_json=plan.model_dump(mode="json"),
provenance_json={"profileVersion": PROFILE_VERSION, "createdBy": "enrollment-plane/v1", "sourceRevision": plan.revision},
)
session.add(enrollment)
session.commit()
session.refresh(enrollment)
return enrollment
def _create_job(self, session: Session, enrollment: Enrollment, idempotency_key: str) -> EnrollmentJob:
digest = _idempotency_digest("enroll", idempotency_key)
existing = session.exec(select(EnrollmentJob).where(EnrollmentJob.idempotency_sha256 == digest)).first()
if existing:
if existing.enrollment_id != enrollment.id:
raise EnrollmentError("idempotency key was already used for a different enrollment")
return existing
job = EnrollmentJob(
id=f"enroll-{uuid.uuid4().hex[:16]}",
enrollment_id=enrollment.id,
application_id=enrollment.application_id,
idempotency_sha256=digest,
status="queued",
stage="planned",
)
session.add(job)
session.commit()
session.refresh(job)
return job
@staticmethod
def serialize_enrollment(enrollment: Enrollment) -> dict[str, Any]:
plan = dict(enrollment.plan_json or {})
exposure = plan.get("exposure") if isinstance(plan.get("exposure"), dict) else {}
authentication = plan.get("authentication") if isinstance(plan.get("authentication"), dict) else {}
return {
"id": enrollment.id,
"applicationId": enrollment.application_id,
"repository": enrollment.repository,
"revision": enrollment.revision,
"profile": enrollment.profile,
"controllerIdentity": enrollment.controller_identity,
"status": enrollment.status,
"publicUrl": f"https://{exposure.get('hostname')}" if exposure.get("hostname") else None,
"authentication": authentication.get("mode", "none"),
"resources": enrollment.resources_json or {},
"targetSecretRef": enrollment.target_secret_ref,
"oidcSecretRef": enrollment.oidc_secret_ref,
"maintenanceSecretRef": (
_secret_ref("repopulse", "MAINTENANCE_TOKEN")
if enrollment.profile == "repopulse-authenticated" else None
),
"lastVerifiedAt": enrollment.last_verified_at.isoformat() if enrollment.last_verified_at else None,
"createdAt": enrollment.created_at.isoformat(),
"updatedAt": enrollment.updated_at.isoformat(),
"completedAt": enrollment.completed_at.isoformat() if enrollment.completed_at else None,
}
@staticmethod
def serialize_job(job: EnrollmentJob, *, replay: bool = False) -> dict[str, Any]:
return {
"id": job.id,
"enrollmentId": job.enrollment_id,
"applicationId": job.application_id,
"operation": job.operation,
"status": job.status,
"stage": job.stage,
"preflight": job.preflight_json or {},
"evidence": job.evidence_json or {},
"result": job.result_summary,
"createdAt": job.created_at.isoformat(),
"updatedAt": job.updated_at.isoformat(),
"completedAt": job.completed_at.isoformat() if job.completed_at else None,
"idempotentReplay": replay,
}
@staticmethod
def _checkpoint(
session: Session,
enrollment: Enrollment,
job: EnrollmentJob,
*,
stage: str,
evidence: dict[str, Any],
) -> None:
"""Durably record intent and receipts around every external boundary.
This is deliberately conservative: after an interrupted external call,
a same-plan request is surfaced as recovery-required instead of being
replayed against NPM, Authentik or the controller without proof of its
prior effect. Values in ``evidence`` are identifiers/status only.
"""
now = utc_now()
enrollment.status = "provisioning"
enrollment.resources_json = {**dict(enrollment.resources_json or {}), **evidence}
enrollment.updated_at = now
job.status = "running"
job.stage = stage
job.evidence_json = {**dict(job.evidence_json or {}), **evidence}
job.updated_at = now
session.add(enrollment)
session.add(job)
session.commit()
def _install_policies(self, session: Session, enrollment: Enrollment, plan: EnrollmentPlanV1) -> list[str]:
provenance = {
"enrollmentId": enrollment.id,
"profileVersion": PROFILE_VERSION,
"createdBy": "enrollment-plane/v1",
"policyTemplateVersion": "exact-target/v1",
"target": plan.application_id,
"sourceRevision": plan.revision,
"createdAt": utc_now().isoformat(),
}
identifiers: list[str] = []
for action, suffix in (("deployment.apply", "deployment-apply"), ("deployment.rollback", "deployment-rollback")):
policy_id = f"enrollment-{plan.application_id}-{suffix}"
existing = session.get(Policy, policy_id)
if existing is not None:
# Enrollment never adopts or repurposes a pre-existing policy,
# even where its visible action/target happen to match. A
# replay is handled before this method; this branch is a
# collision, not a safe recovery path.
raise EnrollmentError("exact policy identifier is already owned and cannot be adopted")
existing = Policy(
id=policy_id,
action=action,
target=plan.application_id,
voice="blocked",
manual="allowed",
risk="high",
reason="Enrollment-generated exact-target deployment policy.",
)
existing.provenance_json = provenance
session.add(existing)
identifiers.append(policy_id)
session.commit()
return identifiers
def _compensate(
self,
session: Session,
enrollment: Enrollment,
evidence: dict[str, Any],
) -> dict[str, Any]:
"""Reverse only resources this execution proved it created.
A failed compensation stays visible as ``compensation_required``. It
never attempts to adopt, overwrite or delete an earlier resource.
"""
plan = EnrollmentPlanV1.model_validate(enrollment.plan_json)
outcomes: dict[str, str] = {}
oidc = evidence.get("oidc") if isinstance(evidence.get("oidc"), dict) else {}
if oidc.get("status") == "created":
remove = getattr(self.authentik, "remove_deployment_oidc_application", None)
result = remove(
application_id=oidc.get("applicationId"),
provider_id=oidc.get("providerId"),
application_slug=plan.application_id,
launch_url=plan.public_url or "",
) if callable(remove) else {"success": False, "status": "unsupported"}
outcomes["oidc"] = str(result.get("status") or "failed")
elif oidc.get("status") == "partial_failure":
outcomes["oidc"] = "ownership_unverified"
proxy = evidence.get("proxy") if isinstance(evidence.get("proxy"), dict) else {}
if proxy.get("status") == "created":
remove = getattr(self.npm, "remove_managed_proxy_host", None)
result = remove(host_id=proxy.get("hostId"), ownership_id=enrollment.id) if callable(remove) else {"success": False, "status": "unsupported"}
outcomes["proxy"] = str(result.get("status") or "failed")
elif proxy.get("status") == "ownership_unverified":
outcomes["proxy"] = "ownership_unverified"
controller = evidence.get("controller") if isinstance(evidence.get("controller"), dict) else {}
if controller.get("status") in {"enrolled", "already_enrolled"}:
trust = self.get_trust_config(session)
bootstrap = get_secret(session, trust.bootstrap_secret_ref, touch=False) if trust else None
if bootstrap:
result = self.broker.unenroll_target(
{"enrollment_id": enrollment.id, "application_id": enrollment.application_id, "repository": enrollment.repository},
secret=bootstrap,
)
outcomes["controller"] = str(result.get("status") or "failed")
else:
outcomes["controller"] = "bootstrap_secret_unavailable"
elif controller.get("status") == "partial_failure":
outcomes["controller"] = "ownership_unverified"
completed = all(value in {"removed", "rolled_back"} for value in outcomes.values())
if completed and outcomes:
delete_secret(session, enrollment.target_secret_ref)
if enrollment.oidc_secret_ref:
delete_secret(session, enrollment.oidc_secret_ref)
if plan.profile == "repopulse-authenticated":
delete_secret(session, _secret_ref("repopulse", "MAINTENANCE_TOKEN"))
return {"completed": completed, "outcomes": outcomes}
def _fail(
self,
session: Session,
enrollment: Enrollment,
job: EnrollmentJob,
*,
stage: str,
message: str,
evidence: dict[str, Any],
) -> dict[str, Any]:
compensation = (
self._compensate(session, enrollment, evidence)
if evidence.get("controller")
else {"completed": True, "outcomes": {}}
)
compensation_required = not bool(compensation.get("completed"))
enrollment.status = "compensation_required" if compensation_required else "failed"
enrollment.resources_json = {**dict(enrollment.resources_json or {}), **evidence, "compensation": compensation}
enrollment.updated_at = utc_now()
enrollment.completed_at = utc_now()
job.status = "compensation_required" if compensation_required else "failed"
job.stage = stage
job.evidence_json = {**dict(job.evidence_json or {}), **evidence, "compensation": compensation}
job.result_summary = message
job.updated_at = utc_now()
job.completed_at = utc_now()
session.add(enrollment)
session.add(job)
session.commit()
log_event(
session,
source="enrollment-plane",
identity="operator",
action_id="target.enroll",
target=enrollment.application_id,
target_type="enrollment",
risk="high",
policy_decision="blocked",
reason=message,
result="failed",
execution_plan={"enrollmentId": enrollment.id, "planSha256": enrollment.plan_sha256},
after_state={"stage": stage, "status": enrollment.status},
)
return {"enrollment": self.serialize_enrollment(enrollment), "job": self.serialize_job(job)}
def enroll(self, session: Session, plan: EnrollmentPlanV1, *, idempotency_key: str) -> dict[str, Any]:
_, plan_digest = _canonical(plan)
existing, repository_existing = self._existing(session, plan.application_id, plan.repository)
if existing and existing.plan_sha256 == plan_digest:
prior = session.exec(
select(EnrollmentJob).where(EnrollmentJob.enrollment_id == existing.id).order_by(EnrollmentJob.created_at.desc())
).first()
if prior is None or prior.status in _TERMINAL:
return {
"enrollment": self.serialize_enrollment(existing),
"job": self.serialize_job(prior, replay=True) if prior else None,
"idempotentReplay": True,
}
# A process may have stopped after an external request was sent but
# before its response was durably checkpointed. Do not infer the
# effect and do not send the request again: retain the exact intent
# and require an explicit reconciliation flow.
existing.status = "recovery_required"
existing.updated_at = utc_now()
prior.status = "recovery_required"
prior.result_summary = "Enrollment stopped during an external stage; reconcile recorded intent and receipts before retrying."
prior.updated_at = utc_now()
session.add(existing)
session.add(prior)
session.commit()
return {
"enrollment": self.serialize_enrollment(existing),
"job": self.serialize_job(prior),
"recoveryRequired": True,
}
if existing or repository_existing:
raise EnrollmentError("application identity or repository is already enrolled")
trust = self.get_trust_config(session)
if trust is None:
raise EnrollmentError("enrollment trust boundaries are not configured")
enrollment = self._create_enrollment(session, plan, plan_digest, trust)
job = self._create_job(session, enrollment, idempotency_key)
if job.status != "queued":
return {"enrollment": self.serialize_enrollment(enrollment), "job": self.serialize_job(job, replay=True)}
preflight = self.preflight(session, plan)
job.preflight_json = preflight
job.updated_at = utc_now()
session.add(job)
session.commit()
if preflight["status"] != "ready":
enrollment.status = "preflight_failed"
enrollment.completed_at = utc_now()
enrollment.updated_at = utc_now()
job.status = "preflight_failed"
job.stage = "preflight"
job.result_summary = "Enrollment preflight was blocked."
job.completed_at = utc_now()
session.add(enrollment)
session.add(job)
session.commit()
return {"enrollment": self.serialize_enrollment(enrollment), "job": self.serialize_job(job)}
repopulse_gitea_token = (
get_secret(session, "REPOPULSE_GITEA_TOKEN", touch=False)
if plan.profile == "repopulse-authenticated" else None
)
if plan.profile == "repopulse-authenticated" and not repopulse_gitea_token:
return self._fail(
session, enrollment, job, stage="preflight",
message="The existing encrypted RepoPulse Gitea token became unavailable after preflight.",
evidence={},
)
target_secret = _credential()
set_secret(
session,
enrollment.target_secret_ref,
target_secret,
used_by=[f"enrollment:{enrollment.id}", "deployment-controller"],
metadata={"enrollmentId": enrollment.id, "credentialFingerprint": _fingerprint(target_secret), "rotationState": "active"},
)
oidc_secret: str | None = None
if enrollment.oidc_secret_ref:
oidc_secret = _credential()
set_secret(
session,
enrollment.oidc_secret_ref,
oidc_secret,
used_by=[f"enrollment:{enrollment.id}", "authentik", "deployment-controller"],
metadata={"enrollmentId": enrollment.id, "credentialFingerprint": _fingerprint(oidc_secret), "rotationState": "active"},
)
repopulse_maintenance_token: str | None = None
if plan.profile == "repopulse-authenticated":
repopulse_maintenance_token = _credential()
set_secret(
session,
_secret_ref("repopulse", "MAINTENANCE_TOKEN"),
repopulse_maintenance_token,
used_by=[f"enrollment:{enrollment.id}", "repopulse-maintenance", "deployment-controller"],
metadata={
"enrollmentId": enrollment.id,
"credentialFingerprint": _fingerprint(repopulse_maintenance_token),
"rotationState": "active",
},
)
bootstrap_secret = get_secret(session, trust.bootstrap_secret_ref, touch=False)
if not bootstrap_secret:
return self._fail(session, enrollment, job, stage="creating_secrets", message="Controller bootstrap secret disappeared after preflight.", evidence={})
evidence: dict[str, Any] = {
"intent": {
"controller": {
"enrollmentId": enrollment.id,
"applicationId": plan.application_id,
"repository": plan.repository,
}
}
}
self._checkpoint(
session, enrollment, job, stage="registering_target", evidence=evidence
)
controller_result = self.broker.enroll_target(
self._controller_payload(plan, enrollment_id=enrollment.id, target_credential=target_secret),
secret=bootstrap_secret,
idempotency_key=idempotency_key,
)
evidence["controller"] = {
key: controller_result[key] for key in _CONTROLLER_EVIDENCE if key in controller_result
}
self._checkpoint(
session, enrollment, job, stage="registering_target", evidence=evidence
)
if controller_result.get("status") not in {"enrolled", "already_enrolled"}:
return self._fail(session, enrollment, job, stage="registering_target", message="Controller rejected the bounded target enrollment.", evidence=evidence)
if plan.exposure:
evidence["intent"] = {
**dict(evidence.get("intent") or {}),
"proxy": {"hostname": plan.exposure.hostname, "ownershipId": enrollment.id},
}
self._checkpoint(
session, enrollment, job, stage="provisioning_proxy", evidence=evidence
)
proxy_arguments = {
"hostname": plan.exposure.hostname,
"forward_host": plan.application_id,
"forward_port": plan.network.internal_port,
"certificate_mode": "managed",
"force_ssl": True,
"http2": True,
"upload_limit_mb": 1024 if plan.exposure.upload_profile == "large-upload" else 128,
"read_timeout_seconds": 900 if plan.exposure.timeout_profile == "long-running" else 120,
"ownership_id": enrollment.id,
}
if plan.profile == "repopulse-authenticated":
access_list_id = trust.repopulse_proxy_access_list_id if trust else None
if not access_list_id or access_list_id <= 0:
return self._fail(
session, enrollment, job,
stage="provisioning_proxy",
message="RepoPulse requires a verified, positive NPM access-list ID.",
evidence=evidence,
)
proxy_arguments["access_list_id"] = access_list_id
proxy_result = self.npm.ensure_managed_proxy_host(
**proxy_arguments,
)
evidence["proxy"] = {key: proxy_result[key] for key in _PROXY_EVIDENCE if key in proxy_result}
self._checkpoint(
session, enrollment, job, stage="provisioning_proxy", evidence=evidence
)
if not proxy_result.get("success") or proxy_result.get("status") != "created":
return self._fail(session, enrollment, job, stage="provisioning_proxy", message="Managed reverse-proxy provisioning was not verified.", evidence=evidence)
profile = profile_contract(plan.profile)
runtime_values: dict[str, str] = {}
if plan.public_url and profile and "APPOPS_PUBLIC_URL" in profile["runtimeEnvironmentKeys"]:
runtime_values["APPOPS_PUBLIC_URL"] = plan.public_url
if plan.authentication.mode == "native-oidc":
assert oidc_secret is not None and enrollment.oidc_secret_ref is not None
evidence["intent"] = {
**dict(evidence.get("intent") or {}),
"oidc": {"applicationSlug": plan.application_id, "launchUrl": plan.public_url},
}
self._checkpoint(
session, enrollment, job, stage="provisioning_identity", evidence=evidence
)
oidc_result = self.authentik.ensure_deployment_oidc_application(
application_slug=plan.application_id,
launch_url=plan.public_url or "",
redirect_uri=f"{plan.public_url}{plan.authentication.redirect_path}",
client_secret=oidc_secret,
administrator_group=trust.administrator_group or "",
support_agent_group=trust.support_agent_group or "",
read_only_group=trust.read_only_group or "",
)
evidence["oidc"] = {key: oidc_result[key] for key in _OIDC_EVIDENCE if key in oidc_result}
self._checkpoint(
session, enrollment, job, stage="provisioning_identity", evidence=evidence
)
if not oidc_result.get("success") or oidc_result.get("status") != "created":
return self._fail(session, enrollment, job, stage="provisioning_identity", message="Bounded OIDC application provisioning was not verified.", evidence=evidence)
runtime_values.update({
"OIDC_CLIENT_ID": plan.application_id,
"OIDC_CLIENT_SECRET": oidc_secret,
"OIDC_ISSUER": trust.oidc_issuer or "",
"OIDC_ROLE_CLAIM": trust.oidc_role_claim,
})
if plan.profile == "repopulse-authenticated":
assert repopulse_gitea_token is not None and repopulse_maintenance_token is not None
runtime_values.update({
"REPOPULSE_GITEA_URL": "https://gitea.example.com",
"REPOPULSE_GITEA_TOKEN": repopulse_gitea_token,
"REPOPULSE_MAINTENANCE_TOKEN": repopulse_maintenance_token,
})
if profile and profile["runtimeEnvironmentKeys"]:
evidence["intent"] = {
**dict(evidence.get("intent") or {}),
"runtime": {"applicationId": plan.application_id, "keys": list(runtime_values)},
}
self._checkpoint(
session, enrollment, job, stage="sealing_runtime", evidence=evidence
)
runtime_result = self.broker.configure_enrollment_runtime(
{
"enrollment_id": enrollment.id,
"application_id": plan.application_id,
"repository": plan.repository,
"values": runtime_values,
},
secret=target_secret,
)
evidence["runtime"] = {key: runtime_result[key] for key in ("status", "enrollmentId", "applicationId") if key in runtime_result}
self._checkpoint(
session, enrollment, job, stage="sealing_runtime", evidence=evidence
)
if runtime_result.get("status") not in {"configured", "already_configured"}:
return self._fail(session, enrollment, job, stage="sealing_runtime", message="Controller could not seal profile-owned runtime values.", evidence=evidence)
evidence["intent"] = {
**dict(evidence.get("intent") or {}),
"policies": [
f"enrollment-{plan.application_id}-deployment-apply",
f"enrollment-{plan.application_id}-deployment-rollback",
],
}
self._checkpoint(
session, enrollment, job, stage="installing_policy", evidence=evidence
)
try:
policy_ids = self._install_policies(session, enrollment, plan)
except EnrollmentError as exc:
return self._fail(session, enrollment, job, stage="installing_policy", message=str(exc), evidence=evidence)
evidence["policies"] = policy_ids
enrollment.status = "ready"
enrollment.resources_json = {
"appdataPath": plan.appdata_path,
"dataPath": plan.data_path,
"network": plan.network.docker_network,
"hostname": plan.exposure.hostname if plan.exposure else None,
**evidence,
}
enrollment.last_verified_at = utc_now()
enrollment.updated_at = utc_now()
enrollment.completed_at = utc_now()
job.status = "ready"
job.stage = "ready"
job.evidence_json = evidence
job.result_summary = "Bounded target, profile resources and exact policies are ready for deployment."
job.updated_at = utc_now()
job.completed_at = utc_now()
session.add(enrollment)
session.add(job)
session.commit()
log_event(
session,
source="enrollment-plane",
identity="operator",
action_id="target.enroll",
target=plan.application_id,
target_type="enrollment",
risk="high",
policy_decision="allowed",
reason="Bounded application identity was enrolled from an autonomous profile.",
result="success",
execution_plan={"enrollmentId": enrollment.id, "planSha256": plan_digest, "profile": plan.profile},
after_state={"status": "ready", "policyIds": policy_ids},
)
return {"enrollment": self.serialize_enrollment(enrollment), "job": self.serialize_job(job)}
def list_enrollments(self, session: Session, *, limit: int = 100) -> list[dict[str, Any]]:
rows = session.exec(select(Enrollment).order_by(Enrollment.created_at.desc()).limit(max(1, min(limit, 250)))).all()
return [self.serialize_enrollment(row) for row in rows]
def get_enrollment(self, session: Session, enrollment_id: str) -> dict[str, Any]:
row = session.get(Enrollment, enrollment_id)
if row is None:
raise EnrollmentError("enrollment was not found")
jobs = session.exec(
select(EnrollmentJob).where(EnrollmentJob.enrollment_id == enrollment_id).order_by(EnrollmentJob.created_at.desc()).limit(20)
).all()
return {"enrollment": self.serialize_enrollment(row), "jobs": [self.serialize_job(job) for job in jobs]}