From d2d16d3ad2f86e88ddfba63a1a253ce923a50d86 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Tue, 6 Oct 2026 01:14:47 +0000 Subject: [PATCH] feat: list a workspace build's secrets with the user's secrets --- cli/secret.go | 139 +++++++++++++++--- cli/secret_test.go | 65 ++++++++ cli/testdata/coder_secret_list_--help.golden | 15 +- coderd/agentapi/manifest.go | 79 +++++----- coderd/agentapi/manifest_internal_test.go | 14 +- coderd/agentapi/manifest_test.go | 30 ++-- coderd/apidoc/docs.go | 116 ++++++++++----- coderd/apidoc/swagger.json | 110 +++++++++----- coderd/database/db2sdk/db2sdk.go | 18 +++ coderd/database/dbauthz/dbauthz.go | 4 +- coderd/database/dbauthz/dbauthz_test.go | 5 +- coderd/database/dbmetrics/querymetrics.go | 2 +- coderd/database/dbmock/dbmock.go | 8 +- coderd/database/querier.go | 4 +- coderd/database/querier_test.go | 9 +- coderd/database/queries.sql.go | 15 +- coderd/database/queries/workspace_secrets.sql | 6 +- coderd/usersecrets.go | 117 ++++++++++++++- coderd/workspaces_test.go | 77 +++++++++- coderd/workspacesecrets/workspacesecrets.go | 92 ++++++++++++ .../workspacesecrets/workspacesecrets_test.go | 71 +++++++++ codersdk/agentsdk/convert.go | 8 +- codersdk/usersecrets.go | 58 +++++++- docs/reference/api/schemas.md | 82 +++++++---- docs/reference/api/secrets.md | 132 +++++++++++------ docs/reference/cli/secret/list.md | 26 +++- enterprise/dbcrypt/cliutil_test.go | 2 +- site/src/api/api.test.ts | 1 + site/src/api/typesGenerated.ts | 60 +++++++- .../SecretsPage/SecretsPageView.stories.tsx | 1 + .../SecretsPage/secretForm.test.ts | 2 + site/src/testHelpers/entities.ts | 7 + site/src/testHelpers/handlers.ts | 1 + 33 files changed, 1098 insertions(+), 278 deletions(-) create mode 100644 coderd/workspacesecrets/workspacesecrets.go create mode 100644 coderd/workspacesecrets/workspacesecrets_test.go diff --git a/cli/secret.go b/cli/secret.go index 2def766193..6f7a1dc2fb 100644 --- a/cli/secret.go +++ b/cli/secret.go @@ -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) diff --git a/cli/secret_test.go b/cli/secret_test.go index 0a24576988..fc0096436d 100644 --- a/cli/secret_test.go +++ b/cli/secret_test.go @@ -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() diff --git a/cli/testdata/coder_secret_list_--help.golden b/cli/testdata/coder_secret_list_--help.golden index 3d2fd83dc5..fb5e0dd837 100644 --- a/cli/testdata/coder_secret_list_--help.golden +++ b/cli/testdata/coder_secret_list_--help.golden @@ -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. diff --git a/coderd/agentapi/manifest.go b/coderd/agentapi/manifest.go index f7526215ca..abaed9b0bc 100644 --- a/coderd/agentapi/manifest.go +++ b/coderd/agentapi/manifest.go @@ -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), - }) -} diff --git a/coderd/agentapi/manifest_internal_test.go b/coderd/agentapi/manifest_internal_test.go index d1da7876eb..5e518f5b38 100644 --- a/coderd/agentapi/manifest_internal_test.go +++ b/coderd/agentapi/manifest_internal_test.go @@ -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")}, }, }, } diff --git a/coderd/agentapi/manifest_test.go b/coderd/agentapi/manifest_test.go index 8f2e566999..b81cd2e64a 100644 --- a/coderd/agentapi/manifest_test.go +++ b/coderd/agentapi/manifest_test.go @@ -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) { diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 62814f13ae..bb407130e0 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -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": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 945f632f60..c028541d3d 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -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": { diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 3a6ea5ed65..28b911f77f 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -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{ diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index a8efdec02f..9a54d0418b 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -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) { diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index c6ab4d8a6a..66bc1f731c 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -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}) })) diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 5bcf6a8f60..6ce90a6c1a 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -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()) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 323d7d375b..0e332fc43d 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -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. diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 06b08a5bcd..2d7a23c783 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -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 diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 2ef1b51aa8..ff4d8ca9dc 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -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 diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 93767ab591..9eeae203e6 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -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 } diff --git a/coderd/database/queries/workspace_secrets.sql b/coderd/database/queries/workspace_secrets.sql index abc157539d..c898585b69 100644 --- a/coderd/database/queries/workspace_secrets.sql +++ b/coderd/database/queries/workspace_secrets.sql @@ -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; diff --git a/coderd/usersecrets.go b/coderd/usersecrets.go index 3aed0e2c16..099714b6c9 100644 --- a/coderd/usersecrets.go +++ b/coderd/usersecrets.go @@ -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] diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index f0daa85009..99cff5618a 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -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") }) diff --git a/coderd/workspacesecrets/workspacesecrets.go b/coderd/workspacesecrets/workspacesecrets.go new file mode 100644 index 0000000000..dcc79ce1e4 --- /dev/null +++ b/coderd/workspacesecrets/workspacesecrets.go @@ -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{} +} diff --git a/coderd/workspacesecrets/workspacesecrets_test.go b/coderd/workspacesecrets/workspacesecrets_test.go new file mode 100644 index 0000000000..734bf4c48e --- /dev/null +++ b/coderd/workspacesecrets/workspacesecrets_test.go @@ -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()) + }) +} diff --git a/codersdk/agentsdk/convert.go b/codersdk/agentsdk/convert.go index caebd1d313..8cad4cf4d4 100644 --- a/codersdk/agentsdk/convert.go +++ b/codersdk/agentsdk/convert.go @@ -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[:], diff --git a/codersdk/usersecrets.go b/codersdk/usersecrets.go index a0a3e2ee20..f2c3cefa04 100644 --- a/codersdk/usersecrets.go +++ b/codersdk/usersecrets.go @@ -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) } diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 5f96137f5d..b802d606a2 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -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 diff --git a/docs/reference/api/secrets.md b/docs/reference/api/secrets.md index 2156bb8162..1034c7d498 100644 --- a/docs/reference/api/secrets.md +++ b/docs/reference/api/secrets.md @@ -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) |

