263 lines
9.6 KiB
Python
263 lines
9.6 KiB
Python
"""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
|