Files
coder/coderd/workspacesecrets.go
Steven Masley 03cba25a63 feat: list a workspace build's secrets in coder secret list (#30388)
`coder secret list` now shows the current workspace build's secrets next to your user secrets, and which user secrets they replace. 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
4. #30251 Deliver secrets to the agent
5. #30252 Docs
6. **#30388 List a build's secrets (this PR)**
7. #30459 Audit a build's secrets

- New `GET /workspacebuilds/{workspacebuild}/secrets` lists a build's secrets (metadata only, never values) and which user secret each one replaces.
- Inside a workspace, `coder secret list` includes the current build's secrets. Outside a workspace, use `--workspace` or `--build-id`.
- The agent now exposes `CODER_WORKSPACE_BUILD_ID`, so the CLI knows which build it is running in.
- The agent manifest and the new endpoint use one shared resolver, so they always agree on which secret wins.
- The user secret API is unchanged.

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

- The manifest carries `workspace_build_id`; the agent sets it as `CODER_WORKSPACE_BUILD_ID`.
- `coderd/workspacesecrets.Resolve(user, build, policy)` decides which secret is delivered on each env var and file. The manifest no longer relies on entry order.
- `codersdk.WorkspaceSecret` embeds `UserSecret` and adds `workspace_build_id`, `ephemeral`, `cleared_at`, `env_replaces`, and `file_replaces`. Secrets whose values a later build cleared are still listed, with `cleared_at` set.
- CLI columns: `source` and `replaced by` are shown by default only when a build is included; `ephemeral` is opt-in. The CLI makes two requests: user secrets, then build secrets.
- Agents that predate the build ID fall back to `CODER_WORKSPACE_ID` and its latest build.

</details>

<details>
<summary>Decision log</summary>

(existing decision log, unchanged)

</details>

---

*Created by Coder Agents on behalf of @Emyrk.*
2026-10-15 17:40:00 -05:00

171 lines
6.1 KiB
Go

package coderd
import (
"fmt"
"net/http"
"github.com/google/uuid"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/db2sdk"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpapi/httperror"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/workspacesecrets"
"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,
})
}
// @Summary Get workspace build secrets
// @Description Lists the metadata of the secrets linked to a workspace build,
// @Description including secrets whose values a later build cleared. Values
// @Description are never returned. env_replaces and file_replaces name the
// @Description workspace owner's user secrets that each live secret displaces,
// @Description and are omitted when the caller cannot read those user secrets.
// @ID get-workspace-build-secrets
// @Security CoderSessionToken
// @Produce json
// @Tags Builds
// @Param workspacebuild path string true "Workspace build ID" format(uuid)
// @Success 200 {array} codersdk.WorkspaceSecret
// @Router /api/v2/workspacebuilds/{workspacebuild}/secrets [get]
func (api *API) workspaceBuildSecrets(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
build = httpmw.WorkspaceBuildParam(r)
workspace = httpmw.WorkspaceParam(r)
)
rows, err := api.Database.GetWorkspaceSecretsHistory(ctx, database.GetWorkspaceSecretsHistoryParams{
WorkspaceID: workspace.ID,
WorkspaceBuildID: build.ID,
})
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error listing workspace build secrets.",
Detail: err.Error(),
})
return
}
secrets := make([]codersdk.WorkspaceSecret, len(rows))
for i, row := range rows {
secrets[i] = db2sdk.WorkspaceBuildSecret(row)
}
userSecrets, err := api.Database.ListUserSecrets(ctx, workspace.OwnerID)
switch {
case dbauthz.IsNotAuthorizedError(err):
// The caller cannot see the owner's user secrets, so report no
// replacements rather than their IDs.
case err != nil:
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error listing the workspace owner's secrets.",
Detail: err.Error(),
})
return
default:
api.applySecretReplacements(secrets, userSecrets)
}
httpapi.Write(ctx, rw, http.StatusOK, secrets)
}
// applySecretReplacements records on each live build secret which of the
// workspace owner's user secrets it displaces, using the same rules as the
// agent manifest. Cleared build secrets are not delivered, so they replace
// nothing.
func (api *API) applySecretReplacements(secrets []codersdk.WorkspaceSecret, userSecrets []database.ListUserSecretsRow) {
policy := workspacesecrets.FilePathAllowed
if api.userSecretFilePathBlocked() {
policy = workspacesecrets.FilePathBlocked
}
user := make([]workspacesecrets.Secret, 0, len(userSecrets))
for _, s := range userSecrets {
user = append(user, workspacesecrets.Secret{
ID: s.ID,
EnvName: s.EnvName,
FilePath: s.FilePath,
Enabled: s.Enabled,
})
}
build := make([]workspacesecrets.Secret, 0, len(secrets))
byID := make(map[uuid.UUID]*codersdk.WorkspaceSecret, len(secrets))
for i, s := range secrets {
if s.ClearedAt != nil {
continue
}
byID[s.ID] = &secrets[i]
build = append(build, workspacesecrets.Secret{
ID: s.ID,
EnvName: s.EnvName,
FilePath: s.FilePath,
})
}
resolvedUser, _ := workspacesecrets.Resolve(user, build, policy)
for _, r := range resolvedUser {
if s, ok := byID[r.EnvReplacedBy.UUID]; r.EnvReplacedBy.Valid && ok {
s.EnvReplaces = &r.ID
}
if s, ok := byID[r.FileReplacedBy.UUID]; r.FileReplacedBy.Valid && ok {
s.FileReplaces = &r.ID
}
}
}