kms:Decrypt in the owning organization’s assumed session. control.secrets keeps only metadata and a workflow-secret/... reference.
The value store is selected by PRELOOP_SECRETS_STORE and defaults by control store:
envelope is compiled only with the aws-kms feature:
What one row is
Each value is one row incontrol.secret_envelopes, keyed by (org_id, secret_ref):
A write calls
kms:GenerateDataKey (AES-256) with that context, seals the value under the returned data key, and drops the plaintext key when the call returns. A read calls kms:Decrypt on the wrapped key with the same context and opens the ciphertext with associated data org_id NUL secret_ref NUL version; a row copied onto another organization, reference, or version fails to authenticate instead of decrypting. Nothing caches a data key beyond one fetch, and no value, key, or ciphertext reaches a log.
control.secrets.value_backend records where each value lives (envelope or file), so a mixed deployment — a hosted PostgreSQL engine and a local SQLite engine — is auditable. Every write stamps it.
IAM: the encryption context must equal the session tag
The engine authenticates as a base role (IAM Roles Anywhere) and assumespreloop-engine-org-secrets for each organization, passing org_id as a session tag. The role’s only tenant-secret grant is KMS, conditioned on the request’s encryption context matching the session’s own tag:
Decrypt, the condition is what stops organization B from naming organization A’s context: the request context must equal the session’s own tag. The trust policy on the ABAC role requires the org_id session tag (and sts:TagSession on the base role), so the store cannot obtain an untagged session. The base role needs kms:Sign for its own OIDC keys; that is a separate policy, not this one.
One CMK (alias/preloop-secrets) covers every organization for now; per-organization CMKs are a later option and need no store change.
Local development: the file store
A SQLite engine defaults tofile, which seals each value with AES-256-GCM + HMAC-SHA256 under a key derived from the engine’s private state directory. It is a development and single-node store: the value and its key are both on the host, so a state-directory copy yields the value. Use it only where that is acceptable:
file or envelope to any other backend. A failed write fails the command.
What is never written
- The control database stores ciphertext and references, never a plaintext value or a plaintext data key.
- Errors name the organization and secret reference (identifiers) and the AWS error code; they never contain a value.
SecretStringredacts itself inDebug. - Job resolution fetches values at job start and masks them in logs, exactly as with the file store.
Operational notes
- Rotating the CMK is a KMS operation. The engine reads
key_idfrom each row, so old rows stay readable while their key is enabled; re-wrap by re-writing a value if you require one key. - A KMS access denial fails the write or the job loudly, naming the organization and reference. A missing row is the only condition reported as “no value”.
- There is no in-place migration command from a previous store: a value that is absent from
secret_envelopesresolves as absent. Re-runpreloop ci secrets import(orpreloop ci migrate) to re-import from GitHub, orpreloop ci secrets setfor a value the human holds. - Back up the control database and the KMS key policy together. A restored database without the CMK is unreadable by design.
