"""Operator API for the bounded Enrollment & Provisioning Plane v1.""" from __future__ import annotations from copy import deepcopy from typing import Annotated, Literal from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request from pydantic import BaseModel, ConfigDict from sqlalchemy import insert as sql_insert from sqlalchemy import update as sql_update from sqlalchemy.exc import SQLAlchemyError from sqlmodel import Session from ..core.security import require_operator_request from ..core.time import utc_now from ..db.models import AppSetting from ..db.session import get_session from ..schemas.enrollment import ( LEGACY_APPDATA_ROOT, EnrollmentPlanV1, EnrollmentStorageRootSelectionV1, EnrollmentTrustConfigV1, ) from ..services.audit_logger import log_event from ..services.enrollment_plane import ( TRUST_SETTING_KEY, EnrollmentError, EnrollmentPlane, ) from ..services.repopulse_adoption_journal import ( RepoPulseAdoptionJournalError, get_repopulse_adoption_status, ) from ..services.user_access import current_user from ..connectors.authentik import AuthentikConnector from ..services.setup_service import apply_stored_connector_settings from ..services.enrollment_oidc_settings import ( OIDC_TEMPLATE_SETTING_KEY, EnrollmentOidcProfileV1, load_profile, trusted_issuer_origin, ) def _require_operator(request: Request) -> str: return require_operator_request(request) router = APIRouter( prefix="/api/enrollments", tags=["enrollments"], dependencies=[Depends(_require_operator)], ) class EnrollmentRequestV1(BaseModel): model_config = ConfigDict(extra="forbid") plan: EnrollmentPlanV1 operation: Literal["preflight", "enroll"] = "preflight" EnrollmentSession = Annotated[Session, Depends(get_session)] EnrollmentIdempotencyKey = Annotated[str | None, Header(alias="Idempotency-Key")] EnrollmentLimit = Annotated[int, Query(ge=1, le=250)] def _idempotency(value: str | None) -> str: if not value: raise HTTPException(status_code=400, detail="Idempotency-Key is required for enrollment mutations.") return value.strip() @router.get("/capabilities") def enrollment_capabilities(session: EnrollmentSession): return EnrollmentPlane().capabilities(session) @router.get("/trust") def enrollment_trust_status(session: EnrollmentSession): return EnrollmentPlane.trust_status(session) @router.put("/trust") def update_enrollment_trust( payload: EnrollmentTrustConfigV1, session: EnrollmentSession, request: Request, ): actor = current_user(request) if actor.role != "admin": row = session.get(AppSetting, TRUST_SETTING_KEY) original_value = deepcopy(row.value_json) if row is not None else None original_updated_at = row.updated_at if row is not None else None if row is None or row.is_secret: existing = None else: try: existing = EnrollmentTrustConfigV1.model_validate(original_value) except ValueError: existing = None if row is None: # Preserve initial legacy setup for settings.manage users, but the # fixed managed-root opt-in must be made by an administrator. selector_changed = ( payload.trusted_appdata_root != LEGACY_APPDATA_ROOT or payload.managed_appdata_backup_acknowledged is not False ) elif existing is None: # A corrupt stored trust document has no safe selector baseline. selector_changed = True else: selector_changed = ( payload.trusted_appdata_root != existing.trusted_appdata_root or payload.managed_appdata_backup_acknowledged != existing.managed_appdata_backup_acknowledged ) if selector_changed: raise HTTPException( status_code=403, detail="Administrator role is required to change the appdata root.", ) try: if row is None: # A concurrent administrator may create the trust row after the # absence check. The unique key makes this insert a safe CAS: # losing the race is a conflict, never an overwrite. session.execute( sql_insert(AppSetting).values( key=TRUST_SETTING_KEY, value_json=payload.model_dump(mode="json"), is_secret=False, updated_at=utc_now(), ) ) else: result = session.execute( sql_update(AppSetting) .where(AppSetting.key == TRUST_SETTING_KEY) .where(AppSetting.updated_at == original_updated_at) .where(AppSetting.value_json == original_value) .values( value_json=payload.model_dump(mode="json"), is_secret=False, updated_at=utc_now(), ) .execution_options(synchronize_session=False) ) if result.rowcount != 1: session.rollback() raise HTTPException(status_code=409, detail="enrollment_trust_changed") session.commit() except HTTPException: raise except SQLAlchemyError: session.rollback() raise HTTPException(status_code=409, detail="enrollment_trust_changed") from None session.expire_all() return EnrollmentPlane.trust_status(session) return EnrollmentPlane.set_trust_config(session, payload) @router.patch("/trust/storage-root") def update_enrollment_storage_root( payload: EnrollmentStorageRootSelectionV1, session: EnrollmentSession, request: Request, ): """Change only the fixed storage selector and persist its backup acknowledgement.""" if current_user(request).role != "admin": raise HTTPException(status_code=403, detail="Administrator role is required.") trust = EnrollmentPlane.get_trust_config(session) row = session.get(AppSetting, TRUST_SETTING_KEY) if trust is None or row is None: raise HTTPException(status_code=409, detail="enrollment_trust_not_configured") original_value = deepcopy(row.value_json) original_updated_at = row.updated_at try: stored_trust = EnrollmentTrustConfigV1.model_validate(original_value) except ValueError: raise HTTPException(status_code=409, detail="enrollment_trust_changed") from None if stored_trust.model_dump(mode="json") != trust.model_dump(mode="json"): raise HTTPException(status_code=409, detail="enrollment_trust_changed") updated = EnrollmentTrustConfigV1.model_validate({ **original_value, **payload.model_dump(mode="json"), }) before = { "trustedAppdataRoot": trust.trusted_appdata_root, "managedAppdataBackupAcknowledged": trust.managed_appdata_backup_acknowledged, } after = { "trustedAppdataRoot": updated.trusted_appdata_root, "managedAppdataBackupAcknowledged": updated.managed_appdata_backup_acknowledged, } try: result = session.execute( sql_update(AppSetting) .where(AppSetting.key == TRUST_SETTING_KEY) .where(AppSetting.updated_at == original_updated_at) .where(AppSetting.value_json == original_value) .values( value_json=updated.model_dump(mode="json"), is_secret=False, updated_at=utc_now(), ) .execution_options(synchronize_session=False) ) except SQLAlchemyError: session.rollback() raise HTTPException(status_code=409, detail="enrollment_trust_changed") from None if result.rowcount != 1: session.rollback() raise HTTPException(status_code=409, detail="enrollment_trust_changed") session.expire(row) log_event( session, source="settings", identity="manual", action_id="enrollment.trust.storage_root.update", target="tower/deployment-controller", risk="high", policy_decision="allowed", reason="Authenticated settings.manage operator selected a fixed appdata root and recorded its backup acknowledgement.", result="updated", target_type="controller", before_state=before, after_state=after, redacted_metadata={"backupLocation": "dockerRootDir" if updated.managed_appdata_backup_acknowledged else None}, ) return EnrollmentPlane.trust_status(session) def _oidc_profile_status(session: Session): apply_stored_connector_settings(session, {"authentik"}) profile = load_profile(session) trust = EnrollmentPlane.get_trust_config(session) matches = bool(profile and trust and profile.issuer == trusted_issuer_origin() and AuthentikConnector.deployment_oidc_origin_matches(profile.issuer) and ( trust.oidc_issuer == profile.issuer and trust.administrator_group == profile.administrator_group and trust.support_agent_group == profile.support_agent_group and trust.read_only_group == profile.read_only_group )) return {"configured": matches, "issuerOrigin": trusted_issuer_origin(), "profile": profile.model_dump(mode="json") if profile else None} @router.get("/trust/oidc-profile") def enrollment_oidc_profile_status(session: EnrollmentSession, request: Request): if current_user(request).role != "admin": raise HTTPException(status_code=403, detail="Administrator role is required.") return _oidc_profile_status(session) @router.patch("/trust/oidc-profile") def update_enrollment_oidc_profile( payload: EnrollmentOidcProfileV1, session: EnrollmentSession, request: Request, ): if current_user(request).role != "admin": raise HTTPException(status_code=403, detail="Administrator role is required.") if payload.issuer != trusted_issuer_origin(): raise HTTPException(status_code=409, detail="oidc_issuer_mismatch") trust_row = session.get(AppSetting, TRUST_SETTING_KEY) binding_row = session.get(AppSetting, OIDC_TEMPLATE_SETTING_KEY) if trust_row is None or trust_row.is_secret: raise HTTPException(status_code=409, detail="enrollment_trust_not_configured") original = deepcopy(trust_row.value_json) trust_updated_at = trust_row.updated_at binding_original = deepcopy(binding_row.value_json) if binding_row else None binding_updated_at = binding_row.updated_at if binding_row else None if binding_row and binding_row.is_secret: raise HTTPException(status_code=409, detail="oidc_profile_binding_invalid") try: updated = EnrollmentTrustConfigV1.model_validate({ **original, "oidc_issuer": payload.issuer, "oidc_role_claim": "roles", "administrator_group": payload.administrator_group, "support_agent_group": payload.support_agent_group, "read_only_group": payload.read_only_group, }) except ValueError: raise HTTPException(status_code=409, detail="enrollment_trust_changed") from None apply_stored_connector_settings(session, {"authentik"}) connector = AuthentikConnector() if not connector.deployment_oidc_origin_matches(payload.issuer): raise HTTPException(status_code=409, detail="oidc_connector_issuer_mismatch") connector.configure_deployment_oidc_template(payload.template_config(), issuer_origin=payload.issuer) template = connector.validate_deployment_oidc_template( role_claim="roles", role_groups=(payload.administrator_group, payload.support_agent_group, payload.read_only_group), ) if template.get("status") != "ready" or not connector.is_configured(): raise HTTPException(status_code=409, detail="oidc_profile_not_verified") groups = connector.groups() names = {str(g.get("name") or "") for g in groups.get("items", []) if isinstance(g, dict)} if groups.get("success") is not True or not {payload.administrator_group, payload.support_agent_group, payload.read_only_group}.issubset(names): raise HTTPException(status_code=409, detail="oidc_profile_not_verified") value = payload.model_dump(mode="json") try: changed = session.execute(sql_update(AppSetting) .where(AppSetting.key == TRUST_SETTING_KEY) .where(AppSetting.updated_at == trust_updated_at) .where(AppSetting.value_json == original) .values(value_json=updated.model_dump(mode="json"), updated_at=utc_now()) .execution_options(synchronize_session=False)) if changed.rowcount != 1: raise HTTPException(status_code=409, detail="enrollment_trust_changed") if binding_row is None: session.execute(sql_insert(AppSetting).values( key=OIDC_TEMPLATE_SETTING_KEY, value_json=value, is_secret=False, updated_at=utc_now(), )) else: changed = session.execute(sql_update(AppSetting) .where(AppSetting.key == OIDC_TEMPLATE_SETTING_KEY) .where(AppSetting.updated_at == binding_updated_at) .where(AppSetting.value_json == binding_original) .values(value_json=value, updated_at=utc_now()) .execution_options(synchronize_session=False)) if changed.rowcount != 1: raise HTTPException(status_code=409, detail="oidc_profile_changed") log_event(session, source="settings", identity="manual", action_id="enrollment.trust.oidc_profile.update", target="tower/deployment-controller", risk="high", policy_decision="allowed", result="updated", target_type="controller", reason="Administrator bound verified existing generic OIDC templates and three empty-or-existing role groups.", before_state={"configured": binding_original is not None}, after_state={"configured": True, "issuer": payload.issuer}, ) except HTTPException: session.rollback() raise except SQLAlchemyError: session.rollback() raise HTTPException(status_code=409, detail="oidc_profile_changed") from None session.expire_all() return _oidc_profile_status(session) @router.post("") def request_enrollment( payload: EnrollmentRequestV1, session: EnrollmentSession, idempotency_key: EnrollmentIdempotencyKey = None, ): plane = EnrollmentPlane() try: if payload.operation == "preflight": return plane.preflight(session, payload.plan) return plane.enroll(session, payload.plan, idempotency_key=_idempotency(idempotency_key)) except EnrollmentError as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc @router.get("") def list_enrollments(session: EnrollmentSession, limit: EnrollmentLimit = 100): return {"items": EnrollmentPlane().list_enrollments(session, limit=limit)} @router.get("/repopulse/adoption") def repopulse_adoption_status(session: EnrollmentSession): """Read the sole fixed migration journal through the operator boundary.""" try: return get_repopulse_adoption_status(session) except RepoPulseAdoptionJournalError as exc: raise HTTPException(status_code=503, detail=exc.code) from None @router.get("/{enrollment_id}") def get_enrollment(enrollment_id: str, session: EnrollmentSession): try: return EnrollmentPlane().get_enrollment(session, enrollment_id) except EnrollmentError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc