Encryption matrix
The narrative is in Encryption; this is the
exhaustive surface. Forze uses envelope encryption — a KMS-held KEK wraps short-lived
DEKs; every ciphertext is an EncryptedEnvelope bound to associated data (AAD) that
includes at least the tenant. Coverage is opt-in per spec and fail-closed: a
surface that marks something for encryption but finds no keyring refuses to persist
plaintext.
Field-level surfaces (one shared policy)¶
A single FieldEncryption policy — encrypted (randomized AEAD) and searchable
(deterministic, equality-only) field sets, plus binds_record_id and
reject_plaintext — is declared once on the document spec and carried to search,
analytics, and graph by pointing their spec at the same policy, so the planes
can't drift.
| Surface | Backends | searchable |
binds_record_id |
Fail-closed code |
|---|---|---|---|---|
| Document fields | Postgres, Mongo, Firestore, mock | yes | yes (randomized fields only) | core.document.encryption_wiring |
| Search | Postgres, Mongo, Meilisearch | yes (rewrites equality filters) | inherited | core.search.encryption_wiring |
| Analytics / warehouse | Postgres, ClickHouse, BigQuery, DuckDB, mock | yes (equality only) | rejected — warehouse rows have no id | core.analytics.encryption_wiring |
| Graph | Neo4j | yes | nodes + key-addressed edges; rejected for endpoint-identity edges | core.graph.encryption_wiring |
- Reach: at rest. Fields are sealed on write and decrypted out of every read path — search results, warehouse offset/cursor/chunked/projection reads, graph get/neighbors/walk/shortest-path. A search route's result snapshots (kept for stable re-pagination) are sealed at rest too, automatically.
- Confidential by physics: an
encrypted(randomized) field is never content-searchable, aggregatable, or matchable in a predicate. Usesearchable(deterministic) for equality lookups — it trades secrecy for queryability (identical plaintexts share a ciphertext), so mark a fieldsearchableonly when you must query it by exact value. Declaring a sealed field (either set) as an indexedSearchSpec.fieldsmember is refused at spec construction — the index would hold ciphertext, so every content search over it would silently match nothing. searchableneeds a stable root (deterministic_rooton the crypto module); rotate withdeterministic_previous_rootoverlap +reencrypt_documents.- Migration tolerance: legacy plaintext in a sealed field passes through on read by
default (zero-downtime backfill). Set
reject_plaintext=Trueon the policy once the backfill is done — an unencrypted or unauthentic value then raisescore.crypto.plaintext_rejectedon every plane sharing the policy.
Whole-payload & object surfaces¶
| Surface | Coverage | Reach | Backends |
|---|---|---|---|
| Object storage | whole object or chunked stream (encrypt=True) |
client-side — backend stores only the envelope | S3, GCS, mock |
| Outbox | whole payload (OutboxEncryptionTier) |
none / at_rest (relay decrypts before publish) / end_to_end (consumer decrypts after dedup) |
Postgres / Mongo store → any transport |
| Direct queue / stream / pub-sub | whole payload | none / end_to_end only (no store, so no at_rest) |
SQS, RabbitMQ, Redis, Kafka, Inngest |
| Idempotency result | whole cached result (encrypt_result=True) |
at rest (sealed on commit, opened on replay; metadata stays plaintext) | Redis, Postgres |
- Object storage caveat: multipart / presigned uploads are blocked when a route
encrypts — the client would write bytes the app never sees, landing as plaintext. Use
a single-shot
upload()orupload_stream()(seals chunk-by-chunk in bounded memory). Ranged reads decrypt stream-uploaded objects; whole-payload envelopes can't be sliced and must be read back whole. - Outbox ↔ direct messaging: both bind the payload AAD to
(tenant, event/message id)reconstructable from envelope headers, and share a payload domain — so anend_to_endmessage decrypts identically whether it was relayed from the outbox or published directly, across every transport. Legacy plaintext rows still relay. - Idempotency AAD:
(tenant, op:key)with a length-prefixed id so(op, key)boundaries can't collide.
Not encrypted¶
| Surface | State | Note |
|---|---|---|
| Inbox | dedup records (id + tenant) stored plaintext | dedup keys on the id, not the payload; works across encrypted and plaintext messages |
| General cache | plaintext | the CachePort / L1 cache have no encryption flag; only idempotency results and search result-snapshots are sealed |
Keys & enforcement¶
- Per-tenant keys (BYOK): swap
StaticKeyDirectoryforTenantTemplateKeyDirectory(template="tenant/{tenant_id}/kek") so each tenant's data is unreadable with another's key. The KEK is provisioned through the sameTenantProvisionerPortas schemas and buckets — every backend ships one (VaultTransitTenantProvisioner,AwsKmsTenantProvisioner,GcpKmsTenantProvisioner,YcKmsTenantProvisioner), and teardown is opt-in (allow_deletion). Yandex Cloud mints its key ids, so it pairs withYcKmsKeyDirectory(name lookup) rather than a template directory. - Replacing a KEK: rotating a key version needs no action (the key id is unchanged, so
old envelopes still decrypt). Replacing the key needs a read overlap —
TenantTemplateKeyDirectory(previous_template=…)/StaticKeyDirectory(previous_key_ref=…), or a customKeyDirectoryWithPrevious— then a re-encryption sweep (reencrypt_documentsfor field-encrypted rows,reencrypt_objectsfor stored blobs), then drop the previous key. Without the overlap the confused-deputy guard refuses the old envelopes and the data is stranded. -
KMS backends: every one holds the KEK outside the app, and its wrapped data key is decryptable without being told which key version sealed it — so rotation never orphans data. (
DataKey.key_versionrecords the version only where the provider reports one.)Backend Package KeyManagementPortVault Transit forze[vault]VaultTransitKeyManagementAWS KMS forze[kms-aws]AwsKmsKeyManagementGoogle Cloud KMS forze[kms-gcp]GcpKmsKeyManagementYandex Cloud KMS forze[kms-yc]YcKmsKeyManagementMockKeyManagementis dev/test only (it protects nothing). Any other KMS — Azure, an HSM — is a customKeyManagementPort. See Cloud KMS. -required_encryptionfloor: set it on a deps module and wiring refuses to assemble any surface whose derived coverage is weaker — a fail-closed floor checked once at startup. See Encryption → Declaring a minimum. - Observability:instrument_crypto({"default": keyring}, meter=…)exports DEK generation, unwraps, and cache hit/miss.