Add identity trust administration surfaces
This commit is contained in:
@@ -14,12 +14,14 @@ from govoplan_core.core.modules import (
|
||||
CapabilityDocumentation,
|
||||
DocumentationLink,
|
||||
DocumentationTopic,
|
||||
FrontendModule,
|
||||
MigrationSpec,
|
||||
ModuleContext,
|
||||
ModuleInterfaceProvider,
|
||||
ModuleManifest,
|
||||
PermissionDefinition,
|
||||
RoleTemplate,
|
||||
ViewSurface,
|
||||
)
|
||||
from govoplan_core.core.provider_governance import declared_module_architecture
|
||||
from govoplan_core.db.base import Base
|
||||
@@ -34,6 +36,7 @@ DEVICE_READ_SCOPE = "identity_trust:device:read"
|
||||
DEVICE_WRITE_SCOPE = "identity_trust:device:write"
|
||||
KEY_ACCESS_SCOPE = "identity_trust:key_access:approve"
|
||||
ASSURANCE_SCOPE = "identity_trust:assurance:record"
|
||||
ASSURANCE_READ_SCOPE = "identity_trust:assurance:read"
|
||||
ADMIN_SCOPE = "identity_trust:device:admin"
|
||||
|
||||
|
||||
@@ -86,6 +89,11 @@ manifest = ModuleManifest(
|
||||
"Evaluate key access",
|
||||
"Evaluate device and key-epoch trust after Access has approved a resource action.",
|
||||
),
|
||||
_permission(
|
||||
ASSURANCE_READ_SCOPE,
|
||||
"View assurance evidence",
|
||||
"View bounded assurance state and provenance for the acting account or, with administrative authority, another account.",
|
||||
),
|
||||
_permission(
|
||||
ASSURANCE_SCOPE,
|
||||
"Record assurance evidence",
|
||||
@@ -102,7 +110,7 @@ manifest = ModuleManifest(
|
||||
slug="identity_trust_user",
|
||||
name="Identity trust user",
|
||||
description="Manage own public device keys.",
|
||||
permissions=(DEVICE_READ_SCOPE, DEVICE_WRITE_SCOPE),
|
||||
permissions=(DEVICE_READ_SCOPE, DEVICE_WRITE_SCOPE, ASSURANCE_READ_SCOPE),
|
||||
),
|
||||
RoleTemplate(
|
||||
slug="identity_trust_officer",
|
||||
@@ -110,6 +118,7 @@ manifest = ModuleManifest(
|
||||
description="Administer trust epochs and assurance evidence.",
|
||||
permissions=(
|
||||
DEVICE_READ_SCOPE,
|
||||
ASSURANCE_READ_SCOPE,
|
||||
KEY_ACCESS_SCOPE,
|
||||
ASSURANCE_SCOPE,
|
||||
ADMIN_SCOPE,
|
||||
@@ -117,6 +126,42 @@ manifest = ModuleManifest(
|
||||
),
|
||||
),
|
||||
route_factory=_router,
|
||||
frontend=FrontendModule(
|
||||
module_id=MODULE_ID,
|
||||
package_name="@govoplan/identity-trust-webui",
|
||||
view_surfaces=(
|
||||
ViewSurface(
|
||||
id="identity_trust.settings.devices",
|
||||
module_id=MODULE_ID,
|
||||
kind="section",
|
||||
label="Device trust",
|
||||
order=10,
|
||||
),
|
||||
ViewSurface(
|
||||
id="identity_trust.admin.trust",
|
||||
module_id=MODULE_ID,
|
||||
kind="section",
|
||||
label="Identity trust administration",
|
||||
order=20,
|
||||
),
|
||||
ViewSurface(
|
||||
id="identity_trust.admin.epochs",
|
||||
module_id=MODULE_ID,
|
||||
kind="section",
|
||||
label="Key epoch administration",
|
||||
parent_id="identity_trust.admin.trust",
|
||||
order=30,
|
||||
),
|
||||
ViewSurface(
|
||||
id="identity_trust.admin.decisions",
|
||||
module_id=MODULE_ID,
|
||||
kind="section",
|
||||
label="Key-access decisions",
|
||||
parent_id="identity_trust.admin.trust",
|
||||
order=40,
|
||||
),
|
||||
),
|
||||
),
|
||||
capability_factories={
|
||||
CAPABILITY_IDENTITY_TRUST_DIRECTORY: _service,
|
||||
CAPABILITY_IDENTITY_TRUST_ASSURANCE: _service,
|
||||
@@ -162,7 +207,7 @@ manifest = ModuleManifest(
|
||||
title="Device keys and key epochs",
|
||||
summary="Separate login authority from public device-key and cryptographic-access trust.",
|
||||
body=(
|
||||
"Identity Trust stores public keys only. Access first decides whether an account may reach a protected resource; Identity Trust then verifies the current device and key epoch and records an auditable decision. Function and Postbox history grants are explicit epoch policy, and revocation cannot erase plaintext already obtained."
|
||||
"Identity Trust stores public keys only. Users can review and revoke their device keys and inspect assurance provenance in Settings. Security officers can select an authorized account, inspect revoked or compromised-device evidence, rotate subject key epochs, and review key-access decisions in Administration. Access first decides whether an account may reach a protected resource; Identity Trust then verifies the current device and key epoch and records an auditable decision. Function and Postbox history grants are explicit epoch policy, and revocation cannot erase plaintext already obtained. Every revoke and rotation is revision-bound and stale actions must be reloaded."
|
||||
),
|
||||
layer="available",
|
||||
documentation_types=("admin", "user"),
|
||||
@@ -211,6 +256,7 @@ def get_manifest() -> ModuleManifest:
|
||||
|
||||
__all__ = [
|
||||
"ADMIN_SCOPE",
|
||||
"ASSURANCE_READ_SCOPE",
|
||||
"ASSURANCE_SCOPE",
|
||||
"DEVICE_READ_SCOPE",
|
||||
"DEVICE_WRITE_SCOPE",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -49,6 +49,28 @@ class DeviceKeyListResponse(BaseModel):
|
||||
keys: list[DeviceKeyResponse]
|
||||
|
||||
|
||||
class AssuranceEvidenceResponse(BaseModel):
|
||||
id: str
|
||||
tenant_id: str
|
||||
account_id: str
|
||||
device_key_id: str | None = None
|
||||
evidence_ref: str
|
||||
assurance_level: str
|
||||
provider_id: str
|
||||
verified_at: datetime
|
||||
expires_at: datetime
|
||||
active: bool
|
||||
provenance: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class AssuranceEvidenceListResponse(BaseModel):
|
||||
evidence: list[AssuranceEvidenceResponse]
|
||||
|
||||
|
||||
class EpochListResponse(BaseModel):
|
||||
epochs: list[EpochResponse]
|
||||
|
||||
|
||||
class EpochRotatePayload(BaseModel):
|
||||
subject_kind: Literal[
|
||||
"identity", "account", "function", "postbox", "external_recipient"
|
||||
@@ -102,6 +124,34 @@ class KeyAccessResponse(BaseModel):
|
||||
provenance: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class KeyAccessDecisionItem(BaseModel):
|
||||
id: str
|
||||
decision_ref: str
|
||||
account_id: str
|
||||
device_key_id: str
|
||||
subject_kind: str
|
||||
subject_id: str
|
||||
key_epoch: int
|
||||
access_decision_ref: str
|
||||
purpose: str
|
||||
allowed: bool
|
||||
reason: str
|
||||
resource_ref: str | None = None
|
||||
function_assignment_id: str | None = None
|
||||
delegation_id: str | None = None
|
||||
provenance: dict[str, Any] = Field(default_factory=dict)
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class KeyAccessDecisionListResponse(BaseModel):
|
||||
decisions: list[KeyAccessDecisionItem]
|
||||
|
||||
|
||||
class ReferenceOptionsResponse(BaseModel):
|
||||
options: list[dict[str, Any]] = Field(default_factory=list)
|
||||
provider_available: bool = False
|
||||
|
||||
|
||||
class AssuranceEvidencePayload(BaseModel):
|
||||
account_id: str = Field(min_length=1, max_length=255)
|
||||
evidence_ref: str = Field(min_length=1, max_length=1000)
|
||||
|
||||
@@ -31,6 +31,10 @@ class IdentityTrustError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class IdentityTrustAccessDenied(IdentityTrustError):
|
||||
pass
|
||||
|
||||
|
||||
class SqlIdentityTrustService:
|
||||
def register_device_key(
|
||||
self,
|
||||
@@ -148,10 +152,8 @@ class SqlIdentityTrustService:
|
||||
) -> tuple[DeviceKeyRef, ...]:
|
||||
db = _session(session)
|
||||
_require_tenant(principal, tenant_id)
|
||||
if account_id != _account_id(principal) and not _has_scope(
|
||||
principal, "identity_trust:device:read_all"
|
||||
):
|
||||
raise IdentityTrustError(
|
||||
if not _may_read_account(principal, account_id):
|
||||
raise IdentityTrustAccessDenied(
|
||||
"The acting account cannot inspect these device keys."
|
||||
)
|
||||
statement = select(DevicePublicKey).where(
|
||||
@@ -170,6 +172,100 @@ class SqlIdentityTrustService:
|
||||
)
|
||||
)
|
||||
|
||||
def list_assurance_evidence(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
account_id: str,
|
||||
active_only: bool = False,
|
||||
limit: int = 200,
|
||||
) -> tuple[AssuranceEvidence, ...]:
|
||||
db = _session(session)
|
||||
_require_tenant(principal, tenant_id)
|
||||
if not _may_read_account(principal, account_id):
|
||||
raise IdentityTrustAccessDenied(
|
||||
"The acting account cannot inspect this assurance evidence."
|
||||
)
|
||||
statement = select(AssuranceEvidence).where(
|
||||
AssuranceEvidence.tenant_id == tenant_id,
|
||||
AssuranceEvidence.account_id == account_id,
|
||||
)
|
||||
if active_only:
|
||||
statement = statement.where(
|
||||
AssuranceEvidence.expires_at >= _as_utc(utcnow())
|
||||
)
|
||||
return tuple(
|
||||
db.scalars(
|
||||
statement.order_by(
|
||||
AssuranceEvidence.verified_at.desc(),
|
||||
AssuranceEvidence.id,
|
||||
).limit(max(1, min(int(limit), 500)))
|
||||
)
|
||||
)
|
||||
|
||||
def list_epochs(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
subject_kind: str,
|
||||
subject_id: str,
|
||||
limit: int = 200,
|
||||
) -> tuple[KeyEpochRef, ...]:
|
||||
db = _session(session)
|
||||
_require_tenant(principal, tenant_id)
|
||||
if not _has_scope(principal, "identity_trust:device:admin"):
|
||||
raise IdentityTrustAccessDenied(
|
||||
"Identity Trust administration is required to inspect key epochs."
|
||||
)
|
||||
values = db.scalars(
|
||||
select(TrustKeyEpoch)
|
||||
.where(
|
||||
TrustKeyEpoch.tenant_id == tenant_id,
|
||||
TrustKeyEpoch.subject_kind == subject_kind,
|
||||
TrustKeyEpoch.subject_id == subject_id,
|
||||
)
|
||||
.order_by(TrustKeyEpoch.epoch.desc())
|
||||
.limit(max(1, min(int(limit), 500)))
|
||||
)
|
||||
return tuple(_epoch_ref(value) for value in values)
|
||||
|
||||
def list_key_access_decisions(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
account_id: str,
|
||||
limit: int = 200,
|
||||
) -> tuple[KeyAccessDecisionRecord, ...]:
|
||||
db = _session(session)
|
||||
_require_tenant(principal, tenant_id)
|
||||
if not (
|
||||
_has_scope(principal, "identity_trust:key_access:approve")
|
||||
or _has_scope(principal, "identity_trust:device:admin")
|
||||
):
|
||||
raise IdentityTrustAccessDenied(
|
||||
"Key-access decision authority is required to inspect decisions."
|
||||
)
|
||||
return tuple(
|
||||
db.scalars(
|
||||
select(KeyAccessDecisionRecord)
|
||||
.where(
|
||||
KeyAccessDecisionRecord.tenant_id == tenant_id,
|
||||
KeyAccessDecisionRecord.account_id == account_id,
|
||||
)
|
||||
.order_by(
|
||||
KeyAccessDecisionRecord.created_at.desc(),
|
||||
KeyAccessDecisionRecord.id,
|
||||
)
|
||||
.limit(max(1, min(int(limit), 500)))
|
||||
)
|
||||
)
|
||||
|
||||
def rotate_epoch(
|
||||
self,
|
||||
session: object,
|
||||
@@ -592,7 +688,9 @@ def _session(value: object) -> Session:
|
||||
|
||||
def _require_tenant(principal: object, tenant_id: str) -> None:
|
||||
if str(getattr(principal, "tenant_id", "")) != tenant_id:
|
||||
raise IdentityTrustError("Cross-tenant identity-trust access is denied.")
|
||||
raise IdentityTrustAccessDenied(
|
||||
"Cross-tenant identity-trust access is denied."
|
||||
)
|
||||
|
||||
|
||||
def _account_id(principal: object) -> str:
|
||||
@@ -605,6 +703,14 @@ def _has_scope(principal: object, scope: str) -> bool:
|
||||
return scope in set(getattr(principal, "scopes", ()))
|
||||
|
||||
|
||||
def _may_read_account(principal: object, account_id: str) -> bool:
|
||||
return (
|
||||
account_id == _account_id(principal)
|
||||
or _has_scope(principal, "identity_trust:device:read_all")
|
||||
or _has_scope(principal, "identity_trust:device:admin")
|
||||
)
|
||||
|
||||
|
||||
def _required(value: str, label: str) -> str:
|
||||
cleaned = value.strip()
|
||||
if not cleaned:
|
||||
@@ -630,6 +736,7 @@ def _as_utc(value: datetime) -> datetime:
|
||||
|
||||
|
||||
__all__ = [
|
||||
"IdentityTrustAccessDenied",
|
||||
"IdentityTrustError",
|
||||
"SqlIdentityTrustService",
|
||||
"record_assurance_evidence",
|
||||
|
||||
Reference in New Issue
Block a user