"""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 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"} 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, "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, "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) broker_ready = bool( bootstrap and self.broker.is_configured() and controller_capability.get("status") == "ready" ) 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"), "controller": { "status": str(controller_capability.get("status") or "unavailable"), "reason": str(controller_capability.get("reason") or "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, "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.")) 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.")) if 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]}