Add identity trust administration surfaces

This commit is contained in:
2026-08-04 01:04:39 +02:00
parent 0ffc8b6b0f
commit 9640ec29dd
16 changed files with 1143 additions and 10 deletions
+151 -1
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
from dataclasses import asdict
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
@@ -13,9 +14,15 @@ from govoplan_core.core.identity_trust import (
KeyAccessRequest,
KeyEpochRotationRequest,
)
from govoplan_core.core.references import (
access_scope_reference_options,
access_scope_reference_provider_available,
)
from govoplan_core.core.runtime import get_registry
from govoplan_core.db.session import get_session
from govoplan_identity_trust.backend.manifest import (
ADMIN_SCOPE,
ASSURANCE_READ_SCOPE,
ASSURANCE_SCOPE,
DEVICE_READ_SCOPE,
DEVICE_WRITE_SCOPE,
@@ -23,18 +30,25 @@ from govoplan_identity_trust.backend.manifest import (
)
from govoplan_identity_trust.backend.schemas import (
AssuranceCheckPayload,
AssuranceEvidenceListResponse,
AssuranceEvidencePayload,
AssuranceEvidenceResponse,
AssuranceResponse,
DeviceKeyListResponse,
DeviceKeyRegisterPayload,
DeviceKeyResponse,
DeviceKeyRevokePayload,
EpochResponse,
EpochListResponse,
EpochRotatePayload,
KeyAccessPayload,
KeyAccessDecisionItem,
KeyAccessDecisionListResponse,
KeyAccessResponse,
ReferenceOptionsResponse,
)
from govoplan_identity_trust.backend.service import (
IdentityTrustAccessDenied,
IdentityTrustError,
SqlIdentityTrustService,
record_assurance_evidence,
@@ -55,7 +69,14 @@ def _require(principal: ApiPrincipal, *scopes: str) -> None:
def _error(exc: IdentityTrustError) -> HTTPException:
return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc))
return HTTPException(
status_code=(
status.HTTP_403_FORBIDDEN
if isinstance(exc, IdentityTrustAccessDenied)
else status.HTTP_409_CONFLICT
),
detail=str(exc),
)
def _device_response(value) -> DeviceKeyResponse:
@@ -66,6 +87,25 @@ def _epoch_response(value) -> EpochResponse:
return EpochResponse(**asdict(value))
def _assurance_response(value) -> AssuranceEvidenceResponse:
expires_at = value.expires_at
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=UTC)
return AssuranceEvidenceResponse(
id=value.id,
tenant_id=value.tenant_id,
account_id=value.account_id,
device_key_id=value.device_key_id,
evidence_ref=value.evidence_ref,
assurance_level=value.assurance_level,
provider_id=value.provider_id,
verified_at=value.verified_at,
expires_at=value.expires_at,
active=expires_at >= datetime.now(UTC),
provenance=dict(value.provenance or {}),
)
@router.post("/device-keys", response_model=DeviceKeyResponse)
def api_register_device_key(
payload: DeviceKeyRegisterPayload,
@@ -119,6 +159,64 @@ def api_list_device_keys(
return DeviceKeyListResponse(keys=[_device_response(value) for value in values])
@router.get(
"/account-options",
response_model=ReferenceOptionsResponse,
)
def api_account_options(
q: str = Query(default="", max_length=200),
selected: list[str] = Query(default=[]),
limit: int = Query(default=50, ge=1, le=200),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> ReferenceOptionsResponse:
_require(principal, ADMIN_SCOPE)
registry = get_registry()
options = access_scope_reference_options(
registry,
principal,
scope_type="user",
reference_kind="user",
query=q,
selected_values=selected,
limit=limit,
administrative=True,
session=session,
)
return ReferenceOptionsResponse(
options=[option.to_dict() for option in options],
provider_available=access_scope_reference_provider_available(registry),
)
@router.get(
"/assurance/evidence",
response_model=AssuranceEvidenceListResponse,
)
def api_list_assurance_evidence(
account_id: str,
active_only: bool = Query(default=False),
limit: int = Query(default=200, ge=1, le=500),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> AssuranceEvidenceListResponse:
_require(principal, ASSURANCE_READ_SCOPE, ADMIN_SCOPE)
try:
values = service.list_assurance_evidence(
session,
principal,
tenant_id=principal.tenant_id,
account_id=account_id,
active_only=active_only,
limit=limit,
)
except IdentityTrustError as exc:
raise _error(exc) from exc
return AssuranceEvidenceListResponse(
evidence=[_assurance_response(value) for value in values]
)
@router.post("/device-keys/{key_id}/revoke", response_model=DeviceKeyResponse)
def api_revoke_device_key(
key_id: str,
@@ -184,6 +282,29 @@ def api_rotate_epoch(
return _epoch_response(value)
@router.get("/epochs", response_model=EpochListResponse)
def api_list_epochs(
subject_kind: str,
subject_id: str,
limit: int = Query(default=200, ge=1, le=500),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> EpochListResponse:
_require(principal, ADMIN_SCOPE)
try:
values = service.list_epochs(
session,
principal,
tenant_id=principal.tenant_id,
subject_kind=subject_kind,
subject_id=subject_id,
limit=limit,
)
except IdentityTrustError as exc:
raise _error(exc) from exc
return EpochListResponse(epochs=[_epoch_response(value) for value in values])
@router.post("/key-access/decide", response_model=KeyAccessResponse)
def api_decide_key_access(
payload: KeyAccessPayload,
@@ -208,6 +329,35 @@ def api_decide_key_access(
return KeyAccessResponse(**data)
@router.get(
"/key-access/decisions",
response_model=KeyAccessDecisionListResponse,
)
def api_list_key_access_decisions(
account_id: str,
limit: int = Query(default=200, ge=1, le=500),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> KeyAccessDecisionListResponse:
_require(principal, KEY_ACCESS_SCOPE, ADMIN_SCOPE)
try:
values = service.list_key_access_decisions(
session,
principal,
tenant_id=principal.tenant_id,
account_id=account_id,
limit=limit,
)
except IdentityTrustError as exc:
raise _error(exc) from exc
return KeyAccessDecisionListResponse(
decisions=[
KeyAccessDecisionItem.model_validate(value, from_attributes=True)
for value in values
]
)
@router.post("/assurance/evidence", response_model=dict[str, object])
def api_record_assurance(
payload: AssuranceEvidencePayload,