diff --git a/README.md b/README.md index c4aae05..e525f28 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,8 @@ recovery ceremonies, and disable/uninstall assurance. - resumable, evidence-backed rewrap, re-encryption, decrypt, export, and destroy state transitions; - recent high-assurance, distinct-custodian quorum recovery authorization; +- bounded tenant administration for safe vault/envelope status, key lifecycle, + two-phase migration coordination, recovery decisions, and disable preflight; - typed APIs, audit-safe events, Alembic migration, and uninstall blocking; - a bundled `local_aesgcm` server-envelope provider using AES-256-GCM and SQL-persisted wrapped vault/content keys; @@ -44,6 +46,12 @@ Feature modules continue to own content, authorization, retention, and resource ownership. Access approval, resource ownership, Identity Trust, and key custody are separate decisions. +The administration surface intentionally omits provider key references, wrapped +key references, ciphertext locations, and cryptographic material. Lifecycle +commands require policy and assurance references; destructive actions explain +their irreversibility and do not imply that previously obtained plaintext can be +recalled. + See [docs/ENCRYPTION_BOUNDARY.md](docs/ENCRYPTION_BOUNDARY.md) for the threat model, profile consequences, algorithms, recovery, and disable semantics. diff --git a/docs/ENCRYPTION_BOUNDARY.md b/docs/ENCRYPTION_BOUNDARY.md index 2d25010..1aa6105 100644 --- a/docs/ENCRYPTION_BOUNDARY.md +++ b/docs/ENCRYPTION_BOUNDARY.md @@ -36,6 +36,27 @@ an ownership transfer does not transfer cryptographic custody. No capability in this module accepts or returns plaintext key material. +## Administration Surface + +Tenant encryption custodians can inspect bounded vault, envelope, migration, +recovery, and disable-preflight summaries. These read models deliberately omit +provider key references, wrapped-key references, ciphertext locations, and +cryptographic material. They are tenant-scoped and bounded to prevent the +operator interface from becoming an unrestricted metadata export. + +Vault lifecycle actions require optimistic revision, policy-decision, recent +assurance, reason, and idempotency evidence. Rotation does not silently migrate +old envelopes. Revocation and scheduled destruction explain that prior +plaintext cannot be recalled and that content may become unavailable. A +migration request only authorizes the operation: the feature module that owns +the content must durably apply it and record evidence before success. Provider +outcome reconciliation never converts an unknown outcome into success without +that evidence. + +Recovery requests show quorum, distinct-custodian, expiry, and requester +separation requirements. Approval authorizes a later provider-specific action; +it does not return keys, change resource ownership, or prove execution. + ## Assets and Threat Actors Protected assets include content plaintext, data-encryption keys, wrapping keys, diff --git a/src/govoplan_encryption/backend/manifest.py b/src/govoplan_encryption/backend/manifest.py index c41da3a..ba78260 100644 --- a/src/govoplan_encryption/backend/manifest.py +++ b/src/govoplan_encryption/backend/manifest.py @@ -19,6 +19,7 @@ from govoplan_core.core.modules import ( CapabilityDocumentation, DocumentationLink, DocumentationTopic, + FrontendModule, MigrationSpec, ModuleContext, ModuleInterfaceProvider, @@ -26,6 +27,7 @@ from govoplan_core.core.modules import ( ModuleUninstallGuardResult, PermissionDefinition, RoleTemplate, + ViewSurface, ) from govoplan_core.core.provider_governance import declared_module_architecture from govoplan_core.db.base import Base @@ -190,6 +192,43 @@ manifest = ModuleManifest( permissions=PERMISSIONS, role_templates=ROLE_TEMPLATES, route_factory=_router, + frontend=FrontendModule( + module_id=MODULE_ID, + package_name="@govoplan/encryption-webui", + view_surfaces=( + ViewSurface( + id="encryption.admin.operations", + module_id=MODULE_ID, + kind="section", + label="Encryption administration", + order=10, + ), + ViewSurface( + id="encryption.admin.vaults", + module_id=MODULE_ID, + kind="section", + label="Key vaults", + parent_id="encryption.admin.operations", + order=20, + ), + ViewSurface( + id="encryption.admin.migrations", + module_id=MODULE_ID, + kind="section", + label="Protection migrations", + parent_id="encryption.admin.operations", + order=30, + ), + ViewSurface( + id="encryption.admin.recovery", + module_id=MODULE_ID, + kind="section", + label="Recovery ceremonies", + parent_id="encryption.admin.operations", + order=40, + ), + ), + ), capability_factories={ CAPABILITY_ENCRYPTION_KEY_VAULT: _service, CAPABILITY_ENCRYPTION_CONTENT_PROTECTION: _service, @@ -319,6 +358,50 @@ manifest = ModuleManifest( ), ), ), + DocumentationTopic( + id="encryption.administration", + title="Administer encryption operations", + summary=( + "Inspect safe vault and envelope metadata, govern key lifecycle, " + "coordinate migrations, and verify disable readiness." + ), + body=( + "Encryption administration exposes bounded tenant metadata but " + "never provider key references, wrapped keys, ciphertext locations, " + "or plaintext. Rotation creates a new current version while existing " + "envelopes remain version-bound. Revocation and destruction cannot " + "recall material already obtained and can make content unavailable. " + "Migrations remain two-phase: the owning module performs the durable " + "content operation and records evidence before success. Disable " + "preflight blocks until every envelope has a terminal disposition." + ), + layer="available", + documentation_types=("admin",), + audience=("administrator", "security_officer", "auditor"), + related_modules=OPTIONAL_DEPENDENCIES, + order=110, + ), + DocumentationTopic( + id="encryption.recovery", + title="Run an encryption recovery ceremony", + summary=( + "Request and decide time-bounded recovery with high assurance and " + "a distinct-custodian quorum." + ), + body=( + "A recovery requester supplies policy and recent high-assurance " + "evidence and cannot approve the same ceremony. Each custodian can " + "decide once; a rejection terminates the request and approvals must " + "reach the vault quorum before expiry. Approval authorizes a later " + "provider operation. It does not release key material, transfer " + "resource ownership, or prove that recovery execution succeeded." + ), + layer="available", + documentation_types=("admin", "user"), + audience=("administrator", "security_officer", "auditor"), + related_modules=("identity_trust", "access", "audit", "policy"), + order=120, + ), ), architecture=declared_module_architecture( layer="institutional_foundation", diff --git a/src/govoplan_encryption/backend/router.py b/src/govoplan_encryption/backend/router.py index f65b999..236da0e 100644 --- a/src/govoplan_encryption/backend/router.py +++ b/src/govoplan_encryption/backend/router.py @@ -2,7 +2,7 @@ from __future__ import annotations from dataclasses import asdict -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy.orm import Session from govoplan_core.audit.logging import audit_event @@ -20,18 +20,26 @@ from govoplan_core.core.encryption import ( from govoplan_core.db.session import get_session from govoplan_encryption.backend.schemas import ( DisablePreflightResponse, + EnvelopeOperationalListResponse, + EnvelopeOperationalSummaryResponse, EnvelopePayload, EnvelopeRegistrationPayload, EnvelopeResponse, KeyLifecyclePayload, KeyRotationPayload, MigrationOutcomePayload, + MigrationOperationalListResponse, + MigrationOperationalSummaryResponse, MigrationRequestPayload, MigrationResponse, RecoveryDecisionPayload, + RecoveryOperationalListResponse, + RecoveryOperationalSummaryResponse, RecoveryRequestPayload, RecoveryResponse, VaultCreatePayload, + VaultOperationalListResponse, + VaultOperationalSummaryResponse, VaultResponse, ) from govoplan_encryption.backend.service import EncryptionError, SqlEncryptionService @@ -41,6 +49,28 @@ def create_router(registry: object | None = None) -> APIRouter: router = APIRouter(prefix="/encryption", tags=["encryption"]) service = SqlEncryptionService(registry) + @router.get("/vaults", response_model=VaultOperationalListResponse) + def list_vaults( + state_filter: str | None = Query(default=None, alias="state", max_length=40), + limit: int = Query(default=100, ge=1, le=500), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), + ) -> VaultOperationalListResponse: + _require(principal, "encryption:vault:admin", "encryption:recovery:approve") + values = service.list_vaults( + session, + principal, + tenant_id=principal.tenant_id, + state=state_filter, + limit=limit, + ) + return VaultOperationalListResponse( + items=[ + VaultOperationalSummaryResponse(**_serializable(value)) + for value in values + ] + ) + @router.post("/vaults", response_model=VaultResponse) def create_vault( payload: VaultCreatePayload, @@ -173,6 +203,32 @@ def create_router(registry: object | None = None) -> APIRouter: session.commit() return _vault_response(value) + @router.get("/envelopes", response_model=EnvelopeOperationalListResponse) + def list_envelopes( + vault_id: str | None = Query(default=None, max_length=255), + owner_module: str | None = Query(default=None, max_length=120), + state_filter: str | None = Query(default=None, alias="state", max_length=40), + limit: int = Query(default=100, ge=1, le=500), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), + ) -> EnvelopeOperationalListResponse: + _require(principal, "encryption:vault:admin") + values = service.list_envelopes( + session, + principal, + tenant_id=principal.tenant_id, + vault_id=vault_id, + owner_module=owner_module, + state=state_filter, + limit=limit, + ) + return EnvelopeOperationalListResponse( + items=[ + EnvelopeOperationalSummaryResponse(**_serializable(value)) + for value in values + ] + ) + @router.post("/envelopes", response_model=EnvelopeResponse) def register_envelope( payload: EnvelopeRegistrationPayload, @@ -226,6 +282,28 @@ def create_router(registry: object | None = None) -> APIRouter: ) return _envelope_response(value) + @router.get("/migrations", response_model=MigrationOperationalListResponse) + def list_migrations( + state_filter: str | None = Query(default=None, alias="state", max_length=40), + limit: int = Query(default=100, ge=1, le=500), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), + ) -> MigrationOperationalListResponse: + _require(principal, "encryption:vault:admin") + values = service.list_migrations( + session, + principal, + tenant_id=principal.tenant_id, + state=state_filter, + limit=limit, + ) + return MigrationOperationalListResponse( + items=[ + MigrationOperationalSummaryResponse(**_serializable(value)) + for value in values + ] + ) + @router.post("/migrations", response_model=MigrationResponse) def request_migration( payload: MigrationRequestPayload, @@ -289,6 +367,53 @@ def create_router(registry: object | None = None) -> APIRouter: session.commit() return _migration_response(value) + @router.post("/migrations/{migration_id}/reconcile", response_model=MigrationResponse) + def reconcile_migration( + migration_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), + ) -> MigrationResponse: + _require(principal, "encryption:vault:admin") + value = _call( + lambda: service.reconcile_migration( + session, + principal, + migration_id=migration_id, + ) + ) + _audit( + session, + principal, + "encryption.migration.reconciled", + "protection_migration", + migration_id, + {"state": value.state}, + ) + session.commit() + return _migration_response(value) + + @router.get("/recoveries", response_model=RecoveryOperationalListResponse) + def list_recoveries( + state_filter: str | None = Query(default=None, alias="state", max_length=40), + limit: int = Query(default=100, ge=1, le=500), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), + ) -> RecoveryOperationalListResponse: + _require(principal, "encryption:vault:admin", "encryption:recovery:approve") + values = service.list_recoveries( + session, + principal, + tenant_id=principal.tenant_id, + state=state_filter, + limit=limit, + ) + return RecoveryOperationalListResponse( + items=[ + RecoveryOperationalSummaryResponse(**_serializable(value)) + for value in values + ] + ) + @router.post("/recoveries", response_model=RecoveryResponse) def request_recovery( payload: RecoveryRequestPayload, diff --git a/src/govoplan_encryption/backend/schemas.py b/src/govoplan_encryption/backend/schemas.py index 725861b..4a171bc 100644 --- a/src/govoplan_encryption/backend/schemas.py +++ b/src/govoplan_encryption/backend/schemas.py @@ -84,6 +84,30 @@ class VaultResponse(BaseModel): contract_version: str = "1" +class VaultOperationalSummaryResponse(BaseModel): + tenant_id: str + vault_id: str + name: str + provider_id: str + purpose: str + profile_kind: str + scope_type: str + scope_id: str | None = None + policy_ref: str + recovery_quorum: int + state: str + revision: int + current_key_version: int | None = None + current_key_state: str | None = None + algorithm_suite: str | None = None + created_at: datetime + updated_at: datetime + + +class VaultOperationalListResponse(BaseModel): + items: list[VaultOperationalSummaryResponse] + + class EnvelopePayload(BaseModel): envelope_id: str = Field(min_length=1, max_length=255) owner_module: str = Field(min_length=1, max_length=120) @@ -126,6 +150,27 @@ class EnvelopeResponse(EnvelopePayload): contract_version: str = "1" +class EnvelopeOperationalSummaryResponse(BaseModel): + envelope_id: str + tenant_id: str + owner_module: str + resource_type: str + resource_id: str + profile_kind: str + provider_id: str + vault_id: str + key_version: int + algorithm_suite: str + state: str + migration_id: str | None = None + created_at: datetime + updated_at: datetime + + +class EnvelopeOperationalListResponse(BaseModel): + items: list[EnvelopeOperationalSummaryResponse] + + class MigrationRequestPayload(BaseModel): envelope_id: str = Field(min_length=1, max_length=255) target_provider_id: str = Field(min_length=1, max_length=120) @@ -156,6 +201,31 @@ class MigrationResponse(BaseModel): contract_version: str = "1" +class MigrationOperationalSummaryResponse(BaseModel): + migration_id: str + tenant_id: str + source_envelope_id: str + target_envelope_id: str | None = None + target_provider_id: str + target_vault_id: str + target_key_version: int + target_algorithm_suite: str + mode: str + state: str + policy_decision_ref: str + assurance_evidence_ref: str + evidence_refs: list[str] = Field(default_factory=list) + error_code: str | None = None + requested_by: str + created_at: datetime + updated_at: datetime + completed_at: datetime | None = None + + +class MigrationOperationalListResponse(BaseModel): + items: list[MigrationOperationalSummaryResponse] + + class RecoveryRequestPayload(BaseModel): vault_id: str = Field(min_length=1, max_length=255) reason: str = Field(min_length=1, max_length=2000) @@ -191,6 +261,30 @@ class RecoveryResponse(BaseModel): contract_version: str = "1" +class RecoveryOperationalSummaryResponse(BaseModel): + recovery_id: str + tenant_id: str + vault_id: str + state: str + requested_scope: str + reason: str + quorum: int + approvals: int + rejections: int + revision: int + policy_decision_ref: str + requester_account_id: str + requester_assurance_ref: str + expires_at: datetime + execution_ref: str | None = None + created_at: datetime + updated_at: datetime + + +class RecoveryOperationalListResponse(BaseModel): + items: list[RecoveryOperationalSummaryResponse] + + class DisablePreflightResponse(BaseModel): allowed: bool protected_count: int @@ -206,15 +300,23 @@ __all__ = [ "DisablePreflightResponse", "EnvelopePayload", "EnvelopeRegistrationPayload", + "EnvelopeOperationalListResponse", + "EnvelopeOperationalSummaryResponse", "EnvelopeResponse", "KeyLifecyclePayload", "KeyRotationPayload", "MigrationOutcomePayload", + "MigrationOperationalListResponse", + "MigrationOperationalSummaryResponse", "MigrationRequestPayload", "MigrationResponse", "RecoveryDecisionPayload", + "RecoveryOperationalListResponse", + "RecoveryOperationalSummaryResponse", "RecoveryRequestPayload", "RecoveryResponse", "VaultCreatePayload", + "VaultOperationalListResponse", + "VaultOperationalSummaryResponse", "VaultResponse", ] diff --git a/src/govoplan_encryption/backend/service.py b/src/govoplan_encryption/backend/service.py index 05d89d0..2f6d815 100644 --- a/src/govoplan_encryption/backend/service.py +++ b/src/govoplan_encryption/backend/service.py @@ -2,13 +2,14 @@ from __future__ import annotations from collections import Counter from collections.abc import Mapping +from dataclasses import dataclass from datetime import datetime, timezone import hashlib import json from types import SimpleNamespace import uuid -from sqlalchemy import func, select +from sqlalchemy import and_, func, select from sqlalchemy.orm import Session from govoplan_core.core.encryption import ( @@ -61,6 +62,88 @@ class EncryptionError(ValueError): pass +@dataclass(frozen=True, slots=True) +class VaultOperationalSummary: + tenant_id: str + vault_id: str + name: str + provider_id: str + purpose: str + profile_kind: str + scope_type: str + scope_id: str | None + policy_ref: str + recovery_quorum: int + state: str + revision: int + current_key_version: int | None + current_key_state: str | None + algorithm_suite: str | None + created_at: datetime + updated_at: datetime + + +@dataclass(frozen=True, slots=True) +class EnvelopeOperationalSummary: + envelope_id: str + tenant_id: str + owner_module: str + resource_type: str + resource_id: str + profile_kind: str + provider_id: str + vault_id: str + key_version: int + algorithm_suite: str + state: str + migration_id: str | None + created_at: datetime + updated_at: datetime + + +@dataclass(frozen=True, slots=True) +class MigrationOperationalSummary: + migration_id: str + tenant_id: str + source_envelope_id: str + target_envelope_id: str | None + target_provider_id: str + target_vault_id: str + target_key_version: int + target_algorithm_suite: str + mode: str + state: str + policy_decision_ref: str + assurance_evidence_ref: str + evidence_refs: tuple[str, ...] + error_code: str | None + requested_by: str + created_at: datetime + updated_at: datetime + completed_at: datetime | None + + +@dataclass(frozen=True, slots=True) +class RecoveryOperationalSummary: + recovery_id: str + tenant_id: str + vault_id: str + state: str + requested_scope: str + reason: str + quorum: int + approvals: int + rejections: int + revision: int + policy_decision_ref: str + requester_account_id: str + requester_assurance_ref: str + expires_at: datetime + execution_ref: str | None + created_at: datetime + updated_at: datetime + + class SqlEncryptionService: """Provider-neutral encryption metadata and recovery orchestration. @@ -282,6 +365,57 @@ class SqlEncryptionService: ) return self._vault_ref(vault, db) if vault is not None else None + def list_vaults( + self, + session: object, + principal: object, + *, + tenant_id: str, + state: str | None = None, + limit: int = 100, + ) -> tuple[VaultOperationalSummary, ...]: + db = _session(session) + _require_tenant(principal, tenant_id) + statement = ( + select(EncryptionVault, EncryptionKeyVersion) + .outerjoin( + EncryptionKeyVersion, + and_( + EncryptionKeyVersion.tenant_id == EncryptionVault.tenant_id, + EncryptionKeyVersion.vault_id == EncryptionVault.vault_id, + EncryptionKeyVersion.version + == EncryptionVault.current_key_version, + ), + ) + .where(EncryptionVault.tenant_id == tenant_id) + .order_by(EncryptionVault.updated_at.desc(), EncryptionVault.vault_id) + .limit(_bounded_limit(limit)) + ) + if state: + statement = statement.where(EncryptionVault.state == state) + return tuple( + VaultOperationalSummary( + tenant_id=vault.tenant_id, + vault_id=vault.vault_id, + name=vault.name, + provider_id=vault.provider_id, + purpose=vault.purpose, + profile_kind=vault.profile_kind, + scope_type=vault.scope_type, + scope_id=vault.scope_id, + policy_ref=vault.policy_ref, + recovery_quorum=vault.recovery_quorum, + state=vault.state, + revision=vault.revision, + current_key_version=vault.current_key_version, + current_key_state=key.state if key is not None else None, + algorithm_suite=key.algorithm_suite if key is not None else None, + created_at=vault.created_at, + updated_at=vault.updated_at, + ) + for vault, key in db.execute(statement) + ) + def reconcile_vault( self, session: object, @@ -848,6 +982,54 @@ class SqlEncryptionService: ) return _envelope_ref(item) if item is not None else None + def list_envelopes( + self, + session: object, + principal: object, + *, + tenant_id: str, + vault_id: str | None = None, + owner_module: str | None = None, + state: str | None = None, + limit: int = 100, + ) -> tuple[EnvelopeOperationalSummary, ...]: + db = _session(session) + _require_tenant(principal, tenant_id) + statement = select(ContentProtectionRecord).where( + ContentProtectionRecord.tenant_id == tenant_id + ) + if vault_id: + statement = statement.where(ContentProtectionRecord.vault_id == vault_id) + if owner_module: + statement = statement.where( + ContentProtectionRecord.owner_module == owner_module + ) + if state: + statement = statement.where(ContentProtectionRecord.state == state) + statement = statement.order_by( + ContentProtectionRecord.updated_at.desc(), + ContentProtectionRecord.envelope_id, + ).limit(_bounded_limit(limit)) + return tuple( + EnvelopeOperationalSummary( + envelope_id=item.envelope_id, + tenant_id=item.tenant_id, + owner_module=item.owner_module, + resource_type=item.resource_type, + resource_id=item.resource_id, + profile_kind=item.profile_kind, + provider_id=item.provider_id, + vault_id=item.vault_id, + key_version=item.key_version, + algorithm_suite=item.algorithm_suite, + state=item.state, + migration_id=item.migration_id, + created_at=item.created_at, + updated_at=item.updated_at, + ) + for item in db.scalars(statement) + ) + def request_migration( self, session: object, @@ -924,6 +1106,49 @@ class SqlEncryptionService: db.flush() return self._migration_ref(db, item) + def list_migrations( + self, + session: object, + principal: object, + *, + tenant_id: str, + state: str | None = None, + limit: int = 100, + ) -> tuple[MigrationOperationalSummary, ...]: + db = _session(session) + _require_tenant(principal, tenant_id) + statement = select(ProtectionMigration).where( + ProtectionMigration.tenant_id == tenant_id + ) + if state: + statement = statement.where(ProtectionMigration.state == state) + statement = statement.order_by( + ProtectionMigration.updated_at.desc(), ProtectionMigration.id + ).limit(_bounded_limit(limit)) + return tuple( + MigrationOperationalSummary( + migration_id=item.id, + tenant_id=item.tenant_id, + source_envelope_id=item.source_envelope_id, + target_envelope_id=item.target_envelope_id, + target_provider_id=item.target_provider_id, + target_vault_id=item.target_vault_id, + target_key_version=item.target_key_version, + target_algorithm_suite=item.target_algorithm_suite, + mode=item.mode, + state=item.state, + policy_decision_ref=item.policy_decision_ref, + assurance_evidence_ref=item.assurance_evidence_ref, + evidence_refs=tuple(item.evidence_refs), + error_code=item.error_code, + requested_by=item.requested_by, + created_at=item.created_at, + updated_at=item.updated_at, + completed_at=item.completed_at, + ) + for item in db.scalars(statement) + ) + def record_migration_outcome( self, session: object, @@ -1092,6 +1317,70 @@ class SqlEncryptionService: db.flush() return self._recovery_ref(db, item) + def list_recoveries( + self, + session: object, + principal: object, + *, + tenant_id: str, + state: str | None = None, + limit: int = 100, + ) -> tuple[RecoveryOperationalSummary, ...]: + db = _session(session) + _require_tenant(principal, tenant_id) + statement = select(RecoveryCeremony).where( + RecoveryCeremony.tenant_id == tenant_id + ) + if state: + statement = statement.where(RecoveryCeremony.state == state) + statement = statement.order_by( + RecoveryCeremony.updated_at.desc(), RecoveryCeremony.id + ).limit(_bounded_limit(limit)) + ceremonies = tuple(db.scalars(statement)) + if not ceremonies: + return () + counts: dict[str, Counter[str]] = { + ceremony.id: Counter() for ceremony in ceremonies + } + count_rows = db.execute( + select( + RecoveryApproval.recovery_id, + RecoveryApproval.decision, + func.count(RecoveryApproval.id), + ) + .where(RecoveryApproval.recovery_id.in_(tuple(counts))) + .group_by(RecoveryApproval.recovery_id, RecoveryApproval.decision) + ) + for recovery_id, decision, count in count_rows: + counts[str(recovery_id)][str(decision)] = int(count) + now = _as_utc(utcnow()) + return tuple( + RecoveryOperationalSummary( + recovery_id=item.id, + tenant_id=item.tenant_id, + vault_id=item.vault_id, + state=( + "expired" + if item.state == "pending" and _as_utc(item.expires_at) <= now + else item.state + ), + requested_scope=item.requested_scope, + reason=item.reason, + quorum=item.quorum, + approvals=counts[item.id]["approve"], + rejections=counts[item.id]["reject"], + revision=item.revision, + policy_decision_ref=item.policy_decision_ref, + requester_account_id=item.requester_account_id, + requester_assurance_ref=item.requester_assurance_ref, + expires_at=item.expires_at, + execution_ref=item.execution_ref, + created_at=item.created_at, + updated_at=item.updated_at, + ) + for item in ceremonies + ) + def decide_recovery( self, session: object, @@ -2007,6 +2296,10 @@ def _as_utc(value: datetime) -> datetime: return value.astimezone(timezone.utc) +def _bounded_limit(value: int) -> int: + return max(1, min(int(value), 500)) + + def _exception_code(exc: Exception) -> str: return f"provider_{type(exc).__name__.lower()}"[:255] diff --git a/tests/test_encryption.py b/tests/test_encryption.py index cdd9a51..932115d 100644 --- a/tests/test_encryption.py +++ b/tests/test_encryption.py @@ -377,6 +377,79 @@ class EncryptionTests(unittest.TestCase): self.assertEqual(2, second.approvals) self.assertFalse(second.provenance["key_material_released"]) + def test_operator_queries_are_bounded_tenant_scoped_and_secret_free(self) -> None: + self.create_vault() + self.service.register_envelope( + self.session, + self.principal, + request=ProtectionRegistrationRequest( + envelope=self.envelope("files", "operator-query"), + idempotency_key="register-operator-query", + policy_decision_ref="policy:protect", + ), + ) + migration = self.service.request_migration( + self.session, + self.principal, + request=ProtectionMigrationRequest( + tenant_id="tenant-1", + envelope_id="envelope-operator-query", + target_provider_id="test", + target_vault_id="vault-1", + target_key_version=1, + target_algorithm_suite="AES-256-GCM", + mode="destroy", + policy_decision_ref="policy:destroy", + assurance_evidence_ref="assurance:destroy", + idempotency_key="migration-operator-query", + ), + ) + recovery = self.service.request_recovery( + self.session, + self.principal, + request=RecoveryRequest( + tenant_id="tenant-1", + vault_id="vault-1", + reason="operator query fixture", + requested_scope="vault-status", + policy_decision_ref="policy:recovery", + assurance_evidence_ref="assurance:requester", + idempotency_key="recovery-operator-query", + expires_at=utcnow() + timedelta(hours=1), + ), + ) + + vaults = self.service.list_vaults( + self.session, self.principal, tenant_id="tenant-1", limit=1000 + ) + envelopes = self.service.list_envelopes( + self.session, self.principal, tenant_id="tenant-1", limit=1000 + ) + migrations = self.service.list_migrations( + self.session, self.principal, tenant_id="tenant-1", limit=1000 + ) + recoveries = self.service.list_recoveries( + self.session, self.principal, tenant_id="tenant-1", limit=1000 + ) + + self.assertEqual(["vault-1"], [item.vault_id for item in vaults]) + self.assertEqual( + ["envelope-operator-query"], + [item.envelope_id for item in envelopes], + ) + self.assertEqual(migration.migration_id, migrations[0].migration_id) + self.assertEqual(recovery.recovery_id, recoveries[0].recovery_id) + self.assertEqual("operator query fixture", recoveries[0].reason) + self.assertFalse(hasattr(vaults[0], "provider_key_ref")) + self.assertFalse(hasattr(envelopes[0], "wrapped_key_refs")) + self.assertFalse(hasattr(envelopes[0], "ciphertext_ref")) + with self.assertRaisesRegex(EncryptionError, "Cross-tenant"): + self.service.list_vaults( + self.session, + Principal("account-1", "tenant-2"), + tenant_id="tenant-1", + ) + def test_contract_rejects_secret_bearing_envelope_metadata(self) -> None: with self.assertRaisesRegex(ValueError, "secret material"): replace( diff --git a/tests/test_manifest.py b/tests/test_manifest.py index e947d59..323cc66 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -16,7 +16,7 @@ from govoplan_encryption.backend.local_provider import LOCAL_PROVIDER_ID class EncryptionManifestTests(unittest.TestCase): - def test_manifest_exposes_headless_governed_capabilities(self) -> None: + def test_manifest_exposes_governed_capabilities_and_operator_ui(self) -> None: manifest = get_manifest() self.assertEqual("encryption", manifest.id) @@ -35,7 +35,21 @@ class EncryptionManifestTests(unittest.TestCase): ) self.assertIsNotNone(manifest.route_factory) self.assertIsNotNone(manifest.migration_spec) - self.assertIsNone(manifest.frontend) + self.assertIsNotNone(manifest.frontend) + self.assertEqual("@govoplan/encryption-webui", manifest.frontend.package_name) + self.assertEqual( + { + "encryption.admin.operations", + "encryption.admin.vaults", + "encryption.admin.migrations", + "encryption.admin.recovery", + }, + {surface.id for surface in manifest.frontend.view_surfaces}, + ) + self.assertIn( + "encryption.administration", + {topic.id for topic in manifest.documentation}, + ) self.assertEqual("vertical_slice", manifest.architecture.maturity) diff --git a/webui/package.json b/webui/package.json new file mode 100644 index 0000000..7ffc904 --- /dev/null +++ b/webui/package.json @@ -0,0 +1,25 @@ +{ + "name": "@govoplan/encryption-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/encryption.css": "./src/styles/encryption.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:encryption-ui": "node tests/encryption-ui-structure.test.mjs" + } +} diff --git a/webui/src/api/encryption.ts b/webui/src/api/encryption.ts new file mode 100644 index 0000000..87974f5 --- /dev/null +++ b/webui/src/api/encryption.ts @@ -0,0 +1,193 @@ +import { apiFetch, type ApiSettings } from "@govoplan/core-webui"; + +export type VaultSummary = { + tenant_id: string; + vault_id: string; + name: string; + provider_id: string; + purpose: string; + profile_kind: string; + scope_type: string; + scope_id?: string | null; + policy_ref: string; + recovery_quorum: number; + state: string; + revision: number; + current_key_version?: number | null; + current_key_state?: string | null; + algorithm_suite?: string | null; + created_at: string; + updated_at: string; +}; + +export type EnvelopeSummary = { + envelope_id: string; + tenant_id: string; + owner_module: string; + resource_type: string; + resource_id: string; + profile_kind: string; + provider_id: string; + vault_id: string; + key_version: number; + algorithm_suite: string; + state: string; + migration_id?: string | null; + created_at: string; + updated_at: string; +}; + +export type MigrationSummary = { + migration_id: string; + tenant_id: string; + source_envelope_id: string; + target_envelope_id?: string | null; + target_provider_id: string; + target_vault_id: string; + target_key_version: number; + target_algorithm_suite: string; + mode: string; + state: string; + policy_decision_ref: string; + assurance_evidence_ref: string; + evidence_refs: string[]; + error_code?: string | null; + requested_by: string; + created_at: string; + updated_at: string; + completed_at?: string | null; +}; + +export type RecoverySummary = { + recovery_id: string; + tenant_id: string; + vault_id: string; + state: string; + requested_scope: string; + reason: string; + quorum: number; + approvals: number; + rejections: number; + revision: number; + policy_decision_ref: string; + requester_account_id: string; + requester_assurance_ref: string; + expires_at: string; + execution_ref?: string | null; + created_at: string; + updated_at: string; +}; + +export type DisablePreflight = { + allowed: boolean; + protected_count: number; + unresolved_count: number; + state_counts: Record; + blocking_envelope_refs: string[]; + required_actions: string[]; + generated_at: string; +}; + +export type VaultCreatePayload = { + vault_id: string; + name: string; + provider_id: string; + purpose: string; + algorithm_suite: string; + scope_type: string; + scope_id?: string | null; + policy_ref: string; + recovery_quorum: number; + profile_kind: "server_envelope" | "tenant_held" | "end_to_end"; + idempotency_key: string; +}; + +export type VaultLifecyclePayload = { + key_version: number; + expected_revision: number; + reason: string; + policy_decision_ref: string; + assurance_evidence_ref: string; + idempotency_key: string; + effective_at?: string | null; +}; + +export async function listVaults(settings: ApiSettings): Promise { + const value = await apiFetch<{ items: VaultSummary[] }>(settings, "/api/v1/encryption/vaults?limit=500"); + return value.items; +} + +export async function listEnvelopes(settings: ApiSettings): Promise { + const value = await apiFetch<{ items: EnvelopeSummary[] }>(settings, "/api/v1/encryption/envelopes?limit=500"); + return value.items; +} + +export async function listMigrations(settings: ApiSettings): Promise { + const value = await apiFetch<{ items: MigrationSummary[] }>(settings, "/api/v1/encryption/migrations?limit=500"); + return value.items; +} + +export async function listRecoveries(settings: ApiSettings): Promise { + const value = await apiFetch<{ items: RecoverySummary[] }>(settings, "/api/v1/encryption/recoveries?limit=500"); + return value.items; +} + +export function loadDisablePreflight(settings: ApiSettings): Promise { + return apiFetch(settings, "/api/v1/encryption/disable-preflight"); +} + +export function createVault(settings: ApiSettings, payload: VaultCreatePayload): Promise { + return apiFetch(settings, "/api/v1/encryption/vaults", { method: "POST", body: JSON.stringify(payload) }); +} + +export function rotateVault(settings: ApiSettings, vaultId: string, payload: Omit): Promise { + return apiFetch(settings, `/api/v1/encryption/vaults/${encodeURIComponent(vaultId)}/rotate`, { method: "POST", body: JSON.stringify(payload) }); +} + +export function changeVaultKey(settings: ApiSettings, vaultId: string, action: "revoke" | "destruction", payload: VaultLifecyclePayload): Promise { + return apiFetch(settings, `/api/v1/encryption/vaults/${encodeURIComponent(vaultId)}/${action}`, { method: "POST", body: JSON.stringify(payload) }); +} + +export function reconcileVault(settings: ApiSettings, vaultId: string): Promise { + return apiFetch(settings, `/api/v1/encryption/vaults/${encodeURIComponent(vaultId)}/reconcile`, { method: "POST" }); +} + +export function requestMigration(settings: ApiSettings, payload: { + envelope_id: string; + target_provider_id: string; + target_vault_id: string; + target_key_version: number; + target_algorithm_suite: string; + mode: "rewrap" | "reencrypt" | "decrypt" | "export" | "destroy"; + policy_decision_ref: string; + assurance_evidence_ref: string; + idempotency_key: string; +}): Promise { + return apiFetch(settings, "/api/v1/encryption/migrations", { method: "POST", body: JSON.stringify(payload) }); +} + +export function reconcileMigration(settings: ApiSettings, migrationId: string): Promise { + return apiFetch(settings, `/api/v1/encryption/migrations/${encodeURIComponent(migrationId)}/reconcile`, { method: "POST" }); +} + +export function requestRecovery(settings: ApiSettings, payload: { + vault_id: string; + reason: string; + requested_scope: string; + policy_decision_ref: string; + assurance_evidence_ref: string; + idempotency_key: string; + expires_at: string; +}): Promise { + return apiFetch(settings, "/api/v1/encryption/recoveries", { method: "POST", body: JSON.stringify(payload) }); +} + +export function decideRecovery(settings: ApiSettings, recoveryId: string, payload: { + decision: "approve" | "reject"; + reason: string; + assurance_evidence_ref: string; + expected_revision: number; + idempotency_key: string; +}): Promise { + return apiFetch(settings, `/api/v1/encryption/recoveries/${encodeURIComponent(recoveryId)}/decision`, { method: "POST", body: JSON.stringify(payload) }); +} diff --git a/webui/src/features/EncryptionAdminPanel.tsx b/webui/src/features/EncryptionAdminPanel.tsx new file mode 100644 index 0000000..e638635 --- /dev/null +++ b/webui/src/features/EncryptionAdminPanel.tsx @@ -0,0 +1,409 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + ArrowRightLeft, + Check, + Plus, + RefreshCw, + RotateCw, + ShieldAlert, + ShieldOff, + Trash2, + X +} from "lucide-react"; +import { + AdminPageLayout, + Button, + Card, + DataGrid, + Dialog, + FormField, + MetricCard, + StatusBadge, + TableActionGroup, + hasScope, + type ApiSettings, + type AuthInfo, + type DataGridColumn +} from "@govoplan/core-webui"; +import { + changeVaultKey, + createVault, + decideRecovery, + listEnvelopes, + listMigrations, + listRecoveries, + listVaults, + loadDisablePreflight, + reconcileMigration, + reconcileVault, + requestMigration, + requestRecovery, + rotateVault, + type DisablePreflight, + type EnvelopeSummary, + type MigrationSummary, + type RecoverySummary, + type VaultCreatePayload, + type VaultSummary +} from "../api/encryption"; + +type Props = { settings: ApiSettings; auth: AuthInfo }; +type LifecycleAction = "rotate" | "revoke" | "destruction"; +type LifecycleDraft = { + action: LifecycleAction; + vault: VaultSummary; + reason: string; + policyRef: string; + assuranceRef: string; + effectiveAt: string; +}; +type MigrationDraft = { + envelope: EnvelopeSummary; + targetVaultId: string; + mode: "rewrap" | "reencrypt" | "decrypt" | "export" | "destroy"; + policyRef: string; + assuranceRef: string; +}; +type RecoveryDecisionDraft = { + recovery: RecoverySummary; + decision: "approve" | "reject"; + reason: string; + assuranceRef: string; +}; + +const EMPTY_VAULT: VaultCreatePayload = { + vault_id: "", + name: "", + provider_id: "local_aesgcm", + purpose: "feature-content", + algorithm_suite: "AES-256-GCM", + scope_type: "tenant", + scope_id: null, + policy_ref: "", + recovery_quorum: 2, + profile_kind: "server_envelope", + idempotency_key: "" +}; + +export default function EncryptionAdminPanel({ settings, auth }: Props) { + const canAdmin = hasScope(auth, "encryption:vault:admin"); + const canRecover = hasScope(auth, "encryption:recovery:approve"); + const [vaults, setVaults] = useState([]); + const [envelopes, setEnvelopes] = useState([]); + const [migrations, setMigrations] = useState([]); + const [recoveries, setRecoveries] = useState([]); + const [preflight, setPreflight] = useState(null); + const [loading, setLoading] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [success, setSuccess] = useState(""); + const [creating, setCreating] = useState(false); + const [vaultDraft, setVaultDraft] = useState(EMPTY_VAULT); + const [lifecycle, setLifecycle] = useState(null); + const [migration, setMigration] = useState(null); + const [requestingRecovery, setRequestingRecovery] = useState(false); + const [recoveryDraft, setRecoveryDraft] = useState({ vaultId: "", reason: "", requestedScope: "vault-status-and-rewrap", policyRef: "", assuranceRef: "", expiresAt: futureLocalTime(24) }); + const [recoveryDecision, setRecoveryDecision] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + setError(""); + try { + const [nextVaults, nextEnvelopes, nextMigrations, nextRecoveries, nextPreflight] = await Promise.all([ + listVaults(settings), + canAdmin ? listEnvelopes(settings) : Promise.resolve([]), + canAdmin ? listMigrations(settings) : Promise.resolve([]), + listRecoveries(settings), + canAdmin ? loadDisablePreflight(settings) : Promise.resolve(null) + ]); + setVaults(nextVaults); + setEnvelopes(nextEnvelopes); + setMigrations(nextMigrations); + setRecoveries(nextRecoveries); + setPreflight(nextPreflight); + } catch (caught) { + setError(errorMessage(caught)); + } finally { + setLoading(false); + } + }, [canAdmin, settings]); + + useEffect(() => { + void load(); + }, [load]); + + async function perform(operation: () => Promise, message: string): Promise { + if (busy) return false; + setBusy(true); + setError(""); + setSuccess(""); + try { + await operation(); + setSuccess(message); + await load(); + return true; + } catch (caught) { + setError(errorMessage(caught)); + return false; + } finally { + setBusy(false); + } + } + + const vaultColumns = useMemo[]>(() => [ + { id: "name", header: "Vault", width: 190, sortable: true, filterable: true, render: (row) => <>{row.name}
{row.vault_id}
, value: (row) => `${row.name} ${row.vault_id}` }, + { id: "provider", header: "Provider", width: 145, sortable: true, filterable: true, render: (row) => row.provider_id, value: (row) => row.provider_id }, + { id: "profile", header: "Profile", width: 145, sortable: true, filterable: true, render: (row) => humanize(row.profile_kind), value: (row) => row.profile_kind }, + { id: "scope", header: "Scope", width: 155, sortable: true, filterable: true, render: (row) => `${humanize(row.scope_type)}${row.scope_id ? `: ${row.scope_id}` : ""}`, value: (row) => `${row.scope_type}:${row.scope_id ?? ""}` }, + { id: "key", header: "Current key", width: 145, sortable: true, filterable: true, render: (row) => row.current_key_version ? `v${row.current_key_version} · ${humanize(row.current_key_state ?? "unknown")}` : "Not provisioned", value: (row) => row.current_key_version ?? 0 }, + { id: "state", header: "State", width: 125, sortable: true, filterable: true, render: (row) => , value: (row) => row.state }, + { id: "revision", header: "Revision", width: 95, sortable: true, filterable: true, filterType: "integer", render: (row) => row.revision, value: (row) => row.revision }, + { + id: "actions", header: "Actions", width: 210, sticky: "end", align: "right", render: (row) =>