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", ]