mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
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.*
67 lines
2.5 KiB
Go
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,
|
|
})
|
|
}
|