Implement governed encryption lifecycle
This commit is contained in:
@@ -4,45 +4,39 @@
|
|||||||
**Repository type:** module (platform).
|
**Repository type:** module (platform).
|
||||||
<!-- govoplan-repository-type:end -->
|
<!-- govoplan-repository-type:end -->
|
||||||
|
|
||||||
`govoplan-encryption` is the optional platform boundary for managed key vaults,
|
`govoplan-encryption` is the optional provider-neutral boundary for governed key
|
||||||
content encryption, end-to-end encryption profiles, key rotation, recovery, and
|
vaults, versioned content-protection envelopes, key lifecycle operations,
|
||||||
cryptographic deletion assurance.
|
recovery ceremonies, and disable/uninstall assurance.
|
||||||
|
|
||||||
The module is deliberately scaffolded before cryptographic implementation. It
|
## Implemented
|
||||||
announces stable integration surfaces and policy boundaries without presenting
|
|
||||||
placeholder cryptography as production-ready protection.
|
|
||||||
|
|
||||||
## 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
|
The module contains no cipher implementation, raw key store, development master
|
||||||
- envelope-key wrapping and rotation workflows
|
key, KMS/HSM driver, client E2EE protocol, or provider credentials. A deployment
|
||||||
- content-protection profiles, including E2EE profiles
|
must install a separately reviewed provider capability. Until a concrete profile
|
||||||
- device and recipient key registration
|
passes conformance, backup/restore, key-loss, interoperability, and security
|
||||||
- recovery ceremonies, assurance evidence, and key custody
|
review, GovOPlaN must not claim that content is encrypted or end-to-end
|
||||||
- disable and uninstall preflight for protected content
|
encrypted.
|
||||||
|
|
||||||
Feature modules continue to own their content, authorization, retention, and
|
Feature modules continue to own content, authorization, retention, and resource
|
||||||
normal ownership records. They integrate through optional encryption
|
ownership. Access approval, resource ownership, Identity Trust, and key custody
|
||||||
capabilities and store only stable protection references and ciphertext.
|
are separate decisions.
|
||||||
|
|
||||||
## Disable Rule
|
See [docs/ENCRYPTION_BOUNDARY.md](docs/ENCRYPTION_BOUNDARY.md) for the threat
|
||||||
|
model, profile consequences, algorithms, recovery, and disable semantics.
|
||||||
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.
|
|
||||||
|
|
||||||
## Verification
|
## 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
|
`govoplan-encryption` is the optional governance and lifecycle boundary for
|
||||||
key and content-protection capabilities to modules that choose to use them. It
|
cryptographic protection. It persists opaque provider references, versioned
|
||||||
does not own feature data, normal authorization, retention, or business
|
content-protection envelopes, idempotent operation state, recovery decisions,
|
||||||
ownership.
|
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.
|
## Ownership Boundary
|
||||||
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.
|
|
||||||
|
|
||||||
## Planned Capability Families
|
Encryption owns:
|
||||||
|
|
||||||
- **Key vault:** create and resolve key hierarchies without exposing raw key
|
- vault and opaque provider-key reference metadata;
|
||||||
material to feature modules.
|
- key-version and provider-operation lifecycle state;
|
||||||
- **Content protection:** authenticated envelope encryption and decryption for
|
- versioned protection envelopes and migration evidence;
|
||||||
authorized feature-owned payloads.
|
- high-assurance, quorum-based recovery authorization;
|
||||||
- **E2EE profiles:** recipient/device key envelopes where the server cannot
|
- disable and uninstall preflight.
|
||||||
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.
|
|
||||||
|
|
||||||
## 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
|
No capability in this module accepts or returns plaintext key material.
|
||||||
- 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
|
|
||||||
|
|
||||||
These decisions require explicit threat models and interoperability fixtures.
|
## Assets and Threat Actors
|
||||||
They must not be inferred from ordinary resource-ownership workflows.
|
|
||||||
|
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 __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 (
|
from govoplan_core.core.modules import (
|
||||||
|
CapabilityDocumentation,
|
||||||
|
DocumentationLink,
|
||||||
DocumentationTopic,
|
DocumentationTopic,
|
||||||
|
MigrationSpec,
|
||||||
|
ModuleContext,
|
||||||
ModuleInterfaceProvider,
|
ModuleInterfaceProvider,
|
||||||
ModuleManifest,
|
ModuleManifest,
|
||||||
|
ModuleUninstallGuardResult,
|
||||||
PermissionDefinition,
|
PermissionDefinition,
|
||||||
RoleTemplate,
|
RoleTemplate,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.provider_governance import declared_module_architecture
|
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"
|
MODULE_ID = "encryption"
|
||||||
@@ -27,14 +47,11 @@ OPTIONAL_DEPENDENCIES = (
|
|||||||
"postbox",
|
"postbox",
|
||||||
"campaigns",
|
"campaigns",
|
||||||
"workflow_engine",
|
"workflow_engine",
|
||||||
|
"identity_trust",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _permission(
|
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
|
||||||
scope: str,
|
|
||||||
label: str,
|
|
||||||
description: str,
|
|
||||||
) -> PermissionDefinition:
|
|
||||||
module_id, resource, action = scope.split(":", 2)
|
module_id, resource, action = scope.split(":", 2)
|
||||||
return PermissionDefinition(
|
return PermissionDefinition(
|
||||||
scope=scope,
|
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 = (
|
PERMISSIONS = (
|
||||||
_permission(
|
_permission(
|
||||||
USE_SCOPE,
|
USE_SCOPE,
|
||||||
"Use encryption profiles",
|
"Use encryption profiles",
|
||||||
"Protect and decrypt authorized content through an available profile.",
|
"Register and resolve protected content through configured profiles.",
|
||||||
),
|
),
|
||||||
_permission(
|
_permission(
|
||||||
ADMIN_SCOPE,
|
ADMIN_SCOPE,
|
||||||
"Administer encryption",
|
"Administer encryption",
|
||||||
"Manage vaults, protection profiles, key rotation, and provider policy.",
|
"Manage vault metadata, provider operations, rotation, migration, and policy provenance.",
|
||||||
),
|
),
|
||||||
_permission(
|
_permission(
|
||||||
RECOVERY_SCOPE,
|
RECOVERY_SCOPE,
|
||||||
"Approve key recovery",
|
"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,
|
version=MODULE_VERSION,
|
||||||
optional_dependencies=OPTIONAL_DEPENDENCIES,
|
optional_dependencies=OPTIONAL_DEPENDENCIES,
|
||||||
provides_interfaces=(
|
provides_interfaces=(
|
||||||
ModuleInterfaceProvider(name="encryption.key_vault", version="1.0.0"),
|
ModuleInterfaceProvider(
|
||||||
|
name="encryption.key_vault",
|
||||||
|
version="1.0.0",
|
||||||
|
),
|
||||||
ModuleInterfaceProvider(
|
ModuleInterfaceProvider(
|
||||||
name="encryption.content_protection",
|
name="encryption.content_protection",
|
||||||
version="1.0.0",
|
version="1.0.0",
|
||||||
@@ -103,6 +166,81 @@ manifest = ModuleManifest(
|
|||||||
),
|
),
|
||||||
permissions=PERMISSIONS,
|
permissions=PERMISSIONS,
|
||||||
role_templates=ROLE_TEMPLATES,
|
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=(
|
documentation=(
|
||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
id="encryption.boundary",
|
id="encryption.boundary",
|
||||||
@@ -114,26 +252,57 @@ manifest = ModuleManifest(
|
|||||||
body=(
|
body=(
|
||||||
"Encryption protects feature-owned content without taking over "
|
"Encryption protects feature-owned content without taking over "
|
||||||
"its business ownership. Resource ownership recovery never "
|
"its business ownership. Resource ownership recovery never "
|
||||||
"implicitly grants cryptographic keys. Disabling the module is "
|
"implicitly grants cryptographic keys. High-risk lifecycle "
|
||||||
"blocked until protected objects are decrypted, rewrapped, "
|
"actions require recent Identity Trust assurance. Disabling is "
|
||||||
"explicitly exported, or cryptographically deleted."
|
"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",
|
layer="available",
|
||||||
documentation_types=("admin", "user"),
|
documentation_types=("admin", "user"),
|
||||||
audience=("user", "administrator", "security_officer", "product_owner"),
|
audience=(
|
||||||
|
"user",
|
||||||
|
"administrator",
|
||||||
|
"security_officer",
|
||||||
|
"product_owner",
|
||||||
|
"auditor",
|
||||||
|
),
|
||||||
related_modules=OPTIONAL_DEPENDENCIES,
|
related_modules=OPTIONAL_DEPENDENCIES,
|
||||||
order=100,
|
order=100,
|
||||||
|
links=(
|
||||||
|
DocumentationLink(
|
||||||
|
label="Encryption boundary and threat model",
|
||||||
|
href="govoplan-encryption/docs/ENCRYPTION_BOUNDARY.md",
|
||||||
|
kind="repository",
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
architecture=declared_module_architecture(
|
architecture=declared_module_architecture(
|
||||||
layer="institutional_foundation",
|
layer="institutional_foundation",
|
||||||
kind="foundation",
|
kind="foundation",
|
||||||
maturity="scaffold",
|
maturity="vertical_slice",
|
||||||
documentation_ref="docs/ENCRYPTION_BOUNDARY.md",
|
documentation_ref="docs/ENCRYPTION_BOUNDARY.md",
|
||||||
known_limits=("No production key vault, protected-content persistence, rotation worker, or recovery ceremony is implemented yet.",),
|
test_ref="tests/test_encryption.py",
|
||||||
owned_concepts=("key vault", "content-protection envelope", "cryptographic recovery ceremony"),
|
known_limits=(
|
||||||
non_owned_concepts=("domain content", "resource ownership", "account authentication"),
|
"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",),
|
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
|
import unittest
|
||||||
|
|
||||||
from govoplan_encryption.backend.manifest import (
|
from govoplan_core.core.encryption import (
|
||||||
ADMIN_SCOPE,
|
CAPABILITY_ENCRYPTION_CONTENT_PROTECTION,
|
||||||
RECOVERY_SCOPE,
|
CAPABILITY_ENCRYPTION_DISABLE_PREFLIGHT,
|
||||||
USE_SCOPE,
|
CAPABILITY_ENCRYPTION_KEY_VAULT,
|
||||||
get_manifest,
|
CAPABILITY_ENCRYPTION_RECOVERY,
|
||||||
)
|
)
|
||||||
|
from govoplan_encryption.backend.manifest import get_manifest
|
||||||
|
|
||||||
|
|
||||||
class EncryptionManifestTests(unittest.TestCase):
|
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()
|
manifest = get_manifest()
|
||||||
|
|
||||||
self.assertEqual(manifest.id, "encryption")
|
self.assertEqual("encryption", manifest.id)
|
||||||
self.assertEqual(manifest.dependencies, ())
|
self.assertEqual((), manifest.dependencies)
|
||||||
self.assertIn("access", manifest.optional_dependencies)
|
|
||||||
self.assertEqual(
|
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",
|
CAPABILITY_ENCRYPTION_KEY_VAULT,
|
||||||
"encryption.content_protection",
|
CAPABILITY_ENCRYPTION_CONTENT_PROTECTION,
|
||||||
"encryption.recovery_ceremony",
|
CAPABILITY_ENCRYPTION_RECOVERY,
|
||||||
"encryption.disable_preflight",
|
CAPABILITY_ENCRYPTION_DISABLE_PREFLIGHT,
|
||||||
},
|
},
|
||||||
|
set(manifest.capability_factories),
|
||||||
)
|
)
|
||||||
self.assertIsNone(manifest.route_factory)
|
self.assertIsNotNone(manifest.route_factory)
|
||||||
self.assertIsNone(manifest.migration_spec)
|
self.assertIsNotNone(manifest.migration_spec)
|
||||||
self.assertIsNone(manifest.frontend)
|
self.assertIsNone(manifest.frontend)
|
||||||
|
self.assertEqual("vertical_slice", manifest.architecture.maturity)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
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