Response Schema

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) |

Response Schema

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). diff --git a/docs/reference/cli/secret/list.md b/docs/reference/cli/secret/list.md index 8ddaae76d5..6ed3cf54ac 100644 --- a/docs/reference/cli/secret/list.md +++ b/docs/reference/cli/secret/list.md @@ -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 | string | + +Include the secrets of this workspace's latest build. Defaults to the current workspace when run inside one. + +### --build-id + +| | | +|------|---------------------| +| Type | string | + +Include the secrets of this workspace build. Takes precedence over --workspace. + ### -c, --column -| | | -|---------|------------------------------------------------------------------------| -| Type | [created\|name\|updated\|env\|file\|enabled\|description] | -| Default | name,created,updated,env,file,enabled,description | +| | | +|---------|---------------------------------------------------------------------------------------------| +| Type | [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. diff --git a/enterprise/dbcrypt/cliutil_test.go b/enterprise/dbcrypt/cliutil_test.go index c97502558f..89aac1a259 100644 --- a/enterprise/dbcrypt/cliutil_test.go +++ b/enterprise/dbcrypt/cliutil_test.go @@ -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") diff --git a/site/src/api/api.test.ts b/site/src/api/api.test.ts index 4fb2190cc9..da95fbddb5 100644 --- a/site/src/api/api.test.ts +++ b/site/src/api/api.test.ts @@ -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 () => { diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index bcb312d009..b066dd0e81 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -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 diff --git a/site/src/pages/UserSettingsPage/SecretsPage/SecretsPageView.stories.tsx b/site/src/pages/UserSettingsPage/SecretsPage/SecretsPageView.stories.tsx index 8c39aca075..083179ae42 100644 --- a/site/src/pages/UserSettingsPage/SecretsPage/SecretsPageView.stories.tsx +++ b/site/src/pages/UserSettingsPage/SecretsPage/SecretsPageView.stories.tsx @@ -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 => { diff --git a/site/src/pages/UserSettingsPage/SecretsPage/secretForm.test.ts b/site/src/pages/UserSettingsPage/SecretsPage/secretForm.test.ts index 1d7b657d02..09c5d7f817 100644 --- a/site/src/pages/UserSettingsPage/SecretsPage/secretForm.test.ts +++ b/site/src/pages/UserSettingsPage/SecretsPage/secretForm.test.ts @@ -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", }, ]; diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 25badb9e85..f39853d9f1 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -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[] = [ diff --git a/site/src/testHelpers/handlers.ts b/site/src/testHelpers/handlers.ts index a3bec9f85e..e76957a2e1 100644 --- a/site/src/testHelpers/handlers.ts +++ b/site/src/testHelpers/handlers.ts @@ -476,6 +476,7 @@ function userSecretFromCreateRequest( enabled: request.enabled ?? true, created_at: now, updated_at: now, + source: "user", }; }