Implement governed encryption lifecycle
This commit is contained in:
@@ -4,45 +4,39 @@
|
||||
**Repository type:** module (platform).
|
||||
<!-- govoplan-repository-type:end -->
|
||||
|
||||
`govoplan-encryption` is the optional platform boundary for managed key vaults,
|
||||
content encryption, end-to-end encryption profiles, key rotation, recovery, and
|
||||
cryptographic deletion assurance.
|
||||
`govoplan-encryption` is the optional provider-neutral boundary for governed key
|
||||
vaults, versioned content-protection envelopes, key lifecycle operations,
|
||||
recovery ceremonies, and disable/uninstall assurance.
|
||||
|
||||
The module is deliberately scaffolded before cryptographic implementation. It
|
||||
announces stable integration surfaces and policy boundaries without presenting
|
||||
placeholder cryptography as production-ready protection.
|
||||
## Implemented
|
||||
|
||||
## Boundary
|
||||
- opaque provider capability contracts; raw key material never crosses them;
|
||||
- tenant/scope-bound vault and key-version metadata;
|
||||
- idempotent provision, rotation, revocation, destruction scheduling, and
|
||||
outcome-unknown reconciliation;
|
||||
- versioned ciphertext envelopes with authenticated-context and migration
|
||||
provenance;
|
||||
- resumable, evidence-backed rewrap, re-encryption, decrypt, export, and destroy
|
||||
state transitions;
|
||||
- recent high-assurance, distinct-custodian quorum recovery authorization;
|
||||
- typed APIs, audit-safe events, Alembic migration, and uninstall blocking;
|
||||
- Files and Postbox boundary fixtures.
|
||||
|
||||
The module will own:
|
||||
## Deliberately Not Bundled
|
||||
|
||||
- vault and key hierarchy metadata
|
||||
- envelope-key wrapping and rotation workflows
|
||||
- content-protection profiles, including E2EE profiles
|
||||
- device and recipient key registration
|
||||
- recovery ceremonies, assurance evidence, and key custody
|
||||
- disable and uninstall preflight for protected content
|
||||
The module contains no cipher implementation, raw key store, development master
|
||||
key, KMS/HSM driver, client E2EE protocol, or provider credentials. A deployment
|
||||
must install a separately reviewed provider capability. Until a concrete profile
|
||||
passes conformance, backup/restore, key-loss, interoperability, and security
|
||||
review, GovOPlaN must not claim that content is encrypted or end-to-end
|
||||
encrypted.
|
||||
|
||||
Feature modules continue to own their content, authorization, retention, and
|
||||
normal ownership records. They integrate through optional encryption
|
||||
capabilities and store only stable protection references and ciphertext.
|
||||
Feature modules continue to own content, authorization, retention, and resource
|
||||
ownership. Access approval, resource ownership, Identity Trust, and key custody
|
||||
are separate decisions.
|
||||
|
||||
## Disable Rule
|
||||
|
||||
Disabling or uninstalling this module must never make protected content silently
|
||||
unreadable. Every protected object must first be decrypted, rewrapped to another
|
||||
available provider, exported with explicit key custody, or deliberately
|
||||
destroyed under an authorized cryptographic-deletion process. The eventual
|
||||
uninstall guard will block while unresolved protection references remain.
|
||||
|
||||
See [docs/ENCRYPTION_BOUNDARY.md](docs/ENCRYPTION_BOUNDARY.md) for the initial
|
||||
threat, ownership, recovery, and lifecycle decisions.
|
||||
|
||||
## Current State
|
||||
|
||||
The repository currently contains a discoverable manifest, permission and role
|
||||
metadata, versioned interface announcements, documentation, and focused tests.
|
||||
It has no routes, persistence, WebUI, key generation, or encryption runtime yet.
|
||||
See [docs/ENCRYPTION_BOUNDARY.md](docs/ENCRYPTION_BOUNDARY.md) for the threat
|
||||
model, profile consequences, algorithms, recovery, and disable semantics.
|
||||
|
||||
## Verification
|
||||
|
||||
|
||||
+203
-41
@@ -1,50 +1,212 @@
|
||||
# Encryption Boundary
|
||||
# Encryption Boundary and Threat Model
|
||||
|
||||
## Purpose
|
||||
## Status
|
||||
|
||||
`govoplan-encryption` is an optional cryptographic service module. It supplies
|
||||
key and content-protection capabilities to modules that choose to use them. It
|
||||
does not own feature data, normal authorization, retention, or business
|
||||
ownership.
|
||||
`govoplan-encryption` is the optional governance and lifecycle boundary for
|
||||
cryptographic protection. It persists opaque provider references, versioned
|
||||
content-protection envelopes, idempotent operation state, recovery decisions,
|
||||
and disable-preflight evidence. It does not ship a cipher, a raw key store, a
|
||||
KMS/HSM driver, or a client E2EE protocol.
|
||||
|
||||
## Initial Invariants
|
||||
Enabling the module is therefore not an encryption or E2EE claim. A deployment
|
||||
may make such a claim only for a named protection profile whose concrete
|
||||
provider, clients, algorithms, backup procedure, and failure tests have passed
|
||||
the profile's conformance and security review.
|
||||
|
||||
1. Resource ownership and cryptographic key custody are independent.
|
||||
2. Administrative ownership recovery does not grant encryption keys.
|
||||
3. Key recovery is a separate, high-assurance, auditable ceremony.
|
||||
4. Every ciphertext carries a stable protection profile, key version, algorithm
|
||||
suite, and authenticated context reference.
|
||||
5. Rotation and rewrapping are resumable and idempotent.
|
||||
6. Protected content cannot silently fall back to plaintext.
|
||||
7. Provider loss, disabled modules, unavailable devices, and recovery delay are
|
||||
visible states, not generic read failures.
|
||||
8. Disabling or uninstalling is blocked while unresolved protected content
|
||||
remains.
|
||||
## Ownership Boundary
|
||||
|
||||
## Planned Capability Families
|
||||
Encryption owns:
|
||||
|
||||
- **Key vault:** create and resolve key hierarchies without exposing raw key
|
||||
material to feature modules.
|
||||
- **Content protection:** authenticated envelope encryption and decryption for
|
||||
authorized feature-owned payloads.
|
||||
- **E2EE profiles:** recipient/device key envelopes where the server cannot
|
||||
decrypt normal content.
|
||||
- **Rotation:** rewrap keys and migrate algorithm suites with bounded progress
|
||||
and evidence.
|
||||
- **Recovery ceremony:** quorum, delay, recent authentication, custody evidence,
|
||||
and explicit scope.
|
||||
- **Disable preflight:** inventory protection references and produce a blocking
|
||||
migration plan.
|
||||
- vault and opaque provider-key reference metadata;
|
||||
- key-version and provider-operation lifecycle state;
|
||||
- versioned protection envelopes and migration evidence;
|
||||
- high-assurance, quorum-based recovery authorization;
|
||||
- disable and uninstall preflight.
|
||||
|
||||
## Decisions Deferred Until Threat Modeling
|
||||
Feature modules still own domain content, access policy, retention, legal-hold
|
||||
meaning, and normal resource ownership. Identity Trust owns public device keys,
|
||||
key epochs, and recent-assurance evidence. Access decides whether an account may
|
||||
perform a resource action. A positive Access decision does not release a key;
|
||||
an ownership transfer does not transfer cryptographic custody.
|
||||
|
||||
- cryptographic libraries and external KMS/HSM providers
|
||||
- server-managed versus client-managed profile suites
|
||||
- per-object, per-vault, and per-recipient data-key granularity
|
||||
- device enrollment and revocation protocol
|
||||
- external-recipient key discovery and trust
|
||||
- recovery quorum defaults for each assurance profile
|
||||
- ciphertext portability and offline export format
|
||||
No capability in this module accepts or returns plaintext key material.
|
||||
|
||||
These decisions require explicit threat models and interoperability fixtures.
|
||||
They must not be inferred from ordinary resource-ownership workflows.
|
||||
## Assets and Threat Actors
|
||||
|
||||
Protected assets include content plaintext, data-encryption keys, wrapping keys,
|
||||
recipient/device private keys, authenticated context, recovery authority, and
|
||||
the integrity and provenance of lifecycle decisions.
|
||||
|
||||
The threat model includes:
|
||||
|
||||
- an unauthorized user or tenant attempting cross-tenant access;
|
||||
- a compromised account, API key, worker, or application node;
|
||||
- a malicious or mistaken tenant/platform administrator;
|
||||
- a compromised storage service, database backup, queue, log collector, or
|
||||
observability system;
|
||||
- a lost or revoked device and a former function holder;
|
||||
- a malicious or unavailable key provider;
|
||||
- replay, race, partial failure, rollback, and outcome-unknown provider calls;
|
||||
- ciphertext substitution, context confusion, algorithm downgrade, and mixed
|
||||
migration state;
|
||||
- backup restoration without matching provider keys, or key restoration without
|
||||
matching metadata.
|
||||
|
||||
The initial model does not claim protection against endpoint compromise while a
|
||||
legitimate client has plaintext open, coercion of all configured recovery
|
||||
custodians, traffic analysis, or denial of service by a provider that controls
|
||||
required keys.
|
||||
|
||||
## Observable Metadata
|
||||
|
||||
Unless an independently reviewed profile says otherwise, the server and its
|
||||
database can observe tenant, owner module, resource type and stable id, profile,
|
||||
vault and key version, algorithm suite, ciphertext location and digest,
|
||||
authenticated-context digest, wrapped-key references, timestamps, migration
|
||||
state, policy references, recovery scope, and actor/evidence references.
|
||||
|
||||
Titles, recipient identities, content length, access patterns, and relationship
|
||||
graphs may also leak through the owning module or storage provider even when the
|
||||
payload is encrypted. Product UI and documentation must not imply that payload
|
||||
encryption hides this metadata.
|
||||
|
||||
## Deployment Profiles
|
||||
|
||||
### Server-side envelope encryption
|
||||
|
||||
The application or a deployment KMS/HSM can unwrap data keys after normal
|
||||
authorization. This protects backups and storage media but not plaintext from a
|
||||
compromised authorized application tier. Provider credentials and key policy are
|
||||
deployment secrets, not envelope metadata. Legal hold can preserve ciphertext
|
||||
and key availability under separately authorized policy.
|
||||
|
||||
### Tenant-held key provider
|
||||
|
||||
The tenant controls an external KMS/HSM or equivalent provider. GovOPlaN holds
|
||||
only references and invokes the provider under tenant policy. The provider may
|
||||
allow server-side processing, but tenant revocation or provider outage can make
|
||||
content unavailable. Backup recovery requires both GovOPlaN state and the
|
||||
tenant's provider recovery procedure.
|
||||
|
||||
### End-to-end encryption
|
||||
|
||||
Only authorized clients possess usable recipient/device private keys; ordinary
|
||||
servers cannot decrypt payloads. Search, preview, server-side Dataflow,
|
||||
virus/content inspection, reporting, legal-hold export, and recovery degrade or
|
||||
become impossible unless the selected profile deliberately adds an authorized
|
||||
recipient or escrow mechanism. Such a mechanism changes the threat model and
|
||||
must be displayed as part of the profile.
|
||||
|
||||
The current code can label and track an `end_to_end` envelope, but it does not
|
||||
implement the client protocol. That label alone is never evidence of E2EE.
|
||||
|
||||
## Profile Consequences
|
||||
|
||||
| Concern | Server envelope | Tenant-held | End-to-end |
|
||||
| --- | --- | --- | --- |
|
||||
| Normal server processing | Available after authorization | Provider-policy dependent | Unavailable unless a client performs it |
|
||||
| Provider outage | Decryption unavailable | Decryption unavailable | Existing enrolled clients may remain usable |
|
||||
| Platform admin can obtain plaintext | Possible under governed server policy | Tenant/provider dependent | Not from ordinary server custody |
|
||||
| Legal hold | Preserve ciphertext and governed keys | Requires tenant/provider cooperation | Ciphertext only unless the profile has a hold recipient |
|
||||
| Export | Governed plaintext or portable encrypted export | Provider cooperation required | Client-produced or recipient-key export |
|
||||
| Recovery | Provider recovery plus GovOPlaN quorum | Tenant provider recovery plus quorum | Device/recipient recovery defined by the client profile |
|
||||
| Ownership transfer | Separate reauthorization/rewrap | Separate reauthorization/rewrap | Explicit recipient change and re-encryption |
|
||||
| Revocation | Stops future provider use; cannot erase prior plaintext | Provider enforced | New key epoch; cannot erase prior client plaintext |
|
||||
|
||||
## Algorithms, Libraries, and Agility
|
||||
|
||||
Concrete providers must declare a versioned suite id and pass conformance tests.
|
||||
The initial allowlist for reviewed profiles is:
|
||||
|
||||
- AES-256-GCM through a deployment KMS/HSM or a maintained OpenSSL-backed
|
||||
cryptographic library;
|
||||
- XChaCha20-Poly1305 through a maintained libsodium implementation where the
|
||||
provider/profile requires extended nonces;
|
||||
- RFC 9180 HPKE suites for a future reviewed recipient-envelope protocol.
|
||||
|
||||
Selection from this list is not automatic approval. A provider profile must
|
||||
also define nonce generation and uniqueness, associated-data construction, key
|
||||
separation, limits, error handling, and test vectors. Home-grown primitives,
|
||||
unauthenticated encryption, AES-ECB, bare AES-CBC, static/reused nonces, raw RSA
|
||||
encryption, SHA-1 integrity, provider-supplied secret values in logs, and silent
|
||||
algorithm fallback are prohibited.
|
||||
|
||||
Every envelope fixes its profile, algorithm suite, vault, key version,
|
||||
ciphertext digest, and authenticated-context digest. Changing any of these is a
|
||||
new, evidence-backed migration. Readers never infer an algorithm from current
|
||||
defaults. Old readers fail closed on unknown suites or contract versions.
|
||||
|
||||
## Durable Operation Semantics
|
||||
|
||||
Key-provider operations use a stable idempotency key. GovOPlaN records an
|
||||
operation as `prepared`, `succeeded`, or `outcome_unknown`. If a provider call
|
||||
raises or a process dies around the external side effect, absence of an error is
|
||||
never treated as success. Reconciliation repeats the exact idempotent provider
|
||||
request and records only bounded error class codes.
|
||||
|
||||
Content migrations are explicit records. Rewrap and re-encryption succeed only
|
||||
when an exact target envelope references the authorized source and migration.
|
||||
Decrypt, export, and destruction require evidence references. Until an outcome
|
||||
is known, the source remains `migration_pending`; no implicit plaintext fallback
|
||||
is allowed.
|
||||
|
||||
All runtime state is in the shared database or an external provider/object
|
||||
store reference. Application and worker nodes require no local encryption state,
|
||||
so multiple nodes can retry the same operation safely when the provider honors
|
||||
the idempotency contract.
|
||||
|
||||
## Recovery Ceremony
|
||||
|
||||
Recovery requires current Identity Trust assurance at the `high` level, a
|
||||
policy-decision reference, expiry, distinct custodian accounts, and the vault's
|
||||
configured quorum. The requester cannot approve their own ceremony; one
|
||||
rejection rejects the current ceremony. Decisions are optimistic-concurrency
|
||||
protected and replay safe.
|
||||
|
||||
An `approved` ceremony authorizes a later provider-specific recovery action. It
|
||||
does not return a key, change resource ownership, or prove that recovery has
|
||||
executed. A production provider must add separately auditable execution and
|
||||
post-recovery rotation/rewrap evidence.
|
||||
|
||||
## Disable and Uninstall
|
||||
|
||||
Disabling or uninstalling never deletes keys or silently makes ciphertext look
|
||||
like ordinary unreadable content. Preflight inventories all envelopes. It blocks
|
||||
while any envelope is active, pending, migrating, unavailable, or otherwise
|
||||
unresolved. An envelope is locally terminal only after it is:
|
||||
|
||||
- migrated to another registered protection envelope;
|
||||
- decrypted by an authorized workflow;
|
||||
- explicitly exported with documented custody; or
|
||||
- cryptographically destroyed with evidence.
|
||||
|
||||
A migrated target remains an active protected envelope and therefore still
|
||||
blocks removal of the module/provider that owns it. Destructive table retirement
|
||||
is permitted only after this proof.
|
||||
|
||||
## Backup, Restore, and Key Loss
|
||||
|
||||
A recoverable backup set consists of the shared database, ciphertext/object
|
||||
storage, provider configuration references, provider-side key backups or HSM
|
||||
replication, and audit evidence. These materials must be backed up separately
|
||||
according to their trust boundaries. Database restore without matching provider
|
||||
keys produces an explicit unavailable state; it must never create replacement
|
||||
keys under an old reference.
|
||||
|
||||
Before production use, each concrete provider/profile needs automated restore,
|
||||
provider-loss, stale-backup, partial-migration, network-failure, and permanent
|
||||
key-loss exercises. Permanent key loss is data loss for the affected ciphertext
|
||||
and must be reported as such.
|
||||
|
||||
## Current Integration Contract
|
||||
|
||||
Files, Postbox, Campaign, Calendar, and connector credentials may store an
|
||||
envelope id and ciphertext reference while retaining their own resource model.
|
||||
They resolve Encryption through optional capabilities and must fail closed when
|
||||
an encrypted record is encountered without the capability. Unencrypted module
|
||||
combinations continue to operate without Encryption.
|
||||
|
||||
Focused fixtures currently validate Files- and Postbox-owned envelope records.
|
||||
Concrete feature adapters and production provider conformance remain tracked
|
||||
work; direct imports of Encryption database models from feature modules are not
|
||||
allowed.
|
||||
|
||||
@@ -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
@@ -0,0 +1,389 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import unittest
|
||||
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.encryption import (
|
||||
CAPABILITY_ENCRYPTION_KEY_MATERIAL_PROVIDER_PREFIX,
|
||||
ContentProtectionEnvelope,
|
||||
KeyLifecycleRequest,
|
||||
KeyMaterialDescriptor,
|
||||
KeyRotationRequest,
|
||||
KeyVaultCreateRequest,
|
||||
ProtectionMigrationRequest,
|
||||
ProtectionRegistrationRequest,
|
||||
RecoveryApprovalRequest,
|
||||
RecoveryRequest,
|
||||
)
|
||||
from govoplan_core.core.identity_trust import (
|
||||
CAPABILITY_IDENTITY_TRUST_ASSURANCE,
|
||||
AssuranceDecision,
|
||||
)
|
||||
from govoplan_core.db.base import utcnow
|
||||
from govoplan_encryption.backend.db.models import (
|
||||
ContentProtectionRecord,
|
||||
EncryptionKeyOperation,
|
||||
EncryptionKeyVersion,
|
||||
EncryptionVault,
|
||||
ProtectionMigration,
|
||||
RecoveryApproval,
|
||||
RecoveryCeremony,
|
||||
)
|
||||
from govoplan_encryption.backend.service import EncryptionError, SqlEncryptionService
|
||||
|
||||
|
||||
NOW = datetime(2026, 8, 1, 12, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
class Principal:
|
||||
def __init__(self, account_id: str, tenant_id: str = "tenant-1") -> None:
|
||||
self.account_id = account_id
|
||||
self.tenant_id = tenant_id
|
||||
|
||||
|
||||
class FakeAssurance:
|
||||
def verify_assurance(self, _session, _principal, *, request):
|
||||
return AssuranceDecision(
|
||||
allowed=True,
|
||||
reason="test evidence accepted",
|
||||
assurance_level="high",
|
||||
evidence_ref=request.evidence_ref,
|
||||
verified_at=request.evaluated_at,
|
||||
expires_at=request.evaluated_at + timedelta(minutes=5),
|
||||
provenance={"provider_id": "test-assurance"},
|
||||
)
|
||||
|
||||
|
||||
class FakeKeyProvider:
|
||||
def __init__(self) -> None:
|
||||
self.algorithms: dict[str, str] = {}
|
||||
self.fail_once: set[str] = set()
|
||||
self.calls: list[str] = []
|
||||
|
||||
def provision_key(self, _session, _principal, *, request):
|
||||
self.calls.append(request.idempotency_key)
|
||||
if request.idempotency_key in self.fail_once:
|
||||
self.fail_once.remove(request.idempotency_key)
|
||||
raise RuntimeError("provider diagnostics must not be persisted")
|
||||
reference = f"provider://{request.vault_id}/v{request.key_version}"
|
||||
self.algorithms[reference] = request.algorithm_suite
|
||||
return self._descriptor(reference, "active")
|
||||
|
||||
def revoke_key(
|
||||
self,
|
||||
_session,
|
||||
_principal,
|
||||
*,
|
||||
provider_key_ref,
|
||||
reason,
|
||||
idempotency_key,
|
||||
):
|
||||
del reason
|
||||
self.calls.append(idempotency_key)
|
||||
return self._descriptor(provider_key_ref, "revoked")
|
||||
|
||||
def schedule_key_destruction(
|
||||
self,
|
||||
_session,
|
||||
_principal,
|
||||
*,
|
||||
provider_key_ref,
|
||||
effective_at,
|
||||
idempotency_key,
|
||||
):
|
||||
del effective_at
|
||||
self.calls.append(idempotency_key)
|
||||
return self._descriptor(provider_key_ref, "destruction_scheduled")
|
||||
|
||||
def key_status(self, _session, *, provider_key_ref):
|
||||
if provider_key_ref not in self.algorithms:
|
||||
return None
|
||||
return self._descriptor(provider_key_ref, "active")
|
||||
|
||||
def _descriptor(self, reference: str, state: str) -> KeyMaterialDescriptor:
|
||||
return KeyMaterialDescriptor(
|
||||
provider_id="test",
|
||||
provider_key_ref=reference,
|
||||
algorithm_suite=self.algorithms.get(reference, "AES-256-GCM"),
|
||||
state=state, # type: ignore[arg-type]
|
||||
created_at=NOW,
|
||||
exportable=False,
|
||||
provenance={"operation": "test-vector"},
|
||||
)
|
||||
|
||||
|
||||
class Registry:
|
||||
def __init__(self, provider: FakeKeyProvider) -> None:
|
||||
self.values = {
|
||||
f"{CAPABILITY_ENCRYPTION_KEY_MATERIAL_PROVIDER_PREFIX}test": provider,
|
||||
CAPABILITY_IDENTITY_TRUST_ASSURANCE: FakeAssurance(),
|
||||
}
|
||||
|
||||
def has_capability(self, name: str) -> bool:
|
||||
return name in self.values
|
||||
|
||||
def capability(self, name: str):
|
||||
return self.values[name]
|
||||
|
||||
|
||||
class EncryptionTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
self.tables = [
|
||||
EncryptionVault.__table__,
|
||||
EncryptionKeyVersion.__table__,
|
||||
EncryptionKeyOperation.__table__,
|
||||
ContentProtectionRecord.__table__,
|
||||
ProtectionMigration.__table__,
|
||||
RecoveryCeremony.__table__,
|
||||
RecoveryApproval.__table__,
|
||||
]
|
||||
for table in self.tables:
|
||||
table.create(self.engine)
|
||||
self.session = Session(self.engine)
|
||||
self.provider = FakeKeyProvider()
|
||||
self.service = SqlEncryptionService(Registry(self.provider))
|
||||
self.principal = Principal("account-1")
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
self.engine.dispose()
|
||||
|
||||
def create_vault(self, **changes):
|
||||
values = {
|
||||
"tenant_id": "tenant-1",
|
||||
"vault_id": "vault-1",
|
||||
"name": "Records",
|
||||
"provider_id": "test",
|
||||
"purpose": "feature-content",
|
||||
"algorithm_suite": "AES-256-GCM",
|
||||
"scope_type": "tenant",
|
||||
"scope_id": "tenant-1",
|
||||
"policy_ref": "policy:v1",
|
||||
"idempotency_key": "vault-create-1",
|
||||
"recovery_quorum": 2,
|
||||
}
|
||||
values.update(changes)
|
||||
return self.service.create_vault(
|
||||
self.session,
|
||||
self.principal,
|
||||
request=KeyVaultCreateRequest(**values),
|
||||
)
|
||||
|
||||
def envelope(self, owner_module: str, suffix: str) -> ContentProtectionEnvelope:
|
||||
return ContentProtectionEnvelope(
|
||||
envelope_id=f"envelope-{suffix}",
|
||||
tenant_id="tenant-1",
|
||||
owner_module=owner_module,
|
||||
resource_type=f"{owner_module}.object",
|
||||
resource_id=f"resource-{suffix}",
|
||||
profile_kind="server_envelope",
|
||||
profile_id="profile-1",
|
||||
provider_id="test",
|
||||
vault_id="vault-1",
|
||||
key_version=1,
|
||||
algorithm_suite="AES-256-GCM",
|
||||
ciphertext_ref=f"object://ciphertext/{suffix}",
|
||||
ciphertext_digest=f"sha256:cipher-{suffix}",
|
||||
authenticated_context_digest=f"sha256:context-{suffix}",
|
||||
state="active",
|
||||
created_at=NOW,
|
||||
metadata={"content_type": "application/octet-stream"},
|
||||
)
|
||||
|
||||
def test_vault_replay_rotation_revocation_and_tenant_isolation(self) -> None:
|
||||
first = self.create_vault()
|
||||
replay = self.create_vault()
|
||||
self.assertEqual("active", first.state)
|
||||
self.assertEqual(
|
||||
first.current_key.provider_key_ref, replay.current_key.provider_key_ref
|
||||
)
|
||||
self.assertEqual(["vault-create-1"], self.provider.calls)
|
||||
|
||||
rotated = self.service.rotate_key(
|
||||
self.session,
|
||||
self.principal,
|
||||
request=KeyRotationRequest(
|
||||
tenant_id="tenant-1",
|
||||
vault_id="vault-1",
|
||||
expected_revision=1,
|
||||
reason="scheduled rotation",
|
||||
policy_decision_ref="policy:rotate",
|
||||
assurance_evidence_ref="assurance:rotate",
|
||||
idempotency_key="rotate-1",
|
||||
),
|
||||
)
|
||||
self.assertEqual(2, rotated.current_key.version)
|
||||
self.assertEqual(2, rotated.revision)
|
||||
|
||||
revoked = self.service.revoke_key(
|
||||
self.session,
|
||||
self.principal,
|
||||
request=KeyLifecycleRequest(
|
||||
tenant_id="tenant-1",
|
||||
vault_id="vault-1",
|
||||
key_version=2,
|
||||
expected_revision=2,
|
||||
reason="compromise response",
|
||||
policy_decision_ref="policy:revoke",
|
||||
assurance_evidence_ref="assurance:revoke",
|
||||
idempotency_key="revoke-1",
|
||||
),
|
||||
)
|
||||
self.assertEqual("revoked", revoked.current_key.state)
|
||||
self.assertEqual("unavailable", revoked.state)
|
||||
with self.assertRaisesRegex(EncryptionError, "Cross-tenant"):
|
||||
self.service.get_envelope(
|
||||
self.session,
|
||||
Principal("account-1", "tenant-2"),
|
||||
tenant_id="tenant-1",
|
||||
envelope_id="missing",
|
||||
)
|
||||
|
||||
def test_provider_failure_is_persisted_and_idempotently_reconciled(self) -> None:
|
||||
self.provider.fail_once.add("vault-failure")
|
||||
value = self.create_vault(
|
||||
vault_id="vault-failure",
|
||||
idempotency_key="vault-failure",
|
||||
)
|
||||
self.assertEqual("outcome_unknown", value.state)
|
||||
operation = self.session.scalar(
|
||||
select(EncryptionKeyOperation).where(
|
||||
EncryptionKeyOperation.idempotency_key == "vault-failure"
|
||||
)
|
||||
)
|
||||
self.assertEqual("provider_runtimeerror", operation.error_code)
|
||||
self.assertNotIn("diagnostics", operation.error_code)
|
||||
|
||||
reconciled = self.service.reconcile_vault(
|
||||
self.session,
|
||||
self.principal,
|
||||
tenant_id="tenant-1",
|
||||
vault_id="vault-failure",
|
||||
)
|
||||
self.assertEqual("active", reconciled.state)
|
||||
self.assertEqual(2, self.provider.calls.count("vault-failure"))
|
||||
|
||||
def test_files_and_postbox_envelopes_migrate_before_disable(self) -> None:
|
||||
self.create_vault()
|
||||
for suffix, owner in (("files", "files"), ("postbox", "postbox")):
|
||||
value = self.service.register_envelope(
|
||||
self.session,
|
||||
self.principal,
|
||||
request=ProtectionRegistrationRequest(
|
||||
envelope=self.envelope(owner, suffix),
|
||||
idempotency_key=f"register-{suffix}",
|
||||
policy_decision_ref="policy:protect",
|
||||
),
|
||||
)
|
||||
self.assertEqual(owner, value.owner_module)
|
||||
|
||||
blocked = self.service.assess_disable(self.session, tenant_id="tenant-1")
|
||||
self.assertFalse(blocked.allowed)
|
||||
self.assertEqual(2, blocked.unresolved_count)
|
||||
|
||||
for suffix in ("files", "postbox"):
|
||||
migration = self.service.request_migration(
|
||||
self.session,
|
||||
self.principal,
|
||||
request=ProtectionMigrationRequest(
|
||||
tenant_id="tenant-1",
|
||||
envelope_id=f"envelope-{suffix}",
|
||||
target_provider_id="test",
|
||||
target_vault_id="vault-1",
|
||||
target_key_version=1,
|
||||
target_algorithm_suite="AES-256-GCM",
|
||||
mode="destroy",
|
||||
policy_decision_ref="policy:destroy",
|
||||
assurance_evidence_ref="assurance:destroy",
|
||||
idempotency_key=f"destroy-{suffix}",
|
||||
),
|
||||
)
|
||||
result = self.service.record_migration_outcome(
|
||||
self.session,
|
||||
self.principal,
|
||||
migration_id=migration.migration_id,
|
||||
state="succeeded",
|
||||
evidence_refs=(f"provider-evidence:{suffix}",),
|
||||
)
|
||||
self.assertEqual("succeeded", result.state)
|
||||
|
||||
allowed = self.service.assess_disable(self.session, tenant_id="tenant-1")
|
||||
self.assertTrue(allowed.allowed)
|
||||
self.assertEqual(0, allowed.unresolved_count)
|
||||
|
||||
def test_recovery_requires_distinct_high_assurance_quorum(self) -> None:
|
||||
self.create_vault()
|
||||
recovery = self.service.request_recovery(
|
||||
self.session,
|
||||
self.principal,
|
||||
request=RecoveryRequest(
|
||||
tenant_id="tenant-1",
|
||||
vault_id="vault-1",
|
||||
reason="custody recovery",
|
||||
requested_scope="vault-status-and-rewrap",
|
||||
policy_decision_ref="policy:recovery",
|
||||
assurance_evidence_ref="assurance:requester",
|
||||
idempotency_key="recovery-1",
|
||||
expires_at=utcnow() + timedelta(hours=1),
|
||||
),
|
||||
)
|
||||
with self.assertRaisesRegex(EncryptionError, "own recovery"):
|
||||
self.service.decide_recovery(
|
||||
self.session,
|
||||
self.principal,
|
||||
request=RecoveryApprovalRequest(
|
||||
tenant_id="tenant-1",
|
||||
recovery_id=recovery.recovery_id,
|
||||
decision="approve",
|
||||
reason="approved",
|
||||
assurance_evidence_ref="assurance:requester",
|
||||
expected_revision=1,
|
||||
idempotency_key="requester-approval",
|
||||
),
|
||||
)
|
||||
first = self.service.decide_recovery(
|
||||
self.session,
|
||||
Principal("custodian-1"),
|
||||
request=RecoveryApprovalRequest(
|
||||
tenant_id="tenant-1",
|
||||
recovery_id=recovery.recovery_id,
|
||||
decision="approve",
|
||||
reason="evidence reviewed",
|
||||
assurance_evidence_ref="assurance:custodian-1",
|
||||
expected_revision=1,
|
||||
idempotency_key="approval-1",
|
||||
),
|
||||
)
|
||||
self.assertEqual("pending", first.state)
|
||||
second = self.service.decide_recovery(
|
||||
self.session,
|
||||
Principal("custodian-2"),
|
||||
request=RecoveryApprovalRequest(
|
||||
tenant_id="tenant-1",
|
||||
recovery_id=recovery.recovery_id,
|
||||
decision="approve",
|
||||
reason="evidence reviewed",
|
||||
assurance_evidence_ref="assurance:custodian-2",
|
||||
expected_revision=2,
|
||||
idempotency_key="approval-2",
|
||||
),
|
||||
)
|
||||
self.assertEqual("approved", second.state)
|
||||
self.assertEqual(2, second.approvals)
|
||||
self.assertFalse(second.provenance["key_material_released"])
|
||||
|
||||
def test_contract_rejects_secret_bearing_envelope_metadata(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "secret material"):
|
||||
replace(
|
||||
self.envelope("files", "bad"),
|
||||
metadata={"password": "must-not-cross-boundary"},
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+17
-20
@@ -2,37 +2,34 @@ from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from govoplan_encryption.backend.manifest import (
|
||||
ADMIN_SCOPE,
|
||||
RECOVERY_SCOPE,
|
||||
USE_SCOPE,
|
||||
get_manifest,
|
||||
from govoplan_core.core.encryption import (
|
||||
CAPABILITY_ENCRYPTION_CONTENT_PROTECTION,
|
||||
CAPABILITY_ENCRYPTION_DISABLE_PREFLIGHT,
|
||||
CAPABILITY_ENCRYPTION_KEY_VAULT,
|
||||
CAPABILITY_ENCRYPTION_RECOVERY,
|
||||
)
|
||||
from govoplan_encryption.backend.manifest import get_manifest
|
||||
|
||||
|
||||
class EncryptionManifestTests(unittest.TestCase):
|
||||
def test_manifest_is_an_optional_contract_only_seed(self) -> None:
|
||||
def test_manifest_exposes_headless_governed_capabilities(self) -> None:
|
||||
manifest = get_manifest()
|
||||
|
||||
self.assertEqual(manifest.id, "encryption")
|
||||
self.assertEqual(manifest.dependencies, ())
|
||||
self.assertIn("access", manifest.optional_dependencies)
|
||||
self.assertEqual("encryption", manifest.id)
|
||||
self.assertEqual((), manifest.dependencies)
|
||||
self.assertEqual(
|
||||
{permission.scope for permission in manifest.permissions},
|
||||
{USE_SCOPE, ADMIN_SCOPE, RECOVERY_SCOPE},
|
||||
)
|
||||
self.assertEqual(
|
||||
{provider.name for provider in manifest.provides_interfaces},
|
||||
{
|
||||
"encryption.key_vault",
|
||||
"encryption.content_protection",
|
||||
"encryption.recovery_ceremony",
|
||||
"encryption.disable_preflight",
|
||||
CAPABILITY_ENCRYPTION_KEY_VAULT,
|
||||
CAPABILITY_ENCRYPTION_CONTENT_PROTECTION,
|
||||
CAPABILITY_ENCRYPTION_RECOVERY,
|
||||
CAPABILITY_ENCRYPTION_DISABLE_PREFLIGHT,
|
||||
},
|
||||
set(manifest.capability_factories),
|
||||
)
|
||||
self.assertIsNone(manifest.route_factory)
|
||||
self.assertIsNone(manifest.migration_spec)
|
||||
self.assertIsNotNone(manifest.route_factory)
|
||||
self.assertIsNotNone(manifest.migration_spec)
|
||||
self.assertIsNone(manifest.frontend)
|
||||
self.assertEqual("vertical_slice", manifest.architecture.maturity)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from alembic.runtime.migration import MigrationContext
|
||||
from sqlalchemy import create_engine, inspect
|
||||
|
||||
from govoplan_core.db.migrations import migrate_database
|
||||
from govoplan_encryption.backend.manifest import get_manifest
|
||||
|
||||
|
||||
class EncryptionMigrationTests(unittest.TestCase):
|
||||
def test_migration_creates_lifecycle_tables(self) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="govoplan-encryption-") as directory:
|
||||
url = f"sqlite:///{Path(directory) / 'encryption.db'}"
|
||||
migrate_database(
|
||||
database_url=url,
|
||||
enabled_modules=("encryption",),
|
||||
manifest_factories=(get_manifest,),
|
||||
)
|
||||
engine = create_engine(url)
|
||||
try:
|
||||
tables = set(inspect(engine).get_table_names())
|
||||
self.assertTrue(
|
||||
{
|
||||
"encryption_vaults",
|
||||
"encryption_key_versions",
|
||||
"encryption_key_operations",
|
||||
"encryption_content_protections",
|
||||
"encryption_protection_migrations",
|
||||
"encryption_recovery_ceremonies",
|
||||
"encryption_recovery_approvals",
|
||||
}.issubset(tables)
|
||||
)
|
||||
with engine.connect() as connection:
|
||||
self.assertIn(
|
||||
"d4a6b8c0e2f3",
|
||||
set(MigrationContext.configure(connection).get_current_heads()),
|
||||
)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user