Implement governed encryption lifecycle
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
from govoplan_encryption.backend.db.models import (
|
||||
ContentProtectionRecord,
|
||||
EncryptionKeyOperation,
|
||||
EncryptionKeyVersion,
|
||||
EncryptionVault,
|
||||
ProtectionMigration,
|
||||
RecoveryApproval,
|
||||
RecoveryCeremony,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ContentProtectionRecord",
|
||||
"EncryptionKeyOperation",
|
||||
"EncryptionKeyVersion",
|
||||
"EncryptionVault",
|
||||
"ProtectionMigration",
|
||||
"RecoveryApproval",
|
||||
"RecoveryCeremony",
|
||||
]
|
||||
@@ -0,0 +1,313 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import (
|
||||
DateTime,
|
||||
Index,
|
||||
Integer,
|
||||
JSON,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from govoplan_core.db.base import Base, TimestampMixin
|
||||
|
||||
|
||||
def new_uuid() -> str:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
class EncryptionVault(Base, TimestampMixin):
|
||||
__tablename__ = "encryption_vaults"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "vault_id", name="uq_encryption_vault"),
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"create_idempotency_key",
|
||||
name="uq_encryption_vault_create_idempotency",
|
||||
),
|
||||
Index(
|
||||
"ix_encryption_vault_scope",
|
||||
"tenant_id",
|
||||
"scope_type",
|
||||
"scope_id",
|
||||
"state",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
vault_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
provider_id: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
|
||||
purpose: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
profile_kind: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||
scope_type: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
|
||||
scope_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
|
||||
policy_ref: Mapped[str] = mapped_column(String(1000), nullable=False)
|
||||
recovery_quorum: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
state: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
|
||||
revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||
current_key_version: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
create_idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
create_request_digest: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
provenance: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON, default=dict, nullable=False
|
||||
)
|
||||
created_by: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
updated_by: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
|
||||
|
||||
class EncryptionKeyVersion(Base, TimestampMixin):
|
||||
__tablename__ = "encryption_key_versions"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"vault_id",
|
||||
"version",
|
||||
name="uq_encryption_key_version",
|
||||
),
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"provider_id",
|
||||
"provider_key_ref",
|
||||
name="uq_encryption_provider_key_ref",
|
||||
),
|
||||
Index(
|
||||
"ix_encryption_key_vault_state",
|
||||
"tenant_id",
|
||||
"vault_id",
|
||||
"state",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
vault_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
version: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
provider_id: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
|
||||
provider_key_ref: Mapped[str | None] = mapped_column(String(1000), nullable=True)
|
||||
algorithm_suite: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
state: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
|
||||
public_key_ref: Mapped[str | None] = mapped_column(String(1000), nullable=True)
|
||||
imported: Mapped[bool] = mapped_column(default=False, nullable=False)
|
||||
exportable: Mapped[bool] = mapped_column(default=False, nullable=False)
|
||||
provider_version: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
provider_provenance: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON, default=dict, nullable=False
|
||||
)
|
||||
activated_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
destruction_scheduled_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
destroyed_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
|
||||
class EncryptionKeyOperation(Base, TimestampMixin):
|
||||
__tablename__ = "encryption_key_operations"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id", "idempotency_key", name="uq_encryption_key_operation_idem"
|
||||
),
|
||||
Index(
|
||||
"ix_encryption_key_operation_state",
|
||||
"tenant_id",
|
||||
"state",
|
||||
"updated_at",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
vault_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
key_version: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
operation: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
|
||||
provider_id: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
state: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
|
||||
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
request_digest: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
request_payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
|
||||
error_code: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
policy_decision_ref: Mapped[str | None] = mapped_column(String(1000), nullable=True)
|
||||
assurance_evidence_ref: Mapped[str | None] = mapped_column(
|
||||
String(1000), nullable=True
|
||||
)
|
||||
requested_by: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
|
||||
class ContentProtectionRecord(Base, TimestampMixin):
|
||||
__tablename__ = "encryption_content_protections"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id", "envelope_id", name="uq_encryption_content_envelope"
|
||||
),
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"idempotency_key",
|
||||
name="uq_encryption_content_idempotency",
|
||||
),
|
||||
Index(
|
||||
"ix_encryption_content_owner",
|
||||
"tenant_id",
|
||||
"owner_module",
|
||||
"resource_type",
|
||||
"resource_id",
|
||||
),
|
||||
Index("ix_encryption_content_state", "tenant_id", "state", "updated_at"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
envelope_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
owner_module: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
|
||||
resource_type: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
resource_id: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
profile_kind: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||
profile_id: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
provider_id: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
vault_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
key_version: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
algorithm_suite: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
ciphertext_ref: Mapped[str] = mapped_column(String(2000), nullable=False)
|
||||
ciphertext_digest: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
authenticated_context_digest: Mapped[str] = mapped_column(
|
||||
String(255), nullable=False
|
||||
)
|
||||
wrapped_key_refs: Mapped[list[str]] = mapped_column(
|
||||
JSON, default=list, nullable=False
|
||||
)
|
||||
state: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
|
||||
source_envelope_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
migration_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
envelope_metadata: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON, default=dict, nullable=False
|
||||
)
|
||||
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
request_digest: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
policy_decision_ref: Mapped[str] = mapped_column(String(1000), nullable=False)
|
||||
registered_by: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
|
||||
|
||||
class ProtectionMigration(Base, TimestampMixin):
|
||||
__tablename__ = "encryption_protection_migrations"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"idempotency_key",
|
||||
name="uq_encryption_migration_idempotency",
|
||||
),
|
||||
Index("ix_encryption_migration_state", "tenant_id", "state", "updated_at"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
source_envelope_id: Mapped[str] = mapped_column(
|
||||
String(255), nullable=False, index=True
|
||||
)
|
||||
target_envelope_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
target_provider_id: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
target_vault_id: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
target_key_version: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
target_algorithm_suite: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
mode: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||
state: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
|
||||
policy_decision_ref: Mapped[str] = mapped_column(String(1000), nullable=False)
|
||||
assurance_evidence_ref: Mapped[str] = mapped_column(String(1000), nullable=False)
|
||||
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
request_digest: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
evidence_refs: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
||||
error_code: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
provenance: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON, default=dict, nullable=False
|
||||
)
|
||||
requested_by: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
|
||||
class RecoveryCeremony(Base, TimestampMixin):
|
||||
__tablename__ = "encryption_recovery_ceremonies"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"idempotency_key",
|
||||
name="uq_encryption_recovery_idempotency",
|
||||
),
|
||||
Index("ix_encryption_recovery_state", "tenant_id", "state", "expires_at"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
vault_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
state: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
|
||||
requested_scope: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
reason: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
quorum: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||
policy_decision_ref: Mapped[str] = mapped_column(String(1000), nullable=False)
|
||||
requester_assurance_ref: Mapped[str] = mapped_column(String(1000), nullable=False)
|
||||
requester_account_id: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
request_digest: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
expires_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, index=True
|
||||
)
|
||||
execution_ref: Mapped[str | None] = mapped_column(String(1000), nullable=True)
|
||||
provenance: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON, default=dict, nullable=False
|
||||
)
|
||||
|
||||
|
||||
class RecoveryApproval(Base, TimestampMixin):
|
||||
__tablename__ = "encryption_recovery_approvals"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"recovery_id",
|
||||
"approver_account_id",
|
||||
name="uq_encryption_recovery_approver",
|
||||
),
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"idempotency_key",
|
||||
name="uq_encryption_recovery_approval_idem",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
recovery_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
approver_account_id: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
decision: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
reason: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
assurance_evidence_ref: Mapped[str] = mapped_column(String(1000), nullable=False)
|
||||
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
request_digest: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ContentProtectionRecord",
|
||||
"EncryptionKeyOperation",
|
||||
"EncryptionKeyVersion",
|
||||
"EncryptionVault",
|
||||
"ProtectionMigration",
|
||||
"RecoveryApproval",
|
||||
"RecoveryCeremony",
|
||||
"new_uuid",
|
||||
]
|
||||
@@ -1,13 +1,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from govoplan_core.core.encryption import (
|
||||
CAPABILITY_ENCRYPTION_CONTENT_PROTECTION,
|
||||
CAPABILITY_ENCRYPTION_DISABLE_PREFLIGHT,
|
||||
CAPABILITY_ENCRYPTION_KEY_VAULT,
|
||||
CAPABILITY_ENCRYPTION_RECOVERY,
|
||||
)
|
||||
from govoplan_core.core.module_guards import (
|
||||
drop_table_retirement_provider,
|
||||
persistent_table_uninstall_guard,
|
||||
)
|
||||
from govoplan_core.core.modules import (
|
||||
CapabilityDocumentation,
|
||||
DocumentationLink,
|
||||
DocumentationTopic,
|
||||
MigrationSpec,
|
||||
ModuleContext,
|
||||
ModuleInterfaceProvider,
|
||||
ModuleManifest,
|
||||
ModuleUninstallGuardResult,
|
||||
PermissionDefinition,
|
||||
RoleTemplate,
|
||||
)
|
||||
from govoplan_core.core.provider_governance import declared_module_architecture
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_encryption.backend.db import models
|
||||
from govoplan_encryption.backend.service import SqlEncryptionService
|
||||
|
||||
|
||||
MODULE_ID = "encryption"
|
||||
@@ -27,14 +47,11 @@ OPTIONAL_DEPENDENCIES = (
|
||||
"postbox",
|
||||
"campaigns",
|
||||
"workflow_engine",
|
||||
"identity_trust",
|
||||
)
|
||||
|
||||
|
||||
def _permission(
|
||||
scope: str,
|
||||
label: str,
|
||||
description: str,
|
||||
) -> PermissionDefinition:
|
||||
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
|
||||
module_id, resource, action = scope.split(":", 2)
|
||||
return PermissionDefinition(
|
||||
scope=scope,
|
||||
@@ -48,21 +65,64 @@ def _permission(
|
||||
)
|
||||
|
||||
|
||||
def _router(context: ModuleContext):
|
||||
from govoplan_encryption.backend.router import create_router
|
||||
|
||||
return create_router(context.registry)
|
||||
|
||||
|
||||
def _service(context: ModuleContext) -> SqlEncryptionService:
|
||||
return SqlEncryptionService(context.registry)
|
||||
|
||||
|
||||
def _disable_guard(
|
||||
session: object | None,
|
||||
_module_id: str,
|
||||
) -> tuple[ModuleUninstallGuardResult, ...]:
|
||||
if session is None:
|
||||
return (
|
||||
ModuleUninstallGuardResult(
|
||||
"blocker",
|
||||
"encryption_disable_unverified",
|
||||
"Encryption cannot be disabled without proving the state of every protection envelope.",
|
||||
),
|
||||
)
|
||||
try:
|
||||
report = SqlEncryptionService().assess_disable(session)
|
||||
except Exception as exc:
|
||||
return (
|
||||
ModuleUninstallGuardResult(
|
||||
"blocker",
|
||||
"encryption_disable_check_failed",
|
||||
f"Encryption disable preflight failed: {type(exc).__name__}.",
|
||||
),
|
||||
)
|
||||
if report.allowed:
|
||||
return ()
|
||||
return (
|
||||
ModuleUninstallGuardResult(
|
||||
"blocker",
|
||||
"encryption_protected_content_present",
|
||||
f"Encryption still protects {report.unresolved_count} unresolved envelope(s). Migrate, decrypt, explicitly export, or cryptographically destroy them before disabling the module.",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
PERMISSIONS = (
|
||||
_permission(
|
||||
USE_SCOPE,
|
||||
"Use encryption profiles",
|
||||
"Protect and decrypt authorized content through an available profile.",
|
||||
"Register and resolve protected content through configured profiles.",
|
||||
),
|
||||
_permission(
|
||||
ADMIN_SCOPE,
|
||||
"Administer encryption",
|
||||
"Manage vaults, protection profiles, key rotation, and provider policy.",
|
||||
"Manage vault metadata, provider operations, rotation, migration, and policy provenance.",
|
||||
),
|
||||
_permission(
|
||||
RECOVERY_SCOPE,
|
||||
"Approve key recovery",
|
||||
"Participate in an auditable recovery ceremony without gaining content ownership.",
|
||||
"Participate in a high-assurance recovery ceremony without gaining content ownership.",
|
||||
),
|
||||
)
|
||||
|
||||
@@ -87,7 +147,10 @@ manifest = ModuleManifest(
|
||||
version=MODULE_VERSION,
|
||||
optional_dependencies=OPTIONAL_DEPENDENCIES,
|
||||
provides_interfaces=(
|
||||
ModuleInterfaceProvider(name="encryption.key_vault", version="1.0.0"),
|
||||
ModuleInterfaceProvider(
|
||||
name="encryption.key_vault",
|
||||
version="1.0.0",
|
||||
),
|
||||
ModuleInterfaceProvider(
|
||||
name="encryption.content_protection",
|
||||
version="1.0.0",
|
||||
@@ -103,6 +166,81 @@ manifest = ModuleManifest(
|
||||
),
|
||||
permissions=PERMISSIONS,
|
||||
role_templates=ROLE_TEMPLATES,
|
||||
route_factory=_router,
|
||||
capability_factories={
|
||||
CAPABILITY_ENCRYPTION_KEY_VAULT: _service,
|
||||
CAPABILITY_ENCRYPTION_CONTENT_PROTECTION: _service,
|
||||
CAPABILITY_ENCRYPTION_RECOVERY: _service,
|
||||
CAPABILITY_ENCRYPTION_DISABLE_PREFLIGHT: _service,
|
||||
},
|
||||
capability_documentation={
|
||||
CAPABILITY_ENCRYPTION_KEY_VAULT: CapabilityDocumentation(
|
||||
label="Governed encryption key vault",
|
||||
summary=(
|
||||
"Orchestrates opaque, idempotent provider key references, "
|
||||
"lifecycle state, and policy provenance without exposing key material."
|
||||
),
|
||||
contract_version="1.0.0",
|
||||
audience=("administrator", "security_officer", "auditor"),
|
||||
),
|
||||
CAPABILITY_ENCRYPTION_CONTENT_PROTECTION: CapabilityDocumentation(
|
||||
label="Content-protection envelope registry",
|
||||
summary=(
|
||||
"Registers versioned protection envelopes and fail-closed, "
|
||||
"evidence-backed migration state for feature-owned content."
|
||||
),
|
||||
contract_version="1.0.0",
|
||||
),
|
||||
CAPABILITY_ENCRYPTION_RECOVERY: CapabilityDocumentation(
|
||||
label="Encryption recovery ceremony",
|
||||
summary=(
|
||||
"Requires recent high assurance, distinct custodians, quorum, "
|
||||
"expiry, and immutable evidence without changing ownership."
|
||||
),
|
||||
contract_version="1.0.0",
|
||||
),
|
||||
CAPABILITY_ENCRYPTION_DISABLE_PREFLIGHT: CapabilityDocumentation(
|
||||
label="Encryption disable preflight",
|
||||
summary=(
|
||||
"Blocks disable or uninstall while any protection envelope "
|
||||
"remains unresolved."
|
||||
),
|
||||
contract_version="1.0.0",
|
||||
),
|
||||
},
|
||||
migration_spec=MigrationSpec(
|
||||
module_id=MODULE_ID,
|
||||
metadata=Base.metadata,
|
||||
script_location=str(Path(__file__).with_name("migrations") / "versions"),
|
||||
retirement_supported=True,
|
||||
retirement_provider=drop_table_retirement_provider(
|
||||
models.RecoveryApproval,
|
||||
models.RecoveryCeremony,
|
||||
models.ProtectionMigration,
|
||||
models.ContentProtectionRecord,
|
||||
models.EncryptionKeyOperation,
|
||||
models.EncryptionKeyVersion,
|
||||
models.EncryptionVault,
|
||||
label=MODULE_NAME,
|
||||
),
|
||||
retirement_notes=(
|
||||
"Destructive retirement remains blocked until disable preflight "
|
||||
"proves that no unresolved protected envelope remains."
|
||||
),
|
||||
),
|
||||
uninstall_guard_providers=(
|
||||
_disable_guard,
|
||||
persistent_table_uninstall_guard(
|
||||
models.EncryptionVault,
|
||||
models.EncryptionKeyVersion,
|
||||
models.EncryptionKeyOperation,
|
||||
models.ContentProtectionRecord,
|
||||
models.ProtectionMigration,
|
||||
models.RecoveryCeremony,
|
||||
models.RecoveryApproval,
|
||||
label=MODULE_NAME,
|
||||
),
|
||||
),
|
||||
documentation=(
|
||||
DocumentationTopic(
|
||||
id="encryption.boundary",
|
||||
@@ -114,26 +252,57 @@ manifest = ModuleManifest(
|
||||
body=(
|
||||
"Encryption protects feature-owned content without taking over "
|
||||
"its business ownership. Resource ownership recovery never "
|
||||
"implicitly grants cryptographic keys. Disabling the module is "
|
||||
"blocked until protected objects are decrypted, rewrapped, "
|
||||
"explicitly exported, or cryptographically deleted."
|
||||
"implicitly grants cryptographic keys. High-risk lifecycle "
|
||||
"actions require recent Identity Trust assurance. Disabling is "
|
||||
"blocked until each envelope is migrated, decrypted, explicitly "
|
||||
"exported, or cryptographically destroyed. No bundled provider "
|
||||
"or E2EE claim is implied by enabling this module."
|
||||
),
|
||||
layer="available",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("user", "administrator", "security_officer", "product_owner"),
|
||||
audience=(
|
||||
"user",
|
||||
"administrator",
|
||||
"security_officer",
|
||||
"product_owner",
|
||||
"auditor",
|
||||
),
|
||||
related_modules=OPTIONAL_DEPENDENCIES,
|
||||
order=100,
|
||||
links=(
|
||||
DocumentationLink(
|
||||
label="Encryption boundary and threat model",
|
||||
href="govoplan-encryption/docs/ENCRYPTION_BOUNDARY.md",
|
||||
kind="repository",
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
architecture=declared_module_architecture(
|
||||
layer="institutional_foundation",
|
||||
kind="foundation",
|
||||
maturity="scaffold",
|
||||
maturity="vertical_slice",
|
||||
documentation_ref="docs/ENCRYPTION_BOUNDARY.md",
|
||||
known_limits=("No production key vault, protected-content persistence, rotation worker, or recovery ceremony is implemented yet.",),
|
||||
owned_concepts=("key vault", "content-protection envelope", "cryptographic recovery ceremony"),
|
||||
non_owned_concepts=("domain content", "resource ownership", "account authentication"),
|
||||
test_ref="tests/test_encryption.py",
|
||||
known_limits=(
|
||||
"The module orchestrates references and evidence but ships no concrete cryptographic provider, raw key store, cipher implementation, client E2EE protocol, KMS/HSM conformance suite, or production recovery executor.",
|
||||
"A true E2EE claim remains prohibited until a selected client/provider profile passes its threat model, interoperability fixtures, backup/restore tests, and independent review.",
|
||||
),
|
||||
owned_concepts=(
|
||||
"key vault",
|
||||
"content-protection envelope",
|
||||
"cryptographic recovery ceremony",
|
||||
),
|
||||
non_owned_concepts=(
|
||||
"domain content",
|
||||
"resource ownership",
|
||||
"account authentication",
|
||||
"provider key material",
|
||||
),
|
||||
migration_docs=("docs/ENCRYPTION_BOUNDARY.md",),
|
||||
recovery_docs=("docs/ENCRYPTION_BOUNDARY.md",),
|
||||
security_docs=("docs/ENCRYPTION_BOUNDARY.md",),
|
||||
operations_docs=("README.md",),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Encryption database migrations."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Encryption Alembic revisions."""
|
||||
+319
@@ -0,0 +1,319 @@
|
||||
"""v0.1.14 encryption lifecycle and protection metadata
|
||||
|
||||
Revision ID: d4a6b8c0e2f3
|
||||
Revises: None
|
||||
Create Date: 2026-08-01 00:00:00.000000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "d4a6b8c0e2f3"
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _timestamps() -> tuple[sa.Column, sa.Column]:
|
||||
return (
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"encryption_vaults",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("tenant_id", sa.String(36), nullable=False),
|
||||
sa.Column("vault_id", sa.String(255), nullable=False),
|
||||
sa.Column("name", sa.String(255), nullable=False),
|
||||
sa.Column("provider_id", sa.String(120), nullable=False),
|
||||
sa.Column("purpose", sa.String(255), nullable=False),
|
||||
sa.Column("profile_kind", sa.String(40), nullable=False),
|
||||
sa.Column("scope_type", sa.String(80), nullable=False),
|
||||
sa.Column("scope_id", sa.String(255), nullable=True),
|
||||
sa.Column("policy_ref", sa.String(1000), nullable=False),
|
||||
sa.Column("recovery_quorum", sa.Integer(), nullable=False),
|
||||
sa.Column("state", sa.String(40), nullable=False),
|
||||
sa.Column("revision", sa.Integer(), nullable=False),
|
||||
sa.Column("current_key_version", sa.Integer(), nullable=True),
|
||||
sa.Column("create_idempotency_key", sa.String(255), nullable=False),
|
||||
sa.Column("create_request_digest", sa.String(64), nullable=False),
|
||||
sa.Column("provenance", sa.JSON(), nullable=False),
|
||||
sa.Column("created_by", sa.String(255), nullable=False),
|
||||
sa.Column("updated_by", sa.String(255), nullable=False),
|
||||
*_timestamps(),
|
||||
sa.UniqueConstraint("tenant_id", "vault_id", name="uq_encryption_vault"),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"create_idempotency_key",
|
||||
name="uq_encryption_vault_create_idempotency",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_encryption_vault_scope",
|
||||
"encryption_vaults",
|
||||
["tenant_id", "scope_type", "scope_id", "state"],
|
||||
)
|
||||
for column in (
|
||||
"tenant_id",
|
||||
"vault_id",
|
||||
"provider_id",
|
||||
"scope_type",
|
||||
"scope_id",
|
||||
"state",
|
||||
):
|
||||
op.create_index(f"ix_encryption_vaults_{column}", "encryption_vaults", [column])
|
||||
|
||||
op.create_table(
|
||||
"encryption_key_versions",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("tenant_id", sa.String(36), nullable=False),
|
||||
sa.Column("vault_id", sa.String(255), nullable=False),
|
||||
sa.Column("version", sa.Integer(), nullable=False),
|
||||
sa.Column("provider_id", sa.String(120), nullable=False),
|
||||
sa.Column("provider_key_ref", sa.String(1000), nullable=True),
|
||||
sa.Column("algorithm_suite", sa.String(120), nullable=False),
|
||||
sa.Column("state", sa.String(40), nullable=False),
|
||||
sa.Column("public_key_ref", sa.String(1000), nullable=True),
|
||||
sa.Column("imported", sa.Boolean(), nullable=False),
|
||||
sa.Column("exportable", sa.Boolean(), nullable=False),
|
||||
sa.Column("provider_version", sa.String(120), nullable=True),
|
||||
sa.Column("provider_provenance", sa.JSON(), nullable=False),
|
||||
sa.Column("activated_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column(
|
||||
"destruction_scheduled_at", sa.DateTime(timezone=True), nullable=True
|
||||
),
|
||||
sa.Column("destroyed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
*_timestamps(),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id", "vault_id", "version", name="uq_encryption_key_version"
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"provider_id",
|
||||
"provider_key_ref",
|
||||
name="uq_encryption_provider_key_ref",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_encryption_key_vault_state",
|
||||
"encryption_key_versions",
|
||||
["tenant_id", "vault_id", "state"],
|
||||
)
|
||||
for column in ("tenant_id", "vault_id", "provider_id", "state"):
|
||||
op.create_index(
|
||||
f"ix_encryption_key_versions_{column}",
|
||||
"encryption_key_versions",
|
||||
[column],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"encryption_key_operations",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("tenant_id", sa.String(36), nullable=False),
|
||||
sa.Column("vault_id", sa.String(255), nullable=False),
|
||||
sa.Column("key_version", sa.Integer(), nullable=False),
|
||||
sa.Column("operation", sa.String(40), nullable=False),
|
||||
sa.Column("provider_id", sa.String(120), nullable=False),
|
||||
sa.Column("state", sa.String(40), nullable=False),
|
||||
sa.Column("idempotency_key", sa.String(255), nullable=False),
|
||||
sa.Column("request_digest", sa.String(64), nullable=False),
|
||||
sa.Column("request_payload", sa.JSON(), nullable=False),
|
||||
sa.Column("error_code", sa.String(255), nullable=True),
|
||||
sa.Column("policy_decision_ref", sa.String(1000), nullable=True),
|
||||
sa.Column("assurance_evidence_ref", sa.String(1000), nullable=True),
|
||||
sa.Column("requested_by", sa.String(255), nullable=False),
|
||||
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
*_timestamps(),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id", "idempotency_key", name="uq_encryption_key_operation_idem"
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_encryption_key_operation_state",
|
||||
"encryption_key_operations",
|
||||
["tenant_id", "state", "updated_at"],
|
||||
)
|
||||
for column in ("tenant_id", "vault_id", "operation", "state"):
|
||||
op.create_index(
|
||||
f"ix_encryption_key_operations_{column}",
|
||||
"encryption_key_operations",
|
||||
[column],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"encryption_content_protections",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("envelope_id", sa.String(255), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(36), nullable=False),
|
||||
sa.Column("owner_module", sa.String(120), nullable=False),
|
||||
sa.Column("resource_type", sa.String(120), nullable=False),
|
||||
sa.Column("resource_id", sa.String(255), nullable=False),
|
||||
sa.Column("profile_kind", sa.String(40), nullable=False),
|
||||
sa.Column("profile_id", sa.String(255), nullable=False),
|
||||
sa.Column("provider_id", sa.String(120), nullable=False),
|
||||
sa.Column("vault_id", sa.String(255), nullable=False),
|
||||
sa.Column("key_version", sa.Integer(), nullable=False),
|
||||
sa.Column("algorithm_suite", sa.String(120), nullable=False),
|
||||
sa.Column("ciphertext_ref", sa.String(2000), nullable=False),
|
||||
sa.Column("ciphertext_digest", sa.String(255), nullable=False),
|
||||
sa.Column("authenticated_context_digest", sa.String(255), nullable=False),
|
||||
sa.Column("wrapped_key_refs", sa.JSON(), nullable=False),
|
||||
sa.Column("state", sa.String(40), nullable=False),
|
||||
sa.Column("source_envelope_id", sa.String(255), nullable=True),
|
||||
sa.Column("migration_id", sa.String(36), nullable=True),
|
||||
sa.Column("envelope_metadata", sa.JSON(), nullable=False),
|
||||
sa.Column("idempotency_key", sa.String(255), nullable=False),
|
||||
sa.Column("request_digest", sa.String(64), nullable=False),
|
||||
sa.Column("policy_decision_ref", sa.String(1000), nullable=False),
|
||||
sa.Column("registered_by", sa.String(255), nullable=False),
|
||||
*_timestamps(),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id", "envelope_id", name="uq_encryption_content_envelope"
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"idempotency_key",
|
||||
name="uq_encryption_content_idempotency",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_encryption_content_owner",
|
||||
"encryption_content_protections",
|
||||
["tenant_id", "owner_module", "resource_type", "resource_id"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_encryption_content_state",
|
||||
"encryption_content_protections",
|
||||
["tenant_id", "state", "updated_at"],
|
||||
)
|
||||
for column in ("envelope_id", "tenant_id", "owner_module", "vault_id", "state"):
|
||||
op.create_index(
|
||||
f"ix_encryption_content_protections_{column}",
|
||||
"encryption_content_protections",
|
||||
[column],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"encryption_protection_migrations",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("tenant_id", sa.String(36), nullable=False),
|
||||
sa.Column("source_envelope_id", sa.String(255), nullable=False),
|
||||
sa.Column("target_envelope_id", sa.String(255), nullable=True),
|
||||
sa.Column("target_provider_id", sa.String(120), nullable=False),
|
||||
sa.Column("target_vault_id", sa.String(255), nullable=False),
|
||||
sa.Column("target_key_version", sa.Integer(), nullable=False),
|
||||
sa.Column("target_algorithm_suite", sa.String(120), nullable=False),
|
||||
sa.Column("mode", sa.String(40), nullable=False),
|
||||
sa.Column("state", sa.String(40), nullable=False),
|
||||
sa.Column("policy_decision_ref", sa.String(1000), nullable=False),
|
||||
sa.Column("assurance_evidence_ref", sa.String(1000), nullable=False),
|
||||
sa.Column("idempotency_key", sa.String(255), nullable=False),
|
||||
sa.Column("request_digest", sa.String(64), nullable=False),
|
||||
sa.Column("evidence_refs", sa.JSON(), nullable=False),
|
||||
sa.Column("error_code", sa.String(255), nullable=True),
|
||||
sa.Column("provenance", sa.JSON(), nullable=False),
|
||||
sa.Column("requested_by", sa.String(255), nullable=False),
|
||||
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
*_timestamps(),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"idempotency_key",
|
||||
name="uq_encryption_migration_idempotency",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_encryption_migration_state",
|
||||
"encryption_protection_migrations",
|
||||
["tenant_id", "state", "updated_at"],
|
||||
)
|
||||
for column in ("tenant_id", "source_envelope_id", "state"):
|
||||
op.create_index(
|
||||
f"ix_encryption_protection_migrations_{column}",
|
||||
"encryption_protection_migrations",
|
||||
[column],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"encryption_recovery_ceremonies",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("tenant_id", sa.String(36), nullable=False),
|
||||
sa.Column("vault_id", sa.String(255), nullable=False),
|
||||
sa.Column("state", sa.String(40), nullable=False),
|
||||
sa.Column("requested_scope", sa.String(255), nullable=False),
|
||||
sa.Column("reason", sa.Text(), nullable=False),
|
||||
sa.Column("quorum", sa.Integer(), nullable=False),
|
||||
sa.Column("revision", sa.Integer(), nullable=False),
|
||||
sa.Column("policy_decision_ref", sa.String(1000), nullable=False),
|
||||
sa.Column("requester_assurance_ref", sa.String(1000), nullable=False),
|
||||
sa.Column("requester_account_id", sa.String(255), nullable=False),
|
||||
sa.Column("idempotency_key", sa.String(255), nullable=False),
|
||||
sa.Column("request_digest", sa.String(64), nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("execution_ref", sa.String(1000), nullable=True),
|
||||
sa.Column("provenance", sa.JSON(), nullable=False),
|
||||
*_timestamps(),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"idempotency_key",
|
||||
name="uq_encryption_recovery_idempotency",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_encryption_recovery_state",
|
||||
"encryption_recovery_ceremonies",
|
||||
["tenant_id", "state", "expires_at"],
|
||||
)
|
||||
for column in ("tenant_id", "vault_id", "state", "expires_at"):
|
||||
op.create_index(
|
||||
f"ix_encryption_recovery_ceremonies_{column}",
|
||||
"encryption_recovery_ceremonies",
|
||||
[column],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"encryption_recovery_approvals",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("tenant_id", sa.String(36), nullable=False),
|
||||
sa.Column("recovery_id", sa.String(36), nullable=False),
|
||||
sa.Column("approver_account_id", sa.String(255), nullable=False),
|
||||
sa.Column("decision", sa.String(20), nullable=False),
|
||||
sa.Column("reason", sa.Text(), nullable=False),
|
||||
sa.Column("assurance_evidence_ref", sa.String(1000), nullable=False),
|
||||
sa.Column("idempotency_key", sa.String(255), nullable=False),
|
||||
sa.Column("request_digest", sa.String(64), nullable=False),
|
||||
*_timestamps(),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"recovery_id",
|
||||
"approver_account_id",
|
||||
name="uq_encryption_recovery_approver",
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"idempotency_key",
|
||||
name="uq_encryption_recovery_approval_idem",
|
||||
),
|
||||
)
|
||||
for column in ("tenant_id", "recovery_id"):
|
||||
op.create_index(
|
||||
f"ix_encryption_recovery_approvals_{column}",
|
||||
"encryption_recovery_approvals",
|
||||
[column],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("encryption_recovery_approvals")
|
||||
op.drop_table("encryption_recovery_ceremonies")
|
||||
op.drop_table("encryption_protection_migrations")
|
||||
op.drop_table("encryption_content_protections")
|
||||
op.drop_table("encryption_key_operations")
|
||||
op.drop_table("encryption_key_versions")
|
||||
op.drop_table("encryption_vaults")
|
||||
@@ -0,0 +1,456 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.audit.logging import audit_event
|
||||
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
|
||||
from govoplan_core.core.encryption import (
|
||||
ContentProtectionEnvelope,
|
||||
KeyLifecycleRequest,
|
||||
KeyRotationRequest,
|
||||
KeyVaultCreateRequest,
|
||||
ProtectionMigrationRequest,
|
||||
ProtectionRegistrationRequest,
|
||||
RecoveryApprovalRequest,
|
||||
RecoveryRequest,
|
||||
)
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_encryption.backend.schemas import (
|
||||
DisablePreflightResponse,
|
||||
EnvelopePayload,
|
||||
EnvelopeRegistrationPayload,
|
||||
EnvelopeResponse,
|
||||
KeyLifecyclePayload,
|
||||
KeyRotationPayload,
|
||||
MigrationOutcomePayload,
|
||||
MigrationRequestPayload,
|
||||
MigrationResponse,
|
||||
RecoveryDecisionPayload,
|
||||
RecoveryRequestPayload,
|
||||
RecoveryResponse,
|
||||
VaultCreatePayload,
|
||||
VaultResponse,
|
||||
)
|
||||
from govoplan_encryption.backend.service import EncryptionError, SqlEncryptionService
|
||||
|
||||
|
||||
def create_router(registry: object | None = None) -> APIRouter:
|
||||
router = APIRouter(prefix="/encryption", tags=["encryption"])
|
||||
service = SqlEncryptionService(registry)
|
||||
|
||||
@router.post("/vaults", response_model=VaultResponse)
|
||||
def create_vault(
|
||||
payload: VaultCreatePayload,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> VaultResponse:
|
||||
_require(principal, "encryption:vault:admin")
|
||||
value = _call(
|
||||
lambda: service.create_vault(
|
||||
session,
|
||||
principal,
|
||||
request=KeyVaultCreateRequest(
|
||||
tenant_id=principal.tenant_id,
|
||||
**payload.model_dump(),
|
||||
),
|
||||
)
|
||||
)
|
||||
_audit(
|
||||
session,
|
||||
principal,
|
||||
"encryption.vault.created",
|
||||
"encryption_vault",
|
||||
value.vault_id,
|
||||
{"provider_id": value.provider_id, "state": value.state},
|
||||
)
|
||||
session.commit()
|
||||
return _vault_response(value)
|
||||
|
||||
@router.get("/vaults/{vault_id}", response_model=VaultResponse)
|
||||
def get_vault(
|
||||
vault_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> VaultResponse:
|
||||
_require(principal, "encryption:vault:use", "encryption:vault:admin")
|
||||
value = service.get_vault(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
vault_id=vault_id,
|
||||
)
|
||||
if value is None:
|
||||
raise HTTPException(status_code=404, detail="Encryption vault not found.")
|
||||
return _vault_response(value)
|
||||
|
||||
@router.post("/vaults/{vault_id}/rotate", response_model=VaultResponse)
|
||||
def rotate_key(
|
||||
vault_id: str,
|
||||
payload: KeyRotationPayload,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> VaultResponse:
|
||||
_require(principal, "encryption:vault:admin")
|
||||
value = _call(
|
||||
lambda: service.rotate_key(
|
||||
session,
|
||||
principal,
|
||||
request=KeyRotationRequest(
|
||||
tenant_id=principal.tenant_id,
|
||||
vault_id=vault_id,
|
||||
**payload.model_dump(),
|
||||
),
|
||||
)
|
||||
)
|
||||
_audit_lifecycle(session, principal, value, "rotated")
|
||||
session.commit()
|
||||
return _vault_response(value)
|
||||
|
||||
@router.post("/vaults/{vault_id}/revoke", response_model=VaultResponse)
|
||||
def revoke_key(
|
||||
vault_id: str,
|
||||
payload: KeyLifecyclePayload,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> VaultResponse:
|
||||
_require(principal, "encryption:vault:admin")
|
||||
value = _call(
|
||||
lambda: service.revoke_key(
|
||||
session,
|
||||
principal,
|
||||
request=KeyLifecycleRequest(
|
||||
tenant_id=principal.tenant_id,
|
||||
vault_id=vault_id,
|
||||
**payload.model_dump(),
|
||||
),
|
||||
)
|
||||
)
|
||||
_audit_lifecycle(session, principal, value, "revoked")
|
||||
session.commit()
|
||||
return _vault_response(value)
|
||||
|
||||
@router.post("/vaults/{vault_id}/destruction", response_model=VaultResponse)
|
||||
def schedule_destruction(
|
||||
vault_id: str,
|
||||
payload: KeyLifecyclePayload,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> VaultResponse:
|
||||
_require(principal, "encryption:vault:admin")
|
||||
value = _call(
|
||||
lambda: service.schedule_destruction(
|
||||
session,
|
||||
principal,
|
||||
request=KeyLifecycleRequest(
|
||||
tenant_id=principal.tenant_id,
|
||||
vault_id=vault_id,
|
||||
**payload.model_dump(),
|
||||
),
|
||||
)
|
||||
)
|
||||
_audit_lifecycle(session, principal, value, "destruction_scheduled")
|
||||
session.commit()
|
||||
return _vault_response(value)
|
||||
|
||||
@router.post("/vaults/{vault_id}/reconcile", response_model=VaultResponse)
|
||||
def reconcile_vault(
|
||||
vault_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> VaultResponse:
|
||||
_require(principal, "encryption:vault:admin")
|
||||
value = _call(
|
||||
lambda: service.reconcile_vault(
|
||||
session,
|
||||
principal,
|
||||
tenant_id=principal.tenant_id,
|
||||
vault_id=vault_id,
|
||||
)
|
||||
)
|
||||
_audit_lifecycle(session, principal, value, "reconciled")
|
||||
session.commit()
|
||||
return _vault_response(value)
|
||||
|
||||
@router.post("/envelopes", response_model=EnvelopeResponse)
|
||||
def register_envelope(
|
||||
payload: EnvelopeRegistrationPayload,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> EnvelopeResponse:
|
||||
_require(principal, "encryption:vault:use", "encryption:vault:admin")
|
||||
envelope = _envelope_contract(payload.envelope, principal.tenant_id)
|
||||
value = _call(
|
||||
lambda: service.register_envelope(
|
||||
session,
|
||||
principal,
|
||||
request=ProtectionRegistrationRequest(
|
||||
envelope=envelope,
|
||||
idempotency_key=payload.idempotency_key,
|
||||
policy_decision_ref=payload.policy_decision_ref,
|
||||
),
|
||||
)
|
||||
)
|
||||
_audit(
|
||||
session,
|
||||
principal,
|
||||
"encryption.envelope.registered",
|
||||
"content_protection_envelope",
|
||||
value.envelope_id,
|
||||
{
|
||||
"owner_module": value.owner_module,
|
||||
"profile_kind": value.profile_kind,
|
||||
"key_version": value.key_version,
|
||||
},
|
||||
)
|
||||
session.commit()
|
||||
return _envelope_response(value)
|
||||
|
||||
@router.get("/envelopes/{envelope_id}", response_model=EnvelopeResponse)
|
||||
def get_envelope(
|
||||
envelope_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> EnvelopeResponse:
|
||||
_require(principal, "encryption:vault:use", "encryption:vault:admin")
|
||||
value = service.get_envelope(
|
||||
session,
|
||||
principal,
|
||||
tenant_id=principal.tenant_id,
|
||||
envelope_id=envelope_id,
|
||||
)
|
||||
if value is None:
|
||||
raise HTTPException(
|
||||
status_code=404, detail="Protection envelope not found."
|
||||
)
|
||||
return _envelope_response(value)
|
||||
|
||||
@router.post("/migrations", response_model=MigrationResponse)
|
||||
def request_migration(
|
||||
payload: MigrationRequestPayload,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> MigrationResponse:
|
||||
_require(principal, "encryption:vault:admin")
|
||||
value = _call(
|
||||
lambda: service.request_migration(
|
||||
session,
|
||||
principal,
|
||||
request=ProtectionMigrationRequest(
|
||||
tenant_id=principal.tenant_id,
|
||||
**payload.model_dump(),
|
||||
),
|
||||
)
|
||||
)
|
||||
_audit(
|
||||
session,
|
||||
principal,
|
||||
"encryption.migration.requested",
|
||||
"protection_migration",
|
||||
value.migration_id,
|
||||
{"state": value.state},
|
||||
)
|
||||
session.commit()
|
||||
return _migration_response(value)
|
||||
|
||||
@router.post("/migrations/{migration_id}/outcome", response_model=MigrationResponse)
|
||||
def record_migration_outcome(
|
||||
migration_id: str,
|
||||
payload: MigrationOutcomePayload,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> MigrationResponse:
|
||||
_require(principal, "encryption:vault:admin")
|
||||
target = (
|
||||
_envelope_contract(payload.target_envelope, principal.tenant_id)
|
||||
if payload.target_envelope is not None
|
||||
else None
|
||||
)
|
||||
value = _call(
|
||||
lambda: service.record_migration_outcome(
|
||||
session,
|
||||
principal,
|
||||
migration_id=migration_id,
|
||||
state=payload.state,
|
||||
evidence_refs=tuple(payload.evidence_refs),
|
||||
target_envelope=target,
|
||||
error_code=payload.error_code,
|
||||
)
|
||||
)
|
||||
_audit(
|
||||
session,
|
||||
principal,
|
||||
"encryption.migration.outcome_recorded",
|
||||
"protection_migration",
|
||||
migration_id,
|
||||
{"state": value.state, "evidence_count": len(value.evidence_refs)},
|
||||
)
|
||||
session.commit()
|
||||
return _migration_response(value)
|
||||
|
||||
@router.post("/recoveries", response_model=RecoveryResponse)
|
||||
def request_recovery(
|
||||
payload: RecoveryRequestPayload,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> RecoveryResponse:
|
||||
_require(principal, "encryption:recovery:approve")
|
||||
value = _call(
|
||||
lambda: service.request_recovery(
|
||||
session,
|
||||
principal,
|
||||
request=RecoveryRequest(
|
||||
tenant_id=principal.tenant_id,
|
||||
**payload.model_dump(),
|
||||
),
|
||||
)
|
||||
)
|
||||
_audit(
|
||||
session,
|
||||
principal,
|
||||
"encryption.recovery.requested",
|
||||
"encryption_recovery",
|
||||
value.recovery_id,
|
||||
{"quorum": value.quorum, "requested_scope": value.requested_scope},
|
||||
)
|
||||
session.commit()
|
||||
return RecoveryResponse(**_serializable(value))
|
||||
|
||||
@router.post("/recoveries/{recovery_id}/decision", response_model=RecoveryResponse)
|
||||
def decide_recovery(
|
||||
recovery_id: str,
|
||||
payload: RecoveryDecisionPayload,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> RecoveryResponse:
|
||||
_require(principal, "encryption:recovery:approve")
|
||||
value = _call(
|
||||
lambda: service.decide_recovery(
|
||||
session,
|
||||
principal,
|
||||
request=RecoveryApprovalRequest(
|
||||
tenant_id=principal.tenant_id,
|
||||
recovery_id=recovery_id,
|
||||
**payload.model_dump(),
|
||||
),
|
||||
)
|
||||
)
|
||||
_audit(
|
||||
session,
|
||||
principal,
|
||||
"encryption.recovery.decided",
|
||||
"encryption_recovery",
|
||||
recovery_id,
|
||||
{"state": value.state, "approvals": value.approvals},
|
||||
)
|
||||
session.commit()
|
||||
return RecoveryResponse(**_serializable(value))
|
||||
|
||||
@router.get("/disable-preflight", response_model=DisablePreflightResponse)
|
||||
def disable_preflight(
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> DisablePreflightResponse:
|
||||
_require(principal, "encryption:vault:admin")
|
||||
value = service.assess_disable(session, tenant_id=principal.tenant_id)
|
||||
return DisablePreflightResponse(**_serializable(value))
|
||||
|
||||
return router
|
||||
|
||||
|
||||
def _require(principal: ApiPrincipal, *scopes: str) -> None:
|
||||
if any(has_scope(principal, scope) for scope in scopes):
|
||||
return
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Missing one of: {', '.join(scopes)}",
|
||||
)
|
||||
|
||||
|
||||
def _call(callback):
|
||||
try:
|
||||
return callback()
|
||||
except (EncryptionError, ValueError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
|
||||
def _envelope_contract(
|
||||
payload: EnvelopePayload,
|
||||
tenant_id: str,
|
||||
) -> ContentProtectionEnvelope:
|
||||
return ContentProtectionEnvelope(
|
||||
tenant_id=tenant_id,
|
||||
**payload.model_dump(),
|
||||
)
|
||||
|
||||
|
||||
def _serializable(value) -> dict[str, object]:
|
||||
data = asdict(value)
|
||||
for key in ("blocking_envelope_refs", "required_actions", "evidence_refs"):
|
||||
if key in data and isinstance(data[key], tuple):
|
||||
data[key] = list(data[key])
|
||||
return data
|
||||
|
||||
|
||||
def _vault_response(value) -> VaultResponse:
|
||||
return VaultResponse(**_serializable(value))
|
||||
|
||||
|
||||
def _envelope_response(value) -> EnvelopeResponse:
|
||||
data = _serializable(value)
|
||||
data["wrapped_key_refs"] = list(value.wrapped_key_refs)
|
||||
return EnvelopeResponse(**data)
|
||||
|
||||
|
||||
def _migration_response(value) -> MigrationResponse:
|
||||
data = _serializable(value)
|
||||
data["evidence_refs"] = list(value.evidence_refs)
|
||||
if value.target_envelope is not None:
|
||||
data["target_envelope"] = _envelope_response(value.target_envelope)
|
||||
return MigrationResponse(**data)
|
||||
|
||||
|
||||
def _audit_lifecycle(
|
||||
session: Session,
|
||||
principal: ApiPrincipal,
|
||||
value,
|
||||
action: str,
|
||||
) -> None:
|
||||
_audit(
|
||||
session,
|
||||
principal,
|
||||
f"encryption.key.{action}",
|
||||
"encryption_vault",
|
||||
value.vault_id,
|
||||
{
|
||||
"state": value.state,
|
||||
"revision": value.revision,
|
||||
"key_version": value.current_key.version if value.current_key else None,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _audit(
|
||||
session: Session,
|
||||
principal: ApiPrincipal,
|
||||
action: str,
|
||||
object_type: str,
|
||||
object_id: str,
|
||||
details: dict[str, object],
|
||||
) -> None:
|
||||
audit_event(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
user_id=getattr(principal.user, "id", None),
|
||||
api_key_id=principal.api_key_id,
|
||||
action=action,
|
||||
object_type=object_type,
|
||||
object_id=object_id,
|
||||
details={**details, "secret_material_present": False},
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["create_router"]
|
||||
@@ -0,0 +1,220 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class VaultCreatePayload(BaseModel):
|
||||
vault_id: str = Field(min_length=1, max_length=255)
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
provider_id: str = Field(min_length=1, max_length=120)
|
||||
purpose: str = Field(min_length=1, max_length=255)
|
||||
algorithm_suite: str = Field(min_length=1, max_length=120)
|
||||
scope_type: str = Field(min_length=1, max_length=80)
|
||||
scope_id: str | None = Field(default=None, max_length=255)
|
||||
policy_ref: str = Field(min_length=1, max_length=1000)
|
||||
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||
recovery_quorum: int = Field(default=2, ge=1, le=32)
|
||||
profile_kind: Literal["server_envelope", "tenant_held", "end_to_end"] = (
|
||||
"server_envelope"
|
||||
)
|
||||
import_reference: str | None = Field(default=None, max_length=1000)
|
||||
provider_policy: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class KeyRotationPayload(BaseModel):
|
||||
expected_revision: int = Field(ge=1)
|
||||
reason: str = Field(min_length=1, max_length=2000)
|
||||
policy_decision_ref: str = Field(min_length=1, max_length=1000)
|
||||
assurance_evidence_ref: str = Field(min_length=1, max_length=1000)
|
||||
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||
algorithm_suite: str | None = Field(default=None, max_length=120)
|
||||
provider_policy: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class KeyLifecyclePayload(BaseModel):
|
||||
key_version: int = Field(ge=1)
|
||||
expected_revision: int = Field(ge=1)
|
||||
reason: str = Field(min_length=1, max_length=2000)
|
||||
policy_decision_ref: str = Field(min_length=1, max_length=1000)
|
||||
assurance_evidence_ref: str = Field(min_length=1, max_length=1000)
|
||||
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||
effective_at: datetime | None = None
|
||||
|
||||
|
||||
class KeyVersionResponse(BaseModel):
|
||||
tenant_id: str
|
||||
vault_id: str
|
||||
version: int
|
||||
provider_id: str
|
||||
provider_key_ref: str
|
||||
algorithm_suite: str
|
||||
state: str
|
||||
created_at: datetime
|
||||
activated_at: datetime | None = None
|
||||
revoked_at: datetime | None = None
|
||||
destruction_scheduled_at: datetime | None = None
|
||||
destroyed_at: datetime | None = None
|
||||
public_key_ref: str | None = None
|
||||
imported: bool = False
|
||||
exportable: bool = False
|
||||
provenance: dict[str, Any] = Field(default_factory=dict)
|
||||
contract_version: str = "1"
|
||||
|
||||
|
||||
class VaultResponse(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: KeyVersionResponse | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
provenance: dict[str, Any] = Field(default_factory=dict)
|
||||
contract_version: str = "1"
|
||||
|
||||
|
||||
class EnvelopePayload(BaseModel):
|
||||
envelope_id: str = Field(min_length=1, max_length=255)
|
||||
owner_module: str = Field(min_length=1, max_length=120)
|
||||
resource_type: str = Field(min_length=1, max_length=120)
|
||||
resource_id: str = Field(min_length=1, max_length=255)
|
||||
profile_kind: Literal["server_envelope", "tenant_held", "end_to_end"]
|
||||
profile_id: str = Field(min_length=1, max_length=255)
|
||||
provider_id: str = Field(min_length=1, max_length=120)
|
||||
vault_id: str = Field(min_length=1, max_length=255)
|
||||
key_version: int = Field(ge=1)
|
||||
algorithm_suite: str = Field(min_length=1, max_length=120)
|
||||
ciphertext_ref: str = Field(min_length=1, max_length=2000)
|
||||
ciphertext_digest: str = Field(min_length=1, max_length=255)
|
||||
authenticated_context_digest: str = Field(min_length=1, max_length=255)
|
||||
state: Literal[
|
||||
"active",
|
||||
"migration_pending",
|
||||
"migrating",
|
||||
"migrated",
|
||||
"decrypted",
|
||||
"exported",
|
||||
"destroyed",
|
||||
"unavailable",
|
||||
] = "active"
|
||||
created_at: datetime
|
||||
wrapped_key_refs: list[str] = Field(default_factory=list, max_length=1000)
|
||||
source_envelope_id: str | None = Field(default=None, max_length=255)
|
||||
migration_id: str | None = Field(default=None, max_length=36)
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class EnvelopeRegistrationPayload(BaseModel):
|
||||
envelope: EnvelopePayload
|
||||
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||
policy_decision_ref: str = Field(min_length=1, max_length=1000)
|
||||
|
||||
|
||||
class EnvelopeResponse(EnvelopePayload):
|
||||
tenant_id: str
|
||||
contract_version: str = "1"
|
||||
|
||||
|
||||
class MigrationRequestPayload(BaseModel):
|
||||
envelope_id: str = Field(min_length=1, max_length=255)
|
||||
target_provider_id: str = Field(min_length=1, max_length=120)
|
||||
target_vault_id: str = Field(min_length=1, max_length=255)
|
||||
target_key_version: int = Field(ge=1)
|
||||
target_algorithm_suite: str = Field(min_length=1, max_length=120)
|
||||
mode: Literal["rewrap", "reencrypt", "decrypt", "export", "destroy"]
|
||||
policy_decision_ref: str = Field(min_length=1, max_length=1000)
|
||||
assurance_evidence_ref: str = Field(min_length=1, max_length=1000)
|
||||
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||
|
||||
|
||||
class MigrationOutcomePayload(BaseModel):
|
||||
state: Literal["succeeded", "rejected", "outcome_unknown"]
|
||||
evidence_refs: list[str] = Field(default_factory=list, max_length=1000)
|
||||
target_envelope: EnvelopePayload | None = None
|
||||
error_code: str | None = Field(default=None, max_length=255)
|
||||
|
||||
|
||||
class MigrationResponse(BaseModel):
|
||||
migration_id: str
|
||||
state: str
|
||||
source_envelope_id: str
|
||||
target_envelope: EnvelopeResponse | None = None
|
||||
error: str | None = None
|
||||
evidence_refs: list[str] = Field(default_factory=list)
|
||||
provenance: dict[str, Any] = Field(default_factory=dict)
|
||||
contract_version: str = "1"
|
||||
|
||||
|
||||
class RecoveryRequestPayload(BaseModel):
|
||||
vault_id: str = Field(min_length=1, max_length=255)
|
||||
reason: str = Field(min_length=1, max_length=2000)
|
||||
requested_scope: str = Field(min_length=1, max_length=255)
|
||||
policy_decision_ref: str = Field(min_length=1, max_length=1000)
|
||||
assurance_evidence_ref: str = Field(min_length=1, max_length=1000)
|
||||
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||
expires_at: datetime
|
||||
|
||||
|
||||
class RecoveryDecisionPayload(BaseModel):
|
||||
decision: Literal["approve", "reject"]
|
||||
reason: str = Field(min_length=1, max_length=2000)
|
||||
assurance_evidence_ref: str = Field(min_length=1, max_length=1000)
|
||||
expected_revision: int = Field(ge=1)
|
||||
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||
|
||||
|
||||
class RecoveryResponse(BaseModel):
|
||||
tenant_id: str
|
||||
recovery_id: str
|
||||
vault_id: str
|
||||
state: str
|
||||
requested_scope: str
|
||||
quorum: int
|
||||
approvals: int
|
||||
rejections: int
|
||||
revision: int
|
||||
expires_at: datetime
|
||||
policy_decision_ref: str
|
||||
execution_ref: str | None = None
|
||||
provenance: dict[str, Any] = Field(default_factory=dict)
|
||||
contract_version: str = "1"
|
||||
|
||||
|
||||
class DisablePreflightResponse(BaseModel):
|
||||
allowed: bool
|
||||
protected_count: int
|
||||
unresolved_count: int
|
||||
state_counts: dict[str, int]
|
||||
blocking_envelope_refs: list[str]
|
||||
required_actions: list[str]
|
||||
generated_at: datetime
|
||||
contract_version: str = "1"
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DisablePreflightResponse",
|
||||
"EnvelopePayload",
|
||||
"EnvelopeRegistrationPayload",
|
||||
"EnvelopeResponse",
|
||||
"KeyLifecyclePayload",
|
||||
"KeyRotationPayload",
|
||||
"MigrationOutcomePayload",
|
||||
"MigrationRequestPayload",
|
||||
"MigrationResponse",
|
||||
"RecoveryDecisionPayload",
|
||||
"RecoveryRequestPayload",
|
||||
"RecoveryResponse",
|
||||
"VaultCreatePayload",
|
||||
"VaultResponse",
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user