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, graph, and procedure params 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, mock | 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, mock | yes | nodes + key-addressed edges; rejected for endpoint-identity edges | core.graph.encryption_wiring |
| Procedure params | Postgres, mock | yes | rejected — params have no stable id | core.procedures.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 cloud 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.) The self-hostedLocalKeyManagementis the exception by design: it wraps under operator-supplied 32-byte master keys held in the process, so your secret delivery is its whole trust model, and the rotation overlap needs the outgoing key kept in the key map (not just the directory) until the sweep finishes.Backend Package KeyManagementPortVault Transit forze[vault]VaultTransitKeyManagementAWS KMS forze[kms-aws]AwsKmsKeyManagementGoogle Cloud KMS forze[kms-gcp]GcpKmsKeyManagementYandex Cloud KMS forze[kms-yc]YcKmsKeyManagementSelf-hosted local — (no extra) LocalKeyManagementSelf-hosted (in-process master keys) core, no extra LocalKeyManagementThe self-hosted backend is the one exception to "the KEK is held outside the app": its master keys live in process memory — the operator's host is the trust boundary, and rotation is by key replacement (it has no versions).
MockKeyManagementis dev/test only (it protects nothing). Any other KMS — Azure, an HSM — is a customKeyManagementPort. See KMS backends. -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.