407 lines
13 KiB
Python
407 lines
13 KiB
Python
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))
|