1470 lines
51 KiB
Python
1470 lines
51 KiB
Python
from __future__ import annotations
|
|
|
|
from collections import Counter
|
|
from collections.abc import Mapping
|
|
from datetime import datetime, timezone
|
|
import hashlib
|
|
import json
|
|
|
|
from sqlalchemy import func, select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from govoplan_core.core.encryption import (
|
|
ContentProtectionEnvelope,
|
|
DisablePreflightReport,
|
|
KeyLifecycleRequest,
|
|
KeyMaterialDescriptor,
|
|
KeyMaterialProvisionRequest,
|
|
KeyRotationRequest,
|
|
KeyVaultCreateRequest,
|
|
KeyVaultRef,
|
|
KeyVersionRef,
|
|
ProtectionMigrationRequest,
|
|
ProtectionMigrationResult,
|
|
ProtectionRegistrationRequest,
|
|
RecoveryApprovalRequest,
|
|
RecoveryRef,
|
|
RecoveryRequest,
|
|
key_material_provider,
|
|
)
|
|
from govoplan_core.core.identity_trust import (
|
|
AssuranceCheckRequest,
|
|
identity_trust_assurance,
|
|
)
|
|
from govoplan_core.db.base import utcnow
|
|
from govoplan_encryption.backend.db.models import (
|
|
ContentProtectionRecord,
|
|
EncryptionKeyOperation,
|
|
EncryptionKeyVersion,
|
|
EncryptionVault,
|
|
ProtectionMigration,
|
|
RecoveryApproval,
|
|
RecoveryCeremony,
|
|
)
|
|
|
|
|
|
TERMINAL_PROTECTION_STATES = frozenset(
|
|
{"migrated", "decrypted", "exported", "destroyed"}
|
|
)
|
|
|
|
|
|
class EncryptionError(ValueError):
|
|
pass
|
|
|
|
|
|
class SqlEncryptionService:
|
|
"""Provider-neutral encryption metadata and recovery orchestration.
|
|
|
|
Concrete providers receive only opaque references and idempotent operation
|
|
requests. This service never accepts or returns plaintext key material.
|
|
"""
|
|
|
|
def __init__(self, registry: object | None = None) -> None:
|
|
self.registry = registry
|
|
|
|
def create_vault(
|
|
self,
|
|
session: object,
|
|
principal: object,
|
|
*,
|
|
request: KeyVaultCreateRequest,
|
|
) -> KeyVaultRef:
|
|
db = _session(session)
|
|
_require_tenant(principal, request.tenant_id)
|
|
provider = self._provider(request.provider_id)
|
|
payload = _create_payload(request)
|
|
digest = _digest(payload)
|
|
replay = self._operation_replay(
|
|
db,
|
|
tenant_id=request.tenant_id,
|
|
idempotency_key=request.idempotency_key,
|
|
digest=digest,
|
|
)
|
|
if replay is not None:
|
|
return self._vault_ref(
|
|
self._vault(
|
|
db,
|
|
tenant_id=request.tenant_id,
|
|
vault_id=replay.vault_id,
|
|
),
|
|
db,
|
|
)
|
|
if db.scalar(
|
|
select(EncryptionVault.id).where(
|
|
EncryptionVault.tenant_id == request.tenant_id,
|
|
EncryptionVault.vault_id == request.vault_id,
|
|
)
|
|
):
|
|
raise EncryptionError("The vault id already exists.")
|
|
actor_id = _account_id(principal)
|
|
vault = EncryptionVault(
|
|
tenant_id=request.tenant_id,
|
|
vault_id=request.vault_id,
|
|
name=request.name.strip(),
|
|
provider_id=request.provider_id,
|
|
purpose=request.purpose,
|
|
profile_kind=request.profile_kind,
|
|
scope_type=request.scope_type,
|
|
scope_id=request.scope_id,
|
|
policy_ref=request.policy_ref,
|
|
recovery_quorum=request.recovery_quorum,
|
|
state="provisioning",
|
|
revision=1,
|
|
current_key_version=None,
|
|
create_idempotency_key=request.idempotency_key,
|
|
create_request_digest=digest,
|
|
provenance={"contract_version": request.contract_version},
|
|
created_by=actor_id,
|
|
updated_by=actor_id,
|
|
)
|
|
key = EncryptionKeyVersion(
|
|
tenant_id=request.tenant_id,
|
|
vault_id=request.vault_id,
|
|
version=1,
|
|
provider_id=request.provider_id,
|
|
provider_key_ref=None,
|
|
algorithm_suite=request.algorithm_suite,
|
|
state="unavailable",
|
|
)
|
|
operation = self._new_operation(
|
|
request.tenant_id,
|
|
request.vault_id,
|
|
1,
|
|
"provision",
|
|
request.provider_id,
|
|
request.idempotency_key,
|
|
digest,
|
|
payload,
|
|
actor_id,
|
|
policy_decision_ref=request.policy_ref,
|
|
)
|
|
db.add_all((vault, key, operation))
|
|
db.flush()
|
|
self._execute_provision(
|
|
db,
|
|
principal,
|
|
provider=provider,
|
|
vault=vault,
|
|
key=key,
|
|
operation=operation,
|
|
)
|
|
return self._vault_ref(vault, db)
|
|
|
|
def rotate_key(
|
|
self,
|
|
session: object,
|
|
principal: object,
|
|
*,
|
|
request: KeyRotationRequest,
|
|
) -> KeyVaultRef:
|
|
db = _session(session)
|
|
_require_tenant(principal, request.tenant_id)
|
|
vault = self._vault(
|
|
db,
|
|
tenant_id=request.tenant_id,
|
|
vault_id=request.vault_id,
|
|
lock=True,
|
|
)
|
|
if vault.current_key_version is None:
|
|
raise EncryptionError("The vault has no active key to rotate.")
|
|
current_key = self._key(db, vault, vault.current_key_version)
|
|
algorithm_suite = request.algorithm_suite or current_key.algorithm_suite
|
|
payload = _rotation_payload(request, vault, algorithm_suite)
|
|
digest = _digest(payload)
|
|
replay = self._operation_replay(
|
|
db,
|
|
tenant_id=request.tenant_id,
|
|
idempotency_key=request.idempotency_key,
|
|
digest=digest,
|
|
)
|
|
if replay is not None:
|
|
return self._vault_ref(vault, db)
|
|
_expected_revision(vault, request.expected_revision)
|
|
self._verify_assurance(
|
|
db,
|
|
principal,
|
|
tenant_id=request.tenant_id,
|
|
evidence_ref=request.assurance_evidence_ref,
|
|
purpose="encryption.key.rotate",
|
|
)
|
|
provider = self._provider(vault.provider_id)
|
|
version = vault.current_key_version + 1
|
|
actor_id = _account_id(principal)
|
|
key = EncryptionKeyVersion(
|
|
tenant_id=request.tenant_id,
|
|
vault_id=request.vault_id,
|
|
version=version,
|
|
provider_id=vault.provider_id,
|
|
provider_key_ref=None,
|
|
algorithm_suite=algorithm_suite,
|
|
state="unavailable",
|
|
)
|
|
operation = self._new_operation(
|
|
request.tenant_id,
|
|
request.vault_id,
|
|
version,
|
|
"rotate",
|
|
vault.provider_id,
|
|
request.idempotency_key,
|
|
digest,
|
|
payload,
|
|
actor_id,
|
|
policy_decision_ref=request.policy_decision_ref,
|
|
assurance_evidence_ref=request.assurance_evidence_ref,
|
|
)
|
|
vault.state = "rotation_pending"
|
|
vault.revision += 1
|
|
vault.updated_by = actor_id
|
|
db.add_all((key, operation))
|
|
db.flush()
|
|
self._execute_provision(
|
|
db,
|
|
principal,
|
|
provider=provider,
|
|
vault=vault,
|
|
key=key,
|
|
operation=operation,
|
|
)
|
|
return self._vault_ref(vault, db)
|
|
|
|
def revoke_key(
|
|
self,
|
|
session: object,
|
|
principal: object,
|
|
*,
|
|
request: KeyLifecycleRequest,
|
|
) -> KeyVaultRef:
|
|
return self._change_key_lifecycle(
|
|
session,
|
|
principal,
|
|
request=request,
|
|
operation_name="revoke",
|
|
)
|
|
|
|
def schedule_destruction(
|
|
self,
|
|
session: object,
|
|
principal: object,
|
|
*,
|
|
request: KeyLifecycleRequest,
|
|
) -> KeyVaultRef:
|
|
if request.effective_at is None:
|
|
raise EncryptionError("A destruction schedule requires an effective time.")
|
|
return self._change_key_lifecycle(
|
|
session,
|
|
principal,
|
|
request=request,
|
|
operation_name="destroy",
|
|
)
|
|
|
|
def get_vault(
|
|
self,
|
|
session: object,
|
|
*,
|
|
tenant_id: str,
|
|
vault_id: str,
|
|
) -> KeyVaultRef | None:
|
|
db = _session(session)
|
|
vault = db.scalar(
|
|
select(EncryptionVault).where(
|
|
EncryptionVault.tenant_id == tenant_id,
|
|
EncryptionVault.vault_id == vault_id,
|
|
)
|
|
)
|
|
return self._vault_ref(vault, db) if vault is not None else None
|
|
|
|
def reconcile_vault(
|
|
self,
|
|
session: object,
|
|
principal: object,
|
|
*,
|
|
tenant_id: str,
|
|
vault_id: str,
|
|
) -> KeyVaultRef:
|
|
db = _session(session)
|
|
_require_tenant(principal, tenant_id)
|
|
vault = self._vault(db, tenant_id=tenant_id, vault_id=vault_id, lock=True)
|
|
operation = db.scalar(
|
|
select(EncryptionKeyOperation)
|
|
.where(
|
|
EncryptionKeyOperation.tenant_id == tenant_id,
|
|
EncryptionKeyOperation.vault_id == vault_id,
|
|
EncryptionKeyOperation.state == "outcome_unknown",
|
|
)
|
|
.order_by(EncryptionKeyOperation.created_at.desc())
|
|
.with_for_update()
|
|
)
|
|
if operation is None:
|
|
return self._vault_ref(vault, db)
|
|
provider = self._provider(operation.provider_id)
|
|
key = self._key(db, vault, operation.key_version)
|
|
if operation.operation in {"provision", "rotate"}:
|
|
self._execute_provision(
|
|
db,
|
|
principal,
|
|
provider=provider,
|
|
vault=vault,
|
|
key=key,
|
|
operation=operation,
|
|
reconciliation=True,
|
|
)
|
|
else:
|
|
self._execute_lifecycle(
|
|
db,
|
|
principal,
|
|
provider=provider,
|
|
vault=vault,
|
|
key=key,
|
|
operation=operation,
|
|
reconciliation=True,
|
|
)
|
|
return self._vault_ref(vault, db)
|
|
|
|
def register_envelope(
|
|
self,
|
|
session: object,
|
|
principal: object,
|
|
*,
|
|
request: ProtectionRegistrationRequest,
|
|
) -> ContentProtectionEnvelope:
|
|
db = _session(session)
|
|
_require_tenant(principal, request.envelope.tenant_id)
|
|
return self._register_envelope(db, principal, request=request)
|
|
|
|
def get_envelope(
|
|
self,
|
|
session: object,
|
|
principal: object,
|
|
*,
|
|
tenant_id: str,
|
|
envelope_id: str,
|
|
) -> ContentProtectionEnvelope | None:
|
|
db = _session(session)
|
|
_require_tenant(principal, tenant_id)
|
|
item = db.scalar(
|
|
select(ContentProtectionRecord).where(
|
|
ContentProtectionRecord.tenant_id == tenant_id,
|
|
ContentProtectionRecord.envelope_id == envelope_id,
|
|
)
|
|
)
|
|
return _envelope_ref(item) if item is not None else None
|
|
|
|
def request_migration(
|
|
self,
|
|
session: object,
|
|
principal: object,
|
|
*,
|
|
request: ProtectionMigrationRequest,
|
|
) -> ProtectionMigrationResult:
|
|
db = _session(session)
|
|
_require_tenant(principal, request.tenant_id)
|
|
source = self._envelope(
|
|
db,
|
|
tenant_id=request.tenant_id,
|
|
envelope_id=request.envelope_id,
|
|
lock=True,
|
|
)
|
|
payload = _migration_payload(request)
|
|
digest = _digest(payload)
|
|
replay = db.scalar(
|
|
select(ProtectionMigration).where(
|
|
ProtectionMigration.tenant_id == request.tenant_id,
|
|
ProtectionMigration.idempotency_key == request.idempotency_key,
|
|
)
|
|
)
|
|
if replay is not None:
|
|
if replay.request_digest != digest:
|
|
raise EncryptionError(
|
|
"The migration idempotency key was reused for another request."
|
|
)
|
|
return self._migration_ref(db, replay)
|
|
if source.state != "active":
|
|
raise EncryptionError("Only an active envelope can enter migration.")
|
|
target_vault = self._vault(
|
|
db,
|
|
tenant_id=request.tenant_id,
|
|
vault_id=request.target_vault_id,
|
|
)
|
|
target_key = self._key(db, target_vault, request.target_key_version)
|
|
if (
|
|
target_vault.provider_id != request.target_provider_id
|
|
or target_key.algorithm_suite != request.target_algorithm_suite
|
|
or target_key.state != "active"
|
|
):
|
|
raise EncryptionError(
|
|
"The target vault, key version, provider, and algorithm do not match."
|
|
)
|
|
self._verify_assurance(
|
|
db,
|
|
principal,
|
|
tenant_id=request.tenant_id,
|
|
evidence_ref=request.assurance_evidence_ref,
|
|
purpose=f"encryption.content.{request.mode}",
|
|
)
|
|
item = ProtectionMigration(
|
|
tenant_id=request.tenant_id,
|
|
source_envelope_id=request.envelope_id,
|
|
target_provider_id=request.target_provider_id,
|
|
target_vault_id=request.target_vault_id,
|
|
target_key_version=request.target_key_version,
|
|
target_algorithm_suite=request.target_algorithm_suite,
|
|
mode=request.mode,
|
|
state="requested",
|
|
policy_decision_ref=request.policy_decision_ref,
|
|
assurance_evidence_ref=request.assurance_evidence_ref,
|
|
idempotency_key=request.idempotency_key,
|
|
request_digest=digest,
|
|
evidence_refs=[],
|
|
provenance={"mixed_state_allowed": False},
|
|
requested_by=_account_id(principal),
|
|
)
|
|
db.add(item)
|
|
db.flush()
|
|
source.state = "migration_pending"
|
|
source.migration_id = item.id
|
|
db.flush()
|
|
return self._migration_ref(db, item)
|
|
|
|
def record_migration_outcome(
|
|
self,
|
|
session: object,
|
|
principal: object,
|
|
*,
|
|
migration_id: str,
|
|
state: str,
|
|
evidence_refs: tuple[str, ...],
|
|
target_envelope: ContentProtectionEnvelope | None = None,
|
|
error_code: str | None = None,
|
|
) -> ProtectionMigrationResult:
|
|
db = _session(session)
|
|
item = db.scalar(
|
|
select(ProtectionMigration)
|
|
.where(ProtectionMigration.id == migration_id)
|
|
.with_for_update()
|
|
)
|
|
if item is None:
|
|
raise EncryptionError("Protection migration not found.")
|
|
_require_tenant(principal, item.tenant_id)
|
|
source = self._envelope(
|
|
db,
|
|
tenant_id=item.tenant_id,
|
|
envelope_id=item.source_envelope_id,
|
|
lock=True,
|
|
)
|
|
normalized_state = state.strip().lower()
|
|
if normalized_state not in {"succeeded", "rejected", "outcome_unknown"}:
|
|
raise EncryptionError("Unsupported migration outcome.")
|
|
clean_evidence = tuple(
|
|
value.strip() for value in evidence_refs if value.strip()
|
|
)
|
|
if item.state in {"succeeded", "rejected", "outcome_unknown"}:
|
|
if (
|
|
item.state != normalized_state
|
|
or tuple(item.evidence_refs) != clean_evidence
|
|
):
|
|
raise EncryptionError("The migration already has another outcome.")
|
|
return self._migration_ref(db, item)
|
|
if normalized_state == "succeeded" and not clean_evidence:
|
|
raise EncryptionError(
|
|
"A successful migration requires evidence references."
|
|
)
|
|
if normalized_state == "succeeded":
|
|
if item.mode in {"rewrap", "reencrypt"}:
|
|
if target_envelope is None:
|
|
raise EncryptionError(
|
|
"Rewrap and re-encryption require a target envelope."
|
|
)
|
|
self._validate_migration_target(item, source, target_envelope)
|
|
target = self._register_envelope(
|
|
db,
|
|
principal,
|
|
request=ProtectionRegistrationRequest(
|
|
envelope=target_envelope,
|
|
idempotency_key=f"migration:{item.id}:target",
|
|
policy_decision_ref=item.policy_decision_ref,
|
|
),
|
|
)
|
|
item.target_envelope_id = target.envelope_id
|
|
source.state = "migrated"
|
|
elif item.mode == "decrypt":
|
|
source.state = "decrypted"
|
|
elif item.mode == "export":
|
|
source.state = "exported"
|
|
elif item.mode == "destroy":
|
|
source.state = "destroyed"
|
|
item.completed_at = _as_utc(utcnow())
|
|
elif normalized_state == "rejected":
|
|
source.state = "active"
|
|
source.migration_id = None
|
|
item.completed_at = _as_utc(utcnow())
|
|
else:
|
|
source.state = "migration_pending"
|
|
item.state = normalized_state
|
|
item.evidence_refs = list(clean_evidence)
|
|
item.error_code = _safe_error_code(error_code)
|
|
item.provenance = {
|
|
**dict(item.provenance),
|
|
"outcome_recorded_by": _account_id(principal),
|
|
"cryptographic_material_present": False,
|
|
}
|
|
db.flush()
|
|
return self._migration_ref(db, item)
|
|
|
|
def reconcile_migration(
|
|
self,
|
|
session: object,
|
|
principal: object,
|
|
*,
|
|
migration_id: str,
|
|
) -> ProtectionMigrationResult:
|
|
db = _session(session)
|
|
item = db.get(ProtectionMigration, migration_id)
|
|
if item is None:
|
|
raise EncryptionError("Protection migration not found.")
|
|
_require_tenant(principal, item.tenant_id)
|
|
return self._migration_ref(db, item)
|
|
|
|
def request_recovery(
|
|
self,
|
|
session: object,
|
|
principal: object,
|
|
*,
|
|
request: RecoveryRequest,
|
|
) -> RecoveryRef:
|
|
db = _session(session)
|
|
_require_tenant(principal, request.tenant_id)
|
|
now = _as_utc(utcnow())
|
|
if _as_utc(request.expires_at) <= now:
|
|
raise EncryptionError("Recovery expiry must be in the future.")
|
|
vault = self._vault(
|
|
db,
|
|
tenant_id=request.tenant_id,
|
|
vault_id=request.vault_id,
|
|
)
|
|
payload = _recovery_payload(request)
|
|
digest = _digest(payload)
|
|
replay = db.scalar(
|
|
select(RecoveryCeremony).where(
|
|
RecoveryCeremony.tenant_id == request.tenant_id,
|
|
RecoveryCeremony.idempotency_key == request.idempotency_key,
|
|
)
|
|
)
|
|
if replay is not None:
|
|
if replay.request_digest != digest:
|
|
raise EncryptionError(
|
|
"The recovery idempotency key was reused for another request."
|
|
)
|
|
return self._recovery_ref(db, replay)
|
|
self._verify_assurance(
|
|
db,
|
|
principal,
|
|
tenant_id=request.tenant_id,
|
|
evidence_ref=request.assurance_evidence_ref,
|
|
purpose="encryption.recovery.request",
|
|
)
|
|
item = RecoveryCeremony(
|
|
tenant_id=request.tenant_id,
|
|
vault_id=request.vault_id,
|
|
state="pending",
|
|
requested_scope=request.requested_scope,
|
|
reason=request.reason,
|
|
quorum=vault.recovery_quorum,
|
|
revision=1,
|
|
policy_decision_ref=request.policy_decision_ref,
|
|
requester_assurance_ref=request.assurance_evidence_ref,
|
|
requester_account_id=_account_id(principal),
|
|
idempotency_key=request.idempotency_key,
|
|
request_digest=digest,
|
|
expires_at=_as_utc(request.expires_at),
|
|
provenance={
|
|
"key_material_released": False,
|
|
"resource_ownership_changed": False,
|
|
},
|
|
)
|
|
db.add(item)
|
|
db.flush()
|
|
return self._recovery_ref(db, item)
|
|
|
|
def decide_recovery(
|
|
self,
|
|
session: object,
|
|
principal: object,
|
|
*,
|
|
request: RecoveryApprovalRequest,
|
|
) -> RecoveryRef:
|
|
db = _session(session)
|
|
_require_tenant(principal, request.tenant_id)
|
|
ceremony = db.scalar(
|
|
select(RecoveryCeremony)
|
|
.where(
|
|
RecoveryCeremony.tenant_id == request.tenant_id,
|
|
RecoveryCeremony.id == request.recovery_id,
|
|
)
|
|
.with_for_update()
|
|
)
|
|
if ceremony is None:
|
|
raise EncryptionError("Recovery ceremony not found.")
|
|
self._refresh_recovery_state(ceremony)
|
|
if ceremony.state != "pending":
|
|
return self._recovery_ref(db, ceremony)
|
|
_expected_revision(ceremony, request.expected_revision)
|
|
actor_id = _account_id(principal)
|
|
if actor_id == ceremony.requester_account_id:
|
|
raise EncryptionError("The requester cannot approve their own recovery.")
|
|
payload = {
|
|
"tenant_id": request.tenant_id,
|
|
"recovery_id": request.recovery_id,
|
|
"decision": request.decision,
|
|
"reason": request.reason,
|
|
"assurance_evidence_ref": request.assurance_evidence_ref,
|
|
}
|
|
digest = _digest(payload)
|
|
replay = db.scalar(
|
|
select(RecoveryApproval).where(
|
|
RecoveryApproval.tenant_id == request.tenant_id,
|
|
RecoveryApproval.idempotency_key == request.idempotency_key,
|
|
)
|
|
)
|
|
if replay is not None:
|
|
if replay.request_digest != digest:
|
|
raise EncryptionError(
|
|
"The approval idempotency key was reused for another decision."
|
|
)
|
|
return self._recovery_ref(db, ceremony)
|
|
if db.scalar(
|
|
select(RecoveryApproval.id).where(
|
|
RecoveryApproval.tenant_id == request.tenant_id,
|
|
RecoveryApproval.recovery_id == request.recovery_id,
|
|
RecoveryApproval.approver_account_id == actor_id,
|
|
)
|
|
):
|
|
raise EncryptionError("Each custodian may decide a recovery only once.")
|
|
self._verify_assurance(
|
|
db,
|
|
principal,
|
|
tenant_id=request.tenant_id,
|
|
evidence_ref=request.assurance_evidence_ref,
|
|
purpose="encryption.recovery.approve",
|
|
)
|
|
db.add(
|
|
RecoveryApproval(
|
|
tenant_id=request.tenant_id,
|
|
recovery_id=request.recovery_id,
|
|
approver_account_id=actor_id,
|
|
decision=request.decision,
|
|
reason=request.reason,
|
|
assurance_evidence_ref=request.assurance_evidence_ref,
|
|
idempotency_key=request.idempotency_key,
|
|
request_digest=digest,
|
|
)
|
|
)
|
|
db.flush()
|
|
decisions = Counter(
|
|
db.scalars(
|
|
select(RecoveryApproval.decision).where(
|
|
RecoveryApproval.tenant_id == request.tenant_id,
|
|
RecoveryApproval.recovery_id == request.recovery_id,
|
|
)
|
|
)
|
|
)
|
|
if decisions["reject"]:
|
|
ceremony.state = "rejected"
|
|
elif decisions["approve"] >= ceremony.quorum:
|
|
ceremony.state = "approved"
|
|
ceremony.revision += 1
|
|
ceremony.provenance = {
|
|
**dict(ceremony.provenance),
|
|
"approved_by_distinct_accounts": decisions["approve"],
|
|
"key_material_released": False,
|
|
}
|
|
db.flush()
|
|
return self._recovery_ref(db, ceremony)
|
|
|
|
def assess_disable(
|
|
self,
|
|
session: object,
|
|
*,
|
|
tenant_id: str | None = None,
|
|
limit: int = 100,
|
|
) -> DisablePreflightReport:
|
|
db = _session(session)
|
|
statement = select(
|
|
ContentProtectionRecord.state,
|
|
func.count(ContentProtectionRecord.id),
|
|
).group_by(ContentProtectionRecord.state)
|
|
if tenant_id is not None:
|
|
statement = statement.where(ContentProtectionRecord.tenant_id == tenant_id)
|
|
state_counts = {
|
|
str(state): int(count) for state, count in db.execute(statement).all()
|
|
}
|
|
unresolved = sum(
|
|
count
|
|
for state, count in state_counts.items()
|
|
if state not in TERMINAL_PROTECTION_STATES
|
|
)
|
|
blockers = select(
|
|
ContentProtectionRecord.tenant_id,
|
|
ContentProtectionRecord.envelope_id,
|
|
).where(ContentProtectionRecord.state.not_in(TERMINAL_PROTECTION_STATES))
|
|
if tenant_id is not None:
|
|
blockers = blockers.where(ContentProtectionRecord.tenant_id == tenant_id)
|
|
blockers = blockers.order_by(
|
|
ContentProtectionRecord.tenant_id,
|
|
ContentProtectionRecord.envelope_id,
|
|
).limit(max(1, min(limit, 500)))
|
|
refs = tuple(
|
|
f"{tenant}:{envelope}" for tenant, envelope in db.execute(blockers)
|
|
)
|
|
return DisablePreflightReport(
|
|
allowed=unresolved == 0,
|
|
protected_count=sum(state_counts.values()),
|
|
unresolved_count=unresolved,
|
|
state_counts=state_counts,
|
|
blocking_envelope_refs=refs,
|
|
required_actions=(
|
|
()
|
|
if unresolved == 0
|
|
else (
|
|
"Migrate, decrypt, explicitly export, or cryptographically destroy every unresolved envelope.",
|
|
"Reconcile all outcome-unknown operations before disabling Encryption.",
|
|
)
|
|
),
|
|
generated_at=_as_utc(utcnow()),
|
|
)
|
|
|
|
def _change_key_lifecycle(
|
|
self,
|
|
session: object,
|
|
principal: object,
|
|
*,
|
|
request: KeyLifecycleRequest,
|
|
operation_name: str,
|
|
) -> KeyVaultRef:
|
|
db = _session(session)
|
|
_require_tenant(principal, request.tenant_id)
|
|
vault = self._vault(
|
|
db,
|
|
tenant_id=request.tenant_id,
|
|
vault_id=request.vault_id,
|
|
lock=True,
|
|
)
|
|
payload = _lifecycle_payload(request, operation_name)
|
|
digest = _digest(payload)
|
|
replay = self._operation_replay(
|
|
db,
|
|
tenant_id=request.tenant_id,
|
|
idempotency_key=request.idempotency_key,
|
|
digest=digest,
|
|
)
|
|
if replay is not None:
|
|
return self._vault_ref(vault, db)
|
|
_expected_revision(vault, request.expected_revision)
|
|
key = self._key(db, vault, request.key_version)
|
|
if key.provider_key_ref is None:
|
|
raise EncryptionError("The key provider reference is unavailable.")
|
|
self._verify_assurance(
|
|
db,
|
|
principal,
|
|
tenant_id=request.tenant_id,
|
|
evidence_ref=request.assurance_evidence_ref,
|
|
purpose=f"encryption.key.{operation_name}",
|
|
)
|
|
operation = self._new_operation(
|
|
request.tenant_id,
|
|
request.vault_id,
|
|
request.key_version,
|
|
operation_name,
|
|
vault.provider_id,
|
|
request.idempotency_key,
|
|
digest,
|
|
payload,
|
|
_account_id(principal),
|
|
policy_decision_ref=request.policy_decision_ref,
|
|
assurance_evidence_ref=request.assurance_evidence_ref,
|
|
)
|
|
vault.state = f"{operation_name}_pending"
|
|
vault.revision += 1
|
|
vault.updated_by = _account_id(principal)
|
|
db.add(operation)
|
|
db.flush()
|
|
self._execute_lifecycle(
|
|
db,
|
|
principal,
|
|
provider=self._provider(vault.provider_id),
|
|
vault=vault,
|
|
key=key,
|
|
operation=operation,
|
|
)
|
|
return self._vault_ref(vault, db)
|
|
|
|
def _execute_provision(
|
|
self,
|
|
db: Session,
|
|
principal: object,
|
|
*,
|
|
provider: object,
|
|
vault: EncryptionVault,
|
|
key: EncryptionKeyVersion,
|
|
operation: EncryptionKeyOperation,
|
|
reconciliation: bool = False,
|
|
) -> None:
|
|
payload = operation.request_payload
|
|
request = KeyMaterialProvisionRequest(
|
|
tenant_id=operation.tenant_id,
|
|
vault_id=operation.vault_id,
|
|
key_version=operation.key_version,
|
|
algorithm_suite=str(payload["algorithm_suite"]),
|
|
purpose=str(payload["purpose"]),
|
|
idempotency_key=operation.idempotency_key,
|
|
provider_policy=dict(payload.get("provider_policy") or {}),
|
|
import_reference=(
|
|
str(payload["import_reference"])
|
|
if payload.get("import_reference") is not None
|
|
else None
|
|
),
|
|
)
|
|
try:
|
|
descriptor = provider.provision_key( # type: ignore[attr-defined]
|
|
db,
|
|
principal,
|
|
request=request,
|
|
)
|
|
self._apply_descriptor(vault, key, descriptor)
|
|
operation.state = "succeeded"
|
|
operation.error_code = None
|
|
operation.completed_at = _as_utc(utcnow())
|
|
vault.current_key_version = key.version
|
|
vault.state = "active"
|
|
if reconciliation:
|
|
vault.revision += 1
|
|
except Exception as exc:
|
|
operation.state = "outcome_unknown"
|
|
operation.error_code = _exception_code(exc)
|
|
vault.state = "outcome_unknown"
|
|
key.state = "unavailable"
|
|
db.flush()
|
|
|
|
def _execute_lifecycle(
|
|
self,
|
|
db: Session,
|
|
principal: object,
|
|
*,
|
|
provider: object,
|
|
vault: EncryptionVault,
|
|
key: EncryptionKeyVersion,
|
|
operation: EncryptionKeyOperation,
|
|
reconciliation: bool = False,
|
|
) -> None:
|
|
if key.provider_key_ref is None:
|
|
raise EncryptionError("The key provider reference is unavailable.")
|
|
try:
|
|
if operation.operation == "revoke":
|
|
descriptor = provider.revoke_key( # type: ignore[attr-defined]
|
|
db,
|
|
principal,
|
|
provider_key_ref=key.provider_key_ref,
|
|
reason=str(operation.request_payload["reason"]),
|
|
idempotency_key=operation.idempotency_key,
|
|
)
|
|
else:
|
|
effective_at = datetime.fromisoformat(
|
|
str(operation.request_payload["effective_at"])
|
|
)
|
|
descriptor = provider.schedule_key_destruction( # type: ignore[attr-defined]
|
|
db,
|
|
principal,
|
|
provider_key_ref=key.provider_key_ref,
|
|
effective_at=effective_at,
|
|
idempotency_key=operation.idempotency_key,
|
|
)
|
|
self._apply_descriptor(vault, key, descriptor)
|
|
operation.state = "succeeded"
|
|
operation.error_code = None
|
|
operation.completed_at = _as_utc(utcnow())
|
|
vault.state = "active"
|
|
if key.version == vault.current_key_version and key.state in {
|
|
"revoked",
|
|
"destroyed",
|
|
}:
|
|
vault.state = "unavailable"
|
|
if reconciliation:
|
|
vault.revision += 1
|
|
except Exception as exc:
|
|
operation.state = "outcome_unknown"
|
|
operation.error_code = _exception_code(exc)
|
|
vault.state = "outcome_unknown"
|
|
db.flush()
|
|
|
|
@staticmethod
|
|
def _apply_descriptor(
|
|
vault: EncryptionVault,
|
|
key: EncryptionKeyVersion,
|
|
descriptor: KeyMaterialDescriptor,
|
|
) -> None:
|
|
if descriptor.provider_id != vault.provider_id:
|
|
raise EncryptionError("The provider returned a key for another provider.")
|
|
if descriptor.algorithm_suite != key.algorithm_suite:
|
|
raise EncryptionError("The provider returned another algorithm suite.")
|
|
key.provider_key_ref = descriptor.provider_key_ref
|
|
key.state = descriptor.state
|
|
key.public_key_ref = descriptor.public_key_ref
|
|
key.imported = descriptor.imported
|
|
key.exportable = descriptor.exportable
|
|
key.provider_version = descriptor.provider_version
|
|
key.provider_provenance = dict(descriptor.provenance)
|
|
if descriptor.state == "active" and key.activated_at is None:
|
|
key.activated_at = _as_utc(descriptor.created_at)
|
|
if descriptor.state == "revoked":
|
|
key.revoked_at = _as_utc(utcnow())
|
|
if descriptor.state == "destruction_scheduled":
|
|
key.destruction_scheduled_at = _as_utc(utcnow())
|
|
if descriptor.state == "destroyed":
|
|
key.destroyed_at = _as_utc(utcnow())
|
|
|
|
def _register_envelope(
|
|
self,
|
|
db: Session,
|
|
principal: object,
|
|
*,
|
|
request: ProtectionRegistrationRequest,
|
|
) -> ContentProtectionEnvelope:
|
|
envelope = request.envelope
|
|
digest = _digest(_envelope_payload(envelope, request.policy_decision_ref))
|
|
replay = db.scalar(
|
|
select(ContentProtectionRecord).where(
|
|
ContentProtectionRecord.tenant_id == envelope.tenant_id,
|
|
ContentProtectionRecord.idempotency_key == request.idempotency_key,
|
|
)
|
|
)
|
|
if replay is not None:
|
|
if replay.request_digest != digest:
|
|
raise EncryptionError(
|
|
"The envelope idempotency key was reused for another envelope."
|
|
)
|
|
return _envelope_ref(replay)
|
|
if db.scalar(
|
|
select(ContentProtectionRecord.id).where(
|
|
ContentProtectionRecord.tenant_id == envelope.tenant_id,
|
|
ContentProtectionRecord.envelope_id == envelope.envelope_id,
|
|
)
|
|
):
|
|
raise EncryptionError("The protection envelope id already exists.")
|
|
vault = self._vault(
|
|
db,
|
|
tenant_id=envelope.tenant_id,
|
|
vault_id=envelope.vault_id,
|
|
)
|
|
key = self._key(db, vault, envelope.key_version)
|
|
if (
|
|
vault.provider_id != envelope.provider_id
|
|
or vault.profile_kind != envelope.profile_kind
|
|
or key.algorithm_suite != envelope.algorithm_suite
|
|
or key.state != "active"
|
|
):
|
|
raise EncryptionError(
|
|
"The envelope does not match an active vault key and protection profile."
|
|
)
|
|
item = ContentProtectionRecord(
|
|
envelope_id=envelope.envelope_id,
|
|
tenant_id=envelope.tenant_id,
|
|
owner_module=envelope.owner_module,
|
|
resource_type=envelope.resource_type,
|
|
resource_id=envelope.resource_id,
|
|
profile_kind=envelope.profile_kind,
|
|
profile_id=envelope.profile_id,
|
|
provider_id=envelope.provider_id,
|
|
vault_id=envelope.vault_id,
|
|
key_version=envelope.key_version,
|
|
algorithm_suite=envelope.algorithm_suite,
|
|
ciphertext_ref=envelope.ciphertext_ref,
|
|
ciphertext_digest=envelope.ciphertext_digest,
|
|
authenticated_context_digest=envelope.authenticated_context_digest,
|
|
wrapped_key_refs=list(envelope.wrapped_key_refs),
|
|
state=envelope.state,
|
|
source_envelope_id=envelope.source_envelope_id,
|
|
migration_id=envelope.migration_id,
|
|
envelope_metadata=dict(envelope.metadata),
|
|
idempotency_key=request.idempotency_key,
|
|
request_digest=digest,
|
|
policy_decision_ref=request.policy_decision_ref,
|
|
registered_by=_account_id(principal),
|
|
)
|
|
db.add(item)
|
|
db.flush()
|
|
return _envelope_ref(item)
|
|
|
|
@staticmethod
|
|
def _validate_migration_target(
|
|
item: ProtectionMigration,
|
|
source: ContentProtectionRecord,
|
|
target: ContentProtectionEnvelope,
|
|
) -> None:
|
|
if (
|
|
target.tenant_id != item.tenant_id
|
|
or target.source_envelope_id != source.envelope_id
|
|
or target.migration_id != item.id
|
|
or target.owner_module != source.owner_module
|
|
or target.resource_type != source.resource_type
|
|
or target.resource_id != source.resource_id
|
|
or target.provider_id != item.target_provider_id
|
|
or target.vault_id != item.target_vault_id
|
|
or target.key_version != item.target_key_version
|
|
or target.algorithm_suite != item.target_algorithm_suite
|
|
or target.state != "active"
|
|
):
|
|
raise EncryptionError(
|
|
"The target envelope does not exactly match the authorized migration."
|
|
)
|
|
|
|
def _migration_ref(
|
|
self,
|
|
db: Session,
|
|
item: ProtectionMigration,
|
|
) -> ProtectionMigrationResult:
|
|
target = None
|
|
if item.target_envelope_id:
|
|
target_item = db.scalar(
|
|
select(ContentProtectionRecord).where(
|
|
ContentProtectionRecord.tenant_id == item.tenant_id,
|
|
ContentProtectionRecord.envelope_id == item.target_envelope_id,
|
|
)
|
|
)
|
|
target = _envelope_ref(target_item) if target_item is not None else None
|
|
return ProtectionMigrationResult(
|
|
migration_id=item.id,
|
|
state=item.state, # type: ignore[arg-type]
|
|
source_envelope_id=item.source_envelope_id,
|
|
target_envelope=target,
|
|
error=item.error_code,
|
|
evidence_refs=tuple(item.evidence_refs),
|
|
provenance={
|
|
**dict(item.provenance),
|
|
"mode": item.mode,
|
|
"policy_decision_ref": item.policy_decision_ref,
|
|
},
|
|
)
|
|
|
|
def _recovery_ref(self, db: Session, item: RecoveryCeremony) -> RecoveryRef:
|
|
self._refresh_recovery_state(item)
|
|
decisions = Counter(
|
|
db.scalars(
|
|
select(RecoveryApproval.decision).where(
|
|
RecoveryApproval.tenant_id == item.tenant_id,
|
|
RecoveryApproval.recovery_id == item.id,
|
|
)
|
|
)
|
|
)
|
|
return RecoveryRef(
|
|
tenant_id=item.tenant_id,
|
|
recovery_id=item.id,
|
|
vault_id=item.vault_id,
|
|
state=item.state, # type: ignore[arg-type]
|
|
requested_scope=item.requested_scope,
|
|
quorum=item.quorum,
|
|
approvals=decisions["approve"],
|
|
rejections=decisions["reject"],
|
|
revision=item.revision,
|
|
expires_at=item.expires_at,
|
|
policy_decision_ref=item.policy_decision_ref,
|
|
execution_ref=item.execution_ref,
|
|
provenance=dict(item.provenance),
|
|
)
|
|
|
|
@staticmethod
|
|
def _refresh_recovery_state(item: RecoveryCeremony) -> None:
|
|
if item.state == "pending" and _as_utc(item.expires_at) <= _as_utc(utcnow()):
|
|
item.state = "expired"
|
|
item.revision += 1
|
|
|
|
def _verify_assurance(
|
|
self,
|
|
db: Session,
|
|
principal: object,
|
|
*,
|
|
tenant_id: str,
|
|
evidence_ref: str,
|
|
purpose: str,
|
|
) -> None:
|
|
capability = identity_trust_assurance(self.registry)
|
|
if capability is None:
|
|
raise EncryptionError(
|
|
"Recent high-assurance evidence is required, but Identity Trust is unavailable."
|
|
)
|
|
decision = capability.verify_assurance(
|
|
db,
|
|
principal,
|
|
request=AssuranceCheckRequest(
|
|
tenant_id=tenant_id,
|
|
account_id=_account_id(principal),
|
|
purpose=purpose,
|
|
minimum_level="high",
|
|
evidence_ref=evidence_ref,
|
|
evaluated_at=_as_utc(utcnow()),
|
|
maximum_age_seconds=300,
|
|
),
|
|
)
|
|
if not decision.allowed:
|
|
raise EncryptionError(
|
|
f"High-assurance verification failed: {decision.reason}"
|
|
)
|
|
|
|
def _provider(self, provider_id: str) -> object:
|
|
provider = key_material_provider(self.registry, provider_id)
|
|
if provider is None:
|
|
raise EncryptionError(
|
|
f"Encryption key-material provider {provider_id!r} is unavailable."
|
|
)
|
|
return provider
|
|
|
|
@staticmethod
|
|
def _new_operation(
|
|
tenant_id: str,
|
|
vault_id: str,
|
|
key_version: int,
|
|
operation: str,
|
|
provider_id: str,
|
|
idempotency_key: str,
|
|
request_digest: str,
|
|
request_payload: Mapping[str, object],
|
|
requested_by: str,
|
|
*,
|
|
policy_decision_ref: str | None = None,
|
|
assurance_evidence_ref: str | None = None,
|
|
) -> EncryptionKeyOperation:
|
|
return EncryptionKeyOperation(
|
|
tenant_id=tenant_id,
|
|
vault_id=vault_id,
|
|
key_version=key_version,
|
|
operation=operation,
|
|
provider_id=provider_id,
|
|
state="prepared",
|
|
idempotency_key=idempotency_key,
|
|
request_digest=request_digest,
|
|
request_payload=dict(request_payload),
|
|
policy_decision_ref=policy_decision_ref,
|
|
assurance_evidence_ref=assurance_evidence_ref,
|
|
requested_by=requested_by,
|
|
)
|
|
|
|
@staticmethod
|
|
def _operation_replay(
|
|
db: Session,
|
|
*,
|
|
tenant_id: str,
|
|
idempotency_key: str,
|
|
digest: str,
|
|
) -> EncryptionKeyOperation | None:
|
|
item = db.scalar(
|
|
select(EncryptionKeyOperation).where(
|
|
EncryptionKeyOperation.tenant_id == tenant_id,
|
|
EncryptionKeyOperation.idempotency_key == idempotency_key,
|
|
)
|
|
)
|
|
if item is not None and item.request_digest != digest:
|
|
raise EncryptionError(
|
|
"The key-operation idempotency key was reused for another request."
|
|
)
|
|
return item
|
|
|
|
@staticmethod
|
|
def _vault(
|
|
db: Session,
|
|
*,
|
|
tenant_id: str,
|
|
vault_id: str,
|
|
lock: bool = False,
|
|
) -> EncryptionVault:
|
|
statement = select(EncryptionVault).where(
|
|
EncryptionVault.tenant_id == tenant_id,
|
|
EncryptionVault.vault_id == vault_id,
|
|
)
|
|
if lock:
|
|
statement = statement.with_for_update()
|
|
item = db.scalar(statement)
|
|
if item is None:
|
|
raise EncryptionError("Encryption vault not found.")
|
|
return item
|
|
|
|
@staticmethod
|
|
def _key(
|
|
db: Session,
|
|
vault: EncryptionVault,
|
|
version: int,
|
|
) -> EncryptionKeyVersion:
|
|
item = db.scalar(
|
|
select(EncryptionKeyVersion).where(
|
|
EncryptionKeyVersion.tenant_id == vault.tenant_id,
|
|
EncryptionKeyVersion.vault_id == vault.vault_id,
|
|
EncryptionKeyVersion.version == version,
|
|
)
|
|
)
|
|
if item is None:
|
|
raise EncryptionError("Encryption key version not found.")
|
|
return item
|
|
|
|
@staticmethod
|
|
def _envelope(
|
|
db: Session,
|
|
*,
|
|
tenant_id: str,
|
|
envelope_id: str,
|
|
lock: bool = False,
|
|
) -> ContentProtectionRecord:
|
|
statement = select(ContentProtectionRecord).where(
|
|
ContentProtectionRecord.tenant_id == tenant_id,
|
|
ContentProtectionRecord.envelope_id == envelope_id,
|
|
)
|
|
if lock:
|
|
statement = statement.with_for_update()
|
|
item = db.scalar(statement)
|
|
if item is None:
|
|
raise EncryptionError("Protection envelope not found.")
|
|
return item
|
|
|
|
def _vault_ref(self, item: EncryptionVault, db: Session) -> KeyVaultRef:
|
|
key = None
|
|
if item.current_key_version is not None:
|
|
key = _key_ref(self._key(db, item, item.current_key_version))
|
|
return KeyVaultRef(
|
|
tenant_id=item.tenant_id,
|
|
vault_id=item.vault_id,
|
|
name=item.name,
|
|
provider_id=item.provider_id,
|
|
purpose=item.purpose,
|
|
profile_kind=item.profile_kind, # type: ignore[arg-type]
|
|
scope_type=item.scope_type,
|
|
scope_id=item.scope_id,
|
|
policy_ref=item.policy_ref,
|
|
recovery_quorum=item.recovery_quorum,
|
|
state=item.state,
|
|
revision=item.revision,
|
|
current_key=key,
|
|
created_at=item.created_at,
|
|
updated_at=item.updated_at,
|
|
provenance=dict(item.provenance),
|
|
)
|
|
|
|
|
|
def _key_ref(item: EncryptionKeyVersion) -> KeyVersionRef:
|
|
if item.provider_key_ref is None:
|
|
raise EncryptionError("The current key has no provider reference.")
|
|
return KeyVersionRef(
|
|
tenant_id=item.tenant_id,
|
|
vault_id=item.vault_id,
|
|
version=item.version,
|
|
provider_id=item.provider_id,
|
|
provider_key_ref=item.provider_key_ref,
|
|
algorithm_suite=item.algorithm_suite,
|
|
state=item.state, # type: ignore[arg-type]
|
|
created_at=item.created_at,
|
|
activated_at=item.activated_at,
|
|
revoked_at=item.revoked_at,
|
|
destruction_scheduled_at=item.destruction_scheduled_at,
|
|
destroyed_at=item.destroyed_at,
|
|
public_key_ref=item.public_key_ref,
|
|
imported=item.imported,
|
|
exportable=item.exportable,
|
|
provenance={
|
|
**dict(item.provider_provenance),
|
|
"provider_version": item.provider_version,
|
|
},
|
|
)
|
|
|
|
|
|
def _envelope_ref(item: ContentProtectionRecord) -> ContentProtectionEnvelope:
|
|
return ContentProtectionEnvelope(
|
|
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, # type: ignore[arg-type]
|
|
profile_id=item.profile_id,
|
|
provider_id=item.provider_id,
|
|
vault_id=item.vault_id,
|
|
key_version=item.key_version,
|
|
algorithm_suite=item.algorithm_suite,
|
|
ciphertext_ref=item.ciphertext_ref,
|
|
ciphertext_digest=item.ciphertext_digest,
|
|
authenticated_context_digest=item.authenticated_context_digest,
|
|
state=item.state, # type: ignore[arg-type]
|
|
created_at=item.created_at,
|
|
wrapped_key_refs=tuple(item.wrapped_key_refs),
|
|
source_envelope_id=item.source_envelope_id,
|
|
migration_id=item.migration_id,
|
|
metadata=dict(item.envelope_metadata),
|
|
)
|
|
|
|
|
|
def _create_payload(request: KeyVaultCreateRequest) -> dict[str, object]:
|
|
return {
|
|
"tenant_id": request.tenant_id,
|
|
"vault_id": request.vault_id,
|
|
"name": request.name,
|
|
"provider_id": request.provider_id,
|
|
"purpose": request.purpose,
|
|
"algorithm_suite": request.algorithm_suite,
|
|
"scope_type": request.scope_type,
|
|
"scope_id": request.scope_id,
|
|
"policy_ref": request.policy_ref,
|
|
"recovery_quorum": request.recovery_quorum,
|
|
"profile_kind": request.profile_kind,
|
|
"import_reference": request.import_reference,
|
|
"provider_policy": dict(request.provider_policy),
|
|
}
|
|
|
|
|
|
def _rotation_payload(
|
|
request: KeyRotationRequest,
|
|
vault: EncryptionVault,
|
|
algorithm_suite: str,
|
|
) -> dict[str, object]:
|
|
return {
|
|
"tenant_id": request.tenant_id,
|
|
"vault_id": request.vault_id,
|
|
"purpose": vault.purpose,
|
|
"algorithm_suite": algorithm_suite,
|
|
"reason": request.reason,
|
|
"policy_decision_ref": request.policy_decision_ref,
|
|
"assurance_evidence_ref": request.assurance_evidence_ref,
|
|
"provider_policy": dict(request.provider_policy),
|
|
"import_reference": None,
|
|
}
|
|
|
|
|
|
def _lifecycle_payload(
|
|
request: KeyLifecycleRequest,
|
|
operation: str,
|
|
) -> dict[str, object]:
|
|
return {
|
|
"tenant_id": request.tenant_id,
|
|
"vault_id": request.vault_id,
|
|
"key_version": request.key_version,
|
|
"operation": operation,
|
|
"reason": request.reason,
|
|
"policy_decision_ref": request.policy_decision_ref,
|
|
"assurance_evidence_ref": request.assurance_evidence_ref,
|
|
"effective_at": (
|
|
_as_utc(request.effective_at).isoformat()
|
|
if request.effective_at is not None
|
|
else None
|
|
),
|
|
}
|
|
|
|
|
|
def _migration_payload(request: ProtectionMigrationRequest) -> dict[str, object]:
|
|
return {
|
|
"tenant_id": request.tenant_id,
|
|
"envelope_id": request.envelope_id,
|
|
"target_provider_id": request.target_provider_id,
|
|
"target_vault_id": request.target_vault_id,
|
|
"target_key_version": request.target_key_version,
|
|
"target_algorithm_suite": request.target_algorithm_suite,
|
|
"mode": request.mode,
|
|
"policy_decision_ref": request.policy_decision_ref,
|
|
"assurance_evidence_ref": request.assurance_evidence_ref,
|
|
}
|
|
|
|
|
|
def _recovery_payload(request: RecoveryRequest) -> dict[str, object]:
|
|
return {
|
|
"tenant_id": request.tenant_id,
|
|
"vault_id": request.vault_id,
|
|
"reason": request.reason,
|
|
"requested_scope": request.requested_scope,
|
|
"policy_decision_ref": request.policy_decision_ref,
|
|
"assurance_evidence_ref": request.assurance_evidence_ref,
|
|
"expires_at": _as_utc(request.expires_at).isoformat(),
|
|
}
|
|
|
|
|
|
def _envelope_payload(
|
|
envelope: ContentProtectionEnvelope,
|
|
policy_decision_ref: str,
|
|
) -> dict[str, object]:
|
|
return {
|
|
"envelope_id": envelope.envelope_id,
|
|
"tenant_id": envelope.tenant_id,
|
|
"owner_module": envelope.owner_module,
|
|
"resource_type": envelope.resource_type,
|
|
"resource_id": envelope.resource_id,
|
|
"profile_kind": envelope.profile_kind,
|
|
"profile_id": envelope.profile_id,
|
|
"provider_id": envelope.provider_id,
|
|
"vault_id": envelope.vault_id,
|
|
"key_version": envelope.key_version,
|
|
"algorithm_suite": envelope.algorithm_suite,
|
|
"ciphertext_ref": envelope.ciphertext_ref,
|
|
"ciphertext_digest": envelope.ciphertext_digest,
|
|
"authenticated_context_digest": envelope.authenticated_context_digest,
|
|
"state": envelope.state,
|
|
"wrapped_key_refs": list(envelope.wrapped_key_refs),
|
|
"source_envelope_id": envelope.source_envelope_id,
|
|
"migration_id": envelope.migration_id,
|
|
"metadata": dict(envelope.metadata),
|
|
"policy_decision_ref": policy_decision_ref,
|
|
}
|
|
|
|
|
|
def _expected_revision(item: object, expected: int) -> None:
|
|
actual = int(getattr(item, "revision", 0))
|
|
if actual != expected:
|
|
raise EncryptionError(
|
|
f"The resource changed (expected revision {expected}, current {actual})."
|
|
)
|
|
|
|
|
|
def _require_tenant(principal: object, tenant_id: str) -> None:
|
|
principal_tenant = str(getattr(principal, "tenant_id", "") or "")
|
|
if not principal_tenant or principal_tenant != tenant_id:
|
|
raise EncryptionError("Cross-tenant encryption access is not allowed.")
|
|
|
|
|
|
def _account_id(principal: object) -> str:
|
|
user = getattr(principal, "user", None)
|
|
value = (
|
|
getattr(user, "id", None)
|
|
or getattr(principal, "account_id", None)
|
|
or getattr(principal, "api_key_id", None)
|
|
)
|
|
if value is None or not str(value).strip():
|
|
raise EncryptionError("An accountable principal is required.")
|
|
return str(value)
|
|
|
|
|
|
def _session(value: object) -> Session:
|
|
if not isinstance(value, Session):
|
|
raise TypeError("Encryption requires a SQLAlchemy Session.")
|
|
return value
|
|
|
|
|
|
def _digest(value: Mapping[str, object]) -> str:
|
|
encoded = json.dumps(value, sort_keys=True, separators=(",", ":"), default=str)
|
|
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
|
|
|
|
|
def _as_utc(value: datetime) -> datetime:
|
|
if value.tzinfo is None:
|
|
return value.replace(tzinfo=timezone.utc)
|
|
return value.astimezone(timezone.utc)
|
|
|
|
|
|
def _exception_code(exc: Exception) -> str:
|
|
return f"provider_{type(exc).__name__.lower()}"[:255]
|
|
|
|
|
|
def _safe_error_code(value: str | None) -> str | None:
|
|
if value is None:
|
|
return None
|
|
clean = "".join(
|
|
character for character in value if character.isalnum() or character in "._-"
|
|
)
|
|
return clean[:255] or "provider_error"
|
|
|
|
|
|
__all__ = [
|
|
"EncryptionError",
|
|
"SqlEncryptionService",
|
|
"TERMINAL_PROTECTION_STATES",
|
|
]
|