"""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 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) @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