Secret Management & Rotation
On this page
Overview
All sensitive Canopy configuration values — database connection
strings, RabbitMQ credentials, Keycloak secrets, the SSN encryption
key — are read through the canopy-secrets crate’s SecretProvider
trait rather than directly from std::env::var.
The trait gives Canopy three things:
-
Audit trail. Every secret access produces a structured
tracing::info!event withtarget = "canopy.secrets"carrying the service name and secret key (never the value). Required for IRS Pub 1075 §9.4.1.4 audit of access to sensitive data. -
Rotation seam. The provider returns a fresh value on every
get(key)call. Phase 1 backs that withstd::env::var, which is process-lifetime; rotation requires a service restart (see below). Phase 2 (HashiCorp Vault) returns versioned values that can be rotated without restart. -
Vault on-ramp without call-site churn. Every call site that needs a secret already takes
&dyn SecretProvider(viaServiceSettings::load_with_secretsincanopy-api’s `bootstrap). SwappingEnvSecretProviderfor a futureVaultSecretProvideris a one-line change at the construction site.
| Concern | Phase 1 (today) | Phase 2 (when Vault is provisioned) |
|---|---|---|
Backend |
|
|
Audit log |
|
Same — trait shared |
Rotation |
Restart the service (env var is process-lifetime) |
Live rotation; provider re-reads on next |
Storage |
|
Vault KV-v2 or similar |
Access control |
Filesystem permissions on the env source |
Vault policies + AppRole / Kubernetes auth |
Audit log format
Every successful secret read emits:
{
"level": "INFO",
"target": "canopy.secrets",
"service": "canopy-snap",
"secret": "CANOPY_SNAP__DATABASE_URL",
"source": "env",
"message": "secret accessed"
}
The secret value is never logged. The source field will become
"vault" once phase 2 lands.
To observe the audit log in dev:
CANOPY_RULES__LOG_LEVEL=info cargo xtask dev start
docker logs canopy-canopy-snap-1 2>&1 | grep canopy.secrets
In production these events flow through the standard tracing pipeline (OpenTelemetry → Loki / OpenSearch / similar) and live alongside other audit-relevant events.
Phase 1 rotation procedure (current)
Phase 1 secrets are environment variables read once at process startup. To rotate a credential:
-
Update the value in the secrets store (1Password, AWS Secrets Manager, etc.) and any environment-specific override files (
.env,docker-compose.override.yml, Kubernetes Secret). -
Restart the affected services. For the devstack:
docker compose restart canopy-snap canopy-tanf canopy-medicaidFor production deployments, follow your standard rolling-restart procedure (Step 5 phase 2 will replace this with Vault’s
secrets revokesemantics). -
Verify rotation: tail the audit log for
canopy.secretsevents on the restarted services and confirm the nextget()returned without error.
Database URL rotations require coordinated DB migration. If you
rotate CANOPY_*__DATABASE_URL in a way that points to a different
database, run cargo xtask migrate snapshot against the old database
first (per
Devstack Migration Snapshot)
so you can roll back if the new database is unhealthy.
Common rotations
| Secret | Env var | Rotation cadence |
|---|---|---|
Database password |
|
Annually + on suspected compromise. Coordinate with PostgreSQL
|
RabbitMQ password |
|
Annually + on suspected compromise. Coordinate with RabbitMQ
|
Keycloak realm signing key |
Rotated in Keycloak admin |
Per Keycloak’s automatic rotation policy. Canopy fetches via JWKS; no per-service rotation needed (operational-infrastructure plan Step 7’s auto-reconnect already handles JWKS refresh). |
SSN encryption key |
|
Currently per-deployment lifetime. Rotation requires re-encrypting existing rows — tracked separately because data migration is a schema-level concern, not a secret-management one. Coordinate with the canopy-persons backfill plan when rotation is required. |
Service-to-service signing keys (ECDSA P-256) |
|
Per |
Phase 2 outlook (Vault)
When HashiCorp Vault is provisioned, ship a VaultSecretProvider
that:
-
Authenticates via AppRole (production) or Kubernetes auth (in-cluster) to a Vault namespace owned by the Canopy deployment.
-
Resolves secrets from a KV-v2 mount (e.g.,
kv/canopy/<env>/). -
Caches values per-process for a TTL configurable per secret — short TTL on credentials that should rotate frequently, long TTL on values that change rarely.
-
Emits the same audit-log shape as
EnvSecretProviderwithsource = "vault".
The bootstrap path constructs VaultSecretProvider instead of
EnvSecretProvider; everything downstream is unchanged. Tracked as
the phase 2 portion of operational-infrastructure plan Step 5.
Implementation references
-
crates/canopy-secrets/src/lib.rs— trait + env-backed impl -
crates/canopy-common/src/settings.rs—ServiceSettings::load_with_secrets -
crates/canopy-api/src/bootstrap.rs— wires the provider into every service -
Plan: Operational Infrastructure — Step 5