Skip to main content

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-scoped secret_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 the job_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:
The 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

  1. Compliance — SOC 2, ISO 27001, and similar frameworks require that secrets are encrypted at rest and access-controlled.
  2. 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.
  3. 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.
  4. 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:
The 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:
The 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 package pkg/secrets/ in the workspace-engine:
The 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)

The SensitiveValue type is extended to carry a SecretReference:
In variableresolver/value.go, the "sensitive" case changes from an error to a resolution call:
The resolved value becomes a 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:
  1. Deployment variable argocd_token is a SensitiveValue with a SecretReference pointing to Doppler
  2. Variable resolver calls the secret provider, gets the plaintext token
  3. Token lands in release.Variables and then DispatchContext.Variables
  4. The agent config template {{.variables.argocd_token}} renders the value
This reuses the existing template interpolation rather than adding a second resolution path in the agent config.

Provider implementations

Each provider is a Go package under pkg/secrets/<provider>/: Doppler (pkg/secrets/doppler/)
HashiCorp Vault (pkg/secrets/vault/)
AWS Secrets Manager (pkg/secrets/awssm/)
Kubernetes (pkg/secrets/kubernetes/)
Environment (pkg/secrets/env/)
The 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:
One symmetric key on the process (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:
The PUT body:
The API layer encrypts 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 for secret_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_provider config is updated (provider credentials changed)
  • The TTL expires
  • The workspace-engine restarts
This means secret rotation in the external provider takes effect within the TTL window. For immediate rotation, the operator can update the secret_provider entity to flush the cache.

Audit

Secret resolution events should be recorded in the existing event table:
The resolved value is never included in the audit event — only the reference metadata.

Examples

ArgoCD API token from Doppler

  1. Workspace admin creates a secret provider:
  1. Deployment author creates a variable:
  1. The ArgoCD agent config template uses the variable:
  1. At release time, the variable resolver hits the SensitiveValue, calls the Doppler provider, and the resolved token flows through release.Variables into the dispatch context.

Vault for database credentials

The Vault provider reads the dynamic credential, and it resolves as a regular LiteralValue in the release variables.

Environment variable fallback (development)

For local development where no external provider is configured, the env provider reads from the workspace-engine process:

Migration

  • The secret_provider table 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 SensitiveValue reference, update the agent config template to use {{.variables.xxx}}, and remove the plaintext credential from the agent config.
  • The SensitiveValue schema extension (secretRef field) is additive. Existing SensitiveValue entries without a secretRef will fail resolution with a clear error message.
  • The EncryptedVariables field on releases, currently always []string{}, will begin to be populated for variables resolved from secret providers.

Open questions

  1. Should the env provider require a secret_provider row? 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.
  2. Secret versioning — Should the SecretReference support pinning to a specific secret version (e.g., Vault lease ID, AWS version stage)? This would improve reproducibility but complicates the reference format.
  3. 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.
  4. Release snapshotting — Should the resolved secret value be stored (encrypted) in release_variable for 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.
  5. RBAC — Should creating SensitiveValue variables that reference a provider require a specific permission? This would prevent unprivileged users from reading arbitrary secrets from the workspace’s providers.