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
+17 -1
View File
@@ -9,13 +9,29 @@ authentication-assurance evidence, and auditable key-access trust decisions.
It deliberately does not own login sessions, resource authorization, private
keys, encryption, or plaintext.
The headless module exposes `identity_trust.directory` and
The backend exposes `identity_trust.directory` and
`identity_trust.assurance`. Access or Policy must approve resource access
first; Identity Trust then verifies the acting account, active public device
key, current subject epoch, and assurance evidence. Encryption providers may
consume that decision to rewrap a key, but no key material is returned by this
module.
The WebUI contributes two optional surfaces:
- **Settings > Device trust** lets an account inspect active/revoked public
device keys, revoke a current key with its expected revision, and inspect
assurance level, provider, device binding, expiry, and provenance.
- **Administration > Identity trust** lets an authorized security officer use
an Access-backed account selector when Access is available, inspect the same
bounded projections, rotate subject epochs with an upstream Access decision,
and review immutable key-access decisions. Explicit account references remain
usable when the optional Access directory is absent.
Revocation and rotation are not retroactive: neither can recall plaintext,
exports, or key material already obtained by an endpoint. Stale revisions fail
closed and must be reloaded. The API and UI expose public JWK metadata only;
private JWK parameters are rejected by the capability contract.
Focused verification:
```bash
+10
View File
@@ -51,6 +51,13 @@ The SQL-backed module now provides:
expiry, and maximum-age checks;
- migrations, uninstall guards, permissions, APIs, capability contracts, and
manifest-driven user/admin documentation.
- bounded user and security-officer projections for public device keys,
assurance provenance, subject epoch history, and immutable key-access
decisions;
- user key revocation and security-officer epoch rotation surfaces with stale
revision rejection and explicit non-retroactivity consequences;
- optional Access-backed account selection without making Access a hard module
dependency.
Only public keys are accepted. JWK private parameters are rejected by the Core
contract. Key-access decisions state explicitly that no cryptographic material
@@ -81,5 +88,8 @@ and quorum recovery remain with `govoplan-encryption` and its selected provider.
- There is no private-key custody or browser/device key generator.
- Attestation references are retained but no WebAuthn/OIDC attestation adapter
is selected yet.
- Device key generation and registration remains provider/browser driven; the
administration UI deliberately does not ask an operator to paste private or
manually generated key material.
- The module decides trust eligibility; it does not perform encryption,
decryption, signing, or rewrapping.
@@ -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",
+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,
@@ -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)
+112 -5
View File
@@ -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",
+93
View File
@@ -19,6 +19,7 @@ from govoplan_identity_trust.backend.db.models import (
TrustKeyEpoch,
)
from govoplan_identity_trust.backend.service import (
IdentityTrustAccessDenied,
IdentityTrustError,
SqlIdentityTrustService,
record_assurance_evidence,
@@ -36,9 +37,18 @@ class Principal:
return scope in {
"identity_trust:device:admin",
"identity_trust:device:read_all",
"identity_trust:key_access:approve",
}
class RestrictedPrincipal:
tenant_id = "tenant-1"
account_id = "account-2"
def has(self, scope: str) -> bool:
return False
def registration(**changes) -> DeviceKeyRegistration:
values = {
"tenant_id": "tenant-1",
@@ -220,6 +230,89 @@ class IdentityTrustTests(unittest.TestCase):
self.assertTrue(allowed.allowed)
self.assertFalse(stale.allowed)
def test_bounded_projections_enforce_subject_access_and_keep_provenance(self) -> None:
self.service.register_device_key(
self.session,
self.principal,
request=registration(),
)
record_assurance_evidence(
self.session,
self.principal,
tenant_id="tenant-1",
account_id="account-1",
evidence_ref="webauthn:assertion-2",
assurance_level="hardware",
provider_id="webauthn",
verified_at=NOW,
expires_at=NOW + timedelta(minutes=10),
device_key_id="key-1",
provenance={"ceremony": "uv", "policy_ref": "policy:assurance:1"},
)
epoch = self.service.rotate_epoch(
self.session,
self.principal,
request=KeyEpochRotationRequest(
tenant_id="tenant-1",
subject_kind="postbox",
subject_id="postbox-1",
reason="Initial epoch.",
access_decision_ref="access:grant-1",
idempotency_key="epoch-list-1",
previous_epoch=None,
history_policy="all_retained",
),
)
self.service.decide_key_access(
self.session,
self.principal,
request=KeyAccessRequest(
tenant_id="tenant-1",
account_id="account-1",
device_key_id="key-1",
subject_kind="postbox",
subject_id="postbox-1",
key_epoch=epoch.epoch,
access_decision_ref="access:grant-1",
purpose="postbox.message.read",
requested_at=NOW,
),
)
evidence = self.service.list_assurance_evidence(
self.session,
self.principal,
tenant_id="tenant-1",
account_id="account-1",
)
epochs = self.service.list_epochs(
self.session,
self.principal,
tenant_id="tenant-1",
subject_kind="postbox",
subject_id="postbox-1",
)
decisions = self.service.list_key_access_decisions(
self.session,
self.principal,
tenant_id="tenant-1",
account_id="account-1",
)
self.assertEqual(1, len(evidence))
self.assertEqual("policy:assurance:1", evidence[0].provenance["policy_ref"])
self.assertEqual((1,), tuple(item.epoch for item in epochs))
self.assertEqual(1, len(decisions))
self.assertFalse(decisions[0].provenance["cryptographic_material_released"])
with self.assertRaises(IdentityTrustAccessDenied):
self.service.list_assurance_evidence(
self.session,
RestrictedPrincipal(),
tenant_id="tenant-1",
account_id="account-1",
)
if __name__ == "__main__":
unittest.main()
+13 -1
View File
@@ -22,7 +22,19 @@ class IdentityTrustManifestTests(unittest.TestCase):
CAPABILITY_IDENTITY_TRUST_ASSURANCE,
manifest.capability_factories,
)
self.assertIsNone(manifest.frontend)
self.assertIsNotNone(manifest.frontend)
self.assertEqual(
"@govoplan/identity-trust-webui",
manifest.frontend.package_name,
)
self.assertIn(
"identity_trust.settings.devices",
{surface.id for surface in manifest.frontend.view_surfaces},
)
self.assertIn(
"identity_trust:assurance:read",
{permission.scope for permission in manifest.permissions},
)
self.assertIsNotNone(manifest.migration_spec)
self.assertEqual("vertical_slice", manifest.architecture.maturity)
+25
View File
@@ -0,0 +1,25 @@
{
"name": "@govoplan/identity-trust-webui",
"version": "0.1.14",
"private": true,
"type": "module",
"main": "src/index.ts",
"module": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": { "types": "./src/index.ts", "import": "./src/index.ts" },
"./styles/identity-trust.css": "./src/styles/identity-trust.css"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.14",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": { "optional": true }
},
"scripts": {
"test:identity-trust-ui": "node tests/identity-trust-ui-structure.test.mjs"
}
}
+123
View File
@@ -0,0 +1,123 @@
import {
apiFetch,
apiReferenceOptionProvider,
type ApiSettings,
type ReferenceOptionProvider
} from "@govoplan/core-webui";
export type DeviceKey = {
tenant_id: string;
identity_id: string;
account_id: string;
device_id: string;
key_id: string;
algorithm: string;
purpose: string;
assurance_level: string;
status: string;
epoch: number;
registered_at: string;
attestation_ref?: string | null;
expires_at?: string | null;
revoked_at?: string | null;
revocation_reason?: string | null;
provenance: Record<string, unknown>;
};
export type AssuranceEvidence = {
id: string;
account_id: string;
device_key_id?: string | null;
evidence_ref: string;
assurance_level: string;
provider_id: string;
verified_at: string;
expires_at: string;
active: boolean;
provenance: Record<string, unknown>;
};
export type KeyEpoch = {
tenant_id: string;
subject_kind: string;
subject_id: string;
epoch: number;
state: string;
history_policy: string;
effective_at: string;
previous_epoch?: number | null;
reason?: string | null;
access_decision_ref?: string | null;
provenance: Record<string, unknown>;
};
export type KeyAccessDecision = {
id: string;
decision_ref: string;
account_id: string;
device_key_id: string;
subject_kind: string;
subject_id: string;
key_epoch: number;
access_decision_ref: string;
purpose: string;
allowed: boolean;
reason: string;
resource_ref?: string | null;
function_assignment_id?: string | null;
delegation_id?: string | null;
provenance: Record<string, unknown>;
created_at: string;
};
export type EpochRotatePayload = {
subject_kind: "identity" | "account" | "function" | "postbox" | "external_recipient";
subject_id: string;
reason: string;
access_decision_ref: string;
idempotency_key: string;
history_policy: string;
previous_epoch?: number | null;
};
export async function listDeviceKeys(settings: ApiSettings, accountId: string, activeOnly = false): Promise<DeviceKey[]> {
const params = new URLSearchParams({ account_id: accountId, active_only: String(activeOnly) });
const response = await apiFetch<{ keys: DeviceKey[] }>(settings, `/api/v1/identity-trust/device-keys?${params}`);
return response.keys;
}
export async function revokeDeviceKey(settings: ApiSettings, keyId: string, expectedEpoch: number, reason: string): Promise<DeviceKey> {
return apiFetch<DeviceKey>(settings, `/api/v1/identity-trust/device-keys/${encodeURIComponent(keyId)}/revoke`, {
method: "POST",
body: JSON.stringify({ expected_epoch: expectedEpoch, reason })
});
}
export async function listAssuranceEvidence(settings: ApiSettings, accountId: string, activeOnly = false): Promise<AssuranceEvidence[]> {
const params = new URLSearchParams({ account_id: accountId, active_only: String(activeOnly) });
const response = await apiFetch<{ evidence: AssuranceEvidence[] }>(settings, `/api/v1/identity-trust/assurance/evidence?${params}`);
return response.evidence;
}
export async function listEpochs(settings: ApiSettings, subjectKind: string, subjectId: string): Promise<KeyEpoch[]> {
const params = new URLSearchParams({ subject_kind: subjectKind, subject_id: subjectId });
const response = await apiFetch<{ epochs: KeyEpoch[] }>(settings, `/api/v1/identity-trust/epochs?${params}`);
return response.epochs;
}
export async function rotateEpoch(settings: ApiSettings, payload: EpochRotatePayload): Promise<KeyEpoch> {
return apiFetch<KeyEpoch>(settings, "/api/v1/identity-trust/epochs/rotate", {
method: "POST",
body: JSON.stringify(payload)
});
}
export async function listKeyAccessDecisions(settings: ApiSettings, accountId: string): Promise<KeyAccessDecision[]> {
const params = new URLSearchParams({ account_id: accountId });
const response = await apiFetch<{ decisions: KeyAccessDecision[] }>(settings, `/api/v1/identity-trust/key-access/decisions?${params}`);
return response.decisions;
}
export function identityTrustAccountProvider(settings: ApiSettings): ReferenceOptionProvider {
return apiReferenceOptionProvider(settings, "/api/v1/identity-trust/account-options");
}
+332
View File
@@ -0,0 +1,332 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Eye, RefreshCw, RotateCw, ShieldOff } from "lucide-react";
import {
AdminPageLayout,
Button,
Card,
DataGrid,
Dialog,
DismissibleAlert,
FormField,
LoadingFrame,
MetricCard,
ReferenceSelect,
StatusBadge,
TableActionGroup,
ToggleSwitch,
hasScope,
type ApiSettings,
type AuthInfo,
type DataGridColumn
} from "@govoplan/core-webui";
import {
identityTrustAccountProvider,
listAssuranceEvidence,
listDeviceKeys,
listEpochs,
listKeyAccessDecisions,
revokeDeviceKey,
rotateEpoch,
type AssuranceEvidence,
type DeviceKey,
type KeyAccessDecision,
type KeyEpoch
} from "../api/identityTrust";
type IdentityTrustPanelProps = {
settings: ApiSettings;
auth: AuthInfo;
administrative?: boolean;
};
type EpochDraft = {
subjectKind: "identity" | "account" | "function" | "postbox" | "external_recipient";
subjectId: string;
reason: string;
accessDecisionRef: string;
};
const EMPTY_EPOCH_DRAFT: EpochDraft = {
subjectKind: "postbox",
subjectId: "",
reason: "",
accessDecisionRef: ""
};
export default function IdentityTrustPanel({ settings, auth, administrative = false }: IdentityTrustPanelProps) {
const ownAccountId = auth.principal?.account_id || auth.user.account_id;
const canRevokeDevice = hasScope(auth, "identity_trust:device:write")
|| hasScope(auth, "identity_trust:device:admin");
const [accountId, setAccountId] = useState(ownAccountId);
const [keys, setKeys] = useState<DeviceKey[]>([]);
const [evidence, setEvidence] = useState<AssuranceEvidence[]>([]);
const [decisions, setDecisions] = useState<KeyAccessDecision[]>([]);
const [epochs, setEpochs] = useState<KeyEpoch[]>([]);
const [showRevoked, setShowRevoked] = useState(false);
const [loading, setLoading] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const [revoking, setRevoking] = useState<DeviceKey | null>(null);
const [revocationReason, setRevocationReason] = useState("");
const [selectedEvidence, setSelectedEvidence] = useState<AssuranceEvidence | null>(null);
const [selectedDecision, setSelectedDecision] = useState<KeyAccessDecision | null>(null);
const [epochDraft, setEpochDraft] = useState<EpochDraft>(EMPTY_EPOCH_DRAFT);
const accountProvider = useMemo(() => identityTrustAccountProvider(settings), [settings]);
const loadAccount = useCallback(async () => {
if (!accountId) return;
setLoading(true);
setError("");
try {
const [nextKeys, nextEvidence, nextDecisions] = await Promise.all([
listDeviceKeys(settings, accountId, false),
listAssuranceEvidence(settings, accountId, false),
administrative ? listKeyAccessDecisions(settings, accountId) : Promise.resolve([])
]);
setKeys(nextKeys);
setEvidence(nextEvidence);
setDecisions(nextDecisions);
} catch (caught) {
setError(errorMessage(caught));
setKeys([]);
setEvidence([]);
setDecisions([]);
} finally {
setLoading(false);
}
}, [accountId, administrative, settings]);
useEffect(() => {
void loadAccount();
}, [loadAccount]);
const visibleKeys = showRevoked ? keys : keys.filter((key) => key.status === "active");
const activeEvidence = evidence.filter((item) => item.active);
const highestAssurance = activeEvidence
.map((item) => item.assurance_level)
.sort((left, right) => assuranceRank(right) - assuranceRank(left))[0] ?? "None";
const keyColumns = useMemo<DataGridColumn<DeviceKey>[]>(() => [
{ id: "device", header: "Device", width: 180, sortable: true, filterable: true, render: (row) => row.device_id, value: (row) => row.device_id },
{ id: "key", header: "Public key", width: 220, sortable: true, filterable: true, render: (row) => row.key_id, value: (row) => row.key_id },
{ id: "purpose", header: "Purpose", width: 170, sortable: true, filterable: true, render: (row) => humanize(row.purpose), value: (row) => row.purpose },
{ id: "algorithm", header: "Algorithm", width: 130, sortable: true, filterable: true, render: (row) => row.algorithm, value: (row) => row.algorithm },
{ id: "assurance", header: "Assurance", width: 140, sortable: true, filterable: true, render: (row) => humanize(row.assurance_level), value: (row) => row.assurance_level },
{ id: "status", header: "Status", width: 130, sortable: true, filterable: true, render: (row) => <StatusBadge status={row.status} />, value: (row) => row.status },
{ id: "epoch", header: "Revision", width: 100, sortable: true, filterable: true, filterType: "integer", render: (row) => row.epoch, value: (row) => row.epoch },
{ id: "expiry", header: "Expiry", width: 180, sortable: true, filterable: true, filterType: "date", render: (row) => formatDateTime(row.expires_at), value: (row) => row.expires_at ?? "" },
{
id: "actions",
header: "Actions",
width: 90,
sticky: "end",
align: "right",
render: (row) => <TableActionGroup actions={[
{
id: "revoke",
label: "Revoke device key",
icon: <ShieldOff aria-hidden="true" />,
variant: "danger",
applicable: row.status === "active",
disabled: !canRevokeDevice,
disabledReason: !canRevokeDevice
? "Device-key write permission is required."
: undefined,
onClick: () => {
setRevocationReason("");
setRevoking(row);
}
}
]} />
}
], [canRevokeDevice]);
const evidenceColumns = useMemo<DataGridColumn<AssuranceEvidence>[]>(() => [
{ id: "level", header: "Level", width: 130, sortable: true, filterable: true, render: (row) => humanize(row.assurance_level), value: (row) => row.assurance_level },
{ id: "provider", header: "Provider", width: 160, sortable: true, filterable: true, render: (row) => row.provider_id, value: (row) => row.provider_id },
{ id: "evidence", header: "Evidence reference", width: 260, sortable: true, filterable: true, render: (row) => row.evidence_ref, value: (row) => row.evidence_ref },
{ id: "device", header: "Device key", width: 190, sortable: true, filterable: true, render: (row) => row.device_key_id || "Any registered device", value: (row) => row.device_key_id ?? "" },
{ id: "verified", header: "Verified", width: 180, sortable: true, filterable: true, filterType: "date", render: (row) => formatDateTime(row.verified_at), value: (row) => row.verified_at },
{ id: "expires", header: "Expires", width: 180, sortable: true, filterable: true, filterType: "date", render: (row) => formatDateTime(row.expires_at), value: (row) => row.expires_at },
{ id: "state", header: "State", width: 120, sortable: true, filterable: true, render: (row) => <StatusBadge status={row.active ? "active" : "expired"} />, value: (row) => row.active ? "active" : "expired" },
{ id: "actions", header: "Actions", width: 80, sticky: "end", align: "right", render: (row) => <TableActionGroup actions={[{ id: "details", label: "View provenance", icon: <Eye aria-hidden="true" />, onClick: () => setSelectedEvidence(row) }]} /> }
], []);
const decisionColumns = useMemo<DataGridColumn<KeyAccessDecision>[]>(() => [
{ id: "time", header: "Recorded", width: 180, sortable: true, filterable: true, filterType: "date", render: (row) => formatDateTime(row.created_at), value: (row) => row.created_at },
{ id: "purpose", header: "Purpose", width: 220, sortable: true, filterable: true, render: (row) => row.purpose, value: (row) => row.purpose },
{ id: "subject", header: "Subject", width: 230, sortable: true, filterable: true, render: (row) => `${humanize(row.subject_kind)}: ${row.subject_id}`, value: (row) => `${row.subject_kind}:${row.subject_id}` },
{ id: "device", header: "Device key", width: 180, sortable: true, filterable: true, render: (row) => row.device_key_id, value: (row) => row.device_key_id },
{ id: "decision", header: "Decision", width: 120, sortable: true, filterable: true, render: (row) => <StatusBadge status={row.allowed ? "allowed" : "denied"} />, value: (row) => row.allowed ? "allowed" : "denied" },
{ id: "reason", header: "Reason", width: 320, render: (row) => row.reason, value: (row) => row.reason },
{ id: "actions", header: "Actions", width: 80, sticky: "end", align: "right", render: (row) => <TableActionGroup actions={[{ id: "details", label: "View decision provenance", icon: <Eye aria-hidden="true" />, onClick: () => setSelectedDecision(row) }]} /> }
], []);
const epochColumns = useMemo<DataGridColumn<KeyEpoch>[]>(() => [
{ id: "epoch", header: "Epoch", width: 90, sortable: true, filterable: true, filterType: "integer", render: (row) => row.epoch, value: (row) => row.epoch },
{ id: "state", header: "State", width: 120, sortable: true, filterable: true, render: (row) => <StatusBadge status={row.state} />, value: (row) => row.state },
{ id: "history", header: "History access", width: 170, sortable: true, filterable: true, render: (row) => humanize(row.history_policy), value: (row) => row.history_policy },
{ id: "effective", header: "Effective", width: 180, sortable: true, filterable: true, filterType: "date", render: (row) => formatDateTime(row.effective_at), value: (row) => row.effective_at },
{ id: "access", header: "Access decision", width: 260, render: (row) => row.access_decision_ref || "-", value: (row) => row.access_decision_ref ?? "" },
{ id: "reason", header: "Reason", width: 320, render: (row) => row.reason || "-", value: (row) => row.reason ?? "" }
], []);
async function applyRevoke() {
if (!revoking || !revocationReason.trim() || busy) return;
setBusy(true);
setError("");
try {
await revokeDeviceKey(settings, revoking.key_id, revoking.epoch, revocationReason.trim());
setRevoking(null);
setSuccess("The device key was revoked. Existing plaintext or exported keys cannot be recalled.");
await loadAccount();
} catch (caught) {
setError(errorMessage(caught));
} finally {
setBusy(false);
}
}
async function loadEpochHistory() {
if (!epochDraft.subjectId.trim()) return;
setBusy(true);
setError("");
try {
setEpochs(await listEpochs(settings, epochDraft.subjectKind, epochDraft.subjectId.trim()));
} catch (caught) {
setError(errorMessage(caught));
setEpochs([]);
} finally {
setBusy(false);
}
}
async function applyEpochRotation() {
if (!epochDraft.subjectId.trim() || !epochDraft.reason.trim() || !epochDraft.accessDecisionRef.trim() || busy) return;
setBusy(true);
setError("");
try {
const current = epochs.find((epoch) => epoch.state === "active");
await rotateEpoch(settings, {
subject_kind: epochDraft.subjectKind,
subject_id: epochDraft.subjectId.trim(),
reason: epochDraft.reason.trim(),
access_decision_ref: epochDraft.accessDecisionRef.trim(),
idempotency_key: crypto.randomUUID(),
history_policy: "all_retained",
previous_epoch: current?.epoch ?? null
});
setSuccess("The key epoch was rotated. Existing device copies and previously obtained plaintext cannot be revoked retroactively.");
setEpochDraft((currentDraft) => ({ ...currentDraft, reason: "", accessDecisionRef: "" }));
await loadEpochHistory();
} catch (caught) {
setError(errorMessage(caught));
} finally {
setBusy(false);
}
}
const content = <>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
{success && <DismissibleAlert tone="success" resetKey={success}>{success}</DismissibleAlert>}
{administrative && <Card title="Account" compact>
<div className="identity-trust-account-selector">
<FormField label="Account">
<ReferenceSelect
value={accountId}
provider={accountProvider}
onChange={(value) => setAccountId(value)}
createCustomOption={(value) => value.trim() ? { value: value.trim(), label: value.trim(), description: "Explicit account reference" } : null}
placeholder="Select or enter an account"
searchPlaceholder="Search accounts" />
</FormField>
<Button onClick={() => void loadAccount()} disabled={loading || busy}><RefreshCw aria-hidden="true" /> Reload</Button>
</div>
</Card>}
<LoadingFrame loading={loading} label="Loading identity trust state">
<div className="metric-grid compact">
<MetricCard label="Active device keys" value={keys.filter((key) => key.status === "active").length} tone="good" />
<MetricCard label="Revoked or expired" value={keys.filter((key) => key.status !== "active").length} tone="warning" />
<MetricCard label="Active assurance evidence" value={activeEvidence.length} tone={activeEvidence.length ? "good" : "warning"} />
<MetricCard label="Highest assurance" value={humanize(highestAssurance)} tone={highestAssurance === "None" ? "warning" : "info"} />
</div>
<Card title="Device keys" actions={<ToggleSwitch label="Show revoked and expired" checked={showRevoked} onChange={setShowRevoked} />}>
<p className="muted small-note">Only public key and trust metadata are stored. Revocation blocks future server-mediated use but cannot erase plaintext or key material already obtained by a device.</p>
<div className="admin-table-surface"><DataGrid id={`identity-trust-device-keys-${administrative ? "admin" : "self"}`} rows={visibleKeys} columns={keyColumns} initialFit="container" getRowKey={(row) => row.key_id} emptyText="No device keys found." /></div>
</Card>
<Card title="Assurance evidence">
<p className="muted small-note">Evidence is bounded by provider, assurance level, device, verification time, and expiry. It does not grant resource access on its own.</p>
<div className="admin-table-surface"><DataGrid id={`identity-trust-assurance-${administrative ? "admin" : "self"}`} rows={evidence} columns={evidenceColumns} initialFit="container" getRowKey={(row) => row.id} emptyText="No assurance evidence found." /></div>
</Card>
{administrative && <>
<Card title="Key epoch administration">
<p className="muted small-note">Rotation supersedes the active epoch and retains history for newly authorized incumbents. It does not grant Access permission, recall exported material, or transfer private keys.</p>
<div className="identity-trust-epoch-form">
<FormField label="Subject type"><select value={epochDraft.subjectKind} disabled={busy} onChange={(event) => { setEpochDraft({ ...epochDraft, subjectKind: event.target.value as EpochDraft["subjectKind"] }); setEpochs([]); }}><option value="identity">Identity</option><option value="account">Account</option><option value="function">Function</option><option value="postbox">Postbox</option><option value="external_recipient">External recipient</option></select></FormField>
<FormField label="Subject reference"><input value={epochDraft.subjectId} disabled={busy} onChange={(event) => { setEpochDraft({ ...epochDraft, subjectId: event.target.value }); setEpochs([]); }} /></FormField>
<Button onClick={() => void loadEpochHistory()} disabled={busy || !epochDraft.subjectId.trim()}><RefreshCw aria-hidden="true" /> Load history</Button>
<FormField label="History access"><input value="All retained history" disabled /></FormField>
<FormField label="Authorizing Access decision"><input value={epochDraft.accessDecisionRef} disabled={busy} onChange={(event) => setEpochDraft({ ...epochDraft, accessDecisionRef: event.target.value })} /></FormField>
<FormField label="Rotation reason"><input value={epochDraft.reason} disabled={busy} onChange={(event) => setEpochDraft({ ...epochDraft, reason: event.target.value })} /></FormField>
<Button variant="danger" onClick={() => void applyEpochRotation()} disabled={busy || !epochDraft.subjectId.trim() || !epochDraft.accessDecisionRef.trim() || !epochDraft.reason.trim()}><RotateCw aria-hidden="true" /> Rotate epoch</Button>
</div>
<div className="admin-table-surface"><DataGrid id="identity-trust-epochs-admin" rows={epochs} columns={epochColumns} initialFit="container" getRowKey={(row) => `${row.subject_kind}:${row.subject_id}:${row.epoch}`} emptyText="Load a subject to inspect its epoch history." /></div>
</Card>
<Card title="Key-access decisions">
<p className="muted small-note">These immutable decisions combine an upstream Access decision with the acting account, current public device key, active epoch, purpose, and resource reference. No cryptographic material is returned by Identity Trust.</p>
<div className="admin-table-surface"><DataGrid id="identity-trust-decisions-admin" rows={decisions} columns={decisionColumns} initialFit="container" getRowKey={(row) => row.id} emptyText="No key-access decisions found for this account." /></div>
</Card>
</>}
</LoadingFrame>
<Dialog open={Boolean(revoking)} title="Revoke device key" onClose={() => !busy && setRevoking(null)} closeDisabled={busy} footer={<><Button onClick={() => setRevoking(null)} disabled={busy}>Cancel</Button><Button variant="danger" onClick={() => void applyRevoke()} disabled={busy || !revocationReason.trim()}>Revoke key</Button></>}>
<p>Revoke <strong>{revoking?.key_id}</strong>? Future key-access decisions will reject this device. Plaintext, exports, and keys already obtained by the device cannot be recalled.</p>
<FormField label="Reason"><textarea rows={4} value={revocationReason} disabled={busy} onChange={(event) => setRevocationReason(event.target.value)} /></FormField>
</Dialog>
<ProvenanceDialog title="Assurance evidence provenance" value={selectedEvidence} onClose={() => setSelectedEvidence(null)} />
<ProvenanceDialog title="Key-access decision provenance" value={selectedDecision} onClose={() => setSelectedDecision(null)} />
</>;
if (administrative) {
return <AdminPageLayout title="Identity trust" description="Inspect public device trust, assurance provenance, epoch history, and immutable key-access decisions." loading={false} error="" success="" actions={<Button onClick={() => void loadAccount()} disabled={loading || busy}><RefreshCw aria-hidden="true" /> Reload</Button>}>{content}</AdminPageLayout>;
}
return <div className="identity-trust-panel">{content}</div>;
}
function ProvenanceDialog({ title, value, onClose }: { title: string; value: AssuranceEvidence | KeyAccessDecision | null; onClose: () => void }) {
return <Dialog open={Boolean(value)} title={title} onClose={onClose} footer={<Button onClick={onClose}>Close</Button>}>
{value && <div className="identity-trust-provenance">
<dl>
{"evidence_ref" in value && <><dt>Evidence reference</dt><dd>{value.evidence_ref}</dd><dt>Provider</dt><dd>{value.provider_id}</dd></>}
{"decision_ref" in value && <><dt>Decision reference</dt><dd>{value.decision_ref}</dd><dt>Upstream Access decision</dt><dd>{value.access_decision_ref}</dd><dt>Resource</dt><dd>{value.resource_ref || "Not bound"}</dd></>}
</dl>
<pre>{JSON.stringify(value.provenance, null, 2)}</pre>
</div>}
</Dialog>;
}
function assuranceRank(value: string): number {
return ({ none: 0, software: 1, mfa: 2, hardware: 3, high: 4 } as Record<string, number>)[value.toLowerCase()] ?? 0;
}
function humanize(value: string): string {
return value.replace(/_/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
}
function formatDateTime(value?: string | null): string {
if (!value) return "-";
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString();
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
+19
View File
@@ -0,0 +1,19 @@
import type { PlatformTranslations } from "@govoplan/core-webui";
const en = {
"i18n:govoplan-identity-trust.identity_trust": "Identity trust",
"i18n:govoplan-identity-trust.device_trust": "Device trust",
"i18n:govoplan-identity-trust.identity_trust_administration": "Identity trust administration",
"i18n:govoplan-identity-trust.key_epochs": "Key epochs",
"i18n:govoplan-identity-trust.key_access_decisions": "Key-access decisions"
} as const;
const de: Record<keyof typeof en, string> = {
"i18n:govoplan-identity-trust.identity_trust": "Identitaetsvertrauen",
"i18n:govoplan-identity-trust.device_trust": "Geraetevertrauen",
"i18n:govoplan-identity-trust.identity_trust_administration": "Administration des Identitaetsvertrauens",
"i18n:govoplan-identity-trust.key_epochs": "Schluesselepochen",
"i18n:govoplan-identity-trust.key_access_decisions": "Schluesselzugriffsentscheidungen"
};
export const generatedTranslations: PlatformTranslations = { en, de };
+2
View File
@@ -0,0 +1,2 @@
export { default, identityTrustModule } from "./module";
export * from "./api/identityTrust";
+61
View File
@@ -0,0 +1,61 @@
import { createElement, lazy } from "react";
import type {
AdminSectionsUiCapability,
PlatformWebModule,
SettingsSectionsUiCapability
} from "@govoplan/core-webui";
import { generatedTranslations } from "./i18n/generatedTranslations";
import "./styles/identity-trust.css";
const IdentityTrustPanel = lazy(() => import("./features/IdentityTrustPanel"));
const settingsSections: SettingsSectionsUiCapability = {
sections: [
{
id: "identity-trust",
surfaceId: "identity_trust.settings.devices",
label: "i18n:govoplan-identity-trust.device_trust",
group: "account",
order: 45,
anyOf: ["identity_trust:device:read", "identity_trust:assurance:read"],
render: ({ settings, auth }) => createElement(IdentityTrustPanel, { settings, auth })
}
]
};
const adminSections: AdminSectionsUiCapability = {
sections: [
{
id: "tenant-identity-trust",
moduleId: "identity_trust",
kind: "management",
surfaceId: "identity_trust.admin.trust",
label: "i18n:govoplan-identity-trust.identity_trust",
group: "TENANT",
order: 75,
anyOf: ["identity_trust:device:admin"],
render: ({ settings, auth }) => createElement(IdentityTrustPanel, { settings, auth, administrative: true })
}
]
};
export const identityTrustModule: PlatformWebModule = {
id: "identity_trust",
label: "i18n:govoplan-identity-trust.identity_trust",
version: "0.1.14",
dependencies: [],
optionalDependencies: ["access", "audit", "policy", "encryption", "postbox"],
translations: generatedTranslations,
viewSurfaces: [
{ id: "identity_trust.settings.devices", moduleId: "identity_trust", kind: "section", label: "i18n:govoplan-identity-trust.device_trust", order: 10 },
{ id: "identity_trust.admin.trust", moduleId: "identity_trust", kind: "section", label: "i18n:govoplan-identity-trust.identity_trust_administration", order: 20 },
{ id: "identity_trust.admin.epochs", moduleId: "identity_trust", kind: "section", label: "i18n:govoplan-identity-trust.key_epochs", parentId: "identity_trust.admin.trust", order: 30 },
{ id: "identity_trust.admin.decisions", moduleId: "identity_trust", kind: "section", label: "i18n:govoplan-identity-trust.key_access_decisions", parentId: "identity_trust.admin.trust", order: 40 }
],
uiCapabilities: {
"settings.sections": settingsSections,
"admin.sections": adminSections
}
};
export default identityTrustModule;
+64
View File
@@ -0,0 +1,64 @@
.identity-trust-panel,
.identity-trust-panel > .loading-frame,
.identity-trust-panel .loading-frame-content {
min-width: 0;
}
.identity-trust-panel {
display: flex;
flex-direction: column;
gap: 16px;
}
.identity-trust-account-selector {
display: grid;
grid-template-columns: minmax(280px, 1fr) auto;
gap: 12px;
align-items: end;
}
.identity-trust-epoch-form {
display: grid;
grid-template-columns: repeat(3, minmax(180px, 1fr));
gap: 12px;
align-items: end;
margin-bottom: 14px;
}
.identity-trust-panel .admin-table-surface {
max-height: 380px;
overflow: auto;
}
.identity-trust-provenance dl {
display: grid;
grid-template-columns: minmax(140px, auto) minmax(0, 1fr);
gap: 8px 14px;
margin: 0 0 14px;
}
.identity-trust-provenance dt {
color: var(--text-muted);
}
.identity-trust-provenance dd {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
}
.identity-trust-provenance pre {
max-height: 280px;
overflow: auto;
padding: 12px;
border: 1px solid var(--line-subtle);
background: var(--surface-muted);
font-size: 0.82rem;
}
@media (max-width: 900px) {
.identity-trust-account-selector,
.identity-trust-epoch-form {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,23 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
const moduleSource = readFileSync("src/module.ts", "utf8");
const panel = readFileSync("src/features/IdentityTrustPanel.tsx", "utf8");
const api = readFileSync("src/api/identityTrust.ts", "utf8");
assert.match(moduleSource, /"settings.sections": settingsSections/);
assert.match(moduleSource, /"admin.sections": adminSections/);
assert.match(moduleSource, /identity_trust\.settings\.devices/);
assert.match(moduleSource, /identity_trust\.admin\.epochs/);
assert.match(panel, /<ReferenceSelect/);
assert.match(panel, /revoking\.epoch/);
assert.match(panel, /disabled: !canRevokeDevice/);
assert.match(panel, /identity_trust:device:write/);
assert.match(panel, /cannot be recalled/);
assert.match(panel, /listKeyAccessDecisions/);
assert.match(panel, /rotateEpoch/);
assert.doesNotMatch(panel, /public_jwk/);
assert.match(api, /expected_epoch: expectedEpoch/);
assert.match(api, /identity-trust\/account-options/);
console.log("Identity Trust user and administration UI structural contract passed.");