1246 lines
62 KiB
Python
1246 lines
62 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 sqlalchemy.exc import IntegrityError
|
|
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"}
|
|
_CAPABILITY_REASON = re.compile(r"^[a-z][a-z0-9_]{0,79}$")
|
|
_RUNTIME_SECURITY_FEATURES = ("exactTargetPolicy", "hmacV2", "replayProtection")
|
|
|
|
|
|
def _safe_capability_reason(value: object) -> str:
|
|
reason = str(value or "")
|
|
return reason if _CAPABILITY_REASON.fullmatch(reason) else ""
|
|
|
|
|
|
def _features_ready(features: object, names: tuple[str, ...]) -> bool:
|
|
return bool(
|
|
isinstance(features, dict)
|
|
and all(features.get(name) is True for name in names)
|
|
)
|
|
|
|
|
|
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, 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]
|
|
value = {"root": root, "status": status}
|
|
reason = _safe_capability_reason(item.get("reason"))
|
|
if reason:
|
|
value["reason"] = reason
|
|
statuses[root] = value
|
|
if set(statuses) != set(roots):
|
|
return [{"root": root, "status": unavailable[root]} for root in roots]
|
|
return [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
|
|
controller_core_ready = bool(
|
|
bootstrap
|
|
and self.broker.is_configured()
|
|
and controller_capability.get("status") == "ready"
|
|
)
|
|
features = controller_capability.get("features")
|
|
runtime_security_ready = _features_ready(features, _RUNTIME_SECURITY_FEATURES)
|
|
apply_ready = bool(
|
|
controller_core_ready
|
|
and runtime_security_ready
|
|
and isinstance(features, dict)
|
|
and features.get("deploymentApply") is True
|
|
)
|
|
verify_ready = bool(
|
|
controller_core_ready
|
|
and runtime_security_ready
|
|
and isinstance(features, dict)
|
|
and features.get("deploymentVerify") is True
|
|
)
|
|
rollback_ready = bool(
|
|
controller_core_ready
|
|
and runtime_security_ready
|
|
and isinstance(features, dict)
|
|
and features.get("rollback") is True
|
|
)
|
|
runtime_deployment_ready = apply_ready and verify_ready
|
|
enrollment_features_ready = _features_ready(
|
|
features,
|
|
("enrollment", "targetRegistration", "runtimeSecretSealing"),
|
|
)
|
|
target_enrollment_ready = bool(
|
|
controller_core_ready
|
|
and controller_root_matches
|
|
and enrollment_features_ready
|
|
)
|
|
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"
|
|
controller_roots_by_path = {
|
|
str(item.get("root") or ""): item
|
|
for item in available_roots
|
|
if isinstance(item, dict)
|
|
}
|
|
selected_root_readiness = (
|
|
controller_roots_by_path.get(trust.trusted_appdata_root)
|
|
if trust is not None
|
|
else None
|
|
)
|
|
controller_enrollment = controller_capability.get("enrollmentReadiness")
|
|
if not isinstance(controller_enrollment, dict):
|
|
controller_enrollment = {}
|
|
root_reason = _safe_capability_reason(
|
|
selected_root_readiness.get("reason")
|
|
if isinstance(selected_root_readiness, dict)
|
|
else None
|
|
)
|
|
if not root_reason and trust is not None and trust.trusted_appdata_root == MANAGED_DOCKER_APPDATA_ROOT:
|
|
root_reason = _safe_capability_reason(controller_managed_storage.get("reason"))
|
|
enrollment_reason = (
|
|
root_reason
|
|
or _safe_capability_reason(controller_enrollment.get("reason"))
|
|
or (
|
|
"enrollment_trust_not_configured"
|
|
if trust is None
|
|
else "trusted_appdata_root_unavailable"
|
|
if not controller_root_matches
|
|
else "controller_enrollment_feature_unavailable"
|
|
if not enrollment_features_ready
|
|
else "deployment_executor_unavailable"
|
|
)
|
|
)
|
|
if target_enrollment_ready:
|
|
enrollment_reason = ""
|
|
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}
|
|
|
|
if not controller_core_ready:
|
|
enrollment_blocker = "deployment_executor_unavailable"
|
|
elif not controller_root_matches:
|
|
enrollment_blocker = "trusted_appdata_root_unavailable"
|
|
else:
|
|
enrollment_blocker = "controller_enrollment_feature_unavailable"
|
|
registry_report = controller_capability.get("registry")
|
|
registry_projection: dict[str, Any] = {"status": "unavailable"}
|
|
if isinstance(registry_report, dict):
|
|
registry_status = registry_report.get("status")
|
|
if registry_status in {"ready", "unavailable"}:
|
|
registry_projection["status"] = registry_status
|
|
count = registry_report.get("registeredProjectCount")
|
|
if (
|
|
registry_status == "ready"
|
|
and type(count) is int
|
|
and 0 <= count <= 500
|
|
):
|
|
registry_projection["registeredProjectCount"] = count
|
|
controller_reason = (
|
|
""
|
|
if controller_core_ready
|
|
else _safe_capability_reason(controller_capability.get("reason"))
|
|
or "deployment_executor_unavailable"
|
|
)
|
|
|
|
return {
|
|
"apiVersion": "itworx.enrollment-capabilities/v1",
|
|
"trust": item(configured, "enrollment_trust_not_configured"),
|
|
"targetEnrollment": item(target_enrollment_ready, enrollment_blocker),
|
|
"secretBootstrap": item(_enrollment_secret_store_ready(storage), "secret_store_not_production_ready"),
|
|
"runtimeDeployment": item(runtime_deployment_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(rollback_ready, "deployment_executor_unavailable"),
|
|
"verification": item(verify_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
|
|
),
|
|
**(
|
|
{"reason": _safe_capability_reason(controller_managed_storage.get("reason"))}
|
|
if trust is not None
|
|
and trust.trusted_appdata_root == MANAGED_DOCKER_APPDATA_ROOT
|
|
and _safe_capability_reason(controller_managed_storage.get("reason"))
|
|
else {}
|
|
),
|
|
},
|
|
"enrollmentReadiness": {
|
|
"status": "ready" if target_enrollment_ready else "unavailable",
|
|
"reason": enrollment_reason,
|
|
},
|
|
"registry": registry_projection,
|
|
"controller": {
|
|
"status": "ready" if controller_core_ready else "unavailable",
|
|
"reason": controller_reason,
|
|
},
|
|
"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."))
|
|
|
|
policy_ids = (
|
|
f"enrollment-{plan.application_id}-deployment-apply",
|
|
f"enrollment-{plan.application_id}-deployment-rollback",
|
|
)
|
|
policy_collisions = [policy_id for policy_id in policy_ids if session.get(Policy, policy_id) is not None]
|
|
checks.append(_check(
|
|
"exact_policy_id_collision",
|
|
"blocked" if policy_collisions else "passed",
|
|
"An enrollment-generated exact policy identifier is already owned and cannot be adopted."
|
|
if policy_collisions else "Both enrollment-generated exact policy identifiers are available.",
|
|
))
|
|
|
|
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(),
|
|
}
|
|
policy_specs = (
|
|
(
|
|
"deployment.apply",
|
|
f"enrollment-{plan.application_id}-deployment-apply",
|
|
),
|
|
(
|
|
"deployment.rollback",
|
|
f"enrollment-{plan.application_id}-deployment-rollback",
|
|
),
|
|
)
|
|
identifiers = [policy_id for _action, policy_id in policy_specs]
|
|
# Check the complete pair before adding either row. The database flush
|
|
# below is one transaction so a concurrent uniqueness collision cannot
|
|
# leave the first insert committed by _fail().
|
|
if any(session.get(Policy, policy_id) is not None for policy_id in identifiers):
|
|
raise EnrollmentError("exact policy identifier is already owned and cannot be adopted")
|
|
|
|
for action, policy_id in policy_specs:
|
|
policy = Policy(
|
|
id=policy_id,
|
|
action=action,
|
|
target=plan.application_id,
|
|
voice="blocked",
|
|
manual="allowed",
|
|
risk="high",
|
|
reason="Enrollment-generated exact-target deployment policy.",
|
|
)
|
|
policy.provenance_json = provenance
|
|
session.add(policy)
|
|
try:
|
|
# Force uniqueness errors inside this boundary; on failure discard
|
|
# the uncommitted unit of work before _fail() persists its outcome.
|
|
session.flush()
|
|
except IntegrityError as exc:
|
|
session.rollback()
|
|
raise EnrollmentError("exact policy identifier is already owned and cannot be adopted") from exc
|
|
# Deliberately do not commit here: the caller commits these rows together
|
|
# with the enrollment/job ready state after policy installation succeeds.
|
|
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]}
|