Skip to main content
Preloop’s hosted control plane stores tenant workflow-secret values in the control database as KMS envelope-encrypted rows. A database dump, a state-directory copy, or a backup contains only ciphertext, a wrapped data key, a nonce, and the CMK’s key id; it cannot produce a value without 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:
A hosted engine selects it explicitly:

What one row is

Each value is one row in control.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 assumes preloop-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:
Because the caller supplies the encryption context on 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 to file, 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:
A write does not fall back from 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. SecretString redacts itself in Debug.
  • 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_id from 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_envelopes resolves as absent. Re-run preloop ci secrets import (or preloop ci migrate) to re-import from GitHub, or preloop ci secrets set for 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.