AI key management
Rollout runbook for letting an AI admin set and rotate the AI provider keys without a redeploy, stored encrypted.
Terraform wiring that lets an ai:admin operator set and rotate the OpenAI and
Gemini API keys from the AI admin Keys tab, without a redeploy. Keys are
stored encrypted at rest in the app DB (DB-first, with the env keys as
fallback), so nothing breaks before adoption. One flag gates everything and it
defaults to false, so terraform plan is a no-op until an operator opts in:
| Variable | Default | Effect when true |
|---|---|---|
enable_ai_key_encryption |
false |
Provisions the ai-key-encryption-secret-<env> secret + backend-SA secretAccessor grant (via module.core), and injects AI_KEY_ENCRYPTION_SECRET (secret ref) onto BOTH the backend and the jobs service Cloud Run revisions. |
This is the same additive, code-gated pattern already used for Sifflet
(sifflet_base_url), Castor (castor_api_url), and Agent Governance
(enable_agent_governance): a disabled flag means the secret + IAM resource are
never created (count = 0) and the env var is never emitted.
Why an encryption key is needed#
The gateway picks a cipher for DB-stored keys in
packages/integrations/ai_assistant/.../key_cipher.py::resolve_key_cipher(), in
order: Cloud KMS (AI_KMS_KEY_REF) then app-level Fernet
(AI_KEY_ENCRYPTION_SECRET) then None. With neither set, the resolver returns
None: GET /api/v1/ai/providers reports key_management_available: false, the
Keys tab shows the "key management unavailable" notice with the set/rotate
buttons disabled, and the assistant keeps using the AI_OPENAI_API_KEY /
AI_GEMINI_API_KEY env keys. Flipping this flag supplies the Fernet tier, so
cipher_mode becomes "local" and the UI unlocks.
Rollout sequence#
With generate_secrets = true (a first install), there is no hand step. module.core writes the
Fernet key's first version itself (32 random bytes, URL-safe base64, as Fernet requires) and its secret-id output
waits for that version and the reader grant, so one apply creates the secret, the version and the backend and jobs
revisions in order: a tenant that turns the feature on needs no step 1 or 2 below. The key is then in the Terraform
state (marked sensitive): the state bucket is as secret as the key. Turn generate_secrets on only at the first
install; on a deployment whose key was added by hand, the generated version would become latest and every stored
provider key would become undecryptable.
The steps below are the hand path, for generate_secrets = false (the default, today's deployments):
Run this against the deploying root (the deployment repository's terraform/), once per
environment. All three tfvars (dev, test, prod) now carry
enable_ai_key_encryption = true, so do steps 1 and 2 for an environment
before the flag reaches a full apply for that environment.
-
Provision the (empty) secret first, two-phase apply.
AI_KEY_ENCRYPTION_SECRETis a Secret Manager reference withversion = "latest", which Cloud Run resolves at revision-deploy time. If the secret creation and the backend/jobs env wiring land in the same apply, the secret has zero versions when Cloud Run deploys the new revision and the deploy fails (a real outage risk on an already-running backend). Create only the secret + IAM first, with a targeted apply:terraform apply -target=module.core -var-file=dev.tfvarsmodule.backend/module.jobsare not dependencies ofmodule.core, so they are untouched at this point. -
Populate the secret (out-of-band, so with
generate_secrets = falsethe cleartext never enters tfvars or state). The value MUST be a Fernet key, exactly 32 url-safe base64 bytes. Generate it withFernet.generate_key(), NOTopenssl rand(an arbitrary secret failsFernet(...)construction, the cipher degrades toNone, and the Keys tab stays locked):python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" \ | gcloud secrets versions add ai-key-encryption-secret-dev \ --project=example-dataplatform-dev --data-file=- -
Full apply / redeploy. Now that a version exists, apply without
-target(or let CI apply normally):terraform apply -var-file=dev.tfvarsThis deploys new backend and jobs service revisions carrying the now-resolvable
AI_KEY_ENCRYPTION_SECRETsecret ref. -
Verify (see below), then repeat 1 to 3 for
testandprod, swapping the-var-file, the secret name suffix, and the--project:env secret name project dev ai-key-encryption-secret-devexample-dataplatform-devtest ai-key-encryption-secret-testexample-dataplatform-testprod ai-key-encryption-secret-prodexample-dataplatform-prod
Verify#
After the full apply for an environment:
GET /api/v1/ai/providers(as anai:admin) returnskey_management_available: trueandcipher_mode: "local".- In the AI admin Keys tab, the "unavailable" notice is gone and the
Set / Replace buttons for OpenAI and Gemini are enabled. Saving a key
flips that provider's status from "Configured via env" to "Configured via DB"
(encrypted at rest, only
last4shown).
Rollback#
Set enable_ai_key_encryption = false for the environment and re-apply. The env
var and the count-gated secret + IAM are removed; any keys already stored in the
DB stay encrypted but unused, and the gateway falls back to the
AI_OPENAI_API_KEY / AI_GEMINI_API_KEY env keys. Rotating the Fernet key
(adding a new secret version) makes previously stored DB keys undecryptable, so
re-enter them from the UI after a rotation.
KMS alternative (not used here)#
Setting AI_KMS_KEY_REF to a Cloud KMS crypto-key resource id takes precedence
over the Fernet key (cipher_mode: "kms"), giving envelope encryption instead of
an app-held key. That path needs nistor_tool_gcp installed in the image
and a provisioned KMS key ring / crypto key, and is out of scope for this
flag-driven Fernet rollout.
Operator notes#
- Both backend and jobs get the key. The env var is emitted from
local.backend_extra_env_vars, which feedsmodule.backendANDmodule.jobs. The gateway resolves DB keys from whichever service runs it (the API on the backend, the investigation worker / routine driver on the jobs service), so both need the secret. - Set it in your deployment repository.
deployments/gcp/is the reference composition: it deploys nothing by itself; a deployment repository sources it pinned to a commit or tag (//deployments/gcp?ref=<tag>) and CI applies that repository'sterraform/. Setenable_ai_key_encryption = truein that repository's per-environment tfvars. - Ordering vs CI. Once the flag is
truein an environment's tfvars, a full apply that runs before the matching secret version exists will fail the backend/jobs deploy. Populate each environment's secret (steps 1 and 2) before that environment's full apply / CI run.