Add governed encryption administration
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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) });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { encryptionModule as default, encryptionModule } from "./module";
|
||||
@@ -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;
|
||||
@@ -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.");
|
||||
Reference in New Issue
Block a user