Files
Unraid-AppOps-Gateway-Public/backend/app/api/routes_enrollments.py
T

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