| Category | Status | Created | Author |
|---|---|---|---|
| Infrastructure | Draft | 2026-03-06 | Justin Brooks |
Summary
Add a pluggable secret provider system that lets workspaces connect to external secret managers (Doppler, HashiCorp Vault, AWS Secrets Manager, etc.) and reference secrets by path rather than storing sensitive values in ctrlplane. A new workspace-scopedsecret_provider entity holds encrypted provider
credentials. A SecretReference type describes a secret’s location. The
workspace-engine resolves references at variable resolution or job dispatch time
by calling the external provider.
Motivation
Ctrlplane currently stores sensitive credentials at two levels: Job agent configs — When an operator creates an ArgoCD runner, the API token is stored in thejob_agent.config JSON column. The same applies to Terraform
Cloud tokens.
Deployment variables — The SensitiveValue type exists in the OpenAPI
schema and the variable resolver recognizes it as a distinct value type, but
resolution is explicitly rejected:
release_variable.encrypted column, job_variable.sensitive flag, and
@ctrlplane/secrets AES-256 service all exist but are not wired end-to-end. The
infrastructure for handling secrets was scaffolded but never completed.
Why this matters
- Compliance — SOC 2, ISO 27001, and similar frameworks require that secrets are encrypted at rest and access-controlled.
- Secret rotation — When credentials are stored directly in ctrlplane, rotation requires updating every agent config and variable value that references the credential. With an external provider, rotation happens in Doppler/Vault/AWS and ctrlplane picks up the new value on the next resolution.
- Separation of concerns — Platform teams manage secrets in their existing secret management infrastructure. Application teams reference secrets by name in ctrlplane without needing access to the actual values.
- Multi-workspace — Each workspace may use a different secret provider or account. The provider credentials themselves need per-workspace encrypted storage.
Existing mechanisms and their limitations
@ctrlplane/secrets AES-256 — A TypeScript encryption service exists in
packages/secrets/ but is not imported anywhere in the application code. It
provides encrypt/decrypt with a 256-bit key from VARIABLES_AES_256_KEY. This
could handle encryption at rest but does not solve external provider
integration.
SensitiveValue type — Defined in the OpenAPI schema with a valueHash
field. The variable resolver detects it but refuses to resolve it, returning an
error. The intent was that a separate decryption path would handle these values,
but that path was never built.
Go template interpolation — The ArgoCD agent config already supports Go
templates, and the docs show apiKey: "{{.variables.argocd_token}}". This means
credentials can technically flow through the variable system into agent configs
at dispatch time. The missing piece is a way to resolve variables whose values
come from an external source.
Proposal
New entity: secret_provider
A workspace-scoped entity that holds the credentials for connecting to an
external secret management service:
| Column | Description |
|---|---|
type | Provider type: doppler, vault, aws-secretsmanager, kubernetes, env |
config | AES-256 encrypted JSON blob with provider-specific credentials |
name | Human-readable name, unique per workspace |
config column uses BYTEA for the encrypted payload rather than JSON,
since the ciphertext is opaque binary. The workspace-engine decrypts it in
memory using the instance-level VARIABLES_AES_256_KEY when a secret resolution
is needed.
Example configs (before encryption):
SecretReference type
A value object that describes where a secret lives:
| Field | Description |
|---|---|
Provider | Matches secret_provider.name within the workspace |
Path | Provider-specific path (e.g., my-project/production for Doppler, secret/data/argocd for Vault) |
Key | The specific secret key within the path |
Provider field matches by name, not by type. This allows a workspace
to have multiple providers of the same type (e.g., two Doppler connections for
different teams).
Go interfaces
A new packagepkg/secrets/ in the workspace-engine:
Resolver uses the ProviderConfigStore to look up the workspace’s
provider credentials, then passes them to the appropriate ProviderFactory to
construct a Provider, then calls Resolve. Provider instances can be cached
per workspace with a TTL to avoid repeated decryption and construction.
Resolution points
The secret resolver integrates at two points in the workspace-engine:1. Variable resolution (deployment variables)
TheSensitiveValue type is extended to carry a SecretReference:
variableresolver/value.go, the "sensitive" case changes from an error to
a resolution call:
LiteralValue and flows into the release like any
other variable. The variable’s key is added to release.EncryptedVariables so
downstream consumers know it originated from a sensitive source.
2. Job agent config resolution
For agent configs that use Go template interpolation (the existing mechanism), no changes are needed. The credential flows through the variable system:- Deployment variable
argocd_tokenis aSensitiveValuewith aSecretReferencepointing to Doppler - Variable resolver calls the secret provider, gets the plaintext token
- Token lands in
release.Variablesand thenDispatchContext.Variables - The agent config template
{{.variables.argocd_token}}renders the value
Provider implementations
Each provider is a Go package underpkg/secrets/<provider>/:
Doppler (pkg/secrets/doppler/)
pkg/secrets/vault/)
pkg/secrets/awssm/)
pkg/secrets/kubernetes/)
pkg/secrets/env/)
env provider is special — it does not use ProviderConfigStore and does
not require a secret_provider row. It reads directly from the process
environment and is available in every workspace by default. This is useful for
development and for instance-level secrets that are the same across all
workspaces.
Bootstrap chain
The bootstrap dependency chain for secrets is:VARIABLES_AES_256_KEY) gates access to all
provider credentials. This key is the same one that @ctrlplane/secrets in the
TypeScript layer uses, so both sides can encrypt/decrypt interchangeably.
API
REST endpoints:PUT body:
config before storing. The GET response never
includes the decrypted config — it returns the provider metadata only:
Web UI
Settings > Secret Providers — A workspace-level settings page to manage provider connections. CRUD forsecret_provider entities. The config form is
type-specific (Doppler shows a service token field, Vault shows address + auth
method, etc.).
Variable editor — When creating a deployment variable value, a “Secret”
option is available alongside “Literal” and “Reference”. Selecting it shows a
provider dropdown (populated from the workspace’s secret_provider entries) and
path/key inputs. The submitted value is a SensitiveValue with a secretRef.
Runner creation — The ArgoCD dialog can suggest using a variable reference
for the API key instead of a direct value, linking to the variable editor
workflow.
Caching
External provider calls add latency to variable resolution. The resolver should cache resolved values with a configurable TTL (default: 5 minutes). The cache is keyed by(workspaceID, providerName, path, key) and held in memory on the
workspace-engine instance. Cache entries are invalidated when:
- The
secret_providerconfig is updated (provider credentials changed) - The TTL expires
- The workspace-engine restarts
secret_provider
entity to flush the cache.
Audit
Secret resolution events should be recorded in the existingevent table:
Examples
ArgoCD API token from Doppler
- Workspace admin creates a secret provider:
- Deployment author creates a variable:
- The ArgoCD agent config template uses the variable:
- At release time, the variable resolver hits the
SensitiveValue, calls the Doppler provider, and the resolved token flows throughrelease.Variablesinto the dispatch context.
Vault for database credentials
LiteralValue in the release variables.
Environment variable fallback (development)
For local development where no external provider is configured, theenv
provider reads from the workspace-engine process:
Migration
- The
secret_providertable is additive — no existing tables are modified. - Existing job agent configs with plaintext credentials continue to work. The
migration path is to create a secret provider, create a deployment variable
with a
SensitiveValuereference, update the agent config template to use{{.variables.xxx}}, and remove the plaintext credential from the agent config. - The
SensitiveValueschema extension (secretReffield) is additive. ExistingSensitiveValueentries without asecretRefwill fail resolution with a clear error message. - The
EncryptedVariablesfield on releases, currently always[]string{}, will begin to be populated for variables resolved from secret providers.
Open questions
-
Should the
envprovider require asecret_providerrow? The current proposal makes it available by default without configuration. This is convenient for development but means any variable can read arbitrary environment variables from the workspace-engine process. -
Secret versioning — Should the
SecretReferencesupport pinning to a specific secret version (e.g., Vault lease ID, AWS version stage)? This would improve reproducibility but complicates the reference format. - Cross-workspace providers — Should an instance admin be able to define providers at the instance level that are available to all workspaces? This avoids duplicating provider configs across workspaces but adds a multi-tenancy concern.
-
Release snapshotting — Should the resolved secret value be stored
(encrypted) in
release_variablefor reproducibility, or should it always be re-resolved from the external provider? Snapshotting means a release is fully reproducible; re-resolving means secrets are never in the database but a provider outage blocks dispatch. -
RBAC — Should creating
SensitiveValuevariables that reference a provider require a specific permission? This would prevent unprivileged users from reading arbitrary secrets from the workspace’s providers.