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

372 lines
16 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
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