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 from govoplan_core.audit.logging import audit_event from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope from govoplan_core.core.identity_trust import ( AssuranceCheckRequest, DeviceKeyRegistration, 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, KEY_ACCESS_SCOPE, ) 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, ) router = APIRouter(prefix="/identity-trust", tags=["identity-trust"]) service = SqlIdentityTrustService() def _require(principal: ApiPrincipal, *scopes: str) -> None: if any(has_scope(principal, scope) for scope in scopes): return raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing one of: {', '.join(scopes)}", ) def _error(exc: IdentityTrustError) -> HTTPException: 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: return DeviceKeyResponse(**asdict(value)) 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, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> DeviceKeyResponse: _require(principal, DEVICE_WRITE_SCOPE, ADMIN_SCOPE) try: value = service.register_device_key( session, principal, request=DeviceKeyRegistration( tenant_id=principal.tenant_id, **payload.model_dump(), ), ) except (IdentityTrustError, ValueError) as exc: raise _error(IdentityTrustError(str(exc))) from exc audit_event( session, tenant_id=principal.tenant_id, user_id=getattr(principal.user, "id", None), api_key_id=principal.api_key_id, action="identity_trust.device_key.registered", object_type="device_public_key", object_id=value.key_id, details={"algorithm": value.algorithm, "private_material": False}, ) session.commit() return _device_response(value) @router.get("/device-keys", response_model=DeviceKeyListResponse) def api_list_device_keys( account_id: str, active_only: bool = Query(default=True), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> DeviceKeyListResponse: _require(principal, DEVICE_READ_SCOPE, ADMIN_SCOPE) try: values = service.list_device_keys( session, principal, tenant_id=principal.tenant_id, account_id=account_id, active_only=active_only, ) except IdentityTrustError as exc: raise _error(exc) from exc 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, payload: DeviceKeyRevokePayload, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> DeviceKeyResponse: _require(principal, DEVICE_WRITE_SCOPE, ADMIN_SCOPE) try: value = service.revoke_device_key( session, principal, tenant_id=principal.tenant_id, key_id=key_id, expected_epoch=payload.expected_epoch, reason=payload.reason, ) except IdentityTrustError as exc: raise _error(exc) from exc audit_event( session, tenant_id=principal.tenant_id, user_id=getattr(principal.user, "id", None), api_key_id=principal.api_key_id, action="identity_trust.device_key.revoked", object_type="device_public_key", object_id=key_id, details={"reason": payload.reason, "epoch": value.epoch}, ) session.commit() return _device_response(value) @router.post("/epochs/rotate", response_model=EpochResponse) def api_rotate_epoch( payload: EpochRotatePayload, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> EpochResponse: _require(principal, ADMIN_SCOPE) try: value = service.rotate_epoch( session, principal, request=KeyEpochRotationRequest( tenant_id=principal.tenant_id, **payload.model_dump(), ), ) except (IdentityTrustError, ValueError) as exc: raise _error(IdentityTrustError(str(exc))) from exc audit_event( session, tenant_id=principal.tenant_id, user_id=getattr(principal.user, "id", None), api_key_id=principal.api_key_id, action="identity_trust.key_epoch.rotated", object_type=payload.subject_kind, object_id=payload.subject_id, details={"epoch": value.epoch, "history_policy": value.history_policy}, ) session.commit() 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, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> KeyAccessResponse: _require(principal, KEY_ACCESS_SCOPE, ADMIN_SCOPE) try: value = service.decide_key_access( session, principal, request=KeyAccessRequest( tenant_id=principal.tenant_id, **payload.model_dump(), ), ) except (IdentityTrustError, ValueError) as exc: raise _error(IdentityTrustError(str(exc))) from exc session.commit() data = asdict(value) data["requirements"] = list(value.requirements) 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, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> dict[str, object]: _require(principal, ASSURANCE_SCOPE, ADMIN_SCOPE) try: value = record_assurance_evidence( session, principal, tenant_id=principal.tenant_id, **payload.model_dump(), ) except IdentityTrustError as exc: raise _error(exc) from exc audit_event( session, tenant_id=principal.tenant_id, user_id=getattr(principal.user, "id", None), api_key_id=principal.api_key_id, action="identity_trust.assurance.recorded", object_type="assurance_evidence", object_id=value.id, details={"provider_id": value.provider_id, "level": value.assurance_level}, ) session.commit() return {"id": value.id, "evidence_ref": value.evidence_ref} @router.post("/assurance/check", response_model=AssuranceResponse) def api_check_assurance( payload: AssuranceCheckPayload, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> AssuranceResponse: _require(principal, KEY_ACCESS_SCOPE, ADMIN_SCOPE) value = service.verify_assurance( session, principal, request=AssuranceCheckRequest( tenant_id=principal.tenant_id, **payload.model_dump(), ), ) return AssuranceResponse(**asdict(value))