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