Recipe: Encrypted per-tenant config
What this shows: how a SaaS customer stores their own API key (Stripe, Slack webhook, SMTP password …) in their settings, without the platform operator being able to read the plaintext from the DB.
Pattern
Section titled “Pattern”import { access, createTenantConfig } from "@cosmicdrift/kumiko-framework/engine";
createTenantConfig("text", { encrypted: true, // ← ciphertext in the DB write: access.admin, // ← the tenant admin writes their own read: access.admin, // ← nobody else sees it mask: { title: "billing.stripe-api-key", order: 1 }, // ← derives the edit screen + nav});The config resolver decrypts on the ctx.config(handle) call using
the EncryptionProvider from extraContext.configEncryption. Anyone
without the master key (= the KUMIKO_SECRETS_MASTER_KEY_V<n> env) only sees
base64 AES ciphertext in the DB.
The mask entry is all the UI needs: buildConfigFeatureSchema derives
the configEdit screen (pre-filled from config:query:values, where the
key comes back as ••••••) and its Settings-Hub nav entry from the
registered key — no hand-written r.screen/r.nav. mask.title is the
i18n key of the field label, mask.order its position.
Use cases
Section titled “Use cases”- Per-tenant API keys — Customer A uses THEIR Stripe account, Customer B uses THEIRS. The platform operator never makes Stripe calls on behalf of customers.
- Webhook secrets — per-tenant Slack/Discord incoming webhook URL.
- SMTP credentials — per-tenant mail server (see
samples/showcases/publicstatusPhase 2).
Vs. r.secret / samples/recipes/secrets-demo
Section titled “Vs. r.secret / samples/recipes/secrets-demo”| Pattern | Scope | Use case |
|---|---|---|
r.secret (envelope encryption, KEK rotation) | App-global | Platform-owned secrets (e.g. the master Stripe key that bills ALL customers). |
encrypted: true config key (this recipe) | Per-tenant | Customer-owned secrets. Customer sets, rotates, and never sees another customer’s. Same envelope encryption + KEK keyring under the hood. |
Combinable: the platform uses r.secret for its internal secrets,
and in parallel customers have their tenant-owned encrypted: true
config keys.
Boot wiring
Section titled “Boot wiring”None. runProdApp / runDevApp wire the envelope cipher automatically as
soon as a master key is present in the environment:
KUMIKO_SECRETS_MASTER_KEY_V1=$(openssl rand -base64 32)That single key drives encrypted: true config keys, r.secret, and
encrypted entity fields — one keyring, one rotation story. Only tests or
custom boots pass their own cipher:
import { createConfigResolver } from "@cosmicdrift/kumiko-bundled-features/config";import { createTestEnvelopeCipher } from "@cosmicdrift/kumiko-framework/testing";
const cipher = createTestEnvelopeCipher();const configResolver = createConfigResolver({ cipher });// extraContext: { configResolver, configEncryption: cipher, ... }- Tenant admin sets
stripe-api-keyviaconfig:write:set(scope tenant). - DB row holds a JSON envelope (AES-GCM ciphertext + wrapped DEK +
kekVersion) — backups andSELECTleak nothing. config:query:valuesreturns••••••for the encrypted key in the UI.- Domain handler calls
ctx.config(handle)→ decrypted value in-process. - Tenant B cannot charge with Tenant A’s key — per-tenant config row isolation.
Security guarantees
Section titled “Security guarantees”- DB plaintext-free:
SELECT value FROM config_values WHERE key = 'stripe-api-key'returns ONLY ciphertext. Backup files, DB dumps, postgres eavesdropping → no plaintext leak. - UI mask:
config:query:valuesreturns"••••••"for encrypted keys. Even the admin allowed to SET the value doesn’t see it back. (If you need the value, go throughctx.config(handle)in the backend, not through the UI.) - Tenant isolation: every tenant has its own entry —
(key, tenantId)is unique in the config feature. Customer A NEVER sees Customer B’s API key.
bun test src/__tests__/feature.integration.test.tsProves:
- DB stores ciphertext, not plaintext
- UI query masks encrypted values
- Charge handler reads decrypted key and succeeds only when set
- Tenant A’s key does not leak to Tenant B
maskderives theconfigEditscreen without hand-writtenr.screen
Key rotation
Section titled “Key rotation”Config values carry their kekVersion, so rotation is operational, not
cryptographic surgery:
- Add
KUMIKO_SECRETS_MASTER_KEY_V2to the environment (old key stays). - Set
KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION=2— new writes use V2, old rows still decrypt via the keyring. - Trigger the manual
config:reencryptjob — it re-encrypts every encrypted config row onto the current version (and migrates any pre-envelope legacy rows). Idempotent, chunked, circuit-breaker on repeated failures. - Only after the job reports
failed: 0remove V1 from the environment.
The same job doubles as the migration path away from the deprecated
single-key CONFIG_ENCRYPTION_KEY format: keep the old env var as the
decrypt fallback until the job has run once, then delete it.
What’s not in this recipe
Section titled “What’s not in this recipe”- Audit trail: secrets-demo tracks every secret read as an event. Not here — config keys are meant as “settings” (read frequent, audit overkill).
- Backup encryption: the
KUMIKO_SECRETS_MASTER_KEY_V<n>keyring must be backed up alongside every backup, otherwise the DB is worthless after restore. Operator’s job.
Related samples
Section titled “Related samples”- managed-config —
backing: "secrets"for platform-owned keys + tenant cascade for SMTP defaults. - apps-cap-billing-demo — billing handlers that read per-tenant Stripe keys.
Source code
Section titled “Source code”The feature entry point — embedded straight from the source file, so the code here is exactly what runs. Multi-file samples keep their remaining files next to it on GitHub (link below):
// Encrypted per-tenant config — Stripe-API-key pattern.//// Minimal-Showcase: ein dummy "billing"-feature mit einem per-tenant// Stripe-API-Key. Der `mask`-Eintrag lässt den configEdit-Screen +// Settings-Hub-Nav automatisch entstehen (kein handgeschriebenes// r.screen/r.nav mehr). charge-handler liest den Key über ctx.config// (entschlüsselt automatisch).//// Production: nicht aufrufen — der echte Stripe-Call ist hier ein Mock.// Pattern in produktiven Apps: Strip-API-Key in tenantBillingConfig.key,// charge-handler lädt key + ruft tatsächlich Stripe.
import { access, type ConfigKeyDefinition, type ConfigKeyHandle, createTenantConfig, defineFeature, defineWriteHandler,} from "@cosmicdrift/kumiko-framework/engine";import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";import { z } from "zod";
const FEATURE = "billing";
// Config-Key-Definition: encrypted=true → ciphertext in der DB. write/// read: access.admin damit nur Tenant-Admin den Key setzt + reads sind// nur backend-side via ctx.config (frontend sieht "••••••"). `mask` →// buildConfigFeatureSchema derivt Screen + Settings-Hub-Nav.const stripeApiKeyDef = createTenantConfig("text", { encrypted: true, write: access.admin, read: access.admin, mask: { title: "billing.stripe-api-key", order: 1 },});
const stripeApiKeyHandle: ConfigKeyHandle<"text"> = { name: `${FEATURE}:config:stripe-api-key`, type: "text",};
const billingConfigKeyMap: Record<string, ConfigKeyDefinition> = { "stripe-api-key": stripeApiKeyDef,};
// Charge-Handler — nutzt den entschlüsselten API-Key. Caller sieht NUR// die charge-id zurück, der Key bleibt server-side.const chargeHandler = defineWriteHandler({ name: "charge", schema: z.object({ amount: z.number().positive(), customerRef: z.string().min(1), }), access: { roles: ["Admin"] }, async handler(event, ctx) { if (!ctx.config) { return writeFailure( new UnprocessableError("config_unavailable", { i18nKey: "billing.errors.configUnavailable", }), ); } const apiKey = await ctx.config(stripeApiKeyHandle); if (!apiKey || apiKey.length === 0) { return writeFailure( new UnprocessableError("stripe_key_missing", { i18nKey: "billing.errors.stripeKeyMissing", }), ); }
// Mock: real impl würde fetch("https://api.stripe.com/v1/charges", // { headers: { Authorization: `Bearer ${apiKey}` }, ... }) ausführen. // Wichtig: apiKey verlässt den server NICHT — kein log, kein // response-field, kein error-detail. const chargeId = `ch_${Date.now()}_${event.payload.customerRef}`;
return { isSuccess: true as const, data: { chargeId }, }; },});
export const billingFeature = defineFeature(FEATURE, (r) => { r.requires("config"); r.config({ keys: billingConfigKeyMap }); r.writeHandler(chargeHandler); // Kein r.screen/r.nav: der `mask`-Eintrag auf dem Key lässt // buildConfigFeatureSchema den configEdit-Screen (••••••-maskiert, // config:write:set verschlüsselt vor dem write) + den Settings-Hub-Nav // automatisch ableiten.});
// Re-exports damit tests die handles ohne re-typing nutzen können.export { stripeApiKeyHandle };📄 On GitHub: samples/recipes/encrypted-tenant-config/src/feature.ts