Add governed encryption administration

This commit is contained in:
2026-08-04 01:27:52 +02:00
parent 42f35f8d00
commit ba92d8bf32
15 changed files with 1472 additions and 4 deletions
+8
View File
@@ -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.
+21
View File
@@ -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,
@@ -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",
+126 -1
View File
@@ -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,
+102
View File
@@ -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",
]
+294 -1
View File
@@ -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]
+73
View File
@@ -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(
+16 -2
View File
@@ -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)
+25
View File
@@ -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"
}
}
+193
View File
@@ -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<string, number>;
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<VaultSummary[]> {
const value = await apiFetch<{ items: VaultSummary[] }>(settings, "/api/v1/encryption/vaults?limit=500");
return value.items;
}
export async function listEnvelopes(settings: ApiSettings): Promise<EnvelopeSummary[]> {
const value = await apiFetch<{ items: EnvelopeSummary[] }>(settings, "/api/v1/encryption/envelopes?limit=500");
return value.items;
}
export async function listMigrations(settings: ApiSettings): Promise<MigrationSummary[]> {
const value = await apiFetch<{ items: MigrationSummary[] }>(settings, "/api/v1/encryption/migrations?limit=500");
return value.items;
}
export async function listRecoveries(settings: ApiSettings): Promise<RecoverySummary[]> {
const value = await apiFetch<{ items: RecoverySummary[] }>(settings, "/api/v1/encryption/recoveries?limit=500");
return value.items;
}
export function loadDisablePreflight(settings: ApiSettings): Promise<DisablePreflight> {
return apiFetch<DisablePreflight>(settings, "/api/v1/encryption/disable-preflight");
}
export function createVault(settings: ApiSettings, payload: VaultCreatePayload): Promise<unknown> {
return apiFetch(settings, "/api/v1/encryption/vaults", { method: "POST", body: JSON.stringify(payload) });
}
export function rotateVault(settings: ApiSettings, vaultId: string, payload: Omit<VaultLifecyclePayload, "key_version" | "effective_at">): Promise<unknown> {
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<unknown> {
return apiFetch(settings, `/api/v1/encryption/vaults/${encodeURIComponent(vaultId)}/${action}`, { method: "POST", body: JSON.stringify(payload) });
}
export function reconcileVault(settings: ApiSettings, vaultId: string): Promise<unknown> {
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<unknown> {
return apiFetch(settings, "/api/v1/encryption/migrations", { method: "POST", body: JSON.stringify(payload) });
}
export function reconcileMigration(settings: ApiSettings, migrationId: string): Promise<unknown> {
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<unknown> {
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<unknown> {
return apiFetch(settings, `/api/v1/encryption/recoveries/${encodeURIComponent(recoveryId)}/decision`, { method: "POST", body: JSON.stringify(payload) });
}
+409
View File
@@ -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<VaultSummary[]>([]);
const [envelopes, setEnvelopes] = useState<EnvelopeSummary[]>([]);
const [migrations, setMigrations] = useState<MigrationSummary[]>([]);
const [recoveries, setRecoveries] = useState<RecoverySummary[]>([]);
const [preflight, setPreflight] = useState<DisablePreflight | null>(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<VaultCreatePayload>(EMPTY_VAULT);
const [lifecycle, setLifecycle] = useState<LifecycleDraft | null>(null);
const [migration, setMigration] = useState<MigrationDraft | null>(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<RecoveryDecisionDraft | null>(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<unknown>, message: string): Promise<boolean> {
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<DataGridColumn<VaultSummary>[]>(() => [
{ id: "name", header: "Vault", width: 190, sortable: true, filterable: true, render: (row) => <><strong>{row.name}</strong><div className="muted small-note">{row.vault_id}</div></>, 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) => <StatusBadge status={row.state} />, 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) => <TableActionGroup minimumSlots={4} actions={[
{ id: "reconcile", label: "Reconcile provider state", icon: <RefreshCw aria-hidden="true" />, disabled: !canAdmin, onClick: () => void perform(() => reconcileVault(settings, row.vault_id), `Vault ${row.name} was reconciled.`) },
{ id: "rotate", label: "Rotate key", icon: <RotateCw aria-hidden="true" />, disabled: !canAdmin || row.state !== "active", onClick: () => openLifecycle("rotate", row) },
{ id: "revoke", label: "Revoke current key", icon: <ShieldOff aria-hidden="true" />, variant: "danger", disabled: !canAdmin || !row.current_key_version, onClick: () => openLifecycle("revoke", row) },
{ id: "destroy", label: "Schedule key destruction", icon: <Trash2 aria-hidden="true" />, variant: "danger", disabled: !canAdmin || !row.current_key_version, onClick: () => openLifecycle("destruction", row) }
]} />
}
], [canAdmin, settings]);
const envelopeColumns = useMemo<DataGridColumn<EnvelopeSummary>[]>(() => [
{ id: "owner", header: "Owner", width: 145, sortable: true, filterable: true, render: (row) => humanize(row.owner_module), value: (row) => row.owner_module },
{ id: "resource", header: "Resource", width: 260, sortable: true, filterable: true, render: (row) => <><strong>{row.resource_type}</strong><div className="muted small-note">{row.resource_id}</div></>, value: (row) => `${row.resource_type} ${row.resource_id}` },
{ id: "vault", header: "Vault / key", width: 180, sortable: true, filterable: true, render: (row) => `${row.vault_id} · v${row.key_version}`, value: (row) => `${row.vault_id}:${row.key_version}` },
{ id: "profile", header: "Profile", width: 145, sortable: true, filterable: true, render: (row) => humanize(row.profile_kind), value: (row) => row.profile_kind },
{ id: "state", header: "State", width: 135, sortable: true, filterable: true, render: (row) => <StatusBadge status={row.state} />, value: (row) => row.state },
{ id: "updated", header: "Updated", width: 175, sortable: true, filterable: true, filterType: "date", render: (row) => formatDateTime(row.updated_at), value: (row) => row.updated_at },
{ id: "actions", header: "Actions", width: 80, sticky: "end", align: "right", render: (row) => <TableActionGroup actions={[{ id: "migrate", label: "Prepare migration", icon: <ArrowRightLeft aria-hidden="true" />, disabled: row.state !== "active", onClick: () => openMigration(row) }]} /> }
], [vaults]);
const migrationColumns = useMemo<DataGridColumn<MigrationSummary>[]>(() => [
{ id: "source", header: "Source envelope", width: 245, sortable: true, filterable: true, render: (row) => row.source_envelope_id, value: (row) => row.source_envelope_id },
{ id: "mode", header: "Mode", width: 125, sortable: true, filterable: true, render: (row) => humanize(row.mode), value: (row) => row.mode },
{ id: "target", header: "Target", width: 190, sortable: true, filterable: true, render: (row) => `${row.target_vault_id} · v${row.target_key_version}`, value: (row) => `${row.target_vault_id}:${row.target_key_version}` },
{ id: "state", header: "State", width: 135, sortable: true, filterable: true, render: (row) => <StatusBadge status={row.state} />, value: (row) => row.state },
{ id: "evidence", header: "Evidence", width: 100, sortable: true, filterable: true, filterType: "integer", render: (row) => row.evidence_refs.length, value: (row) => row.evidence_refs.length },
{ id: "updated", header: "Updated", width: 175, sortable: true, filterable: true, filterType: "date", render: (row) => formatDateTime(row.updated_at), value: (row) => row.updated_at },
{ id: "actions", header: "Actions", width: 80, sticky: "end", align: "right", render: (row) => <TableActionGroup actions={[{ id: "reconcile", label: "Reconcile migration state", icon: <RefreshCw aria-hidden="true" />, onClick: () => void perform(() => reconcileMigration(settings, row.migration_id), "Migration state was reconciled.") }]} /> }
], [settings]);
const recoveryColumns = useMemo<DataGridColumn<RecoverySummary>[]>(() => [
{ id: "vault", header: "Vault", width: 175, sortable: true, filterable: true, render: (row) => row.vault_id, value: (row) => row.vault_id },
{ id: "scope", header: "Requested scope", width: 220, sortable: true, filterable: true, render: (row) => humanize(row.requested_scope), value: (row) => row.requested_scope },
{ id: "requester", header: "Requester", width: 180, sortable: true, filterable: true, render: (row) => row.requester_account_id, value: (row) => row.requester_account_id },
{ id: "quorum", header: "Quorum", width: 120, sortable: true, filterable: true, render: (row) => `${row.approvals}/${row.quorum}`, value: (row) => row.approvals },
{ id: "state", header: "State", width: 125, sortable: true, filterable: true, render: (row) => <StatusBadge status={row.state} />, value: (row) => row.state },
{ id: "expiry", header: "Expires", width: 175, sortable: true, filterable: true, filterType: "date", render: (row) => formatDateTime(row.expires_at), value: (row) => row.expires_at },
{ id: "actions", header: "Actions", width: 120, sticky: "end", align: "right", render: (row) => <TableActionGroup minimumSlots={2} actions={[
{ id: "approve", label: "Approve recovery", icon: <Check aria-hidden="true" />, variant: "primary", disabled: !canRecover || row.state !== "pending", onClick: () => openRecoveryDecision(row, "approve") },
{ id: "reject", label: "Reject recovery", icon: <X aria-hidden="true" />, variant: "danger", disabled: !canRecover || row.state !== "pending", onClick: () => openRecoveryDecision(row, "reject") }
]} /> }
], [canRecover]);
function openLifecycle(action: LifecycleAction, vault: VaultSummary) {
setLifecycle({ action, vault, reason: "", policyRef: vault.policy_ref, assuranceRef: "", effectiveAt: futureLocalTime(24) });
}
function openMigration(envelope: EnvelopeSummary) {
const target = vaults.find((vault) => vault.state === "active" && vault.current_key_version);
setMigration({ envelope, targetVaultId: target?.vault_id ?? "", mode: "rewrap", policyRef: "", assuranceRef: "" });
}
function openRecoveryDecision(recovery: RecoverySummary, decision: "approve" | "reject") {
setRecoveryDecision({ recovery, decision, reason: "", assuranceRef: "" });
}
async function submitVault() {
if (await perform(() => createVault(settings, { ...vaultDraft, idempotency_key: crypto.randomUUID() }), "The encryption vault was created.")) {
setCreating(false);
setVaultDraft(EMPTY_VAULT);
}
}
async function submitLifecycle() {
if (!lifecycle || !lifecycle.vault.current_key_version) return;
const common = { expected_revision: lifecycle.vault.revision, reason: lifecycle.reason.trim(), policy_decision_ref: lifecycle.policyRef.trim(), assurance_evidence_ref: lifecycle.assuranceRef.trim(), idempotency_key: crypto.randomUUID() };
const operation = lifecycle.action === "rotate"
? () => rotateVault(settings, lifecycle.vault.vault_id, common)
: () => changeVaultKey(settings, lifecycle.vault.vault_id, lifecycle.action, { ...common, key_version: lifecycle.vault.current_key_version!, effective_at: lifecycle.action === "destruction" ? new Date(lifecycle.effectiveAt).toISOString() : null });
if (await perform(operation, `Vault key ${humanize(lifecycle.action)} was recorded.`)) {
setLifecycle(null);
}
}
async function submitMigration() {
if (!migration) return;
const target = vaults.find((vault) => vault.vault_id === migration.targetVaultId);
if (!target?.current_key_version || !target.algorithm_suite) return;
if (await perform(() => requestMigration(settings, {
envelope_id: migration.envelope.envelope_id,
target_provider_id: target.provider_id,
target_vault_id: target.vault_id,
target_key_version: target.current_key_version!,
target_algorithm_suite: target.algorithm_suite!,
mode: migration.mode,
policy_decision_ref: migration.policyRef.trim(),
assurance_evidence_ref: migration.assuranceRef.trim(),
idempotency_key: crypto.randomUUID()
}), "The migration was authorized. The owning module must perform and confirm the content operation.")) {
setMigration(null);
}
}
async function submitRecovery() {
if (await perform(() => requestRecovery(settings, {
vault_id: recoveryDraft.vaultId,
reason: recoveryDraft.reason.trim(),
requested_scope: recoveryDraft.requestedScope.trim(),
policy_decision_ref: recoveryDraft.policyRef.trim(),
assurance_evidence_ref: recoveryDraft.assuranceRef.trim(),
idempotency_key: crypto.randomUUID(),
expires_at: new Date(recoveryDraft.expiresAt).toISOString()
}), "The recovery ceremony was requested. It releases no key material and changes no resource ownership.")) {
setRequestingRecovery(false);
}
}
async function submitRecoveryDecision() {
if (!recoveryDecision) return;
if (await perform(() => decideRecovery(settings, recoveryDecision.recovery.recovery_id, {
decision: recoveryDecision.decision,
reason: recoveryDecision.reason.trim(),
assurance_evidence_ref: recoveryDecision.assuranceRef.trim(),
expected_revision: recoveryDecision.recovery.revision,
idempotency_key: crypto.randomUUID()
}), `The recovery decision was recorded as ${recoveryDecision.decision}.`)) {
setRecoveryDecision(null);
}
}
const unresolved = preflight?.unresolved_count ?? envelopes.filter((item) => !["migrated", "decrypted", "exported", "destroyed"].includes(item.state)).length;
return <AdminPageLayout
title="Encryption"
description="Govern vault metadata, protection migrations, recovery quorum, and disable readiness without exposing cryptographic material."
loading={loading}
error={error}
success={success}
actions={<><Button onClick={() => void load()} disabled={busy}><RefreshCw aria-hidden="true" /> Reload</Button>{canAdmin && <Button variant="primary" onClick={() => setCreating(true)} disabled={busy}><Plus aria-hidden="true" /> Add vault</Button>}</>}>
<div className="metric-grid compact">
<MetricCard label="Vaults" value={vaults.length} tone="info" />
<MetricCard label="Protected envelopes" value={preflight?.protected_count ?? envelopes.length} tone="info" />
<MetricCard label="Unresolved before disable" value={unresolved} tone={unresolved ? "warning" : "good"} />
<MetricCard label="Pending recoveries" value={recoveries.filter((item) => item.state === "pending").length} tone={recoveries.some((item) => item.state === "pending") ? "warning" : "good"} />
</div>
<Card title="Key vaults" collapsible collapseKey="encryption-vaults">
<p className="muted small-note">The operator view contains lifecycle metadata only. Provider key references and key material are intentionally excluded.</p>
<div className="encryption-table"><DataGrid id="encryption-vaults" rows={vaults} columns={vaultColumns} initialFit="container" getRowKey={(row) => row.vault_id} emptyText="No encryption vaults are configured." /></div>
</Card>
{canAdmin && <>
<Card title="Protection envelopes" collapsible collapseKey="encryption-envelopes">
<p className="muted small-note">Encryption tracks protection state; the owning module retains content authorization, retention, and migration execution.</p>
<div className="encryption-table"><DataGrid id="encryption-envelopes" rows={envelopes} columns={envelopeColumns} initialFit="container" getRowKey={(row) => row.envelope_id} emptyText="No protection envelopes were registered." /></div>
</Card>
<Card title="Protection migrations" collapsible collapseKey="encryption-migrations">
<p className="muted small-note">Rewrap and re-encryption are two-phase operations. A request is not success until the owning module records evidence for the durable content change.</p>
<div className="encryption-table"><DataGrid id="encryption-migrations" rows={migrations} columns={migrationColumns} initialFit="container" getRowKey={(row) => row.migration_id} emptyText="No protection migrations were requested." /></div>
</Card>
<Card title="Disable preflight" collapsible collapseKey="encryption-disable-preflight">
<div className={`encryption-preflight ${preflight?.allowed ? "is-ready" : "is-blocked"}`}>
<ShieldAlert aria-hidden="true" />
<div><strong>{preflight?.allowed ? "Encryption can be disabled" : "Encryption cannot be disabled"}</strong><p>{preflight?.allowed ? "Every registered envelope has a terminal, evidenced disposition." : `${unresolved} envelope(s) still require migration, authorized decryption, explicit export, or cryptographic destruction.`}</p></div>
</div>
{preflight?.blocking_envelope_refs.length ? <ul className="encryption-blockers">{preflight.blocking_envelope_refs.map((item) => <li key={item}>{item}</li>)}</ul> : null}
</Card>
</>}
<Card title="Recovery ceremonies" actions={canRecover ? <Button variant="primary" onClick={() => { setRecoveryDraft((value) => ({ ...value, vaultId: value.vaultId || vaults[0]?.vault_id || "" })); setRequestingRecovery(true); }} disabled={!vaults.length || busy}><Plus aria-hidden="true" /> Request recovery</Button> : undefined}>
<p className="muted small-note">Recovery requires recent high assurance and distinct custodians. The requester cannot approve; quorum authorization neither releases keys nor transfers ownership.</p>
<div className="encryption-table"><DataGrid id="encryption-recoveries" rows={recoveries} columns={recoveryColumns} initialFit="container" getRowKey={(row) => row.recovery_id} emptyText="No recovery ceremonies were requested." /></div>
</Card>
<Dialog open={creating} title="Add encryption vault" onClose={() => !busy && setCreating(false)} closeDisabled={busy} footer={<><Button onClick={() => setCreating(false)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void submitVault()} disabled={busy || !vaultDraft.vault_id.trim() || !vaultDraft.name.trim() || !vaultDraft.policy_ref.trim()}>Create vault</Button></>}>
<div className="encryption-form-grid">
<FormField label="Vault ID"><input value={vaultDraft.vault_id} disabled={busy} onChange={(event) => setVaultDraft({ ...vaultDraft, vault_id: event.target.value })} /></FormField>
<FormField label="Name"><input value={vaultDraft.name} disabled={busy} onChange={(event) => setVaultDraft({ ...vaultDraft, name: event.target.value })} /></FormField>
<FormField label="Provider"><input value={vaultDraft.provider_id} disabled={busy} onChange={(event) => setVaultDraft({ ...vaultDraft, provider_id: event.target.value })} /></FormField>
<FormField label="Protection profile"><select value={vaultDraft.profile_kind} disabled={busy} onChange={(event) => setVaultDraft({ ...vaultDraft, profile_kind: event.target.value as VaultCreatePayload["profile_kind"] })}><option value="server_envelope">Server envelope</option><option value="tenant_held">Tenant-held</option><option value="end_to_end">End-to-end metadata only</option></select></FormField>
<FormField label="Algorithm suite"><input value={vaultDraft.algorithm_suite} disabled={busy} onChange={(event) => setVaultDraft({ ...vaultDraft, algorithm_suite: event.target.value })} /></FormField>
<FormField label="Recovery quorum"><input type="number" min={1} max={32} value={vaultDraft.recovery_quorum} disabled={busy} onChange={(event) => setVaultDraft({ ...vaultDraft, recovery_quorum: Number(event.target.value) })} /></FormField>
<FormField label="Purpose"><input value={vaultDraft.purpose} disabled={busy} onChange={(event) => setVaultDraft({ ...vaultDraft, purpose: event.target.value })} /></FormField>
<FormField label="Policy reference"><input value={vaultDraft.policy_ref} disabled={busy} onChange={(event) => setVaultDraft({ ...vaultDraft, policy_ref: event.target.value })} /></FormField>
</div>
<p className="muted small-note">Selecting end-to-end records a profile label only. It does not install or certify a client E2EE protocol.</p>
</Dialog>
<Dialog open={Boolean(lifecycle)} title={`${humanize(lifecycle?.action ?? "key")} vault key`} onClose={() => !busy && setLifecycle(null)} closeDisabled={busy} footer={<><Button onClick={() => setLifecycle(null)} disabled={busy}>Cancel</Button><Button variant={lifecycle?.action === "rotate" ? "primary" : "danger"} onClick={() => void submitLifecycle()} disabled={busy || !lifecycle?.reason.trim() || !lifecycle?.policyRef.trim() || !lifecycle?.assuranceRef.trim()}>Confirm {lifecycle?.action}</Button></>}>
{lifecycle && <>
<p>{lifecycleText(lifecycle.action)}</p>
<div className="encryption-form-grid">
<FormField label="Reason"><textarea rows={3} value={lifecycle.reason} disabled={busy} onChange={(event) => setLifecycle({ ...lifecycle, reason: event.target.value })} /></FormField>
<FormField label="Policy decision reference"><input value={lifecycle.policyRef} disabled={busy} onChange={(event) => setLifecycle({ ...lifecycle, policyRef: event.target.value })} /></FormField>
<FormField label="High-assurance evidence reference"><input value={lifecycle.assuranceRef} disabled={busy} onChange={(event) => setLifecycle({ ...lifecycle, assuranceRef: event.target.value })} /></FormField>
{lifecycle.action === "destruction" && <FormField label="Destruction effective at"><input type="datetime-local" value={lifecycle.effectiveAt} disabled={busy} onChange={(event) => setLifecycle({ ...lifecycle, effectiveAt: event.target.value })} /></FormField>}
</div>
</>}
</Dialog>
<Dialog open={Boolean(migration)} title="Prepare protection migration" onClose={() => !busy && setMigration(null)} closeDisabled={busy} footer={<><Button onClick={() => setMigration(null)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void submitMigration()} disabled={busy || !migration?.targetVaultId || !migration?.policyRef.trim() || !migration?.assuranceRef.trim()}>Authorize migration</Button></>}>
{migration && <>
<p>This authorizes a two-phase content operation. The owning module must durably update or dispose of its content and record evidence before the migration can succeed.</p>
<div className="encryption-form-grid">
<FormField label="Source envelope"><input value={migration.envelope.envelope_id} disabled /></FormField>
<FormField label="Mode"><select value={migration.mode} disabled={busy} onChange={(event) => setMigration({ ...migration, mode: event.target.value as MigrationDraft["mode"] })}><option value="rewrap">Rewrap</option><option value="reencrypt">Re-encrypt</option><option value="decrypt">Decrypt</option><option value="export">Export</option><option value="destroy">Destroy</option></select></FormField>
<FormField label="Target vault"><select value={migration.targetVaultId} disabled={busy} onChange={(event) => setMigration({ ...migration, targetVaultId: event.target.value })}>{vaults.filter((vault) => vault.current_key_version && vault.state === "active").map((vault) => <option key={vault.vault_id} value={vault.vault_id}>{vault.name} · v{vault.current_key_version}</option>)}</select></FormField>
<FormField label="Policy decision reference"><input value={migration.policyRef} disabled={busy} onChange={(event) => setMigration({ ...migration, policyRef: event.target.value })} /></FormField>
<FormField label="High-assurance evidence reference"><input value={migration.assuranceRef} disabled={busy} onChange={(event) => setMigration({ ...migration, assuranceRef: event.target.value })} /></FormField>
</div>
</>}
</Dialog>
<Dialog open={requestingRecovery} title="Request recovery ceremony" onClose={() => !busy && setRequestingRecovery(false)} closeDisabled={busy} footer={<><Button onClick={() => setRequestingRecovery(false)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void submitRecovery()} disabled={busy || !recoveryDraft.vaultId || !recoveryDraft.reason.trim() || !recoveryDraft.requestedScope.trim() || !recoveryDraft.policyRef.trim() || !recoveryDraft.assuranceRef.trim()}>Request recovery</Button></>}>
<p>The request expires automatically and needs the vault's configured number of distinct custodians. You cannot approve your own request.</p>
<div className="encryption-form-grid">
<FormField label="Vault"><select value={recoveryDraft.vaultId} disabled={busy} onChange={(event) => setRecoveryDraft({ ...recoveryDraft, vaultId: event.target.value })}>{vaults.map((vault) => <option key={vault.vault_id} value={vault.vault_id}>{vault.name}</option>)}</select></FormField>
<FormField label="Requested scope"><input value={recoveryDraft.requestedScope} disabled={busy} onChange={(event) => setRecoveryDraft({ ...recoveryDraft, requestedScope: event.target.value })} /></FormField>
<FormField label="Reason"><textarea rows={3} value={recoveryDraft.reason} disabled={busy} onChange={(event) => setRecoveryDraft({ ...recoveryDraft, reason: event.target.value })} /></FormField>
<FormField label="Expires at"><input type="datetime-local" value={recoveryDraft.expiresAt} disabled={busy} onChange={(event) => setRecoveryDraft({ ...recoveryDraft, expiresAt: event.target.value })} /></FormField>
<FormField label="Policy decision reference"><input value={recoveryDraft.policyRef} disabled={busy} onChange={(event) => setRecoveryDraft({ ...recoveryDraft, policyRef: event.target.value })} /></FormField>
<FormField label="High-assurance evidence reference"><input value={recoveryDraft.assuranceRef} disabled={busy} onChange={(event) => setRecoveryDraft({ ...recoveryDraft, assuranceRef: event.target.value })} /></FormField>
</div>
</Dialog>
<Dialog open={Boolean(recoveryDecision)} title={`${humanize(recoveryDecision?.decision ?? "decide")} recovery`} onClose={() => !busy && setRecoveryDecision(null)} closeDisabled={busy} footer={<><Button onClick={() => setRecoveryDecision(null)} disabled={busy}>Cancel</Button><Button variant={recoveryDecision?.decision === "reject" ? "danger" : "primary"} onClick={() => void submitRecoveryDecision()} disabled={busy || !recoveryDecision?.reason.trim() || !recoveryDecision?.assuranceRef.trim()}>Record {recoveryDecision?.decision}</Button></>}>
{recoveryDecision && <>
<p>{recoveryDecision.decision === "approve" ? "Approval counts only if you are a distinct, high-assurance custodian. Quorum authorization still releases no key material." : "One rejection terminates this recovery ceremony. A new recovery needs a new governed request."}</p>
<FormField label="Reason"><textarea rows={4} value={recoveryDecision.reason} disabled={busy} onChange={(event) => setRecoveryDecision({ ...recoveryDecision, reason: event.target.value })} /></FormField>
<FormField label="High-assurance evidence reference"><input value={recoveryDecision.assuranceRef} disabled={busy} onChange={(event) => setRecoveryDecision({ ...recoveryDecision, assuranceRef: event.target.value })} /></FormField>
</>}
</Dialog>
</AdminPageLayout>;
}
function lifecycleText(action: LifecycleAction): string {
if (action === "rotate") return "Rotation creates a new current key version. Existing envelopes remain bound to their recorded key version until explicitly migrated.";
if (action === "revoke") return "Revocation blocks future provider use. It cannot recall plaintext or key material already obtained, and protected content may become unavailable.";
return "Destruction is irreversible once the provider performs it. Any content still bound to this key becomes permanently unreadable unless it was migrated first.";
}
function futureLocalTime(hours: number): string {
const value = new Date(Date.now() + hours * 60 * 60 * 1000);
value.setMinutes(value.getMinutes() - value.getTimezoneOffset());
return value.toISOString().slice(0, 16);
}
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);
}
+1
View File
@@ -0,0 +1 @@
export { encryptionModule as default, encryptionModule } from "./module";
+38
View File
@@ -0,0 +1,38 @@
import { createElement, lazy } from "react";
import type { AdminSectionsUiCapability, PlatformWebModule } from "@govoplan/core-webui";
import "./styles/encryption.css";
const EncryptionAdminPanel = lazy(() => import("./features/EncryptionAdminPanel"));
const adminSections: AdminSectionsUiCapability = {
sections: [
{
id: "tenant-encryption",
moduleId: "encryption",
kind: "management",
surfaceId: "encryption.admin.operations",
label: "Encryption",
group: "TENANT",
order: 78,
anyOf: ["encryption:vault:admin", "encryption:recovery:approve"],
render: ({ settings, auth }) => createElement(EncryptionAdminPanel, { settings, auth })
}
]
};
export const encryptionModule: PlatformWebModule = {
id: "encryption",
label: "Encryption",
version: "0.1.14",
dependencies: [],
optionalDependencies: ["access", "audit", "policy", "identity_trust", "notifications"],
viewSurfaces: [
{ id: "encryption.admin.operations", moduleId: "encryption", kind: "section", label: "Encryption administration", order: 10 },
{ id: "encryption.admin.vaults", moduleId: "encryption", kind: "section", label: "Key vaults", parentId: "encryption.admin.operations", order: 20 },
{ id: "encryption.admin.migrations", moduleId: "encryption", kind: "section", label: "Protection migrations", parentId: "encryption.admin.operations", order: 30 },
{ id: "encryption.admin.recovery", moduleId: "encryption", kind: "section", label: "Recovery ceremonies", parentId: "encryption.admin.operations", order: 40 }
],
uiCapabilities: { "admin.sections": adminSections }
};
export default encryptionModule;
+56
View File
@@ -0,0 +1,56 @@
.encryption-table {
min-height: 10rem;
max-height: 25rem;
overflow: auto;
}
.encryption-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.85rem 1rem;
}
.encryption-form-grid .form-field:has(textarea) {
grid-column: 1 / -1;
}
.encryption-preflight {
display: flex;
align-items: flex-start;
gap: 0.8rem;
padding: 0.85rem 0;
border-top: 2px solid var(--color-warning, #c38b22);
}
.encryption-preflight.is-ready {
border-top-color: var(--color-success, #43885b);
}
.encryption-preflight svg {
width: 1.35rem;
height: 1.35rem;
flex: none;
}
.encryption-preflight p {
margin: 0.25rem 0 0;
}
.encryption-blockers {
max-height: 9rem;
overflow: auto;
margin: 0;
padding-left: 1.25rem;
font-family: var(--font-mono, monospace);
font-size: 0.82rem;
}
@media (max-width: 760px) {
.encryption-form-grid {
grid-template-columns: minmax(0, 1fr);
}
.encryption-form-grid .form-field:has(textarea) {
grid-column: auto;
}
}
@@ -0,0 +1,27 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
const moduleSource = readFileSync("src/module.ts", "utf8");
const panel = readFileSync("src/features/EncryptionAdminPanel.tsx", "utf8");
const api = readFileSync("src/api/encryption.ts", "utf8");
assert.match(moduleSource, /"admin.sections": adminSections/);
assert.match(moduleSource, /encryption\.admin\.vaults/);
assert.match(moduleSource, /encryption\.admin\.migrations/);
assert.match(moduleSource, /encryption\.admin\.recovery/);
assert.match(panel, /<AdminPageLayout/);
assert.match(panel, /<DataGrid/);
assert.match(panel, /<StatusBadge/);
assert.match(panel, /<TableActionGroup/);
assert.match(panel, /cannot recall plaintext/);
assert.match(panel, /distinct custodians/);
assert.match(panel, /owning module must durably update/);
assert.match(panel, /Encryption cannot be disabled/);
assert.doesNotMatch(panel, /provider_key_ref/);
assert.doesNotMatch(panel, /wrapped_key_refs/);
assert.doesNotMatch(api, /provider_key_ref/);
assert.doesNotMatch(api, /wrapped_key_refs/);
assert.match(api, /expected_revision/);
assert.match(api, /disable-preflight/);
console.log("Encryption administration UI structural contract passed.");