Add governed encryption administration

This commit is contained in:
2026-08-04 01:27:52 +02:00
parent 42f35f8d00
commit ba92d8bf32
15 changed files with 1472 additions and 4 deletions
@@ -19,6 +19,7 @@ from govoplan_core.core.modules import (
CapabilityDocumentation,
DocumentationLink,
DocumentationTopic,
FrontendModule,
MigrationSpec,
ModuleContext,
ModuleInterfaceProvider,
@@ -26,6 +27,7 @@ from govoplan_core.core.modules import (
ModuleUninstallGuardResult,
PermissionDefinition,
RoleTemplate,
ViewSurface,
)
from govoplan_core.core.provider_governance import declared_module_architecture
from govoplan_core.db.base import Base
@@ -190,6 +192,43 @@ manifest = ModuleManifest(
permissions=PERMISSIONS,
role_templates=ROLE_TEMPLATES,
route_factory=_router,
frontend=FrontendModule(
module_id=MODULE_ID,
package_name="@govoplan/encryption-webui",
view_surfaces=(
ViewSurface(
id="encryption.admin.operations",
module_id=MODULE_ID,
kind="section",
label="Encryption administration",
order=10,
),
ViewSurface(
id="encryption.admin.vaults",
module_id=MODULE_ID,
kind="section",
label="Key vaults",
parent_id="encryption.admin.operations",
order=20,
),
ViewSurface(
id="encryption.admin.migrations",
module_id=MODULE_ID,
kind="section",
label="Protection migrations",
parent_id="encryption.admin.operations",
order=30,
),
ViewSurface(
id="encryption.admin.recovery",
module_id=MODULE_ID,
kind="section",
label="Recovery ceremonies",
parent_id="encryption.admin.operations",
order=40,
),
),
),
capability_factories={
CAPABILITY_ENCRYPTION_KEY_VAULT: _service,
CAPABILITY_ENCRYPTION_CONTENT_PROTECTION: _service,
@@ -319,6 +358,50 @@ manifest = ModuleManifest(
),
),
),
DocumentationTopic(
id="encryption.administration",
title="Administer encryption operations",
summary=(
"Inspect safe vault and envelope metadata, govern key lifecycle, "
"coordinate migrations, and verify disable readiness."
),
body=(
"Encryption administration exposes bounded tenant metadata but "
"never provider key references, wrapped keys, ciphertext locations, "
"or plaintext. Rotation creates a new current version while existing "
"envelopes remain version-bound. Revocation and destruction cannot "
"recall material already obtained and can make content unavailable. "
"Migrations remain two-phase: the owning module performs the durable "
"content operation and records evidence before success. Disable "
"preflight blocks until every envelope has a terminal disposition."
),
layer="available",
documentation_types=("admin",),
audience=("administrator", "security_officer", "auditor"),
related_modules=OPTIONAL_DEPENDENCIES,
order=110,
),
DocumentationTopic(
id="encryption.recovery",
title="Run an encryption recovery ceremony",
summary=(
"Request and decide time-bounded recovery with high assurance and "
"a distinct-custodian quorum."
),
body=(
"A recovery requester supplies policy and recent high-assurance "
"evidence and cannot approve the same ceremony. Each custodian can "
"decide once; a rejection terminates the request and approvals must "
"reach the vault quorum before expiry. Approval authorizes a later "
"provider operation. It does not release key material, transfer "
"resource ownership, or prove that recovery execution succeeded."
),
layer="available",
documentation_types=("admin", "user"),
audience=("administrator", "security_officer", "auditor"),
related_modules=("identity_trust", "access", "audit", "policy"),
order=120,
),
),
architecture=declared_module_architecture(
layer="institutional_foundation",
+126 -1
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from dataclasses import asdict
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from govoplan_core.audit.logging import audit_event
@@ -20,18 +20,26 @@ from govoplan_core.core.encryption import (
from govoplan_core.db.session import get_session
from govoplan_encryption.backend.schemas import (
DisablePreflightResponse,
EnvelopeOperationalListResponse,
EnvelopeOperationalSummaryResponse,
EnvelopePayload,
EnvelopeRegistrationPayload,
EnvelopeResponse,
KeyLifecyclePayload,
KeyRotationPayload,
MigrationOutcomePayload,
MigrationOperationalListResponse,
MigrationOperationalSummaryResponse,
MigrationRequestPayload,
MigrationResponse,
RecoveryDecisionPayload,
RecoveryOperationalListResponse,
RecoveryOperationalSummaryResponse,
RecoveryRequestPayload,
RecoveryResponse,
VaultCreatePayload,
VaultOperationalListResponse,
VaultOperationalSummaryResponse,
VaultResponse,
)
from govoplan_encryption.backend.service import EncryptionError, SqlEncryptionService
@@ -41,6 +49,28 @@ def create_router(registry: object | None = None) -> APIRouter:
router = APIRouter(prefix="/encryption", tags=["encryption"])
service = SqlEncryptionService(registry)
@router.get("/vaults", response_model=VaultOperationalListResponse)
def list_vaults(
state_filter: str | None = Query(default=None, alias="state", max_length=40),
limit: int = Query(default=100, ge=1, le=500),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> VaultOperationalListResponse:
_require(principal, "encryption:vault:admin", "encryption:recovery:approve")
values = service.list_vaults(
session,
principal,
tenant_id=principal.tenant_id,
state=state_filter,
limit=limit,
)
return VaultOperationalListResponse(
items=[
VaultOperationalSummaryResponse(**_serializable(value))
for value in values
]
)
@router.post("/vaults", response_model=VaultResponse)
def create_vault(
payload: VaultCreatePayload,
@@ -173,6 +203,32 @@ def create_router(registry: object | None = None) -> APIRouter:
session.commit()
return _vault_response(value)
@router.get("/envelopes", response_model=EnvelopeOperationalListResponse)
def list_envelopes(
vault_id: str | None = Query(default=None, max_length=255),
owner_module: str | None = Query(default=None, max_length=120),
state_filter: str | None = Query(default=None, alias="state", max_length=40),
limit: int = Query(default=100, ge=1, le=500),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> EnvelopeOperationalListResponse:
_require(principal, "encryption:vault:admin")
values = service.list_envelopes(
session,
principal,
tenant_id=principal.tenant_id,
vault_id=vault_id,
owner_module=owner_module,
state=state_filter,
limit=limit,
)
return EnvelopeOperationalListResponse(
items=[
EnvelopeOperationalSummaryResponse(**_serializable(value))
for value in values
]
)
@router.post("/envelopes", response_model=EnvelopeResponse)
def register_envelope(
payload: EnvelopeRegistrationPayload,
@@ -226,6 +282,28 @@ def create_router(registry: object | None = None) -> APIRouter:
)
return _envelope_response(value)
@router.get("/migrations", response_model=MigrationOperationalListResponse)
def list_migrations(
state_filter: str | None = Query(default=None, alias="state", max_length=40),
limit: int = Query(default=100, ge=1, le=500),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> MigrationOperationalListResponse:
_require(principal, "encryption:vault:admin")
values = service.list_migrations(
session,
principal,
tenant_id=principal.tenant_id,
state=state_filter,
limit=limit,
)
return MigrationOperationalListResponse(
items=[
MigrationOperationalSummaryResponse(**_serializable(value))
for value in values
]
)
@router.post("/migrations", response_model=MigrationResponse)
def request_migration(
payload: MigrationRequestPayload,
@@ -289,6 +367,53 @@ def create_router(registry: object | None = None) -> APIRouter:
session.commit()
return _migration_response(value)
@router.post("/migrations/{migration_id}/reconcile", response_model=MigrationResponse)
def reconcile_migration(
migration_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> MigrationResponse:
_require(principal, "encryption:vault:admin")
value = _call(
lambda: service.reconcile_migration(
session,
principal,
migration_id=migration_id,
)
)
_audit(
session,
principal,
"encryption.migration.reconciled",
"protection_migration",
migration_id,
{"state": value.state},
)
session.commit()
return _migration_response(value)
@router.get("/recoveries", response_model=RecoveryOperationalListResponse)
def list_recoveries(
state_filter: str | None = Query(default=None, alias="state", max_length=40),
limit: int = Query(default=100, ge=1, le=500),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> RecoveryOperationalListResponse:
_require(principal, "encryption:vault:admin", "encryption:recovery:approve")
values = service.list_recoveries(
session,
principal,
tenant_id=principal.tenant_id,
state=state_filter,
limit=limit,
)
return RecoveryOperationalListResponse(
items=[
RecoveryOperationalSummaryResponse(**_serializable(value))
for value in values
]
)
@router.post("/recoveries", response_model=RecoveryResponse)
def request_recovery(
payload: RecoveryRequestPayload,
+102
View File
@@ -84,6 +84,30 @@ class VaultResponse(BaseModel):
contract_version: str = "1"
class VaultOperationalSummaryResponse(BaseModel):
tenant_id: str
vault_id: str
name: str
provider_id: str
purpose: str
profile_kind: str
scope_type: str
scope_id: str | None = None
policy_ref: str
recovery_quorum: int
state: str
revision: int
current_key_version: int | None = None
current_key_state: str | None = None
algorithm_suite: str | None = None
created_at: datetime
updated_at: datetime
class VaultOperationalListResponse(BaseModel):
items: list[VaultOperationalSummaryResponse]
class EnvelopePayload(BaseModel):
envelope_id: str = Field(min_length=1, max_length=255)
owner_module: str = Field(min_length=1, max_length=120)
@@ -126,6 +150,27 @@ class EnvelopeResponse(EnvelopePayload):
contract_version: str = "1"
class EnvelopeOperationalSummaryResponse(BaseModel):
envelope_id: str
tenant_id: str
owner_module: str
resource_type: str
resource_id: str
profile_kind: str
provider_id: str
vault_id: str
key_version: int
algorithm_suite: str
state: str
migration_id: str | None = None
created_at: datetime
updated_at: datetime
class EnvelopeOperationalListResponse(BaseModel):
items: list[EnvelopeOperationalSummaryResponse]
class MigrationRequestPayload(BaseModel):
envelope_id: str = Field(min_length=1, max_length=255)
target_provider_id: str = Field(min_length=1, max_length=120)
@@ -156,6 +201,31 @@ class MigrationResponse(BaseModel):
contract_version: str = "1"
class MigrationOperationalSummaryResponse(BaseModel):
migration_id: str
tenant_id: str
source_envelope_id: str
target_envelope_id: str | None = None
target_provider_id: str
target_vault_id: str
target_key_version: int
target_algorithm_suite: str
mode: str
state: str
policy_decision_ref: str
assurance_evidence_ref: str
evidence_refs: list[str] = Field(default_factory=list)
error_code: str | None = None
requested_by: str
created_at: datetime
updated_at: datetime
completed_at: datetime | None = None
class MigrationOperationalListResponse(BaseModel):
items: list[MigrationOperationalSummaryResponse]
class RecoveryRequestPayload(BaseModel):
vault_id: str = Field(min_length=1, max_length=255)
reason: str = Field(min_length=1, max_length=2000)
@@ -191,6 +261,30 @@ class RecoveryResponse(BaseModel):
contract_version: str = "1"
class RecoveryOperationalSummaryResponse(BaseModel):
recovery_id: str
tenant_id: str
vault_id: str
state: str
requested_scope: str
reason: str
quorum: int
approvals: int
rejections: int
revision: int
policy_decision_ref: str
requester_account_id: str
requester_assurance_ref: str
expires_at: datetime
execution_ref: str | None = None
created_at: datetime
updated_at: datetime
class RecoveryOperationalListResponse(BaseModel):
items: list[RecoveryOperationalSummaryResponse]
class DisablePreflightResponse(BaseModel):
allowed: bool
protected_count: int
@@ -206,15 +300,23 @@ __all__ = [
"DisablePreflightResponse",
"EnvelopePayload",
"EnvelopeRegistrationPayload",
"EnvelopeOperationalListResponse",
"EnvelopeOperationalSummaryResponse",
"EnvelopeResponse",
"KeyLifecyclePayload",
"KeyRotationPayload",
"MigrationOutcomePayload",
"MigrationOperationalListResponse",
"MigrationOperationalSummaryResponse",
"MigrationRequestPayload",
"MigrationResponse",
"RecoveryDecisionPayload",
"RecoveryOperationalListResponse",
"RecoveryOperationalSummaryResponse",
"RecoveryRequestPayload",
"RecoveryResponse",
"VaultCreatePayload",
"VaultOperationalListResponse",
"VaultOperationalSummaryResponse",
"VaultResponse",
]
+294 -1
View File
@@ -2,13 +2,14 @@ from __future__ import annotations
from collections import Counter
from collections.abc import Mapping
from dataclasses import dataclass
from datetime import datetime, timezone
import hashlib
import json
from types import SimpleNamespace
import uuid
from sqlalchemy import func, select
from sqlalchemy import and_, func, select
from sqlalchemy.orm import Session
from govoplan_core.core.encryption import (
@@ -61,6 +62,88 @@ class EncryptionError(ValueError):
pass
@dataclass(frozen=True, slots=True)
class VaultOperationalSummary:
tenant_id: str
vault_id: str
name: str
provider_id: str
purpose: str
profile_kind: str
scope_type: str
scope_id: str | None
policy_ref: str
recovery_quorum: int
state: str
revision: int
current_key_version: int | None
current_key_state: str | None
algorithm_suite: str | None
created_at: datetime
updated_at: datetime
@dataclass(frozen=True, slots=True)
class EnvelopeOperationalSummary:
envelope_id: str
tenant_id: str
owner_module: str
resource_type: str
resource_id: str
profile_kind: str
provider_id: str
vault_id: str
key_version: int
algorithm_suite: str
state: str
migration_id: str | None
created_at: datetime
updated_at: datetime
@dataclass(frozen=True, slots=True)
class MigrationOperationalSummary:
migration_id: str
tenant_id: str
source_envelope_id: str
target_envelope_id: str | None
target_provider_id: str
target_vault_id: str
target_key_version: int
target_algorithm_suite: str
mode: str
state: str
policy_decision_ref: str
assurance_evidence_ref: str
evidence_refs: tuple[str, ...]
error_code: str | None
requested_by: str
created_at: datetime
updated_at: datetime
completed_at: datetime | None
@dataclass(frozen=True, slots=True)
class RecoveryOperationalSummary:
recovery_id: str
tenant_id: str
vault_id: str
state: str
requested_scope: str
reason: str
quorum: int
approvals: int
rejections: int
revision: int
policy_decision_ref: str
requester_account_id: str
requester_assurance_ref: str
expires_at: datetime
execution_ref: str | None
created_at: datetime
updated_at: datetime
class SqlEncryptionService:
"""Provider-neutral encryption metadata and recovery orchestration.
@@ -282,6 +365,57 @@ class SqlEncryptionService:
)
return self._vault_ref(vault, db) if vault is not None else None
def list_vaults(
self,
session: object,
principal: object,
*,
tenant_id: str,
state: str | None = None,
limit: int = 100,
) -> tuple[VaultOperationalSummary, ...]:
db = _session(session)
_require_tenant(principal, tenant_id)
statement = (
select(EncryptionVault, EncryptionKeyVersion)
.outerjoin(
EncryptionKeyVersion,
and_(
EncryptionKeyVersion.tenant_id == EncryptionVault.tenant_id,
EncryptionKeyVersion.vault_id == EncryptionVault.vault_id,
EncryptionKeyVersion.version
== EncryptionVault.current_key_version,
),
)
.where(EncryptionVault.tenant_id == tenant_id)
.order_by(EncryptionVault.updated_at.desc(), EncryptionVault.vault_id)
.limit(_bounded_limit(limit))
)
if state:
statement = statement.where(EncryptionVault.state == state)
return tuple(
VaultOperationalSummary(
tenant_id=vault.tenant_id,
vault_id=vault.vault_id,
name=vault.name,
provider_id=vault.provider_id,
purpose=vault.purpose,
profile_kind=vault.profile_kind,
scope_type=vault.scope_type,
scope_id=vault.scope_id,
policy_ref=vault.policy_ref,
recovery_quorum=vault.recovery_quorum,
state=vault.state,
revision=vault.revision,
current_key_version=vault.current_key_version,
current_key_state=key.state if key is not None else None,
algorithm_suite=key.algorithm_suite if key is not None else None,
created_at=vault.created_at,
updated_at=vault.updated_at,
)
for vault, key in db.execute(statement)
)
def reconcile_vault(
self,
session: object,
@@ -848,6 +982,54 @@ class SqlEncryptionService:
)
return _envelope_ref(item) if item is not None else None
def list_envelopes(
self,
session: object,
principal: object,
*,
tenant_id: str,
vault_id: str | None = None,
owner_module: str | None = None,
state: str | None = None,
limit: int = 100,
) -> tuple[EnvelopeOperationalSummary, ...]:
db = _session(session)
_require_tenant(principal, tenant_id)
statement = select(ContentProtectionRecord).where(
ContentProtectionRecord.tenant_id == tenant_id
)
if vault_id:
statement = statement.where(ContentProtectionRecord.vault_id == vault_id)
if owner_module:
statement = statement.where(
ContentProtectionRecord.owner_module == owner_module
)
if state:
statement = statement.where(ContentProtectionRecord.state == state)
statement = statement.order_by(
ContentProtectionRecord.updated_at.desc(),
ContentProtectionRecord.envelope_id,
).limit(_bounded_limit(limit))
return tuple(
EnvelopeOperationalSummary(
envelope_id=item.envelope_id,
tenant_id=item.tenant_id,
owner_module=item.owner_module,
resource_type=item.resource_type,
resource_id=item.resource_id,
profile_kind=item.profile_kind,
provider_id=item.provider_id,
vault_id=item.vault_id,
key_version=item.key_version,
algorithm_suite=item.algorithm_suite,
state=item.state,
migration_id=item.migration_id,
created_at=item.created_at,
updated_at=item.updated_at,
)
for item in db.scalars(statement)
)
def request_migration(
self,
session: object,
@@ -924,6 +1106,49 @@ class SqlEncryptionService:
db.flush()
return self._migration_ref(db, item)
def list_migrations(
self,
session: object,
principal: object,
*,
tenant_id: str,
state: str | None = None,
limit: int = 100,
) -> tuple[MigrationOperationalSummary, ...]:
db = _session(session)
_require_tenant(principal, tenant_id)
statement = select(ProtectionMigration).where(
ProtectionMigration.tenant_id == tenant_id
)
if state:
statement = statement.where(ProtectionMigration.state == state)
statement = statement.order_by(
ProtectionMigration.updated_at.desc(), ProtectionMigration.id
).limit(_bounded_limit(limit))
return tuple(
MigrationOperationalSummary(
migration_id=item.id,
tenant_id=item.tenant_id,
source_envelope_id=item.source_envelope_id,
target_envelope_id=item.target_envelope_id,
target_provider_id=item.target_provider_id,
target_vault_id=item.target_vault_id,
target_key_version=item.target_key_version,
target_algorithm_suite=item.target_algorithm_suite,
mode=item.mode,
state=item.state,
policy_decision_ref=item.policy_decision_ref,
assurance_evidence_ref=item.assurance_evidence_ref,
evidence_refs=tuple(item.evidence_refs),
error_code=item.error_code,
requested_by=item.requested_by,
created_at=item.created_at,
updated_at=item.updated_at,
completed_at=item.completed_at,
)
for item in db.scalars(statement)
)
def record_migration_outcome(
self,
session: object,
@@ -1092,6 +1317,70 @@ class SqlEncryptionService:
db.flush()
return self._recovery_ref(db, item)
def list_recoveries(
self,
session: object,
principal: object,
*,
tenant_id: str,
state: str | None = None,
limit: int = 100,
) -> tuple[RecoveryOperationalSummary, ...]:
db = _session(session)
_require_tenant(principal, tenant_id)
statement = select(RecoveryCeremony).where(
RecoveryCeremony.tenant_id == tenant_id
)
if state:
statement = statement.where(RecoveryCeremony.state == state)
statement = statement.order_by(
RecoveryCeremony.updated_at.desc(), RecoveryCeremony.id
).limit(_bounded_limit(limit))
ceremonies = tuple(db.scalars(statement))
if not ceremonies:
return ()
counts: dict[str, Counter[str]] = {
ceremony.id: Counter() for ceremony in ceremonies
}
count_rows = db.execute(
select(
RecoveryApproval.recovery_id,
RecoveryApproval.decision,
func.count(RecoveryApproval.id),
)
.where(RecoveryApproval.recovery_id.in_(tuple(counts)))
.group_by(RecoveryApproval.recovery_id, RecoveryApproval.decision)
)
for recovery_id, decision, count in count_rows:
counts[str(recovery_id)][str(decision)] = int(count)
now = _as_utc(utcnow())
return tuple(
RecoveryOperationalSummary(
recovery_id=item.id,
tenant_id=item.tenant_id,
vault_id=item.vault_id,
state=(
"expired"
if item.state == "pending" and _as_utc(item.expires_at) <= now
else item.state
),
requested_scope=item.requested_scope,
reason=item.reason,
quorum=item.quorum,
approvals=counts[item.id]["approve"],
rejections=counts[item.id]["reject"],
revision=item.revision,
policy_decision_ref=item.policy_decision_ref,
requester_account_id=item.requester_account_id,
requester_assurance_ref=item.requester_assurance_ref,
expires_at=item.expires_at,
execution_ref=item.execution_ref,
created_at=item.created_at,
updated_at=item.updated_at,
)
for item in ceremonies
)
def decide_recovery(
self,
session: object,
@@ -2007,6 +2296,10 @@ def _as_utc(value: datetime) -> datetime:
return value.astimezone(timezone.utc)
def _bounded_limit(value: int) -> int:
return max(1, min(int(value), 500))
def _exception_code(exc: Exception) -> str:
return f"provider_{type(exc).__name__.lower()}"[:255]