Per-tenant key isolation / BYOK
What this is. probectl can encrypt each tenant's sensitive at-rest values under that tenant's own encryption key. That makes tenants cryptographically separable — even with raw database access, you cannot read tenant A's sealed data without tenant A's key — and it turns offboarding into a key-destruction event. How far that reaches depends on the key mode: under BYOK probectl never holds the key, so destroying it makes even a pre-offboard backup permanently unreadable; under managed mode the tenant key is wrapped by the deployment master, which survives offboarding, so key destruction crypto-shreds the live stores but a pre-offboard backup restored beside the live deployment master still decrypts (see the Cryptographic offboarding section below).
Think of it as the cryptographic complement to siloed storage (see isolation.md): silos separate where a tenant's data lives; per-tenant keys separate who can ever decrypt it. BYOK ("Bring Your Own Key") is the stronger variant where the key lives in your secret manager and probectl never holds it.
Edition. This lives under ee/ (the commercial tree) and is unlocked by the
byok license feature (Enterprise tier; MSP crypto-offboarding consumes it too).
Without the license, deployments keep the deployment-wide envelope key (below)
and the /v1/security/keys API surface stays hidden — it returns 404, not a
locked-feature error.
The sealing model
Every sensitive tenant-owned value passes through one core seam,
internal/tenantcrypto. (Today that means alert-channel secrets — webhook HMAC
keys, integration tokens; the set grows consumer-by-consumer, not by changing the
mechanism.) "Sealing" is authenticated encryption: the value goes into the
database as ciphertext that detects tampering, not merely hides content. Stored
values are self-describing — a short prefix tells the reader exactly how
the value was sealed:
| Stored prefix | Sealed by | Notes |
|---|---|---|
| (none) | nothing (legacy / dev plaintext) | read as-is; re-sealed on the next write |
dv1: |
the deployment envelope — one master key (PROBECTL_ENVELOPE_KEY) |
the default whenever an envelope key is configured |
tk1:<version>: |
the per-tenant keyring (this feature) | the tenant id and key version are bound into the AEAD |
Because reads dispatch on the prefix, turning on per-tenant keys never migrates
existing data: old dv1: rows keep decrypting (the deployment sealer stays
registered as an opener), and new writes seal under the tenant key. Backward
compatibility is structural, not a migration step you have to run.
The fail-safe rule (non-negotiable). Once a value is sealed under a tenant key, opening it requires that key. An unavailable, unresolvable, or destroyed key is an error — never a silent fallback to the deployment master or any shared key. (A recognized sealed prefix with no installed sealer also refuses to pass through as plaintext, so a misconfiguration cannot leak ciphertext as if it were cleartext.)
Key hierarchy
%%{init: {'theme':'base','themeVariables':{'background':'#0d1117','primaryColor':'#161b22','primaryTextColor':'#e6edf3','primaryBorderColor':'#3b82f6','lineColor':'#8b949e','secondaryColor':'#21262d','tertiaryColor':'#0d1117','clusterBkg':'#161b22','clusterBorder':'#30363d','fontFamily':'ui-monospace, SFMono-Regular, Menlo, monospace'},'flowchart':{'curve':'basis','nodeSpacing':55,'rankSpacing':55,'padding':12}}}%%
flowchart TD
subgraph managed["managed mode"]
direction TB
M1["deployment master key<br/>(PROBECTL_ENVELOPE_KEY)"]
M2["each tenant's key<br/>(random 32B, stored wrapped in tenant_keys)"]
M3["that tenant's values<br/>(AES-256-GCM via internal/crypto)"]
M1 -->|wraps| M2 -->|encrypts| M3
end
subgraph byok["byok mode"]
direction TB
B1["customer's secret manager<br/>(Vault / CyberArk / cloud KMS, via secret refs)"]
B2["tenant key<br/>(base64, exactly 32 bytes)"]
B3["that tenant's values"]
B1 -->|holds| B2 -->|encrypts| B3
end
REF["probectl stores ONLY the reference<br/>(e.g. vault:kv/tenants/acme#kek) and resolves it<br/>at use time — the material is never persisted"] -.-> B2
In managed mode probectl generates a random 32-byte key per tenant, encrypts
("wraps") it under the deployment master key, and stores the wrapped form in the
tenant_keys table. "Wrapping" is encryption applied to a key: the master acts
as a KEK — a key-encryption key, whose only job is to lock up other keys —
so the database holds tenant keys the way a bank vault holds safe-deposit
boxes: physically inside, but each one openable only with its own key. In
BYOK mode the key lives in your secret manager; probectl stores only the
reference and resolves it at use time.
A tenant's first seal auto-provisions a managed v1 key. The encryption binds
tenant:<id>:<caller-context>:v<version> as additional authenticated data (AAD)
into every ciphertext — AES-256-GCM is an AEAD ("authenticated encryption
with associated data"): decryption recomputes an integrity check that covers
both the ciphertext and that AAD label, so a sealed blob cannot be replayed
into another tenant's row even by a direct database write — the decrypt would
fail the authenticity check.
Rotation (no downtime)
POST /v1/security/keys/rotate (requires the security.keys permission,
admin-seeded):
{"mode": "managed"}— mint a new wrapped key as version N+1.{"mode": "byok", "byok_ref": "vault:kv/tenants/acme#kek"}— activate BYOK against the given reference.
The previous version is retired, not destroyed: new data seals under the new version immediately, while existing data keeps decrypting under the version it was written with. There is no re-encryption pass and no downtime. (Bulk re-wrap of old rows is deliberately deferred — values re-seal naturally on their next write.)
The BYOK lockout guard. A byok_ref is probe-resolved before activation:
a dead reference is rejected, so you cannot rotate yourself into a key probectl
cannot reach. After activation, though, you own the lock. If you later revoke
probectl's access to the reference (or delete the secret), your sealed data
becomes unreadable and sealing fails — that is the feature, not a bug, and there
is no recovery path through probectl. Document your secret manager's own
backup/escrow policy accordingly.
Cryptographic offboarding
Tenant erasure destroys the tenant's entire key chain before the deletion
attestation is sealed: every version's wrapped key is nulled (managed) or its
reference cleared (BYOK) and the chain's state set to destroyed. The
tenant_keys rows survive as evidence, but material-free. From the next call
on, the live keyring can no longer open any tk1: value for that tenant.
How far this reaches into backups depends on the mode — and this is the one place the distinction matters:
- BYOK: the tenant key never lived inside probectl — a backup holds only the
reference. Destroy the key in your secret manager and any
tk1:ciphertext that reached a backup is permanently unreadable: there is no key, anywhere probectl can reach, to resolve. This is true crypto-shred of backups. - Managed: the tenant key is wrapped under the deployment master
(
PROBECTL_ENVELOPE_KEY), which is deployment-wide and survives offboarding. Destroying the tenant key nulls the wrapped copy in the live store and purges the cache, but a pre-offboard backup still contains that wrapped key, so a backup restored beside the live deployment master still decrypts. Managed-mode offboarding therefore relies on verifiable deletion of the live stores plus your backup-retention/TTL policy (see data-retention.md) — not on crypto-shredding backups. To get backup crypto-shred for a managed tenant, move it to BYOK (or destroy the backups themselves).
The deletion attestation carries a tenant_keys line recording how many key
versions were destroyed. Deployments without the byok feature record "no
per-tenant keyring installed" — stated honestly, never implied. Destroyed
chains refuse re-keying: a destroyed tenant cannot silently get a fresh v1 by
writing new data.
API and UI surfaces
GET /v1/security/keys— the key chain (version / mode / state / timestamps). Key material never crosses this API in either direction.POST /v1/security/keys/rotate— managed rotation or BYOK activation.- Tenant Admin → Encryption keys card: chain state, managed rotation, BYOK activation. Hidden entirely when unlicensed.
- Rotations are audited (
security.key_rotate, in the tenant audit stream). - When a running license ages past its 30-day grace period, the status card and
GETremain readable and existing/newly sealed values keep using the active tenant key for encrypt/decrypt continuity. New managed rotations and BYOK activations return403 license_read_onlybefore the key manager is called.
Configuration
| Key | Meaning |
|---|---|
PROBECTL_ENVELOPE_KEY |
base64 32-byte deployment master key. Required for dv1 sealing, and (as the wrap root) for managed per-tenant keys. A licensed BYOK deployment must still set it. |
PROBECTL_ENVELOPE_KEY_ID |
key identifier recorded in dv1 values (default dev). |
| Secret backends | BYOK references resolve through the same resolver as every other secret reference — vault:, cyberark:, aws:, azure:, gcp: (see secrets.md). |
Operational notes
- Deployment-envelope rotation:
PROBECTL_ENVELOPE_OPENER_KEYSis only the overlap phase. Complete rotation withprobectl-control envelope-rewrap, then remove the retired opener and runprobectl-control envelope-rewrap --verify-retired-key-id=<old-id>. Rewrap or expire old.pbkbackups withprobectl-control backup-rewrapbefore destroying the old deployment KEK. - Key cache: managed tenant KEKs are cached in memory for 30 seconds by default (rotation and destroy purge the tenant's cache immediately, and TTL eviction wipes the old bytes in place). BYOK keys are never cached — neither by the keyring nor by the secrets resolver's lease cache (secrets.md): every seal and open resolves the reference again, so a customer revocation is seen by the next seal or open. The cost is one secret-manager read per use of a sealed value.
- Key-store outage: if Postgres is unavailable for the
tenant_keystable, seal and open fail with an error (fail safe). Telemetry ingestion is unaffected — only sealed-value reads/writes (e.g. using an alert-channel secret) degrade. - What is NOT per-tenant-keyed: the bulk telemetry stores (ClickHouse / TSDB) rely on isolation-model separation plus verifiable deletion at offboarding; per-tenant keys cover the sensitive-value class. Extending coverage to another data class is a consumer-by-consumer decision through the same seam.