Files
coder/coderd/workspacesecrets.go
Steven Masley f25b6294b6 feat: set workspace secrets on workspace builds (#30250)
Lets clients set secrets when creating a workspace or a build. Secrets are saved with the build and are never sent to the provisioner. Part of [RELAY-7](https://linear.app/codercom/issue/RELAY-7/agent-relay-deliver-provider-credentials-via-coder-secrets-instead-of).

**Stack** (design decisions in #30248)
1. #30248 Database schema, queries, and RBAC
2. #30249 Encryption at rest
3. **#30250 Set secrets on workspace builds (this PR)**
4. #30251 Deliver secrets to the agent
5. #30252 Docs
6. #30388 List a build's secrets
7. #30459 Audit a build's secrets

- `secrets` is a new field on the create workspace and create build requests.
- Each secret is delivered as an env var, a file, or both. `ephemeral` secrets last for one build only.
- Secrets carry forward to every later build until a request replaces or removes them.
- A secret set on another secret's env var or file path replaces that secret entirely.
- A null `value` removes a secret, which needs `update` permission on the owner's workspace secrets.
- Deleting a workspace clears its secret values.

Secrets are stored but not delivered to the agent until #30251.

<details>
<summary>Example request</summary>

```json
POST /api/v2/workspaces/{workspace}/builds
{
  "transition": "start",
  "secrets": [
    { "name": "openai-key", "value": "sk-...", "env_name": "OPENAI_API_KEY" },
    { "name": "kubeconfig", "value": "apiVersion: v1 ...", "file_path": "~/.kube/config" },
    { "name": "session-jwt", "value": "eyJ...", "env_name": "SESSION_JWT", "ephemeral": true },
    { "name": "old-token", "value": null }
  ]
}
```

</details>

<details>
<summary>Implementation details</summary>

- Validation reuses the user secret field rules and the file path delivery policy. It rejects:
  - duplicate names, env names, or file paths within one request
  - a removal that also sets a target or `ephemeral`
- In the build transaction, the builder:
  1. copies the previous build's live, non-ephemeral secrets that the request does not name or take over
  2. inserts the requested secrets
  3. "Earlier builds' rows are cleared when the new build's job completes
- Copying and clearing run as `AsWorkspaceSecretManager`, so autostart and other system-initiated builds work without secret permissions. Requested secrets are authorized as the build's actor.
- Each row records `source` (`request` or `carry_forward`).
- Per-build limit violations return a 400.
- A completed delete in `provisionerdserver`, or an orphan delete in the builder, clears the workspace's values and keeps the rows.

</details>

---

*Created by Coder Agents on behalf of @Emyrk.*
2026-10-15 14:20:44 -05:00

67 lines
2.5 KiB
Go

package coderd
import (
"fmt"
"net/http"
"github.com/coder/coder/v2/coderd/httpapi/httperror"
"github.com/coder/coder/v2/codersdk"
)
// validateWorkspaceSecretInputs validates the secrets supplied on a
// workspace or workspace build request. It applies the same per-field rules
// and file path delivery policy as user secrets. It also rejects entries in
// one request that share a name, env_name, or file_path, which would
// otherwise fail on the build's unique indexes inside the build transaction.
func (api *API) validateWorkspaceSecretInputs(secrets []codersdk.WorkspaceSecretInput) error {
if len(secrets) == 0 {
return nil
}
blocked := api.userSecretFilePathBlocked()
seenNames := make(map[string]struct{}, len(secrets))
seenEnvNames := make(map[string]struct{}, len(secrets))
seenFilePaths := make(map[string]struct{}, len(secrets))
var validations []codersdk.ValidationError
for i, secret := range secrets {
fieldErrs := codersdk.ValidateWorkspaceSecretInput(secret)
if blocked && !secret.IsRemoval() && secret.FilePath != "" {
fieldErrs = append(fieldErrs, codersdk.ValidationError{
Field: codersdk.UserSecretFilePathField,
Detail: userSecretFilePathDisabledDetail,
})
}
if _, dup := seenNames[secret.Name]; dup {
fieldErrs = append(fieldErrs, codersdk.ValidationError{
Field: codersdk.UserSecretNameField,
Detail: fmt.Sprintf("Secret %q is listed more than once.", secret.Name),
})
}
seenNames[secret.Name] = struct{}{}
if !secret.IsRemoval() {
if _, dup := seenEnvNames[secret.EnvName]; dup && secret.EnvName != "" {
fieldErrs = append(fieldErrs, codersdk.ValidationError{
Field: codersdk.UserSecretEnvNameField,
Detail: fmt.Sprintf("env_name %q is used by more than one secret in this request.", secret.EnvName),
})
}
seenEnvNames[secret.EnvName] = struct{}{}
if _, dup := seenFilePaths[secret.FilePath]; dup && secret.FilePath != "" {
fieldErrs = append(fieldErrs, codersdk.ValidationError{
Field: codersdk.UserSecretFilePathField,
Detail: fmt.Sprintf("file_path %q is used by more than one secret in this request.", secret.FilePath),
})
}
seenFilePaths[secret.FilePath] = struct{}{}
}
validations = append(validations, prefixUserSecretValidationErrors(i, fieldErrs)...)
}
if len(validations) == 0 {
return nil
}
return httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
Message: "Invalid workspace secrets.",
Validations: validations,
})
}