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
|
- resumable, evidence-backed rewrap, re-encryption, decrypt, export, and destroy
|
||||||
state transitions;
|
state transitions;
|
||||||
- recent high-assurance, distinct-custodian quorum recovery authorization;
|
- 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;
|
- typed APIs, audit-safe events, Alembic migration, and uninstall blocking;
|
||||||
- a bundled `local_aesgcm` server-envelope provider using AES-256-GCM and
|
- a bundled `local_aesgcm` server-envelope provider using AES-256-GCM and
|
||||||
SQL-persisted wrapped vault/content keys;
|
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
|
ownership. Access approval, resource ownership, Identity Trust, and key custody
|
||||||
are separate decisions.
|
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
|
See [docs/ENCRYPTION_BOUNDARY.md](docs/ENCRYPTION_BOUNDARY.md) for the threat
|
||||||
model, profile consequences, algorithms, recovery, and disable semantics.
|
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.
|
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
|
## Assets and Threat Actors
|
||||||
|
|
||||||
Protected assets include content plaintext, data-encryption keys, wrapping keys,
|
Protected assets include content plaintext, data-encryption keys, wrapping keys,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from govoplan_core.core.modules import (
|
|||||||
CapabilityDocumentation,
|
CapabilityDocumentation,
|
||||||
DocumentationLink,
|
DocumentationLink,
|
||||||
DocumentationTopic,
|
DocumentationTopic,
|
||||||
|
FrontendModule,
|
||||||
MigrationSpec,
|
MigrationSpec,
|
||||||
ModuleContext,
|
ModuleContext,
|
||||||
ModuleInterfaceProvider,
|
ModuleInterfaceProvider,
|
||||||
@@ -26,6 +27,7 @@ from govoplan_core.core.modules import (
|
|||||||
ModuleUninstallGuardResult,
|
ModuleUninstallGuardResult,
|
||||||
PermissionDefinition,
|
PermissionDefinition,
|
||||||
RoleTemplate,
|
RoleTemplate,
|
||||||
|
ViewSurface,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.provider_governance import declared_module_architecture
|
from govoplan_core.core.provider_governance import declared_module_architecture
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
@@ -190,6 +192,43 @@ manifest = ModuleManifest(
|
|||||||
permissions=PERMISSIONS,
|
permissions=PERMISSIONS,
|
||||||
role_templates=ROLE_TEMPLATES,
|
role_templates=ROLE_TEMPLATES,
|
||||||
route_factory=_router,
|
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_factories={
|
||||||
CAPABILITY_ENCRYPTION_KEY_VAULT: _service,
|
CAPABILITY_ENCRYPTION_KEY_VAULT: _service,
|
||||||
CAPABILITY_ENCRYPTION_CONTENT_PROTECTION: _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(
|
architecture=declared_module_architecture(
|
||||||
layer="institutional_foundation",
|
layer="institutional_foundation",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from dataclasses import asdict
|
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 sqlalchemy.orm import Session
|
||||||
|
|
||||||
from govoplan_core.audit.logging import audit_event
|
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_core.db.session import get_session
|
||||||
from govoplan_encryption.backend.schemas import (
|
from govoplan_encryption.backend.schemas import (
|
||||||
DisablePreflightResponse,
|
DisablePreflightResponse,
|
||||||
|
EnvelopeOperationalListResponse,
|
||||||
|
EnvelopeOperationalSummaryResponse,
|
||||||
EnvelopePayload,
|
EnvelopePayload,
|
||||||
EnvelopeRegistrationPayload,
|
EnvelopeRegistrationPayload,
|
||||||
EnvelopeResponse,
|
EnvelopeResponse,
|
||||||
KeyLifecyclePayload,
|
KeyLifecyclePayload,
|
||||||
KeyRotationPayload,
|
KeyRotationPayload,
|
||||||
MigrationOutcomePayload,
|
MigrationOutcomePayload,
|
||||||
|
MigrationOperationalListResponse,
|
||||||
|
MigrationOperationalSummaryResponse,
|
||||||
MigrationRequestPayload,
|
MigrationRequestPayload,
|
||||||
MigrationResponse,
|
MigrationResponse,
|
||||||
RecoveryDecisionPayload,
|
RecoveryDecisionPayload,
|
||||||
|
RecoveryOperationalListResponse,
|
||||||
|
RecoveryOperationalSummaryResponse,
|
||||||
RecoveryRequestPayload,
|
RecoveryRequestPayload,
|
||||||
RecoveryResponse,
|
RecoveryResponse,
|
||||||
VaultCreatePayload,
|
VaultCreatePayload,
|
||||||
|
VaultOperationalListResponse,
|
||||||
|
VaultOperationalSummaryResponse,
|
||||||
VaultResponse,
|
VaultResponse,
|
||||||
)
|
)
|
||||||
from govoplan_encryption.backend.service import EncryptionError, SqlEncryptionService
|
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"])
|
router = APIRouter(prefix="/encryption", tags=["encryption"])
|
||||||
service = SqlEncryptionService(registry)
|
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)
|
@router.post("/vaults", response_model=VaultResponse)
|
||||||
def create_vault(
|
def create_vault(
|
||||||
payload: VaultCreatePayload,
|
payload: VaultCreatePayload,
|
||||||
@@ -173,6 +203,32 @@ def create_router(registry: object | None = None) -> APIRouter:
|
|||||||
session.commit()
|
session.commit()
|
||||||
return _vault_response(value)
|
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)
|
@router.post("/envelopes", response_model=EnvelopeResponse)
|
||||||
def register_envelope(
|
def register_envelope(
|
||||||
payload: EnvelopeRegistrationPayload,
|
payload: EnvelopeRegistrationPayload,
|
||||||
@@ -226,6 +282,28 @@ def create_router(registry: object | None = None) -> APIRouter:
|
|||||||
)
|
)
|
||||||
return _envelope_response(value)
|
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)
|
@router.post("/migrations", response_model=MigrationResponse)
|
||||||
def request_migration(
|
def request_migration(
|
||||||
payload: MigrationRequestPayload,
|
payload: MigrationRequestPayload,
|
||||||
@@ -289,6 +367,53 @@ def create_router(registry: object | None = None) -> APIRouter:
|
|||||||
session.commit()
|
session.commit()
|
||||||
return _migration_response(value)
|
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)
|
@router.post("/recoveries", response_model=RecoveryResponse)
|
||||||
def request_recovery(
|
def request_recovery(
|
||||||
payload: RecoveryRequestPayload,
|
payload: RecoveryRequestPayload,
|
||||||
|
|||||||
@@ -84,6 +84,30 @@ class VaultResponse(BaseModel):
|
|||||||
contract_version: str = "1"
|
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):
|
class EnvelopePayload(BaseModel):
|
||||||
envelope_id: str = Field(min_length=1, max_length=255)
|
envelope_id: str = Field(min_length=1, max_length=255)
|
||||||
owner_module: str = Field(min_length=1, max_length=120)
|
owner_module: str = Field(min_length=1, max_length=120)
|
||||||
@@ -126,6 +150,27 @@ class EnvelopeResponse(EnvelopePayload):
|
|||||||
contract_version: str = "1"
|
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):
|
class MigrationRequestPayload(BaseModel):
|
||||||
envelope_id: str = Field(min_length=1, max_length=255)
|
envelope_id: str = Field(min_length=1, max_length=255)
|
||||||
target_provider_id: str = Field(min_length=1, max_length=120)
|
target_provider_id: str = Field(min_length=1, max_length=120)
|
||||||
@@ -156,6 +201,31 @@ class MigrationResponse(BaseModel):
|
|||||||
contract_version: str = "1"
|
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):
|
class RecoveryRequestPayload(BaseModel):
|
||||||
vault_id: str = Field(min_length=1, max_length=255)
|
vault_id: str = Field(min_length=1, max_length=255)
|
||||||
reason: str = Field(min_length=1, max_length=2000)
|
reason: str = Field(min_length=1, max_length=2000)
|
||||||
@@ -191,6 +261,30 @@ class RecoveryResponse(BaseModel):
|
|||||||
contract_version: str = "1"
|
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):
|
class DisablePreflightResponse(BaseModel):
|
||||||
allowed: bool
|
allowed: bool
|
||||||
protected_count: int
|
protected_count: int
|
||||||
@@ -206,15 +300,23 @@ __all__ = [
|
|||||||
"DisablePreflightResponse",
|
"DisablePreflightResponse",
|
||||||
"EnvelopePayload",
|
"EnvelopePayload",
|
||||||
"EnvelopeRegistrationPayload",
|
"EnvelopeRegistrationPayload",
|
||||||
|
"EnvelopeOperationalListResponse",
|
||||||
|
"EnvelopeOperationalSummaryResponse",
|
||||||
"EnvelopeResponse",
|
"EnvelopeResponse",
|
||||||
"KeyLifecyclePayload",
|
"KeyLifecyclePayload",
|
||||||
"KeyRotationPayload",
|
"KeyRotationPayload",
|
||||||
"MigrationOutcomePayload",
|
"MigrationOutcomePayload",
|
||||||
|
"MigrationOperationalListResponse",
|
||||||
|
"MigrationOperationalSummaryResponse",
|
||||||
"MigrationRequestPayload",
|
"MigrationRequestPayload",
|
||||||
"MigrationResponse",
|
"MigrationResponse",
|
||||||
"RecoveryDecisionPayload",
|
"RecoveryDecisionPayload",
|
||||||
|
"RecoveryOperationalListResponse",
|
||||||
|
"RecoveryOperationalSummaryResponse",
|
||||||
"RecoveryRequestPayload",
|
"RecoveryRequestPayload",
|
||||||
"RecoveryResponse",
|
"RecoveryResponse",
|
||||||
"VaultCreatePayload",
|
"VaultCreatePayload",
|
||||||
|
"VaultOperationalListResponse",
|
||||||
|
"VaultOperationalSummaryResponse",
|
||||||
"VaultResponse",
|
"VaultResponse",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -2,13 +2,14 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import and_, func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from govoplan_core.core.encryption import (
|
from govoplan_core.core.encryption import (
|
||||||
@@ -61,6 +62,88 @@ class EncryptionError(ValueError):
|
|||||||
pass
|
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:
|
class SqlEncryptionService:
|
||||||
"""Provider-neutral encryption metadata and recovery orchestration.
|
"""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
|
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(
|
def reconcile_vault(
|
||||||
self,
|
self,
|
||||||
session: object,
|
session: object,
|
||||||
@@ -848,6 +982,54 @@ class SqlEncryptionService:
|
|||||||
)
|
)
|
||||||
return _envelope_ref(item) if item is not None else None
|
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(
|
def request_migration(
|
||||||
self,
|
self,
|
||||||
session: object,
|
session: object,
|
||||||
@@ -924,6 +1106,49 @@ class SqlEncryptionService:
|
|||||||
db.flush()
|
db.flush()
|
||||||
return self._migration_ref(db, item)
|
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(
|
def record_migration_outcome(
|
||||||
self,
|
self,
|
||||||
session: object,
|
session: object,
|
||||||
@@ -1092,6 +1317,70 @@ class SqlEncryptionService:
|
|||||||
db.flush()
|
db.flush()
|
||||||
return self._recovery_ref(db, item)
|
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(
|
def decide_recovery(
|
||||||
self,
|
self,
|
||||||
session: object,
|
session: object,
|
||||||
@@ -2007,6 +2296,10 @@ def _as_utc(value: datetime) -> datetime:
|
|||||||
return value.astimezone(timezone.utc)
|
return value.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _bounded_limit(value: int) -> int:
|
||||||
|
return max(1, min(int(value), 500))
|
||||||
|
|
||||||
|
|
||||||
def _exception_code(exc: Exception) -> str:
|
def _exception_code(exc: Exception) -> str:
|
||||||
return f"provider_{type(exc).__name__.lower()}"[:255]
|
return f"provider_{type(exc).__name__.lower()}"[:255]
|
||||||
|
|
||||||
|
|||||||
@@ -377,6 +377,79 @@ class EncryptionTests(unittest.TestCase):
|
|||||||
self.assertEqual(2, second.approvals)
|
self.assertEqual(2, second.approvals)
|
||||||
self.assertFalse(second.provenance["key_material_released"])
|
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:
|
def test_contract_rejects_secret_bearing_envelope_metadata(self) -> None:
|
||||||
with self.assertRaisesRegex(ValueError, "secret material"):
|
with self.assertRaisesRegex(ValueError, "secret material"):
|
||||||
replace(
|
replace(
|
||||||
|
|||||||
+16
-2
@@ -16,7 +16,7 @@ from govoplan_encryption.backend.local_provider import LOCAL_PROVIDER_ID
|
|||||||
|
|
||||||
|
|
||||||
class EncryptionManifestTests(unittest.TestCase):
|
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()
|
manifest = get_manifest()
|
||||||
|
|
||||||
self.assertEqual("encryption", manifest.id)
|
self.assertEqual("encryption", manifest.id)
|
||||||
@@ -35,7 +35,21 @@ class EncryptionManifestTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertIsNotNone(manifest.route_factory)
|
self.assertIsNotNone(manifest.route_factory)
|
||||||
self.assertIsNotNone(manifest.migration_spec)
|
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)
|
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