feat: list a workspace build's secrets with the user's secrets

This commit is contained in:
Steven Masley
2026-10-15 21:25:14 +00:00
parent bf1dba164b
commit d2d16d3ad2
33 changed files with 1098 additions and 278 deletions
+118 -21
View File
@@ -12,6 +12,7 @@ import (
"github.com/dustin/go-humanize"
"github.com/dustin/go-humanize/english"
"github.com/google/uuid"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/cli/cliui"
@@ -479,27 +480,40 @@ func warnSuspiciousTrailingNewline(w io.Writer, value string) {
}
type secretListRow struct {
codersdk.UserSecret `table:"-"`
codersdk.WorkspaceSecret `table:"-"`
Created string `json:"-" table:"created"`
Name string `json:"-" table:"name,default_sort"`
Updated string `json:"-" table:"updated"`
Source string `json:"-" table:"source"`
Env string `json:"-" table:"env"`
File string `json:"-" table:"file"`
Enabled string `json:"-" table:"enabled"`
ReplacedBy string `json:"-" table:"replaced by"`
Description string `json:"-" table:"description"`
}
func secretListRowFromSecret(secret codersdk.UserSecret) secretListRow {
// secretListRowFromSecret builds a table row. names maps secret IDs to
// names so replacements can be shown by name; it may be nil.
func secretListRowFromSecret(secret codersdk.WorkspaceSecret, names map[uuid.UUID]string) secretListRow {
var replaced []string
if secret.EnvReplacedBy != nil {
replaced = append(replaced, "env: "+secretNameOrID(names, *secret.EnvReplacedBy))
}
if secret.FileReplacedBy != nil {
replaced = append(replaced, "file: "+secretNameOrID(names, *secret.FileReplacedBy))
}
return secretListRow{
UserSecret: secret,
Created: humanize.Time(secret.CreatedAt),
Name: secret.Name,
Updated: humanize.Time(secret.UpdatedAt),
Env: secret.EnvName,
File: secret.FilePath,
Enabled: strconv.FormatBool(secret.Enabled),
Description: secret.Description,
WorkspaceSecret: secret,
Created: humanize.Time(secret.CreatedAt),
Name: secret.Name,
Updated: humanize.Time(secret.UpdatedAt),
Source: string(secret.Source),
Env: secret.EnvName,
File: secret.FilePath,
Enabled: strconv.FormatBool(secret.Enabled),
ReplacedBy: strings.Join(replaced, ", "),
Description: secret.Description,
}
}
@@ -574,12 +588,64 @@ func (r *RootCmd) secretEnabledSetter(state secretEnabledState) *serpent.Command
return cmd
}
// secretNameOrID returns the name of the secret with the given ID, or the ID
// itself when the secret is not in the listing.
func secretNameOrID(names map[uuid.UUID]string, id uuid.UUID) string {
if name, ok := names[id]; ok {
return name
}
return id.String()
}
// secretListBuildID picks the workspace build whose secrets to include.
// Explicit flags win; otherwise the current workspace is used when the
// command runs inside one. uuid.Nil means list user secrets only.
func secretListBuildID(inv *serpent.Invocation, client *codersdk.Client, workspace, buildID string) (uuid.UUID, error) {
if buildID != "" {
id, err := uuid.Parse(buildID)
if err != nil {
return uuid.Nil, xerrors.Errorf("parse --build-id: %w", err)
}
return id, nil
}
if workspace != "" {
ws, err := client.ResolveWorkspace(inv.Context(), workspace)
if err != nil {
return uuid.Nil, xerrors.Errorf("get workspace %q: %w", workspace, err)
}
return ws.LatestBuild.ID, nil
}
// Set by the agent inside a workspace.
if v := inv.Environ.Get("CODER_WORKSPACE_BUILD_ID"); v != "" {
id, err := uuid.Parse(v)
if err != nil {
return uuid.Nil, xerrors.Errorf("parse CODER_WORKSPACE_BUILD_ID: %w", err)
}
return id, nil
}
// Older servers do not send the build ID, so fall back to the
// workspace's latest build.
if v := inv.Environ.Get("CODER_WORKSPACE_ID"); v != "" {
id, err := uuid.Parse(v)
if err != nil {
return uuid.Nil, xerrors.Errorf("parse CODER_WORKSPACE_ID: %w", err)
}
ws, err := client.Workspace(inv.Context(), id)
if err != nil {
return uuid.Nil, xerrors.Errorf("get current workspace: %w", err)
}
return ws.LatestBuild.ID, nil
}
return uuid.Nil, nil
}
func (r *RootCmd) secretList() *serpent.Command {
var workspace, buildID string
formatter := cliui.NewOutputFormatter(
cliui.ChangeFormatterData(
cliui.TableFormat(
[]secretListRow{},
[]string{"name", "created", "updated", "env", "file", "enabled", "description"},
[]string{"name", "source", "created", "updated", "env", "file", "enabled", "replaced by", "description"},
),
func(data any) (any, error) {
switch rows := data.(type) {
@@ -597,13 +663,13 @@ func (r *RootCmd) secretList() *serpent.Command {
func(data any) (any, error) {
switch rows := data.(type) {
case []secretListRow:
secrets := make([]codersdk.UserSecret, len(rows))
secrets := make([]codersdk.WorkspaceSecret, len(rows))
for i := range rows {
secrets[i] = rows[i].UserSecret
secrets[i] = rows[i].WorkspaceSecret
}
return secrets, nil
case secretListRow:
return []codersdk.UserSecret{rows.UserSecret}, nil
return []codersdk.WorkspaceSecret{rows.WorkspaceSecret}, nil
default:
return nil, xerrors.Errorf("expected []secretListRow or secretListRow, got %T", data)
}
@@ -612,10 +678,13 @@ func (r *RootCmd) secretList() *serpent.Command {
)
cmd := &serpent.Command{
Use: "list [name]",
Aliases: []string{"ls"},
Short: "List secrets, or show one by name",
Long: "Secret values are omitted from the output.",
Use: "list [name]",
Aliases: []string{"ls"},
Short: "List secrets, or show one by name",
Long: "Secret values are omitted from the output.\n\n" +
"When listing all secrets inside a workspace, or with --workspace or --build-id, " +
"the workspace build's secrets are included and the \"replaced by\" column shows " +
"user secrets whose env var or file is taken by a workspace secret.",
Middleware: serpent.RequireRangeArgs(0, 1),
Handler: func(inv *serpent.Invocation) error {
client, err := r.InitClient(inv)
@@ -625,20 +694,36 @@ func (r *RootCmd) secretList() *serpent.Command {
var data any
if len(inv.Args) == 1 {
if workspace != "" || buildID != "" {
return xerrors.New("--workspace and --build-id cannot be used with a secret name")
}
secret, err := client.UserSecretByName(inv.Context(), codersdk.Me, inv.Args[0])
if err != nil {
return xerrors.Errorf("get secret %q: %w", inv.Args[0], err)
}
data = secretListRowFromSecret(secret)
data = secretListRowFromSecret(secret, nil)
} else {
secrets, err := client.UserSecrets(inv.Context(), codersdk.Me)
id, err := secretListBuildID(inv, client, workspace, buildID)
if err != nil {
return err
}
var secrets []codersdk.WorkspaceSecret
if id == uuid.Nil {
secrets, err = client.UserSecrets(inv.Context(), codersdk.Me)
} else {
secrets, err = client.UserSecretsForWorkspaceBuild(inv.Context(), codersdk.Me, id)
}
if err != nil {
return xerrors.Errorf("list secrets: %w", err)
}
names := make(map[uuid.UUID]string, len(secrets))
for _, s := range secrets {
names[s.ID] = s.Name
}
rows := make([]secretListRow, len(secrets))
for i := range secrets {
rows[i] = secretListRowFromSecret(secrets[i])
rows[i] = secretListRowFromSecret(secrets[i], names)
}
data = rows
}
@@ -655,6 +740,18 @@ func (r *RootCmd) secretList() *serpent.Command {
_, err = fmt.Fprintln(inv.Stdout, out)
return err
},
Options: serpent.OptionSet{
{
Flag: "workspace",
Description: "Include the secrets of this workspace's latest build. Defaults to the current workspace when run inside one.",
Value: serpent.StringOf(&workspace),
},
{
Flag: "build-id",
Description: "Include the secrets of this workspace build. Takes precedence over --workspace.",
Value: serpent.StringOf(&buildID),
},
},
}
formatter.AttachOptions(&cmd.Options)
+65
View File
@@ -18,6 +18,7 @@ import (
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
"github.com/coder/coder/v2/testutil/expecter"
@@ -541,6 +542,70 @@ func TestSecretList(t *testing.T) {
})
}
func TestSecretListWorkspaceBuild(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil)
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
setupCtx := testutil.Context(t, testutil.WaitMedium)
_, err := client.CreateUserSecret(setupCtx, codersdk.Me, codersdk.CreateUserSecretRequest{
Name: "user-token", Value: "u", EnvName: "TOKEN",
})
require.NoError(t, err)
workspace := coderdtest.CreateWorkspace(t, client, template.ID, func(req *codersdk.CreateWorkspaceRequest) {
req.Secrets = []codersdk.WorkspaceSecretInput{
{Name: "build-token", Value: ptr.Ref("b"), EnvName: "TOKEN"},
}
})
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
cases := []struct {
name string
args []string
env map[string]string
}{
{name: "InsideWorkspace", env: map[string]string{"CODER_WORKSPACE_BUILD_ID": workspace.LatestBuild.ID.String()}},
{name: "InsideWorkspaceOlderServer", env: map[string]string{"CODER_WORKSPACE_ID": workspace.ID.String()}},
{name: "WorkspaceFlag", args: []string{"--workspace", workspace.Name}},
{name: "BuildIDFlag", args: []string{"--build-id", workspace.LatestBuild.ID.String()}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
inv, root := clitest.New(t, append([]string{"secret", "list"}, tc.args...)...)
for k, v := range tc.env {
inv.Environ.Set(k, v)
}
output := clitest.Capture(inv)
clitest.SetupConfig(t, client, root)
ctx := testutil.Context(t, testutil.WaitMedium)
require.NoError(t, inv.WithContext(ctx).Run())
out := output.Stdout()
assert.Contains(t, out, "SOURCE")
assert.Contains(t, out, "REPLACED BY")
assert.Contains(t, out, "build-token")
assert.Contains(t, out, "env: build-token")
})
}
t.Run("NameWithFlag", func(t *testing.T) {
t.Parallel()
inv, root := clitest.New(t, "secret", "list", "user-token", "--build-id", workspace.LatestBuild.ID.String())
clitest.SetupConfig(t, client, root)
ctx := testutil.Context(t, testutil.WaitMedium)
require.ErrorContains(t, inv.WithContext(ctx).Run(), "cannot be used with a secret name")
})
}
func TestSecretDelete(t *testing.T) {
t.Parallel()
+14 -1
View File
@@ -8,13 +8,26 @@ USAGE:
Aliases: ls
Secret values are omitted from the output.
When listing all secrets inside a workspace, or with --workspace or
--build-id, the workspace build's secrets are included and the "replaced by"
column shows user secrets whose env var or file is taken by a workspace
secret.
OPTIONS:
-c, --column [created|name|updated|env|file|enabled|description] (default: name,created,updated,env,file,enabled,description)
--build-id string
Include the secrets of this workspace build. Takes precedence over
--workspace.
-c, --column [created|name|updated|source|env|file|enabled|replaced by|description] (default: name,source,created,updated,env,file,enabled,replaced by,description)
Columns to display in table output.
-o, --output table|json (default: table)
Output format.
--workspace string
Include the secrets of this workspace's latest build. Defaults to the
current workspace when run inside one.
———
Run `coder --help` for a list of global options.
+37 -42
View File
@@ -20,6 +20,7 @@ import (
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/externalauth"
"github.com/coder/coder/v2/coderd/workspaceapps/appurl"
"github.com/coder/coder/v2/coderd/workspacesecrets"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/tailnet"
)
@@ -143,9 +144,9 @@ func (a *ManifestAPI) GetManifest(ctx context.Context, _ *agentproto.GetManifest
parentID = workspaceAgent.ParentID.UUID[:]
}
secretFilePathPolicy := userSecretFilePathAllowed
secretFilePathPolicy := workspacesecrets.FilePathAllowed
if a.DisableUserSecretFilePath {
secretFilePathPolicy = userSecretFilePathBlocked
secretFilePathPolicy = workspacesecrets.FilePathBlocked
}
return &agentproto.Manifest{
@@ -336,29 +337,20 @@ func dbAgentDevcontainersToProto(devcontainers []database.WorkspaceAgentDevconta
return ret
}
// userSecretFilePathPolicy is a named type rather than a bool parameter to
// satisfy revive's flag-parameter rule.
type userSecretFilePathPolicy int
const (
userSecretFilePathAllowed userSecretFilePathPolicy = iota
userSecretFilePathBlocked
)
func dbSecretsToProto(userSecrets []database.UserSecret, workspaceSecrets []database.WorkspaceSecret, policy userSecretFilePathPolicy) []*agentproto.WorkspaceSecret {
// Workspace secrets are appended after user secrets. The agent applies
// env vars in order, so a workspace secret overrides a user secret that
// targets the same environment variable.
ret := make([]*agentproto.WorkspaceSecret, 0, len(userSecrets)+len(workspaceSecrets))
// dbSecretsToProto builds the manifest secrets from the user's and the
// agent build's secrets, delivering each on the targets Resolve assigns.
func dbSecretsToProto(userSecrets []database.UserSecret, workspaceSecrets []database.WorkspaceSecret, policy workspacesecrets.FilePathPolicy) []*agentproto.WorkspaceSecret {
candidates := make([]workspacesecrets.Secret, 0, len(userSecrets)+len(workspaceSecrets))
for _, s := range userSecrets {
// Skip disabled secrets so they are not injected as env vars or
// written to secret files. The API guarantees every enabled
// secret has at least one of env_name or file_path set, so we
// don't need to filter both-empty rows separately here.
if !s.Enabled {
continue
}
ret = appendSecretProto(ret, s.EnvName, s.FilePath, s.Value, policy)
candidates = append(candidates, workspacesecrets.Secret{
ID: s.ID,
Source: codersdk.WorkspaceSecretSourceUser,
Name: s.Name,
EnvName: s.EnvName,
FilePath: s.FilePath,
Enabled: s.Enabled,
Value: s.Value,
})
}
for _, s := range workspaceSecrets {
// ListActiveWorkspaceSecrets only returns rows that still hold a
@@ -366,24 +358,27 @@ func dbSecretsToProto(userSecrets []database.UserSecret, workspaceSecrets []data
if !s.Value.Valid {
continue
}
ret = appendSecretProto(ret, s.EnvName, s.FilePath, s.Value.String, policy)
candidates = append(candidates, workspacesecrets.Secret{
ID: s.ID,
Source: codersdk.WorkspaceSecretSourceBuild,
Name: s.Name,
EnvName: s.EnvName,
FilePath: s.FilePath,
Enabled: true,
Value: s.Value.String,
})
}
ret := make([]*agentproto.WorkspaceSecret, 0, len(candidates))
for _, r := range workspacesecrets.Resolve(candidates, policy) {
if !r.Delivered() {
continue
}
ret = append(ret, &agentproto.WorkspaceSecret{
EnvName: r.DeliveredEnvName,
FilePath: r.DeliveredFilePath,
Value: []byte(r.Value),
})
}
return ret
}
// appendSecretProto appends one manifest secret, applying the deployment's
// file path delivery policy. A secret that only targets a file is dropped
// entirely when file delivery is blocked.
func appendSecretProto(ret []*agentproto.WorkspaceSecret, envName, filePath, value string, policy userSecretFilePathPolicy) []*agentproto.WorkspaceSecret {
if policy == userSecretFilePathBlocked {
if envName == "" {
return ret
}
filePath = ""
}
return append(ret, &agentproto.WorkspaceSecret{
EnvName: envName,
FilePath: filePath,
Value: []byte(value),
})
}
+11 -3
View File
@@ -11,6 +11,7 @@ import (
agentproto "github.com/coder/coder/v2/agent/proto"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/workspaceapps/appurl"
"github.com/coder/coder/v2/coderd/workspacesecrets"
)
func Test_dbSecretsToProto(t *testing.T) {
@@ -21,36 +22,43 @@ func Test_dbSecretsToProto(t *testing.T) {
{Name: "file-only", FilePath: "~/.ssh/id_rsa", Value: "file-val", Enabled: true},
{Name: "dual", EnvName: "DUAL_ENV", FilePath: "/etc/dual", Value: "dual-val", Enabled: true},
{Name: "disabled", EnvName: "DISABLED_ENV", FilePath: "/etc/disabled", Value: "disabled-val"},
{Name: "overridden", EnvName: "SHARED_ENV", FilePath: "/etc/overridden", Value: "user-val", Enabled: true},
}
workspaceSecrets := []database.WorkspaceSecret{
{Name: "ws-env", EnvName: "WS_ENV", Value: sql.NullString{String: "ws-val", Valid: true}},
{Name: "ws-file", FilePath: "/etc/ws", Value: sql.NullString{String: "ws-file-val", Valid: true}},
{Name: "ws-cleared", EnvName: "WS_CLEARED"},
{Name: "ws-shared", EnvName: "SHARED_ENV", Value: sql.NullString{String: "ws-shared-val", Valid: true}},
}
// A workspace secret on the same env var replaces the user secret's env
// delivery; the user secret's file is still written.
cases := []struct {
name string
policy userSecretFilePathPolicy
policy workspacesecrets.FilePathPolicy
expected []*agentproto.WorkspaceSecret
}{
{
name: "FilePathAllowed",
policy: userSecretFilePathAllowed,
policy: workspacesecrets.FilePathAllowed,
expected: []*agentproto.WorkspaceSecret{
{EnvName: "ENV_ONLY", Value: []byte("env-val")},
{FilePath: "~/.ssh/id_rsa", Value: []byte("file-val")},
{EnvName: "DUAL_ENV", FilePath: "/etc/dual", Value: []byte("dual-val")},
{FilePath: "/etc/overridden", Value: []byte("user-val")},
{EnvName: "WS_ENV", Value: []byte("ws-val")},
{FilePath: "/etc/ws", Value: []byte("ws-file-val")},
{EnvName: "SHARED_ENV", Value: []byte("ws-shared-val")},
},
},
{
name: "FilePathBlocked",
policy: userSecretFilePathBlocked,
policy: workspacesecrets.FilePathBlocked,
expected: []*agentproto.WorkspaceSecret{
{EnvName: "ENV_ONLY", Value: []byte("env-val")},
{EnvName: "DUAL_ENV", Value: []byte("dual-val")},
{EnvName: "WS_ENV", Value: []byte("ws-val")},
{EnvName: "SHARED_ENV", Value: []byte("ws-shared-val")},
},
},
}
+13 -17
View File
@@ -620,8 +620,7 @@ func TestGetManifest(t *testing.T) {
{EnvName: "BOTH_ENV", FilePath: "/etc/both", Value: "both-val", Enabled: true},
{EnvName: "DISABLED_ENV", FilePath: "", Value: "disabled-val", Enabled: false},
}, nil)
// Workspace secrets follow user secrets so a workspace secret that
// targets the same env var wins when the agent applies them in order.
// A workspace secret on the same env var as a user secret wins.
mDB.EXPECT().ListActiveWorkspaceSecrets(gomock.Any(), agentBuild.ID).Return([]database.WorkspaceSecret{
{EnvName: "GITHUB_TOKEN", Value: sql.NullString{String: "ghp_workspace", Valid: true}},
}, nil)
@@ -629,23 +628,20 @@ func TestGetManifest(t *testing.T) {
got, err := api.GetManifest(context.Background(), &agentproto.GetManifestRequest{})
require.NoError(t, err)
// The disabled secret should be filtered out, leaving 3 user
// secrets followed by the workspace secret.
require.Len(t, got.Secrets, 4)
require.Equal(t, "GITHUB_TOKEN", got.Secrets[0].EnvName)
require.Equal(t, "", got.Secrets[0].FilePath)
require.Equal(t, []byte("ghp_xxxx"), got.Secrets[0].Value)
// The disabled secret is filtered out, and the workspace secret
// replaces the user secret on GITHUB_TOKEN, so that user secret is
// not sent at all.
require.Len(t, got.Secrets, 3)
require.Equal(t, "", got.Secrets[0].EnvName)
require.Equal(t, "~/.ssh/id_rsa", got.Secrets[0].FilePath)
require.Equal(t, []byte("private-key"), got.Secrets[0].Value)
require.Equal(t, "", got.Secrets[1].EnvName)
require.Equal(t, "~/.ssh/id_rsa", got.Secrets[1].FilePath)
require.Equal(t, []byte("private-key"), got.Secrets[1].Value)
require.Equal(t, "BOTH_ENV", got.Secrets[1].EnvName)
require.Equal(t, "/etc/both", got.Secrets[1].FilePath)
require.Equal(t, []byte("both-val"), got.Secrets[1].Value)
require.Equal(t, "BOTH_ENV", got.Secrets[2].EnvName)
require.Equal(t, "/etc/both", got.Secrets[2].FilePath)
require.Equal(t, []byte("both-val"), got.Secrets[2].Value)
require.Equal(t, "GITHUB_TOKEN", got.Secrets[3].EnvName)
require.Equal(t, []byte("ghp_workspace"), got.Secrets[3].Value)
require.Equal(t, "GITHUB_TOKEN", got.Secrets[2].EnvName)
require.Equal(t, []byte("ghp_workspace"), got.Secrets[2].Value)
})
t.Run("NoAppHostname", func(t *testing.T) {
+78 -38
View File
@@ -14576,6 +14576,7 @@ const docTemplate = `{
},
"/api/v2/users/{user}/secrets": {
"get": {
"description": "With workspace_build set, the response also includes that\nbuild's workspace secrets, and reports which secrets are\nreplaced on their env_name or file_path by another secret.",
"produces": [
"application/json"
],
@@ -14591,6 +14592,13 @@ const docTemplate = `{
"name": "user",
"in": "path",
"required": true
},
{
"type": "string",
"format": "uuid",
"description": "Workspace build ID owned by the user",
"name": "workspace_build",
"in": "query"
}
],
"responses": {
@@ -14599,7 +14607,7 @@ const docTemplate = `{
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.UserSecret"
"$ref": "#/definitions/codersdk.WorkspaceSecret"
}
}
}
@@ -14644,7 +14652,7 @@ const docTemplate = `{
"201": {
"description": "Created",
"schema": {
"$ref": "#/definitions/codersdk.UserSecret"
"$ref": "#/definitions/codersdk.WorkspaceSecret"
}
},
"400": {
@@ -14704,7 +14712,7 @@ const docTemplate = `{
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.UserSecret"
"$ref": "#/definitions/codersdk.WorkspaceSecret"
}
}
},
@@ -14764,7 +14772,7 @@ const docTemplate = `{
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.UserSecret"
"$ref": "#/definitions/codersdk.WorkspaceSecret"
}
}
},
@@ -14848,7 +14856,7 @@ const docTemplate = `{
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.UserSecret"
"$ref": "#/definitions/codersdk.WorkspaceSecret"
}
},
"400": {
@@ -33124,39 +33132,6 @@ const docTemplate = `{
}
}
},
"codersdk.UserSecret": {
"type": "object",
"properties": {
"created_at": {
"type": "string",
"format": "date-time"
},
"description": {
"type": "string"
},
"enabled": {
"description": "Enabled controls whether the secret is injected into workspaces.\nDisabled secrets remain visible and editable, but are not added\nto the agent manifest, so they are not exposed as environment\nvariables or written to secret files.",
"type": "boolean"
},
"env_name": {
"type": "string"
},
"file_path": {
"type": "string"
},
"id": {
"type": "string",
"format": "uuid"
},
"name": {
"type": "string"
},
"updated_at": {
"type": "string",
"format": "date-time"
}
}
},
"codersdk.UserSecretsCapabilities": {
"type": "object",
"properties": {
@@ -34793,6 +34768,60 @@ const docTemplate = `{
"WorkspaceRoleDeleted"
]
},
"codersdk.WorkspaceSecret": {
"type": "object",
"properties": {
"created_at": {
"type": "string",
"format": "date-time"
},
"description": {
"type": "string"
},
"enabled": {
"description": "Enabled controls whether the secret is injected into workspaces.\nDisabled secrets remain visible and editable, but are not added\nto the agent manifest, so they are not exposed as environment\nvariables or written to secret files.",
"type": "boolean"
},
"env_name": {
"type": "string"
},
"env_replaced_by": {
"description": "EnvReplacedBy is the ID of the secret delivered on this secret's\nenv_name instead of it. Only set when listing secrets for a\nworkspace build.",
"type": "string",
"format": "uuid"
},
"file_path": {
"type": "string"
},
"file_replaced_by": {
"description": "FileReplacedBy is the ID of the secret delivered on this secret's\nfile_path instead of it. Only set when listing secrets for a\nworkspace build.",
"type": "string",
"format": "uuid"
},
"id": {
"type": "string",
"format": "uuid"
},
"name": {
"type": "string"
},
"source": {
"enum": [
"user",
"build"
],
"allOf": [
{
"$ref": "#/definitions/codersdk.WorkspaceSecretSource"
}
]
},
"updated_at": {
"type": "string",
"format": "date-time"
}
}
},
"codersdk.WorkspaceSecretInput": {
"type": "object",
"properties": {
@@ -34817,6 +34846,17 @@ const docTemplate = `{
}
}
},
"codersdk.WorkspaceSecretSource": {
"type": "string",
"enum": [
"user",
"build"
],
"x-enum-varnames": [
"WorkspaceSecretSourceUser",
"WorkspaceSecretSourceBuild"
]
},
"codersdk.WorkspaceSharingSettings": {
"type": "object",
"properties": {
+72 -38
View File
@@ -12985,6 +12985,7 @@
},
"/api/v2/users/{user}/secrets": {
"get": {
"description": "With workspace_build set, the response also includes that\nbuild's workspace secrets, and reports which secrets are\nreplaced on their env_name or file_path by another secret.",
"produces": ["application/json"],
"tags": ["Secrets"],
"summary": "List user secrets",
@@ -12996,6 +12997,13 @@
"name": "user",
"in": "path",
"required": true
},
{
"type": "string",
"format": "uuid",
"description": "Workspace build ID owned by the user",
"name": "workspace_build",
"in": "query"
}
],
"responses": {
@@ -13004,7 +13012,7 @@
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.UserSecret"
"$ref": "#/definitions/codersdk.WorkspaceSecret"
}
}
}
@@ -13043,7 +13051,7 @@
"201": {
"description": "Created",
"schema": {
"$ref": "#/definitions/codersdk.UserSecret"
"$ref": "#/definitions/codersdk.WorkspaceSecret"
}
},
"400": {
@@ -13097,7 +13105,7 @@
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.UserSecret"
"$ref": "#/definitions/codersdk.WorkspaceSecret"
}
}
},
@@ -13153,7 +13161,7 @@
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.UserSecret"
"$ref": "#/definitions/codersdk.WorkspaceSecret"
}
}
},
@@ -13229,7 +13237,7 @@
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.UserSecret"
"$ref": "#/definitions/codersdk.WorkspaceSecret"
}
},
"400": {
@@ -30569,39 +30577,6 @@
}
}
},
"codersdk.UserSecret": {
"type": "object",
"properties": {
"created_at": {
"type": "string",
"format": "date-time"
},
"description": {
"type": "string"
},
"enabled": {
"description": "Enabled controls whether the secret is injected into workspaces.\nDisabled secrets remain visible and editable, but are not added\nto the agent manifest, so they are not exposed as environment\nvariables or written to secret files.",
"type": "boolean"
},
"env_name": {
"type": "string"
},
"file_path": {
"type": "string"
},
"id": {
"type": "string",
"format": "uuid"
},
"name": {
"type": "string"
},
"updated_at": {
"type": "string",
"format": "date-time"
}
}
},
"codersdk.UserSecretsCapabilities": {
"type": "object",
"properties": {
@@ -32144,6 +32119,57 @@
"WorkspaceRoleDeleted"
]
},
"codersdk.WorkspaceSecret": {
"type": "object",
"properties": {
"created_at": {
"type": "string",
"format": "date-time"
},
"description": {
"type": "string"
},
"enabled": {
"description": "Enabled controls whether the secret is injected into workspaces.\nDisabled secrets remain visible and editable, but are not added\nto the agent manifest, so they are not exposed as environment\nvariables or written to secret files.",
"type": "boolean"
},
"env_name": {
"type": "string"
},
"env_replaced_by": {
"description": "EnvReplacedBy is the ID of the secret delivered on this secret's\nenv_name instead of it. Only set when listing secrets for a\nworkspace build.",
"type": "string",
"format": "uuid"
},
"file_path": {
"type": "string"
},
"file_replaced_by": {
"description": "FileReplacedBy is the ID of the secret delivered on this secret's\nfile_path instead of it. Only set when listing secrets for a\nworkspace build.",
"type": "string",
"format": "uuid"
},
"id": {
"type": "string",
"format": "uuid"
},
"name": {
"type": "string"
},
"source": {
"enum": ["user", "build"],
"allOf": [
{
"$ref": "#/definitions/codersdk.WorkspaceSecretSource"
}
]
},
"updated_at": {
"type": "string",
"format": "date-time"
}
}
},
"codersdk.WorkspaceSecretInput": {
"type": "object",
"properties": {
@@ -32168,6 +32194,14 @@
}
}
},
"codersdk.WorkspaceSecretSource": {
"type": "string",
"enum": ["user", "build"],
"x-enum-varnames": [
"WorkspaceSecretSourceUser",
"WorkspaceSecretSourceBuild"
]
},
"codersdk.WorkspaceSharingSettings": {
"type": "object",
"properties": {
+18
View File
@@ -2398,6 +2398,7 @@ func UserSecret(secret database.ListUserSecretsRow) codersdk.UserSecret {
Enabled: secret.Enabled,
CreatedAt: secret.CreatedAt,
UpdatedAt: secret.UpdatedAt,
Source: codersdk.WorkspaceSecretSourceUser,
}
}
@@ -2413,6 +2414,7 @@ func UserSecretFromFull(secret database.UserSecret) codersdk.UserSecret {
Enabled: secret.Enabled,
CreatedAt: secret.CreatedAt,
UpdatedAt: secret.UpdatedAt,
Source: codersdk.WorkspaceSecretSourceUser,
}
}
@@ -2426,6 +2428,22 @@ func UserSecrets(secrets []database.ListUserSecretsRow) []codersdk.UserSecret {
return result
}
// WorkspaceBuildSecret converts workspace secret metadata to an SDK
// WorkspaceSecret. Workspace secrets have no description and cannot be
// disabled.
func WorkspaceBuildSecret(secret database.GetWorkspaceSecretsHistoryRow) codersdk.WorkspaceSecret {
return codersdk.WorkspaceSecret{
ID: secret.ID,
Name: secret.Name,
EnvName: secret.EnvName,
FilePath: secret.FilePath,
Enabled: true,
CreatedAt: secret.CreatedAt,
UpdatedAt: secret.CreatedAt,
Source: codersdk.WorkspaceSecretSourceBuild,
}
}
// Skill converts a database Skill to an SDK Skill.
func Skill(skill database.Skill) codersdk.Skill {
return codersdk.Skill{
+2 -2
View File
@@ -6236,9 +6236,9 @@ func (q *querier) GetWorkspaceSecrets(ctx context.Context, arg database.GetWorks
return q.db.GetWorkspaceSecrets(ctx, arg)
}
func (q *querier) GetWorkspaceSecretsHistory(ctx context.Context, workspaceID uuid.UUID) ([]database.GetWorkspaceSecretsHistoryRow, error) {
func (q *querier) GetWorkspaceSecretsHistory(ctx context.Context, arg database.GetWorkspaceSecretsHistoryParams) ([]database.GetWorkspaceSecretsHistoryRow, error) {
// Metadata only; values are never selected.
return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetWorkspaceSecretsHistory)(ctx, workspaceID)
return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetWorkspaceSecretsHistory)(ctx, arg)
}
func (q *querier) GetWorkspaceUniqueOwnerCountByTemplateIDs(ctx context.Context, templateIDs []uuid.UUID) ([]database.GetWorkspaceUniqueOwnerCountByTemplateIDsRow, error) {
+3 -2
View File
@@ -6978,8 +6978,9 @@ func (s *MethodTestSuite) TestWorkspaceSecrets() {
WorkspaceOwnerID: ws.OwnerID,
WorkspaceOrganizationID: ws.OrganizationID,
})
dbm.EXPECT().GetWorkspaceSecretsHistory(gomock.Any(), ws.ID).Return([]database.GetWorkspaceSecretsHistoryRow{row}, nil).AnyTimes()
check.Args(ws.ID).
arg := database.GetWorkspaceSecretsHistoryParams{WorkspaceID: ws.ID}
dbm.EXPECT().GetWorkspaceSecretsHistory(gomock.Any(), arg).Return([]database.GetWorkspaceSecretsHistoryRow{row}, nil).AnyTimes()
check.Args(arg).
Asserts(row, policy.ActionRead).
Returns([]database.GetWorkspaceSecretsHistoryRow{row})
}))
+1 -1
View File
@@ -4192,7 +4192,7 @@ func (m queryMetricsStore) GetWorkspaceSecrets(ctx context.Context, arg database
return r0, r1
}
func (m queryMetricsStore) GetWorkspaceSecretsHistory(ctx context.Context, workspaceID uuid.UUID) ([]database.GetWorkspaceSecretsHistoryRow, error) {
func (m queryMetricsStore) GetWorkspaceSecretsHistory(ctx context.Context, workspaceID database.GetWorkspaceSecretsHistoryParams) ([]database.GetWorkspaceSecretsHistoryRow, error) {
start := time.Now()
r0, r1 := m.s.GetWorkspaceSecretsHistory(ctx, workspaceID)
m.queryLatencies.WithLabelValues("GetWorkspaceSecretsHistory").Observe(time.Since(start).Seconds())
+4 -4
View File
@@ -7900,18 +7900,18 @@ func (mr *MockStoreMockRecorder) GetWorkspaceSecrets(ctx, arg any) *gomock.Call
}
// GetWorkspaceSecretsHistory mocks base method.
func (m *MockStore) GetWorkspaceSecretsHistory(ctx context.Context, workspaceID uuid.UUID) ([]database.GetWorkspaceSecretsHistoryRow, error) {
func (m *MockStore) GetWorkspaceSecretsHistory(ctx context.Context, arg database.GetWorkspaceSecretsHistoryParams) ([]database.GetWorkspaceSecretsHistoryRow, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetWorkspaceSecretsHistory", ctx, workspaceID)
ret := m.ctrl.Call(m, "GetWorkspaceSecretsHistory", ctx, arg)
ret0, _ := ret[0].([]database.GetWorkspaceSecretsHistoryRow)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetWorkspaceSecretsHistory indicates an expected call of GetWorkspaceSecretsHistory.
func (mr *MockStoreMockRecorder) GetWorkspaceSecretsHistory(ctx, workspaceID any) *gomock.Call {
func (mr *MockStoreMockRecorder) GetWorkspaceSecretsHistory(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWorkspaceSecretsHistory", reflect.TypeOf((*MockStore)(nil).GetWorkspaceSecretsHistory), ctx, workspaceID)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWorkspaceSecretsHistory", reflect.TypeOf((*MockStore)(nil).GetWorkspaceSecretsHistory), ctx, arg)
}
// GetWorkspaceUniqueOwnerCountByTemplateIDs mocks base method.
+2 -2
View File
@@ -1162,8 +1162,8 @@ type sqlcQuerier interface {
// Returns metadata for every workspace secret row of a workspace, including
// cleared rows, so the secrets each build received can be inspected. Values
// are never selected. The workspace owner and organization are included for
// authorization.
GetWorkspaceSecretsHistory(ctx context.Context, workspaceID uuid.UUID) ([]GetWorkspaceSecretsHistoryRow, error)
// authorization. A non-nil workspace_build_id limits the rows to that build.
GetWorkspaceSecretsHistory(ctx context.Context, arg GetWorkspaceSecretsHistoryParams) ([]GetWorkspaceSecretsHistoryRow, error)
GetWorkspaceUniqueOwnerCountByTemplateIDs(ctx context.Context, templateIds []uuid.UUID) ([]GetWorkspaceUniqueOwnerCountByTemplateIDsRow, error)
// build_params is used to filter by build parameters if present.
// It has to be a CTE because the set returning function 'unnest' cannot
+8 -1
View File
@@ -20195,13 +20195,20 @@ func TestClearWorkspaceSecretsBeforeBuild(t *testing.T) {
require.Equal(t, 1, live(build2.ID), "the clearing build keeps its own secrets")
require.Equal(t, 1, live(build3.ID), "a later build is untouched")
history, err := db.GetWorkspaceSecretsHistory(ctx, ws.ID)
history, err := db.GetWorkspaceSecretsHistory(ctx, database.GetWorkspaceSecretsHistoryParams{WorkspaceID: ws.ID})
require.NoError(t, err)
require.Len(t, history, 3, "cleared rows are kept as history")
for _, row := range history {
require.Equal(t, row.WorkspaceBuildID == build1.ID, row.ClearedAt.Valid,
"only build 1's row is cleared")
}
// Filtering by build includes that build's cleared rows.
build1History, err := db.GetWorkspaceSecretsHistory(ctx, database.GetWorkspaceSecretsHistoryParams{WorkspaceID: ws.ID, WorkspaceBuildID: build1.ID})
require.NoError(t, err)
require.Len(t, build1History, 1)
require.Equal(t, build1.ID, build1History[0].WorkspaceBuildID)
require.True(t, build1History[0].ClearedAt.Valid)
}
// TestInsertWorkspaceSecretBuildMismatch checks that a secret cannot name a
+12 -3
View File
@@ -34858,9 +34858,18 @@ SELECT
FROM workspace_secrets ws
JOIN workspaces w ON w.id = ws.workspace_id
WHERE ws.workspace_id = $1
AND (
$2::uuid = '00000000-0000-0000-0000-000000000000'::uuid
OR ws.workspace_build_id = $2
)
ORDER BY ws.created_at ASC, ws.name ASC
`
type GetWorkspaceSecretsHistoryParams struct {
WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"`
WorkspaceBuildID uuid.UUID `db:"workspace_build_id" json:"workspace_build_id"`
}
type GetWorkspaceSecretsHistoryRow struct {
ID uuid.UUID `db:"id" json:"id"`
WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"`
@@ -34879,9 +34888,9 @@ type GetWorkspaceSecretsHistoryRow struct {
// Returns metadata for every workspace secret row of a workspace, including
// cleared rows, so the secrets each build received can be inspected. Values
// are never selected. The workspace owner and organization are included for
// authorization.
func (q *sqlQuerier) GetWorkspaceSecretsHistory(ctx context.Context, workspaceID uuid.UUID) ([]GetWorkspaceSecretsHistoryRow, error) {
rows, err := q.db.QueryContext(ctx, getWorkspaceSecretsHistory, workspaceID)
// authorization. A non-nil workspace_build_id limits the rows to that build.
func (q *sqlQuerier) GetWorkspaceSecretsHistory(ctx context.Context, arg GetWorkspaceSecretsHistoryParams) ([]GetWorkspaceSecretsHistoryRow, error) {
rows, err := q.db.QueryContext(ctx, getWorkspaceSecretsHistory, arg.WorkspaceID, arg.WorkspaceBuildID)
if err != nil {
return nil, err
}
@@ -98,7 +98,7 @@ RETURNING *;
-- Returns metadata for every workspace secret row of a workspace, including
-- cleared rows, so the secrets each build received can be inspected. Values
-- are never selected. The workspace owner and organization are included for
-- authorization.
-- authorization. A non-nil workspace_build_id limits the rows to that build.
SELECT
ws.id, ws.workspace_id, ws.workspace_build_id, ws.name,
ws.env_name, ws.file_path, ws.ephemeral, ws.source, ws.created_at, ws.cleared_at,
@@ -107,4 +107,8 @@ SELECT
FROM workspace_secrets ws
JOIN workspaces w ON w.id = ws.workspace_id
WHERE ws.workspace_id = @workspace_id
AND (
@workspace_build_id::uuid = '00000000-0000-0000-0000-000000000000'::uuid
OR ws.workspace_build_id = @workspace_build_id
)
ORDER BY ws.created_at ASC, ws.name ASC;
+111 -6
View File
@@ -16,6 +16,7 @@ import (
"github.com/coder/coder/v2/coderd/database/db2sdk"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/workspacesecrets"
"github.com/coder/coder/v2/codersdk"
)
@@ -141,7 +142,7 @@ func (api *API) userSecretsCapabilities(rw http.ResponseWriter, r *http.Request)
// @Tags Secrets
// @Param user path string true "User ID, username, or me"
// @Param request body codersdk.CreateUserSecretRequest true "Create secret request"
// @Success 201 {object} codersdk.UserSecret
// @Success 201 {object} codersdk.WorkspaceSecret
// @Failure 400 {object} codersdk.Response
// @Failure 409 {object} codersdk.Response
// @Router /api/v2/users/{user}/secrets [post]
@@ -221,7 +222,7 @@ func (api *API) postUserSecret(rw http.ResponseWriter, r *http.Request) {
// @Tags Secrets
// @Param user path string true "User ID, username, or me"
// @Param request body codersdk.ImportUserSecretsRequest true "Import secrets request"
// @Success 201 {array} codersdk.UserSecret
// @Success 201 {array} codersdk.WorkspaceSecret
// @Failure 400 {object} codersdk.Response
// @Failure 409 {object} codersdk.Response
// @Failure 413 {object} codersdk.Response "Request body exceeds 8 MiB"
@@ -351,12 +352,16 @@ func (api *API) postUserSecretsBatch(rw http.ResponseWriter, r *http.Request) {
}
// @Summary List user secrets
// @Description With workspace_build set, the response also includes that
// @Description build's workspace secrets, and reports which secrets are
// @Description replaced on their env_name or file_path by another secret.
// @ID list-user-secrets
// @Security CoderSessionToken
// @Produce json
// @Tags Secrets
// @Param user path string true "User ID, username, or me"
// @Success 200 {array} codersdk.UserSecret
// @Param workspace_build query string false "Workspace build ID owned by the user" format(uuid)
// @Success 200 {array} codersdk.WorkspaceSecret
// @Router /api/v2/users/{user}/secrets [get]
func (api *API) getUserSecrets(rw http.ResponseWriter, r *http.Request) { //nolint:revive // Method name matches route.
ctx := r.Context()
@@ -370,8 +375,108 @@ func (api *API) getUserSecrets(rw http.ResponseWriter, r *http.Request) { //noli
})
return
}
out := db2sdk.UserSecrets(secrets)
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.UserSecrets(secrets))
rawBuildID := r.URL.Query().Get("workspace_build")
if rawBuildID == "" {
httpapi.Write(ctx, rw, http.StatusOK, out)
return
}
buildID, err := uuid.Parse(rawBuildID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid workspace_build.",
Validations: []codersdk.ValidationError{
{Field: "workspace_build", Detail: "Must be a workspace build ID."},
},
})
return
}
// These reads are authorized as the caller, so a build the caller
// cannot see is a 404 before its owner is compared.
build, err := api.Database.GetWorkspaceBuildByID(ctx, buildID)
if httpapi.Is404Error(err) {
httpapi.ResourceNotFound(rw)
return
}
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching workspace build.",
Detail: err.Error(),
})
return
}
workspace, err := api.Database.GetWorkspaceByID(ctx, build.WorkspaceID)
if httpapi.Is404Error(err) {
httpapi.ResourceNotFound(rw)
return
}
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching workspace.",
Detail: err.Error(),
})
return
}
if workspace.OwnerID != user.ID {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "The workspace build does not belong to this user.",
Validations: []codersdk.ValidationError{
{Field: "workspace_build", Detail: "Must be a build of one of the user's workspaces."},
},
})
return
}
buildSecrets, 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 secrets.",
Detail: err.Error(),
})
return
}
for _, s := range buildSecrets {
out = append(out, db2sdk.WorkspaceBuildSecret(s))
}
applySecretReplacements(out, api.userSecretFilePathBlocked())
httpapi.Write(ctx, rw, http.StatusOK, out)
}
// applySecretReplacements records on each secret which other secret is
// delivered on its env_name or file_path instead, using the same rules as
// the agent manifest.
//
//nolint:revive // blocked is deployment configuration, not caller control coupling.
func applySecretReplacements(secrets []codersdk.WorkspaceSecret, blocked bool) {
policy := workspacesecrets.FilePathAllowed
if blocked {
policy = workspacesecrets.FilePathBlocked
}
candidates := make([]workspacesecrets.Secret, len(secrets))
for i, s := range secrets {
candidates[i] = workspacesecrets.Secret{
ID: s.ID,
Source: s.Source,
Name: s.Name,
EnvName: s.EnvName,
FilePath: s.FilePath,
Enabled: s.Enabled,
}
}
for i, r := range workspacesecrets.Resolve(candidates, policy) {
if r.EnvReplacedBy.Valid {
secrets[i].EnvReplacedBy = &r.EnvReplacedBy.UUID
}
if r.FileReplacedBy.Valid {
secrets[i].FileReplacedBy = &r.FileReplacedBy.UUID
}
}
}
// @Summary Get a user secret by name
@@ -381,7 +486,7 @@ func (api *API) getUserSecrets(rw http.ResponseWriter, r *http.Request) { //noli
// @Tags Secrets
// @Param user path string true "User ID, username, or me"
// @Param name path string true "Secret name"
// @Success 200 {object} codersdk.UserSecret
// @Success 200 {object} codersdk.WorkspaceSecret
// @Router /api/v2/users/{user}/secrets/{name} [get]
func (api *API) getUserSecret(rw http.ResponseWriter, r *http.Request) { //nolint:revive // Method name matches route.
ctx := r.Context()
@@ -416,7 +521,7 @@ func (api *API) getUserSecret(rw http.ResponseWriter, r *http.Request) { //nolin
// @Param user path string true "User ID, username, or me"
// @Param name path string true "Secret name"
// @Param request body codersdk.UpdateUserSecretRequest true "Update secret request"
// @Success 200 {object} codersdk.UserSecret
// @Success 200 {object} codersdk.WorkspaceSecret
// @Failure 400 {object} codersdk.Response
// @Failure 409 {object} codersdk.Response
// @Router /api/v2/users/{user}/secrets/{name} [patch]
+75 -2
View File
@@ -6672,7 +6672,7 @@ func TestWorkspaceSecrets(t *testing.T) {
clearedRows := func(ctx context.Context, t *testing.T, workspaceID uuid.UUID) map[uuid.UUID]map[string]database.GetWorkspaceSecretsHistoryRow {
t.Helper()
//nolint:gocritic // Test-only read of every row's metadata.
rows, err := db.GetWorkspaceSecretsHistory(dbauthz.AsWorkspaceSecretManager(ctx), workspaceID)
rows, err := db.GetWorkspaceSecretsHistory(dbauthz.AsWorkspaceSecretManager(ctx), database.GetWorkspaceSecretsHistoryParams{WorkspaceID: workspaceID})
require.NoError(t, err)
out := map[uuid.UUID]map[string]database.GetWorkspaceSecretsHistoryRow{}
for _, row := range rows {
@@ -6793,6 +6793,79 @@ func TestWorkspaceSecrets(t *testing.T) {
require.Equal(t, "d", secrets["new-file"].Value.String)
})
t.Run("ListWithBuild", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
member, memberUser := coderdtest.CreateAnotherUser(t, client, user.OrganizationID)
overridden, err := member.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
Name: "user-token", Value: "u", EnvName: "TOKEN", FilePath: "/home/coder/.user-token",
})
require.NoError(t, err)
disabled, err := member.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
Name: "disabled", Value: "d", EnvName: "OTHER", Enabled: ptr.Ref(false),
})
require.NoError(t, err)
workspace := coderdtest.CreateWorkspace(t, member, template.ID, func(req *codersdk.CreateWorkspaceRequest) {
req.Secrets = []codersdk.WorkspaceSecretInput{
{Name: "build-token", Value: ptr.Ref("b"), EnvName: "TOKEN"},
{Name: "build-other", Value: ptr.Ref("o"), EnvName: "OTHER"},
}
})
build1 := workspace.LatestBuild
coderdtest.AwaitWorkspaceBuildJobCompleted(t, member, build1.ID)
listForBuild := func(buildID uuid.UUID) map[string]codersdk.WorkspaceSecret {
t.Helper()
secrets, err := member.UserSecretsForWorkspaceBuild(ctx, codersdk.Me, buildID)
require.NoError(t, err)
out := make(map[string]codersdk.WorkspaceSecret, len(secrets))
for _, s := range secrets {
out[s.Name] = s
}
return out
}
// Without a build, only user secrets are listed.
plain, err := member.UserSecrets(ctx, codersdk.Me)
require.NoError(t, err)
require.Len(t, plain, 2)
for _, s := range plain {
require.Equal(t, codersdk.WorkspaceSecretSourceUser, s.Source)
require.Nil(t, s.EnvReplacedBy)
}
got := listForBuild(build1.ID)
require.Len(t, got, 4)
buildToken := got["build-token"]
require.Equal(t, codersdk.WorkspaceSecretSourceBuild, buildToken.Source)
// The user secret loses its env var but keeps its file.
require.Equal(t, &buildToken.ID, got[overridden.Name].EnvReplacedBy)
require.Nil(t, got[overridden.Name].FileReplacedBy)
// Disabled secrets are never reported as replaced.
require.Nil(t, got[disabled.Name].EnvReplacedBy)
// After another build, the old build's values are cleared but its
// secrets are still listed.
build2 := coderdtest.CreateWorkspaceBuild(t, member, coderdtest.MustWorkspace(t, member, workspace.ID), database.WorkspaceTransitionStop)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, member, build2.ID)
require.Len(t, listForBuild(build1.ID), 4)
// A build of another user's workspace is rejected once the caller
// is authorized to read it.
_, err = client.UserSecretsForWorkspaceBuild(ctx, codersdk.Me, build1.ID)
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusBadRequest, apiErr.StatusCode())
// A build the caller cannot read is not found.
ownerBuild := coderdtest.CreateWorkspace(t, client, template.ID).LatestBuild
_, err = member.UserSecretsForWorkspaceBuild(ctx, memberUser.Username, ownerBuild.ID)
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusNotFound, apiErr.StatusCode())
})
t.Run("Validation", func(t *testing.T) {
t.Parallel()
@@ -6898,7 +6971,7 @@ func TestWorkspaceSecrets(t *testing.T) {
require.Equal(t, http.StatusBadRequest, apiErr.StatusCode())
require.Contains(t, apiErr.Message, "exceeds a per-build limit")
//nolint:gocritic // Test-only read of every row's metadata.
history, err := db.GetWorkspaceSecretsHistory(dbauthz.AsWorkspaceSecretManager(ctx), workspace.ID)
history, err := db.GetWorkspaceSecretsHistory(dbauthz.AsWorkspaceSecretManager(ctx), database.GetWorkspaceSecretsHistoryParams{WorkspaceID: workspace.ID})
require.NoError(t, err)
require.Empty(t, history, "failed build must not persist any secret")
})
@@ -0,0 +1,92 @@
// Package workspacesecrets decides which secrets are delivered to a
// workspace and on which targets. The agent manifest and the secrets API
// both use Resolve so they always agree.
package workspacesecrets
import (
"github.com/google/uuid"
"github.com/coder/coder/v2/codersdk"
)
// FilePathPolicy controls whether secrets may be delivered as files.
type FilePathPolicy int
const (
FilePathAllowed FilePathPolicy = iota
FilePathBlocked
)
// Secret is a candidate for delivery to a workspace. Value may be empty when
// only metadata is needed.
type Secret struct {
ID uuid.UUID
Source codersdk.WorkspaceSecretSource
Name string
EnvName string
FilePath string
// Enabled is false for disabled user secrets. Workspace secrets are
// always enabled.
Enabled bool
Value string
}
// Resolved is a Secret with its delivery outcome.
type Resolved struct {
Secret
// DeliveredEnvName and DeliveredFilePath are the targets the secret is
// actually delivered on. They are empty when the secret is disabled, the
// target is unset, blocked by policy, or taken by another secret.
DeliveredEnvName string
DeliveredFilePath string
// EnvReplacedBy and FileReplacedBy name the secret delivered on this
// secret's target instead of it.
EnvReplacedBy uuid.NullUUID
FileReplacedBy uuid.NullUUID
}
// Delivered reports whether the secret is delivered on any target.
func (r Resolved) Delivered() bool {
return r.DeliveredEnvName != "" || r.DeliveredFilePath != ""
}
// Resolve returns the delivery outcome of each secret, in input order.
//
// Disabled secrets are never delivered and never replace or get replaced.
// When secrets share an env_name or file_path, a workspace build secret wins
// over a user secret. Targets are compared exactly as written.
func Resolve(secrets []Secret, policy FilePathPolicy) []Resolved {
out := make([]Resolved, len(secrets))
envOwner := map[string]uuid.UUID{}
fileOwner := map[string]uuid.UUID{}
// Claim targets in precedence order: build secrets first.
for _, pass := range []codersdk.WorkspaceSecretSource{codersdk.WorkspaceSecretSourceBuild, codersdk.WorkspaceSecretSourceUser} {
for i, s := range secrets {
if s.Source != pass {
continue
}
r := Resolved{Secret: s}
if s.Enabled {
r.DeliveredEnvName, r.EnvReplacedBy = claim(envOwner, s.EnvName, s.ID)
if policy == FilePathAllowed {
r.DeliveredFilePath, r.FileReplacedBy = claim(fileOwner, s.FilePath, s.ID)
}
}
out[i] = r
}
}
return out
}
// claim assigns target to id unless another secret already holds it.
func claim(owners map[string]uuid.UUID, target string, id uuid.UUID) (string, uuid.NullUUID) {
if target == "" {
return "", uuid.NullUUID{}
}
if owner, taken := owners[target]; taken {
return "", uuid.NullUUID{UUID: owner, Valid: true}
}
owners[target] = id
return target, uuid.NullUUID{}
}
@@ -0,0 +1,71 @@
package workspacesecrets_test
import (
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/workspacesecrets"
"github.com/coder/coder/v2/codersdk"
)
func TestResolve(t *testing.T) {
t.Parallel()
user := func(name, env, file string, enabled bool) workspacesecrets.Secret {
return workspacesecrets.Secret{ID: uuid.New(), Source: codersdk.WorkspaceSecretSourceUser, Name: name, EnvName: env, FilePath: file, Enabled: enabled}
}
build := func(name, env, file string) workspacesecrets.Secret {
return workspacesecrets.Secret{ID: uuid.New(), Source: codersdk.WorkspaceSecretSourceBuild, Name: name, EnvName: env, FilePath: file, Enabled: true}
}
var (
userBoth = user("both", "TOKEN", "/etc/token", true)
userFileOnly = user("file-only", "", "/etc/cert", true)
userDisabled = user("disabled", "DISABLED", "", false)
userPlain = user("plain", "PLAIN", "", true)
buildEnv = build("build-env", "TOKEN", "")
buildFile = build("build-file", "", "/etc/cert")
buildOnDis = build("build-on-disabled", "DISABLED", "")
)
// Build secrets listed last still win, so input order does not matter.
secrets := []workspacesecrets.Secret{userBoth, userFileOnly, userDisabled, userPlain, buildEnv, buildFile, buildOnDis}
t.Run("FilePathAllowed", func(t *testing.T) {
t.Parallel()
got := workspacesecrets.Resolve(secrets, workspacesecrets.FilePathAllowed)
require.Len(t, got, len(secrets))
// Partial override: the env var is taken, the file is still delivered.
require.Empty(t, got[0].DeliveredEnvName)
require.Equal(t, uuid.NullUUID{UUID: buildEnv.ID, Valid: true}, got[0].EnvReplacedBy)
require.Equal(t, "/etc/token", got[0].DeliveredFilePath)
require.False(t, got[0].FileReplacedBy.Valid)
require.True(t, got[0].Delivered())
// Full override.
require.False(t, got[1].Delivered())
require.Equal(t, uuid.NullUUID{UUID: buildFile.ID, Valid: true}, got[1].FileReplacedBy)
// Disabled secrets are neither delivered nor replaced.
require.False(t, got[2].Delivered())
require.False(t, got[2].EnvReplacedBy.Valid)
require.Equal(t, "PLAIN", got[3].DeliveredEnvName)
require.Equal(t, "TOKEN", got[4].DeliveredEnvName)
require.Equal(t, "/etc/cert", got[5].DeliveredFilePath)
require.Equal(t, "DISABLED", got[6].DeliveredEnvName)
})
t.Run("FilePathBlocked", func(t *testing.T) {
t.Parallel()
got := workspacesecrets.Resolve(secrets, workspacesecrets.FilePathBlocked)
// No file is delivered, so no file target is reported as replaced.
require.False(t, got[0].Delivered())
require.False(t, got[1].Delivered())
require.False(t, got[1].FileReplacedBy.Valid)
require.False(t, got[5].Delivered())
})
}
+4 -4
View File
@@ -91,10 +91,10 @@ func ProtoFromManifest(manifest Manifest) (*proto.Manifest, error) {
return nil, xerrors.Errorf("convert workspace agent scripts: %w", err)
}
return &proto.Manifest{
ParentId: manifest.ParentID[:],
AgentId: manifest.AgentID[:],
AgentName: manifest.AgentName,
OwnerUsername: manifest.OwnerName,
ParentId: manifest.ParentID[:],
AgentId: manifest.AgentID[:],
AgentName: manifest.AgentName,
OwnerUsername: manifest.OwnerName,
WorkspaceId: manifest.WorkspaceID[:],
WorkspaceName: manifest.WorkspaceName,
WorkspaceBuildId: manifest.WorkspaceBuildID[:],
+50 -8
View File
@@ -9,9 +9,22 @@ import (
"github.com/google/uuid"
)
// UserSecret represents a user secret's metadata. The secret value
// is never included in API responses.
type UserSecret struct {
// WorkspaceSecretSource identifies where a secret delivered to a workspace
// comes from.
type WorkspaceSecretSource string
const (
// WorkspaceSecretSourceUser is a user secret, shared by all of the
// user's workspaces.
WorkspaceSecretSourceUser WorkspaceSecretSource = "user"
// WorkspaceSecretSourceBuild is a workspace secret set on a workspace
// build.
WorkspaceSecretSourceBuild WorkspaceSecretSource = "build"
)
// WorkspaceSecret represents a secret's metadata. The secret value is never
// included in API responses.
type WorkspaceSecret struct {
ID uuid.UUID `json:"id" format:"uuid"`
Name string `json:"name"`
Description string `json:"description"`
@@ -21,11 +34,24 @@ type UserSecret struct {
// Disabled secrets remain visible and editable, but are not added
// to the agent manifest, so they are not exposed as environment
// variables or written to secret files.
Enabled bool `json:"enabled"`
CreatedAt time.Time `json:"created_at" format:"date-time"`
UpdatedAt time.Time `json:"updated_at" format:"date-time"`
Enabled bool `json:"enabled"`
CreatedAt time.Time `json:"created_at" format:"date-time"`
UpdatedAt time.Time `json:"updated_at" format:"date-time"`
Source WorkspaceSecretSource `json:"source" enums:"user,build"`
// EnvReplacedBy is the ID of the secret delivered on this secret's
// env_name instead of it. Only set when listing secrets for a
// workspace build.
EnvReplacedBy *uuid.UUID `json:"env_replaced_by,omitempty" format:"uuid"`
// FileReplacedBy is the ID of the secret delivered on this secret's
// file_path instead of it. Only set when listing secrets for a
// workspace build.
FileReplacedBy *uuid.UUID `json:"file_replaced_by,omitempty" format:"uuid"`
}
// UserSecret is the previous name of WorkspaceSecret, kept for backwards
// compatibility.
type UserSecret = WorkspaceSecret
// CreateUserSecretRequest is the payload for creating a new user
// secret. Name and Value are required. An enabled secret must have at
// least one of EnvName or FilePath non-empty so it has an injection
@@ -97,7 +123,23 @@ func (c *Client) CreateUserSecret(ctx context.Context, user string, req CreateUs
}
func (c *Client) UserSecrets(ctx context.Context, user string) ([]UserSecret, error) {
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/secrets", user), nil)
return c.listUserSecrets(ctx, user, uuid.Nil)
}
// UserSecretsForWorkspaceBuild lists the user's secrets together with the
// workspace secrets of the given build, and reports which secrets are
// replaced by another on the same env_name or file_path. The build must
// belong to one of the user's workspaces.
func (c *Client) UserSecretsForWorkspaceBuild(ctx context.Context, user string, workspaceBuildID uuid.UUID) ([]WorkspaceSecret, error) {
return c.listUserSecrets(ctx, user, workspaceBuildID)
}
func (c *Client) listUserSecrets(ctx context.Context, user string, workspaceBuildID uuid.UUID) ([]WorkspaceSecret, error) {
var opts []RequestOption
if workspaceBuildID != uuid.Nil {
opts = append(opts, WithQueryParam("workspace_build", workspaceBuildID.String()))
}
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/secrets", user), nil, opts...)
if err != nil {
return nil, err
}
@@ -105,7 +147,7 @@ func (c *Client) UserSecrets(ctx context.Context, user string) ([]UserSecret, er
if res.StatusCode != http.StatusOK {
return nil, ReadBodyAsError(res)
}
var secrets []UserSecret
var secrets []WorkspaceSecret
return secrets, ReadBodyAsJSON(res, &secrets)
}
+54 -28
View File
@@ -18255,34 +18255,6 @@ If the schedule is empty, the user will be updated to use the default schedule.|
| `user_can_set` | boolean | false | | User can set is true if the user is allowed to set their own quiet hours schedule. If false, the user cannot set a custom schedule and the default schedule will always be used. |
| `user_set` | boolean | false | | User set is true if the user has set their own quiet hours schedule. If false, the user is using the default schedule. |
## codersdk.UserSecret
```json
{
"created_at": "2019-08-24T14:15:22Z",
"description": "string",
"enabled": true,
"env_name": "string",
"file_path": "string",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"name": "string",
"updated_at": "2019-08-24T14:15:22Z"
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
|---------------|---------|----------|--------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `created_at` | string | false | | |
| `description` | string | false | | |
| `enabled` | boolean | false | | Enabled controls whether the secret is injected into workspaces. Disabled secrets remain visible and editable, but are not added to the agent manifest, so they are not exposed as environment variables or written to secret files. |
| `env_name` | string | false | | |
| `file_path` | string | false | | |
| `id` | string | false | | |
| `name` | string | false | | |
| `updated_at` | string | false | | |
## codersdk.UserSecretsCapabilities
```json
@@ -20613,6 +20585,46 @@ If the schedule is empty, the user will be updated to use the default schedule.|
|--------------------|
| ``, `admin`, `use` |
## codersdk.WorkspaceSecret
```json
{
"created_at": "2019-08-24T14:15:22Z",
"description": "string",
"enabled": true,
"env_name": "string",
"env_replaced_by": "36faa5cb-cfc3-47a0-9c3e-df076ae8c261",
"file_path": "string",
"file_replaced_by": "1c5fa235-bacf-4250-9f02-952c83136140",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"name": "string",
"source": "user",
"updated_at": "2019-08-24T14:15:22Z"
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
|--------------------|------------------------------------------------------------------|----------|--------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `created_at` | string | false | | |
| `description` | string | false | | |
| `enabled` | boolean | false | | Enabled controls whether the secret is injected into workspaces. Disabled secrets remain visible and editable, but are not added to the agent manifest, so they are not exposed as environment variables or written to secret files. |
| `env_name` | string | false | | |
| `env_replaced_by` | string | false | | Env replaced by is the ID of the secret delivered on this secret's env_name instead of it. Only set when listing secrets for a workspace build. |
| `file_path` | string | false | | |
| `file_replaced_by` | string | false | | File replaced by is the ID of the secret delivered on this secret's file_path instead of it. Only set when listing secrets for a workspace build. |
| `id` | string | false | | |
| `name` | string | false | | |
| `source` | [codersdk.WorkspaceSecretSource](#codersdkworkspacesecretsource) | false | | |
| `updated_at` | string | false | | |
#### Enumerated Values
| Property | Value(s) |
|----------|-----------------|
| `source` | `build`, `user` |
## codersdk.WorkspaceSecretInput
```json
@@ -20635,6 +20647,20 @@ If the schedule is empty, the user will be updated to use the default schedule.|
| `name` | string | false | | |
| `value` | string | false | | Value is the plaintext secret. A null value removes the secret. An empty string sets an empty secret. |
## codersdk.WorkspaceSecretSource
```json
"user"
```
### Properties
#### Enumerated Values
| Value(s) |
|-----------------|
| `build`, `user` |
## codersdk.WorkspaceSharingSettings
```json
+85 -47
View File
@@ -18,11 +18,16 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/secrets \
`GET /api/v2/users/{user}/secrets`
With workspace_build set, the response also includes that
build's workspace secrets, and reports which secrets are
replaced on their env_name or file_path by another secret.
### Parameters
| Name | In | Type | Required | Description |
|--------|------|--------|----------|--------------------------|
| `user` | path | string | true | User ID, username, or me |
| Name | In | Type | Required | Description |
|-------------------|-------|--------------|----------|--------------------------------------|
| `user` | path | string | true | User ID, username, or me |
| `workspace_build` | query | string(uuid) | false | Workspace build ID owned by the user |
### Example responses
@@ -35,9 +40,12 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/secrets \
"description": "string",
"enabled": true,
"env_name": "string",
"env_replaced_by": "36faa5cb-cfc3-47a0-9c3e-df076ae8c261",
"file_path": "string",
"file_replaced_by": "1c5fa235-bacf-4250-9f02-952c83136140",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"name": "string",
"source": "user",
"updated_at": "2019-08-24T14:15:22Z"
}
]
@@ -45,25 +53,34 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/secrets \
### Responses
| Status | Meaning | Description | Schema |
|--------|---------------------------------------------------------|-------------|---------------------------------------------------------------|
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.UserSecret](schemas.md#codersdkusersecret) |
| Status | Meaning | Description | Schema |
|--------|---------------------------------------------------------|-------------|-------------------------------------------------------------------------|
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.WorkspaceSecret](schemas.md#codersdkworkspacesecret) |
<h3 id="list-user-secrets-responseschema">Response Schema</h3>
Status Code **200**
| Name | Type | Required | Restrictions | Description |
|-----------------|-------------------|----------|--------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `[array item]` | array | false | | |
| `» created_at` | string(date-time) | false | | |
| `» description` | string | false | | |
| `» enabled` | boolean | false | | Enabled controls whether the secret is injected into workspaces. Disabled secrets remain visible and editable, but are not added to the agent manifest, so they are not exposed as environment variables or written to secret files. |
| `» env_name` | string | false | | |
| `» file_path` | string | false | | |
| `» id` | string(uuid) | false | | |
| `» name` | string | false | | |
| `» updated_at` | string(date-time) | false | | |
| Name | Type | Required | Restrictions | Description |
|----------------------|----------------------------------------------------------------------------|----------|--------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `[array item]` | array | false | | |
| `» created_at` | string(date-time) | false | | |
| `» description` | string | false | | |
| `» enabled` | boolean | false | | Enabled controls whether the secret is injected into workspaces. Disabled secrets remain visible and editable, but are not added to the agent manifest, so they are not exposed as environment variables or written to secret files. |
| `» env_name` | string | false | | |
| `» env_replaced_by` | string(uuid) | false | | Env replaced by is the ID of the secret delivered on this secret's env_name instead of it. Only set when listing secrets for a workspace build. |
| `» file_path` | string | false | | |
| `» file_replaced_by` | string(uuid) | false | | File replaced by is the ID of the secret delivered on this secret's file_path instead of it. Only set when listing secrets for a workspace build. |
| `» id` | string(uuid) | false | | |
| `» name` | string | false | | |
| `» source` | [codersdk.WorkspaceSecretSource](schemas.md#codersdkworkspacesecretsource) | false | | |
| `» updated_at` | string(date-time) | false | | |
#### Enumerated Values
| Property | Value(s) |
|----------|-----------------|
| `source` | `build`, `user` |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
@@ -111,20 +128,23 @@ curl -X POST http://coder-server:8080/api/v2/users/{user}/secrets \
"description": "string",
"enabled": true,
"env_name": "string",
"env_replaced_by": "36faa5cb-cfc3-47a0-9c3e-df076ae8c261",
"file_path": "string",
"file_replaced_by": "1c5fa235-bacf-4250-9f02-952c83136140",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"name": "string",
"source": "user",
"updated_at": "2019-08-24T14:15:22Z"
}
```
### Responses
| Status | Meaning | Description | Schema |
|--------|------------------------------------------------------------------|-------------|------------------------------------------------------|
| 201 | [Created](https://tools.ietf.org/html/rfc7231#section-6.3.2) | Created | [codersdk.UserSecret](schemas.md#codersdkusersecret) |
| 400 | [Bad Request](https://tools.ietf.org/html/rfc7231#section-6.5.1) | Bad Request | [codersdk.Response](schemas.md#codersdkresponse) |
| 409 | [Conflict](https://tools.ietf.org/html/rfc7231#section-6.5.8) | Conflict | [codersdk.Response](schemas.md#codersdkresponse) |
| Status | Meaning | Description | Schema |
|--------|------------------------------------------------------------------|-------------|----------------------------------------------------------------|
| 201 | [Created](https://tools.ietf.org/html/rfc7231#section-6.3.2) | Created | [codersdk.WorkspaceSecret](schemas.md#codersdkworkspacesecret) |
| 400 | [Bad Request](https://tools.ietf.org/html/rfc7231#section-6.5.1) | Bad Request | [codersdk.Response](schemas.md#codersdkresponse) |
| 409 | [Conflict](https://tools.ietf.org/html/rfc7231#section-6.5.8) | Conflict | [codersdk.Response](schemas.md#codersdkresponse) |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
@@ -169,9 +189,12 @@ curl -X POST http://coder-server:8080/api/v2/users/{user}/secrets/batch \
"description": "string",
"enabled": true,
"env_name": "string",
"env_replaced_by": "36faa5cb-cfc3-47a0-9c3e-df076ae8c261",
"file_path": "string",
"file_replaced_by": "1c5fa235-bacf-4250-9f02-952c83136140",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"name": "string",
"source": "user",
"updated_at": "2019-08-24T14:15:22Z"
}
]
@@ -179,28 +202,37 @@ curl -X POST http://coder-server:8080/api/v2/users/{user}/secrets/batch \
### Responses
| Status | Meaning | Description | Schema |
|--------|-------------------------------------------------------------------------|----------------------------|---------------------------------------------------------------|
| 201 | [Created](https://tools.ietf.org/html/rfc7231#section-6.3.2) | Created | array of [codersdk.UserSecret](schemas.md#codersdkusersecret) |
| 400 | [Bad Request](https://tools.ietf.org/html/rfc7231#section-6.5.1) | Bad Request | [codersdk.Response](schemas.md#codersdkresponse) |
| 409 | [Conflict](https://tools.ietf.org/html/rfc7231#section-6.5.8) | Conflict | [codersdk.Response](schemas.md#codersdkresponse) |
| 413 | [Payload Too Large](https://tools.ietf.org/html/rfc7231#section-6.5.11) | Request body exceeds 8 MiB | [codersdk.Response](schemas.md#codersdkresponse) |
| Status | Meaning | Description | Schema |
|--------|-------------------------------------------------------------------------|----------------------------|-------------------------------------------------------------------------|
| 201 | [Created](https://tools.ietf.org/html/rfc7231#section-6.3.2) | Created | array of [codersdk.WorkspaceSecret](schemas.md#codersdkworkspacesecret) |
| 400 | [Bad Request](https://tools.ietf.org/html/rfc7231#section-6.5.1) | Bad Request | [codersdk.Response](schemas.md#codersdkresponse) |
| 409 | [Conflict](https://tools.ietf.org/html/rfc7231#section-6.5.8) | Conflict | [codersdk.Response](schemas.md#codersdkresponse) |
| 413 | [Payload Too Large](https://tools.ietf.org/html/rfc7231#section-6.5.11) | Request body exceeds 8 MiB | [codersdk.Response](schemas.md#codersdkresponse) |
<h3 id="import-user-secrets-from-a-file-responseschema">Response Schema</h3>
Status Code **201**
| Name | Type | Required | Restrictions | Description |
|-----------------|-------------------|----------|--------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `[array item]` | array | false | | |
| `» created_at` | string(date-time) | false | | |
| `» description` | string | false | | |
| `» enabled` | boolean | false | | Enabled controls whether the secret is injected into workspaces. Disabled secrets remain visible and editable, but are not added to the agent manifest, so they are not exposed as environment variables or written to secret files. |
| `» env_name` | string | false | | |
| `» file_path` | string | false | | |
| `» id` | string(uuid) | false | | |
| `» name` | string | false | | |
| `» updated_at` | string(date-time) | false | | |
| Name | Type | Required | Restrictions | Description |
|----------------------|----------------------------------------------------------------------------|----------|--------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `[array item]` | array | false | | |
| `» created_at` | string(date-time) | false | | |
| `» description` | string | false | | |
| `» enabled` | boolean | false | | Enabled controls whether the secret is injected into workspaces. Disabled secrets remain visible and editable, but are not added to the agent manifest, so they are not exposed as environment variables or written to secret files. |
| `» env_name` | string | false | | |
| `» env_replaced_by` | string(uuid) | false | | Env replaced by is the ID of the secret delivered on this secret's env_name instead of it. Only set when listing secrets for a workspace build. |
| `» file_path` | string | false | | |
| `» file_replaced_by` | string(uuid) | false | | File replaced by is the ID of the secret delivered on this secret's file_path instead of it. Only set when listing secrets for a workspace build. |
| `» id` | string(uuid) | false | | |
| `» name` | string | false | | |
| `» source` | [codersdk.WorkspaceSecretSource](schemas.md#codersdkworkspacesecretsource) | false | | |
| `» updated_at` | string(date-time) | false | | |
#### Enumerated Values
| Property | Value(s) |
|----------|-----------------|
| `source` | `build`, `user` |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
@@ -234,18 +266,21 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/secrets/{name} \
"description": "string",
"enabled": true,
"env_name": "string",
"env_replaced_by": "36faa5cb-cfc3-47a0-9c3e-df076ae8c261",
"file_path": "string",
"file_replaced_by": "1c5fa235-bacf-4250-9f02-952c83136140",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"name": "string",
"source": "user",
"updated_at": "2019-08-24T14:15:22Z"
}
```
### Responses
| Status | Meaning | Description | Schema |
|--------|---------------------------------------------------------|-------------|------------------------------------------------------|
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.UserSecret](schemas.md#codersdkusersecret) |
| Status | Meaning | Description | Schema |
|--------|---------------------------------------------------------|-------------|----------------------------------------------------------------|
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.WorkspaceSecret](schemas.md#codersdkworkspacesecret) |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
@@ -320,19 +355,22 @@ curl -X PATCH http://coder-server:8080/api/v2/users/{user}/secrets/{name} \
"description": "string",
"enabled": true,
"env_name": "string",
"env_replaced_by": "36faa5cb-cfc3-47a0-9c3e-df076ae8c261",
"file_path": "string",
"file_replaced_by": "1c5fa235-bacf-4250-9f02-952c83136140",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"name": "string",
"source": "user",
"updated_at": "2019-08-24T14:15:22Z"
}
```
### Responses
| Status | Meaning | Description | Schema |
|--------|------------------------------------------------------------------|-------------|------------------------------------------------------|
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.UserSecret](schemas.md#codersdkusersecret) |
| 400 | [Bad Request](https://tools.ietf.org/html/rfc7231#section-6.5.1) | Bad Request | [codersdk.Response](schemas.md#codersdkresponse) |
| 409 | [Conflict](https://tools.ietf.org/html/rfc7231#section-6.5.8) | Conflict | [codersdk.Response](schemas.md#codersdkresponse) |
| Status | Meaning | Description | Schema |
|--------|------------------------------------------------------------------|-------------|----------------------------------------------------------------|
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.WorkspaceSecret](schemas.md#codersdkworkspacesecret) |
| 400 | [Bad Request](https://tools.ietf.org/html/rfc7231#section-6.5.1) | Bad Request | [codersdk.Response](schemas.md#codersdkresponse) |
| 409 | [Conflict](https://tools.ietf.org/html/rfc7231#section-6.5.8) | Conflict | [codersdk.Response](schemas.md#codersdkresponse) |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
+22 -4
View File
@@ -22,16 +22,34 @@ coder secret list [flags] [name]
```console
Secret values are omitted from the output.
When listing all secrets inside a workspace, or with --workspace or --build-id, the workspace build's secrets are included and the "replaced by" column shows user secrets whose env var or file is taken by a workspace secret.
```
## Options
### --workspace
| | |
|------|---------------------|
| Type | <code>string</code> |
Include the secrets of this workspace's latest build. Defaults to the current workspace when run inside one.
### --build-id
| | |
|------|---------------------|
| Type | <code>string</code> |
Include the secrets of this workspace build. Takes precedence over --workspace.
### -c, --column
| | |
|---------|------------------------------------------------------------------------|
| Type | <code>[created\|name\|updated\|env\|file\|enabled\|description]</code> |
| Default | <code>name,created,updated,env,file,enabled,description</code> |
| | |
|---------|---------------------------------------------------------------------------------------------|
| Type | <code>[created\|name\|updated\|source\|env\|file\|enabled\|replaced by\|description]</code> |
| Default | <code>name,source,created,updated,env,file,enabled,replaced by,description</code> |
Columns to display in table output.
+1 -1
View File
@@ -464,7 +464,7 @@ func TestUpdateEncryptedWorkspaceSecretValueSkipsCleared(t *testing.T) {
})
require.ErrorIs(t, err, sql.ErrNoRows)
history, err := f.rawDB.GetWorkspaceSecretsHistory(f.ctx, first.Workspace.ID)
history, err := f.rawDB.GetWorkspaceSecretsHistory(f.ctx, database.GetWorkspaceSecretsHistoryParams{WorkspaceID: first.Workspace.ID})
require.NoError(t, err)
require.Len(t, history, 1)
require.True(t, history[0].ClearedAt.Valid, "row stays cleared")
+1
View File
@@ -647,6 +647,7 @@ describe("api.ts", () => {
enabled: true,
created_at: "2026-05-04T00:00:00Z",
updated_at: "2026-05-04T00:00:00Z",
source: "user",
};
it("lists user secrets with the correct method and URL", async () => {
+58 -2
View File
@@ -11721,8 +11721,8 @@ export interface UserRoles {
// From codersdk/usersecrets.go
/**
* UserSecret represents a user secret's metadata. The secret value
* is never included in API responses.
* UserSecret is the previous name of WorkspaceSecret, kept for backwards
* compatibility.
*/
export interface UserSecret {
readonly id: string;
@@ -11739,6 +11739,19 @@ export interface UserSecret {
readonly enabled: boolean;
readonly created_at: string;
readonly updated_at: string;
readonly source: WorkspaceSecretSource;
/**
* EnvReplacedBy is the ID of the secret delivered on this secret's
* env_name instead of it. Only set when listing secrets for a
* workspace build.
*/
readonly env_replaced_by?: string;
/**
* FileReplacedBy is the ID of the secret delivered on this secret's
* file_path instead of it. Only set when listing secrets for a
* workspace build.
*/
readonly file_replaced_by?: string;
}
// From codersdk/usersecretvalidation.go
@@ -12773,6 +12786,41 @@ export type WorkspaceRole = "admin" | "" | "use";
export const WorkspaceRoles: WorkspaceRole[] = ["admin", "", "use"];
// From codersdk/usersecrets.go
/**
* WorkspaceSecret represents a secret's metadata. The secret value is never
* included in API responses.
*/
export interface WorkspaceSecret {
readonly id: string;
readonly name: string;
readonly description: string;
readonly env_name: string;
readonly file_path: string;
/**
* Enabled controls whether the secret is injected into workspaces.
* Disabled secrets remain visible and editable, but are not added
* to the agent manifest, so they are not exposed as environment
* variables or written to secret files.
*/
readonly enabled: boolean;
readonly created_at: string;
readonly updated_at: string;
readonly source: WorkspaceSecretSource;
/**
* EnvReplacedBy is the ID of the secret delivered on this secret's
* env_name instead of it. Only set when listing secrets for a
* workspace build.
*/
readonly env_replaced_by?: string;
/**
* FileReplacedBy is the ID of the secret delivered on this secret's
* file_path instead of it. Only set when listing secrets for a
* workspace build.
*/
readonly file_replaced_by?: string;
}
// From codersdk/workspacesecrets.go
/**
* WorkspaceSecretInjectionTargetRequiredDetail explains that a workspace
@@ -12832,6 +12880,14 @@ export interface WorkspaceSecretInput {
export const WorkspaceSecretRemovalWithFieldsDetail =
"A null value removes the secret, so env_name, file_path, and ephemeral must not be set. Set value to set the secret."; //nolint:gosec // G101: message text, not a hardcoded credential.
// From codersdk/usersecrets.go
export type WorkspaceSecretSource = "build" | "user";
export const WorkspaceSecretSources: WorkspaceSecretSource[] = [
"build",
"user",
];
// From codersdk/workspacesharing.go
/**
* WorkspaceSharingSettings represents workspace sharing settings affecting an
@@ -89,6 +89,7 @@ const createSecretFromRequest = (
enabled: request.enabled ?? true,
created_at: "2026-05-04T00:00:00Z",
updated_at: "2026-05-04T00:00:00Z",
source: "user",
});
const findVisibleSecretByName = (name: string): UserSecret => {
@@ -20,6 +20,7 @@ const existingSecrets: UserSecret[] = [
enabled: true,
created_at: "2026-05-04T00:00:00Z",
updated_at: "2026-05-04T00:00:00Z",
source: "user",
},
{
id: "22222222-2222-2222-2222-222222222222",
@@ -30,6 +31,7 @@ const existingSecrets: UserSecret[] = [
enabled: true,
created_at: "2026-05-04T00:00:00Z",
updated_at: "2026-05-04T00:00:00Z",
source: "user",
},
];
+7
View File
@@ -629,6 +629,7 @@ export const MockUserSecrets: TypesGen.UserSecret[] = [
enabled: true,
created_at: "2026-04-28T16:30:00Z",
updated_at: "2026-04-30T16:30:00Z",
source: "user",
},
{
id: "secret-file-only",
@@ -639,6 +640,7 @@ export const MockUserSecrets: TypesGen.UserSecret[] = [
enabled: true,
created_at: "2026-04-29T16:30:00Z",
updated_at: "2026-05-01T16:30:00Z",
source: "user",
},
{
id: "secret-env-and-file",
@@ -649,6 +651,7 @@ export const MockUserSecrets: TypesGen.UserSecret[] = [
enabled: true,
created_at: "2026-04-30T16:30:00Z",
updated_at: "2026-05-02T16:30:00Z",
source: "user",
},
{
// Mirrors a pre-migration secret that had both env_name and
@@ -662,6 +665,7 @@ export const MockUserSecrets: TypesGen.UserSecret[] = [
enabled: false,
created_at: "2026-05-01T16:30:00Z",
updated_at: "2026-05-03T16:30:00Z",
source: "user",
},
{
id: "secret-duplicate",
@@ -672,6 +676,7 @@ export const MockUserSecrets: TypesGen.UserSecret[] = [
enabled: true,
created_at: "2026-05-01T18:30:00Z",
updated_at: "2026-05-03T18:30:00Z",
source: "user",
},
];
@@ -685,6 +690,7 @@ export const MockDisabledFileOnlyUserSecret: TypesGen.UserSecret = {
enabled: false,
created_at: "2026-04-27T16:30:00Z",
updated_at: "2026-05-03T20:30:00Z",
source: "user",
};
export const MockImportedUserSecret: TypesGen.UserSecret = {
@@ -696,6 +702,7 @@ export const MockImportedUserSecret: TypesGen.UserSecret = {
enabled: true,
created_at: "2026-05-04T00:00:00Z",
updated_at: "2026-05-04T00:00:00Z",
source: "user",
};
export const MockImportedUserSecrets: TypesGen.UserSecret[] = [
+1
View File
@@ -476,6 +476,7 @@ function userSecretFromCreateRequest(
enabled: request.enabled ?? true,
created_at: now,
updated_at: now,
source: "user",
};
}