mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
feat: list a workspace build's secrets with the user's secrets
This commit is contained in:
+118
-21
@@ -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)
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/v2/coderd/util/ptr"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
"github.com/coder/coder/v2/testutil/expecter"
|
||||
@@ -541,6 +542,70 @@ func TestSecretList(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestSecretListWorkspaceBuild(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil)
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
|
||||
|
||||
setupCtx := testutil.Context(t, testutil.WaitMedium)
|
||||
_, err := client.CreateUserSecret(setupCtx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
||||
Name: "user-token", Value: "u", EnvName: "TOKEN",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
workspace := coderdtest.CreateWorkspace(t, client, template.ID, func(req *codersdk.CreateWorkspaceRequest) {
|
||||
req.Secrets = []codersdk.WorkspaceSecretInput{
|
||||
{Name: "build-token", Value: ptr.Ref("b"), EnvName: "TOKEN"},
|
||||
}
|
||||
})
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
env map[string]string
|
||||
}{
|
||||
{name: "InsideWorkspace", env: map[string]string{"CODER_WORKSPACE_BUILD_ID": workspace.LatestBuild.ID.String()}},
|
||||
{name: "InsideWorkspaceOlderServer", env: map[string]string{"CODER_WORKSPACE_ID": workspace.ID.String()}},
|
||||
{name: "WorkspaceFlag", args: []string{"--workspace", workspace.Name}},
|
||||
{name: "BuildIDFlag", args: []string{"--build-id", workspace.LatestBuild.ID.String()}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
inv, root := clitest.New(t, append([]string{"secret", "list"}, tc.args...)...)
|
||||
for k, v := range tc.env {
|
||||
inv.Environ.Set(k, v)
|
||||
}
|
||||
output := clitest.Capture(inv)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
require.NoError(t, inv.WithContext(ctx).Run())
|
||||
|
||||
out := output.Stdout()
|
||||
assert.Contains(t, out, "SOURCE")
|
||||
assert.Contains(t, out, "REPLACED BY")
|
||||
assert.Contains(t, out, "build-token")
|
||||
assert.Contains(t, out, "env: build-token")
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("NameWithFlag", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
inv, root := clitest.New(t, "secret", "list", "user-token", "--build-id", workspace.LatestBuild.ID.String())
|
||||
clitest.SetupConfig(t, client, root)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
require.ErrorContains(t, inv.WithContext(ctx).Run(), "cannot be used with a secret name")
|
||||
})
|
||||
}
|
||||
|
||||
func TestSecretDelete(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
+14
-1
@@ -8,13 +8,26 @@ USAGE:
|
||||
Aliases: ls
|
||||
|
||||
Secret values are omitted from the output.
|
||||
|
||||
When listing all secrets inside a workspace, or with --workspace or
|
||||
--build-id, the workspace build's secrets are included and the "replaced by"
|
||||
column shows user secrets whose env var or file is taken by a workspace
|
||||
secret.
|
||||
|
||||
OPTIONS:
|
||||
-c, --column [created|name|updated|env|file|enabled|description] (default: name,created,updated,env,file,enabled,description)
|
||||
--build-id string
|
||||
Include the secrets of this workspace build. Takes precedence over
|
||||
--workspace.
|
||||
|
||||
-c, --column [created|name|updated|source|env|file|enabled|replaced by|description] (default: name,source,created,updated,env,file,enabled,replaced by,description)
|
||||
Columns to display in table output.
|
||||
|
||||
-o, --output table|json (default: table)
|
||||
Output format.
|
||||
|
||||
--workspace string
|
||||
Include the secrets of this workspace's latest build. Defaults to the
|
||||
current workspace when run inside one.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
|
||||
+37
-42
@@ -20,6 +20,7 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/v2/coderd/externalauth"
|
||||
"github.com/coder/coder/v2/coderd/workspaceapps/appurl"
|
||||
"github.com/coder/coder/v2/coderd/workspacesecrets"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/tailnet"
|
||||
)
|
||||
@@ -143,9 +144,9 @@ func (a *ManifestAPI) GetManifest(ctx context.Context, _ *agentproto.GetManifest
|
||||
parentID = workspaceAgent.ParentID.UUID[:]
|
||||
}
|
||||
|
||||
secretFilePathPolicy := userSecretFilePathAllowed
|
||||
secretFilePathPolicy := workspacesecrets.FilePathAllowed
|
||||
if a.DisableUserSecretFilePath {
|
||||
secretFilePathPolicy = userSecretFilePathBlocked
|
||||
secretFilePathPolicy = workspacesecrets.FilePathBlocked
|
||||
}
|
||||
|
||||
return &agentproto.Manifest{
|
||||
@@ -336,29 +337,20 @@ func dbAgentDevcontainersToProto(devcontainers []database.WorkspaceAgentDevconta
|
||||
return ret
|
||||
}
|
||||
|
||||
// userSecretFilePathPolicy is a named type rather than a bool parameter to
|
||||
// satisfy revive's flag-parameter rule.
|
||||
type userSecretFilePathPolicy int
|
||||
|
||||
const (
|
||||
userSecretFilePathAllowed userSecretFilePathPolicy = iota
|
||||
userSecretFilePathBlocked
|
||||
)
|
||||
|
||||
func dbSecretsToProto(userSecrets []database.UserSecret, workspaceSecrets []database.WorkspaceSecret, policy userSecretFilePathPolicy) []*agentproto.WorkspaceSecret {
|
||||
// Workspace secrets are appended after user secrets. The agent applies
|
||||
// env vars in order, so a workspace secret overrides a user secret that
|
||||
// targets the same environment variable.
|
||||
ret := make([]*agentproto.WorkspaceSecret, 0, len(userSecrets)+len(workspaceSecrets))
|
||||
// dbSecretsToProto builds the manifest secrets from the user's and the
|
||||
// agent build's secrets, delivering each on the targets Resolve assigns.
|
||||
func dbSecretsToProto(userSecrets []database.UserSecret, workspaceSecrets []database.WorkspaceSecret, policy workspacesecrets.FilePathPolicy) []*agentproto.WorkspaceSecret {
|
||||
candidates := make([]workspacesecrets.Secret, 0, len(userSecrets)+len(workspaceSecrets))
|
||||
for _, s := range userSecrets {
|
||||
// Skip disabled secrets so they are not injected as env vars or
|
||||
// written to secret files. The API guarantees every enabled
|
||||
// secret has at least one of env_name or file_path set, so we
|
||||
// don't need to filter both-empty rows separately here.
|
||||
if !s.Enabled {
|
||||
continue
|
||||
}
|
||||
ret = appendSecretProto(ret, s.EnvName, s.FilePath, s.Value, policy)
|
||||
candidates = append(candidates, workspacesecrets.Secret{
|
||||
ID: s.ID,
|
||||
Source: codersdk.WorkspaceSecretSourceUser,
|
||||
Name: s.Name,
|
||||
EnvName: s.EnvName,
|
||||
FilePath: s.FilePath,
|
||||
Enabled: s.Enabled,
|
||||
Value: s.Value,
|
||||
})
|
||||
}
|
||||
for _, s := range workspaceSecrets {
|
||||
// ListActiveWorkspaceSecrets only returns rows that still hold a
|
||||
@@ -366,24 +358,27 @@ func dbSecretsToProto(userSecrets []database.UserSecret, workspaceSecrets []data
|
||||
if !s.Value.Valid {
|
||||
continue
|
||||
}
|
||||
ret = appendSecretProto(ret, s.EnvName, s.FilePath, s.Value.String, policy)
|
||||
candidates = append(candidates, workspacesecrets.Secret{
|
||||
ID: s.ID,
|
||||
Source: codersdk.WorkspaceSecretSourceBuild,
|
||||
Name: s.Name,
|
||||
EnvName: s.EnvName,
|
||||
FilePath: s.FilePath,
|
||||
Enabled: true,
|
||||
Value: s.Value.String,
|
||||
})
|
||||
}
|
||||
|
||||
ret := make([]*agentproto.WorkspaceSecret, 0, len(candidates))
|
||||
for _, r := range workspacesecrets.Resolve(candidates, policy) {
|
||||
if !r.Delivered() {
|
||||
continue
|
||||
}
|
||||
ret = append(ret, &agentproto.WorkspaceSecret{
|
||||
EnvName: r.DeliveredEnvName,
|
||||
FilePath: r.DeliveredFilePath,
|
||||
Value: []byte(r.Value),
|
||||
})
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
// appendSecretProto appends one manifest secret, applying the deployment's
|
||||
// file path delivery policy. A secret that only targets a file is dropped
|
||||
// entirely when file delivery is blocked.
|
||||
func appendSecretProto(ret []*agentproto.WorkspaceSecret, envName, filePath, value string, policy userSecretFilePathPolicy) []*agentproto.WorkspaceSecret {
|
||||
if policy == userSecretFilePathBlocked {
|
||||
if envName == "" {
|
||||
return ret
|
||||
}
|
||||
filePath = ""
|
||||
}
|
||||
return append(ret, &agentproto.WorkspaceSecret{
|
||||
EnvName: envName,
|
||||
FilePath: filePath,
|
||||
Value: []byte(value),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -11,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")},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Generated
+78
-38
@@ -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": {
|
||||
|
||||
Generated
+72
-38
@@ -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": {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -6978,8 +6978,9 @@ func (s *MethodTestSuite) TestWorkspaceSecrets() {
|
||||
WorkspaceOwnerID: ws.OwnerID,
|
||||
WorkspaceOrganizationID: ws.OrganizationID,
|
||||
})
|
||||
dbm.EXPECT().GetWorkspaceSecretsHistory(gomock.Any(), ws.ID).Return([]database.GetWorkspaceSecretsHistoryRow{row}, nil).AnyTimes()
|
||||
check.Args(ws.ID).
|
||||
arg := database.GetWorkspaceSecretsHistoryParams{WorkspaceID: ws.ID}
|
||||
dbm.EXPECT().GetWorkspaceSecretsHistory(gomock.Any(), arg).Return([]database.GetWorkspaceSecretsHistoryRow{row}, nil).AnyTimes()
|
||||
check.Args(arg).
|
||||
Asserts(row, policy.ActionRead).
|
||||
Returns([]database.GetWorkspaceSecretsHistoryRow{row})
|
||||
}))
|
||||
|
||||
+1
-1
@@ -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())
|
||||
|
||||
Generated
+4
-4
@@ -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.
|
||||
|
||||
Generated
+2
-2
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+12
-3
@@ -34858,9 +34858,18 @@ SELECT
|
||||
FROM workspace_secrets ws
|
||||
JOIN workspaces w ON w.id = ws.workspace_id
|
||||
WHERE ws.workspace_id = $1
|
||||
AND (
|
||||
$2::uuid = '00000000-0000-0000-0000-000000000000'::uuid
|
||||
OR ws.workspace_build_id = $2
|
||||
)
|
||||
ORDER BY ws.created_at ASC, ws.name ASC
|
||||
`
|
||||
|
||||
type GetWorkspaceSecretsHistoryParams struct {
|
||||
WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"`
|
||||
WorkspaceBuildID uuid.UUID `db:"workspace_build_id" json:"workspace_build_id"`
|
||||
}
|
||||
|
||||
type GetWorkspaceSecretsHistoryRow struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"`
|
||||
@@ -34879,9 +34888,9 @@ type GetWorkspaceSecretsHistoryRow struct {
|
||||
// Returns metadata for every workspace secret row of a workspace, including
|
||||
// cleared rows, so the secrets each build received can be inspected. Values
|
||||
// are never selected. The workspace owner and organization are included for
|
||||
// authorization.
|
||||
func (q *sqlQuerier) GetWorkspaceSecretsHistory(ctx context.Context, workspaceID uuid.UUID) ([]GetWorkspaceSecretsHistoryRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getWorkspaceSecretsHistory, workspaceID)
|
||||
// authorization. A non-nil workspace_build_id limits the rows to that build.
|
||||
func (q *sqlQuerier) GetWorkspaceSecretsHistory(ctx context.Context, arg GetWorkspaceSecretsHistoryParams) ([]GetWorkspaceSecretsHistoryRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getWorkspaceSecretsHistory, arg.WorkspaceID, arg.WorkspaceBuildID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ RETURNING *;
|
||||
-- Returns metadata for every workspace secret row of a workspace, including
|
||||
-- cleared rows, so the secrets each build received can be inspected. Values
|
||||
-- are never selected. The workspace owner and organization are included for
|
||||
-- authorization.
|
||||
-- authorization. A non-nil workspace_build_id limits the rows to that build.
|
||||
SELECT
|
||||
ws.id, ws.workspace_id, ws.workspace_build_id, ws.name,
|
||||
ws.env_name, ws.file_path, ws.ephemeral, ws.source, ws.created_at, ws.cleared_at,
|
||||
@@ -107,4 +107,8 @@ SELECT
|
||||
FROM workspace_secrets ws
|
||||
JOIN workspaces w ON w.id = ws.workspace_id
|
||||
WHERE ws.workspace_id = @workspace_id
|
||||
AND (
|
||||
@workspace_build_id::uuid = '00000000-0000-0000-0000-000000000000'::uuid
|
||||
OR ws.workspace_build_id = @workspace_build_id
|
||||
)
|
||||
ORDER BY ws.created_at ASC, ws.name ASC;
|
||||
|
||||
+111
-6
@@ -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]
|
||||
|
||||
@@ -6672,7 +6672,7 @@ func TestWorkspaceSecrets(t *testing.T) {
|
||||
clearedRows := func(ctx context.Context, t *testing.T, workspaceID uuid.UUID) map[uuid.UUID]map[string]database.GetWorkspaceSecretsHistoryRow {
|
||||
t.Helper()
|
||||
//nolint:gocritic // Test-only read of every row's metadata.
|
||||
rows, err := db.GetWorkspaceSecretsHistory(dbauthz.AsWorkspaceSecretManager(ctx), workspaceID)
|
||||
rows, err := db.GetWorkspaceSecretsHistory(dbauthz.AsWorkspaceSecretManager(ctx), database.GetWorkspaceSecretsHistoryParams{WorkspaceID: workspaceID})
|
||||
require.NoError(t, err)
|
||||
out := map[uuid.UUID]map[string]database.GetWorkspaceSecretsHistoryRow{}
|
||||
for _, row := range rows {
|
||||
@@ -6793,6 +6793,79 @@ func TestWorkspaceSecrets(t *testing.T) {
|
||||
require.Equal(t, "d", secrets["new-file"].Value.String)
|
||||
})
|
||||
|
||||
t.Run("ListWithBuild", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
member, memberUser := coderdtest.CreateAnotherUser(t, client, user.OrganizationID)
|
||||
overridden, err := member.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
||||
Name: "user-token", Value: "u", EnvName: "TOKEN", FilePath: "/home/coder/.user-token",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
disabled, err := member.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
||||
Name: "disabled", Value: "d", EnvName: "OTHER", Enabled: ptr.Ref(false),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
workspace := coderdtest.CreateWorkspace(t, member, template.ID, func(req *codersdk.CreateWorkspaceRequest) {
|
||||
req.Secrets = []codersdk.WorkspaceSecretInput{
|
||||
{Name: "build-token", Value: ptr.Ref("b"), EnvName: "TOKEN"},
|
||||
{Name: "build-other", Value: ptr.Ref("o"), EnvName: "OTHER"},
|
||||
}
|
||||
})
|
||||
build1 := workspace.LatestBuild
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, member, build1.ID)
|
||||
|
||||
listForBuild := func(buildID uuid.UUID) map[string]codersdk.WorkspaceSecret {
|
||||
t.Helper()
|
||||
secrets, err := member.UserSecretsForWorkspaceBuild(ctx, codersdk.Me, buildID)
|
||||
require.NoError(t, err)
|
||||
out := make(map[string]codersdk.WorkspaceSecret, len(secrets))
|
||||
for _, s := range secrets {
|
||||
out[s.Name] = s
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Without a build, only user secrets are listed.
|
||||
plain, err := member.UserSecrets(ctx, codersdk.Me)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, plain, 2)
|
||||
for _, s := range plain {
|
||||
require.Equal(t, codersdk.WorkspaceSecretSourceUser, s.Source)
|
||||
require.Nil(t, s.EnvReplacedBy)
|
||||
}
|
||||
|
||||
got := listForBuild(build1.ID)
|
||||
require.Len(t, got, 4)
|
||||
buildToken := got["build-token"]
|
||||
require.Equal(t, codersdk.WorkspaceSecretSourceBuild, buildToken.Source)
|
||||
// The user secret loses its env var but keeps its file.
|
||||
require.Equal(t, &buildToken.ID, got[overridden.Name].EnvReplacedBy)
|
||||
require.Nil(t, got[overridden.Name].FileReplacedBy)
|
||||
// Disabled secrets are never reported as replaced.
|
||||
require.Nil(t, got[disabled.Name].EnvReplacedBy)
|
||||
|
||||
// After another build, the old build's values are cleared but its
|
||||
// secrets are still listed.
|
||||
build2 := coderdtest.CreateWorkspaceBuild(t, member, coderdtest.MustWorkspace(t, member, workspace.ID), database.WorkspaceTransitionStop)
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, member, build2.ID)
|
||||
require.Len(t, listForBuild(build1.ID), 4)
|
||||
|
||||
// A build of another user's workspace is rejected once the caller
|
||||
// is authorized to read it.
|
||||
_, err = client.UserSecretsForWorkspaceBuild(ctx, codersdk.Me, build1.ID)
|
||||
var apiErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &apiErr)
|
||||
require.Equal(t, http.StatusBadRequest, apiErr.StatusCode())
|
||||
|
||||
// A build the caller cannot read is not found.
|
||||
ownerBuild := coderdtest.CreateWorkspace(t, client, template.ID).LatestBuild
|
||||
_, err = member.UserSecretsForWorkspaceBuild(ctx, memberUser.Username, ownerBuild.ID)
|
||||
require.ErrorAs(t, err, &apiErr)
|
||||
require.Equal(t, http.StatusNotFound, apiErr.StatusCode())
|
||||
})
|
||||
|
||||
t.Run("Validation", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -6898,7 +6971,7 @@ func TestWorkspaceSecrets(t *testing.T) {
|
||||
require.Equal(t, http.StatusBadRequest, apiErr.StatusCode())
|
||||
require.Contains(t, apiErr.Message, "exceeds a per-build limit")
|
||||
//nolint:gocritic // Test-only read of every row's metadata.
|
||||
history, err := db.GetWorkspaceSecretsHistory(dbauthz.AsWorkspaceSecretManager(ctx), workspace.ID)
|
||||
history, err := db.GetWorkspaceSecretsHistory(dbauthz.AsWorkspaceSecretManager(ctx), database.GetWorkspaceSecretsHistoryParams{WorkspaceID: workspace.ID})
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, history, "failed build must not persist any secret")
|
||||
})
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
// Package workspacesecrets decides which secrets are delivered to a
|
||||
// workspace and on which targets. The agent manifest and the secrets API
|
||||
// both use Resolve so they always agree.
|
||||
package workspacesecrets
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
// FilePathPolicy controls whether secrets may be delivered as files.
|
||||
type FilePathPolicy int
|
||||
|
||||
const (
|
||||
FilePathAllowed FilePathPolicy = iota
|
||||
FilePathBlocked
|
||||
)
|
||||
|
||||
// Secret is a candidate for delivery to a workspace. Value may be empty when
|
||||
// only metadata is needed.
|
||||
type Secret struct {
|
||||
ID uuid.UUID
|
||||
Source codersdk.WorkspaceSecretSource
|
||||
Name string
|
||||
EnvName string
|
||||
FilePath string
|
||||
// Enabled is false for disabled user secrets. Workspace secrets are
|
||||
// always enabled.
|
||||
Enabled bool
|
||||
Value string
|
||||
}
|
||||
|
||||
// Resolved is a Secret with its delivery outcome.
|
||||
type Resolved struct {
|
||||
Secret
|
||||
// DeliveredEnvName and DeliveredFilePath are the targets the secret is
|
||||
// actually delivered on. They are empty when the secret is disabled, the
|
||||
// target is unset, blocked by policy, or taken by another secret.
|
||||
DeliveredEnvName string
|
||||
DeliveredFilePath string
|
||||
// EnvReplacedBy and FileReplacedBy name the secret delivered on this
|
||||
// secret's target instead of it.
|
||||
EnvReplacedBy uuid.NullUUID
|
||||
FileReplacedBy uuid.NullUUID
|
||||
}
|
||||
|
||||
// Delivered reports whether the secret is delivered on any target.
|
||||
func (r Resolved) Delivered() bool {
|
||||
return r.DeliveredEnvName != "" || r.DeliveredFilePath != ""
|
||||
}
|
||||
|
||||
// Resolve returns the delivery outcome of each secret, in input order.
|
||||
//
|
||||
// Disabled secrets are never delivered and never replace or get replaced.
|
||||
// When secrets share an env_name or file_path, a workspace build secret wins
|
||||
// over a user secret. Targets are compared exactly as written.
|
||||
func Resolve(secrets []Secret, policy FilePathPolicy) []Resolved {
|
||||
out := make([]Resolved, len(secrets))
|
||||
envOwner := map[string]uuid.UUID{}
|
||||
fileOwner := map[string]uuid.UUID{}
|
||||
|
||||
// Claim targets in precedence order: build secrets first.
|
||||
for _, pass := range []codersdk.WorkspaceSecretSource{codersdk.WorkspaceSecretSourceBuild, codersdk.WorkspaceSecretSourceUser} {
|
||||
for i, s := range secrets {
|
||||
if s.Source != pass {
|
||||
continue
|
||||
}
|
||||
r := Resolved{Secret: s}
|
||||
if s.Enabled {
|
||||
r.DeliveredEnvName, r.EnvReplacedBy = claim(envOwner, s.EnvName, s.ID)
|
||||
if policy == FilePathAllowed {
|
||||
r.DeliveredFilePath, r.FileReplacedBy = claim(fileOwner, s.FilePath, s.ID)
|
||||
}
|
||||
}
|
||||
out[i] = r
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// claim assigns target to id unless another secret already holds it.
|
||||
func claim(owners map[string]uuid.UUID, target string, id uuid.UUID) (string, uuid.NullUUID) {
|
||||
if target == "" {
|
||||
return "", uuid.NullUUID{}
|
||||
}
|
||||
if owner, taken := owners[target]; taken {
|
||||
return "", uuid.NullUUID{UUID: owner, Valid: true}
|
||||
}
|
||||
owners[target] = id
|
||||
return target, uuid.NullUUID{}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package workspacesecrets_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/workspacesecrets"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
func TestResolve(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
user := func(name, env, file string, enabled bool) workspacesecrets.Secret {
|
||||
return workspacesecrets.Secret{ID: uuid.New(), Source: codersdk.WorkspaceSecretSourceUser, Name: name, EnvName: env, FilePath: file, Enabled: enabled}
|
||||
}
|
||||
build := func(name, env, file string) workspacesecrets.Secret {
|
||||
return workspacesecrets.Secret{ID: uuid.New(), Source: codersdk.WorkspaceSecretSourceBuild, Name: name, EnvName: env, FilePath: file, Enabled: true}
|
||||
}
|
||||
|
||||
var (
|
||||
userBoth = user("both", "TOKEN", "/etc/token", true)
|
||||
userFileOnly = user("file-only", "", "/etc/cert", true)
|
||||
userDisabled = user("disabled", "DISABLED", "", false)
|
||||
userPlain = user("plain", "PLAIN", "", true)
|
||||
buildEnv = build("build-env", "TOKEN", "")
|
||||
buildFile = build("build-file", "", "/etc/cert")
|
||||
buildOnDis = build("build-on-disabled", "DISABLED", "")
|
||||
)
|
||||
// Build secrets listed last still win, so input order does not matter.
|
||||
secrets := []workspacesecrets.Secret{userBoth, userFileOnly, userDisabled, userPlain, buildEnv, buildFile, buildOnDis}
|
||||
|
||||
t.Run("FilePathAllowed", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := workspacesecrets.Resolve(secrets, workspacesecrets.FilePathAllowed)
|
||||
require.Len(t, got, len(secrets))
|
||||
|
||||
// Partial override: the env var is taken, the file is still delivered.
|
||||
require.Empty(t, got[0].DeliveredEnvName)
|
||||
require.Equal(t, uuid.NullUUID{UUID: buildEnv.ID, Valid: true}, got[0].EnvReplacedBy)
|
||||
require.Equal(t, "/etc/token", got[0].DeliveredFilePath)
|
||||
require.False(t, got[0].FileReplacedBy.Valid)
|
||||
require.True(t, got[0].Delivered())
|
||||
|
||||
// Full override.
|
||||
require.False(t, got[1].Delivered())
|
||||
require.Equal(t, uuid.NullUUID{UUID: buildFile.ID, Valid: true}, got[1].FileReplacedBy)
|
||||
|
||||
// Disabled secrets are neither delivered nor replaced.
|
||||
require.False(t, got[2].Delivered())
|
||||
require.False(t, got[2].EnvReplacedBy.Valid)
|
||||
|
||||
require.Equal(t, "PLAIN", got[3].DeliveredEnvName)
|
||||
require.Equal(t, "TOKEN", got[4].DeliveredEnvName)
|
||||
require.Equal(t, "/etc/cert", got[5].DeliveredFilePath)
|
||||
require.Equal(t, "DISABLED", got[6].DeliveredEnvName)
|
||||
})
|
||||
|
||||
t.Run("FilePathBlocked", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := workspacesecrets.Resolve(secrets, workspacesecrets.FilePathBlocked)
|
||||
|
||||
// No file is delivered, so no file target is reported as replaced.
|
||||
require.False(t, got[0].Delivered())
|
||||
require.False(t, got[1].Delivered())
|
||||
require.False(t, got[1].FileReplacedBy.Valid)
|
||||
require.False(t, got[5].Delivered())
|
||||
})
|
||||
}
|
||||
@@ -91,10 +91,10 @@ func ProtoFromManifest(manifest Manifest) (*proto.Manifest, error) {
|
||||
return nil, xerrors.Errorf("convert workspace agent scripts: %w", err)
|
||||
}
|
||||
return &proto.Manifest{
|
||||
ParentId: manifest.ParentID[:],
|
||||
AgentId: manifest.AgentID[:],
|
||||
AgentName: manifest.AgentName,
|
||||
OwnerUsername: manifest.OwnerName,
|
||||
ParentId: manifest.ParentID[:],
|
||||
AgentId: manifest.AgentID[:],
|
||||
AgentName: manifest.AgentName,
|
||||
OwnerUsername: manifest.OwnerName,
|
||||
WorkspaceId: manifest.WorkspaceID[:],
|
||||
WorkspaceName: manifest.WorkspaceName,
|
||||
WorkspaceBuildId: manifest.WorkspaceBuildID[:],
|
||||
|
||||
+50
-8
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Generated
+54
-28
@@ -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
|
||||
|
||||
Generated
+85
-47
@@ -18,11 +18,16 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/secrets \
|
||||
|
||||
`GET /api/v2/users/{user}/secrets`
|
||||
|
||||
With workspace_build set, the response also includes that
|
||||
build's workspace secrets, and reports which secrets are
|
||||
replaced on their env_name or file_path by another secret.
|
||||
|
||||
### Parameters
|
||||
|
||||
| Name | In | Type | Required | Description |
|
||||
|--------|------|--------|----------|--------------------------|
|
||||
| `user` | path | string | true | User ID, username, or me |
|
||||
| Name | In | Type | Required | Description |
|
||||
|-------------------|-------|--------------|----------|--------------------------------------|
|
||||
| `user` | path | string | true | User ID, username, or me |
|
||||
| `workspace_build` | query | string(uuid) | false | Workspace build ID owned by the user |
|
||||
|
||||
### Example responses
|
||||
|
||||
@@ -35,9 +40,12 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/secrets \
|
||||
"description": "string",
|
||||
"enabled": true,
|
||||
"env_name": "string",
|
||||
"env_replaced_by": "36faa5cb-cfc3-47a0-9c3e-df076ae8c261",
|
||||
"file_path": "string",
|
||||
"file_replaced_by": "1c5fa235-bacf-4250-9f02-952c83136140",
|
||||
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
|
||||
"name": "string",
|
||||
"source": "user",
|
||||
"updated_at": "2019-08-24T14:15:22Z"
|
||||
}
|
||||
]
|
||||
@@ -45,25 +53,34 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/secrets \
|
||||
|
||||
### Responses
|
||||
|
||||
| Status | Meaning | Description | Schema |
|
||||
|--------|---------------------------------------------------------|-------------|---------------------------------------------------------------|
|
||||
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.UserSecret](schemas.md#codersdkusersecret) |
|
||||
| Status | Meaning | Description | Schema |
|
||||
|--------|---------------------------------------------------------|-------------|-------------------------------------------------------------------------|
|
||||
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.WorkspaceSecret](schemas.md#codersdkworkspacesecret) |
|
||||
|
||||
<h3 id="list-user-secrets-responseschema">Response Schema</h3>
|
||||
|
||||
Status Code **200**
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
|-----------------|-------------------|----------|--------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `[array item]` | array | false | | |
|
||||
| `» created_at` | string(date-time) | false | | |
|
||||
| `» description` | string | false | | |
|
||||
| `» enabled` | boolean | false | | Enabled controls whether the secret is injected into workspaces. Disabled secrets remain visible and editable, but are not added to the agent manifest, so they are not exposed as environment variables or written to secret files. |
|
||||
| `» env_name` | string | false | | |
|
||||
| `» file_path` | string | false | | |
|
||||
| `» id` | string(uuid) | false | | |
|
||||
| `» name` | string | false | | |
|
||||
| `» updated_at` | string(date-time) | false | | |
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
|----------------------|----------------------------------------------------------------------------|----------|--------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `[array item]` | array | false | | |
|
||||
| `» created_at` | string(date-time) | false | | |
|
||||
| `» description` | string | false | | |
|
||||
| `» enabled` | boolean | false | | Enabled controls whether the secret is injected into workspaces. Disabled secrets remain visible and editable, but are not added to the agent manifest, so they are not exposed as environment variables or written to secret files. |
|
||||
| `» env_name` | string | false | | |
|
||||
| `» env_replaced_by` | string(uuid) | false | | Env replaced by is the ID of the secret delivered on this secret's env_name instead of it. Only set when listing secrets for a workspace build. |
|
||||
| `» file_path` | string | false | | |
|
||||
| `» file_replaced_by` | string(uuid) | false | | File replaced by is the ID of the secret delivered on this secret's file_path instead of it. Only set when listing secrets for a workspace build. |
|
||||
| `» id` | string(uuid) | false | | |
|
||||
| `» name` | string | false | | |
|
||||
| `» source` | [codersdk.WorkspaceSecretSource](schemas.md#codersdkworkspacesecretsource) | false | | |
|
||||
| `» updated_at` | string(date-time) | false | | |
|
||||
|
||||
#### Enumerated Values
|
||||
|
||||
| Property | Value(s) |
|
||||
|----------|-----------------|
|
||||
| `source` | `build`, `user` |
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
@@ -111,20 +128,23 @@ curl -X POST http://coder-server:8080/api/v2/users/{user}/secrets \
|
||||
"description": "string",
|
||||
"enabled": true,
|
||||
"env_name": "string",
|
||||
"env_replaced_by": "36faa5cb-cfc3-47a0-9c3e-df076ae8c261",
|
||||
"file_path": "string",
|
||||
"file_replaced_by": "1c5fa235-bacf-4250-9f02-952c83136140",
|
||||
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
|
||||
"name": "string",
|
||||
"source": "user",
|
||||
"updated_at": "2019-08-24T14:15:22Z"
|
||||
}
|
||||
```
|
||||
|
||||
### Responses
|
||||
|
||||
| Status | Meaning | Description | Schema |
|
||||
|--------|------------------------------------------------------------------|-------------|------------------------------------------------------|
|
||||
| 201 | [Created](https://tools.ietf.org/html/rfc7231#section-6.3.2) | Created | [codersdk.UserSecret](schemas.md#codersdkusersecret) |
|
||||
| 400 | [Bad Request](https://tools.ietf.org/html/rfc7231#section-6.5.1) | Bad Request | [codersdk.Response](schemas.md#codersdkresponse) |
|
||||
| 409 | [Conflict](https://tools.ietf.org/html/rfc7231#section-6.5.8) | Conflict | [codersdk.Response](schemas.md#codersdkresponse) |
|
||||
| Status | Meaning | Description | Schema |
|
||||
|--------|------------------------------------------------------------------|-------------|----------------------------------------------------------------|
|
||||
| 201 | [Created](https://tools.ietf.org/html/rfc7231#section-6.3.2) | Created | [codersdk.WorkspaceSecret](schemas.md#codersdkworkspacesecret) |
|
||||
| 400 | [Bad Request](https://tools.ietf.org/html/rfc7231#section-6.5.1) | Bad Request | [codersdk.Response](schemas.md#codersdkresponse) |
|
||||
| 409 | [Conflict](https://tools.ietf.org/html/rfc7231#section-6.5.8) | Conflict | [codersdk.Response](schemas.md#codersdkresponse) |
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
@@ -169,9 +189,12 @@ curl -X POST http://coder-server:8080/api/v2/users/{user}/secrets/batch \
|
||||
"description": "string",
|
||||
"enabled": true,
|
||||
"env_name": "string",
|
||||
"env_replaced_by": "36faa5cb-cfc3-47a0-9c3e-df076ae8c261",
|
||||
"file_path": "string",
|
||||
"file_replaced_by": "1c5fa235-bacf-4250-9f02-952c83136140",
|
||||
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
|
||||
"name": "string",
|
||||
"source": "user",
|
||||
"updated_at": "2019-08-24T14:15:22Z"
|
||||
}
|
||||
]
|
||||
@@ -179,28 +202,37 @@ curl -X POST http://coder-server:8080/api/v2/users/{user}/secrets/batch \
|
||||
|
||||
### Responses
|
||||
|
||||
| Status | Meaning | Description | Schema |
|
||||
|--------|-------------------------------------------------------------------------|----------------------------|---------------------------------------------------------------|
|
||||
| 201 | [Created](https://tools.ietf.org/html/rfc7231#section-6.3.2) | Created | array of [codersdk.UserSecret](schemas.md#codersdkusersecret) |
|
||||
| 400 | [Bad Request](https://tools.ietf.org/html/rfc7231#section-6.5.1) | Bad Request | [codersdk.Response](schemas.md#codersdkresponse) |
|
||||
| 409 | [Conflict](https://tools.ietf.org/html/rfc7231#section-6.5.8) | Conflict | [codersdk.Response](schemas.md#codersdkresponse) |
|
||||
| 413 | [Payload Too Large](https://tools.ietf.org/html/rfc7231#section-6.5.11) | Request body exceeds 8 MiB | [codersdk.Response](schemas.md#codersdkresponse) |
|
||||
| Status | Meaning | Description | Schema |
|
||||
|--------|-------------------------------------------------------------------------|----------------------------|-------------------------------------------------------------------------|
|
||||
| 201 | [Created](https://tools.ietf.org/html/rfc7231#section-6.3.2) | Created | array of [codersdk.WorkspaceSecret](schemas.md#codersdkworkspacesecret) |
|
||||
| 400 | [Bad Request](https://tools.ietf.org/html/rfc7231#section-6.5.1) | Bad Request | [codersdk.Response](schemas.md#codersdkresponse) |
|
||||
| 409 | [Conflict](https://tools.ietf.org/html/rfc7231#section-6.5.8) | Conflict | [codersdk.Response](schemas.md#codersdkresponse) |
|
||||
| 413 | [Payload Too Large](https://tools.ietf.org/html/rfc7231#section-6.5.11) | Request body exceeds 8 MiB | [codersdk.Response](schemas.md#codersdkresponse) |
|
||||
|
||||
<h3 id="import-user-secrets-from-a-file-responseschema">Response Schema</h3>
|
||||
|
||||
Status Code **201**
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
|-----------------|-------------------|----------|--------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `[array item]` | array | false | | |
|
||||
| `» created_at` | string(date-time) | false | | |
|
||||
| `» description` | string | false | | |
|
||||
| `» enabled` | boolean | false | | Enabled controls whether the secret is injected into workspaces. Disabled secrets remain visible and editable, but are not added to the agent manifest, so they are not exposed as environment variables or written to secret files. |
|
||||
| `» env_name` | string | false | | |
|
||||
| `» file_path` | string | false | | |
|
||||
| `» id` | string(uuid) | false | | |
|
||||
| `» name` | string | false | | |
|
||||
| `» updated_at` | string(date-time) | false | | |
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
|----------------------|----------------------------------------------------------------------------|----------|--------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `[array item]` | array | false | | |
|
||||
| `» created_at` | string(date-time) | false | | |
|
||||
| `» description` | string | false | | |
|
||||
| `» enabled` | boolean | false | | Enabled controls whether the secret is injected into workspaces. Disabled secrets remain visible and editable, but are not added to the agent manifest, so they are not exposed as environment variables or written to secret files. |
|
||||
| `» env_name` | string | false | | |
|
||||
| `» env_replaced_by` | string(uuid) | false | | Env replaced by is the ID of the secret delivered on this secret's env_name instead of it. Only set when listing secrets for a workspace build. |
|
||||
| `» file_path` | string | false | | |
|
||||
| `» file_replaced_by` | string(uuid) | false | | File replaced by is the ID of the secret delivered on this secret's file_path instead of it. Only set when listing secrets for a workspace build. |
|
||||
| `» id` | string(uuid) | false | | |
|
||||
| `» name` | string | false | | |
|
||||
| `» source` | [codersdk.WorkspaceSecretSource](schemas.md#codersdkworkspacesecretsource) | false | | |
|
||||
| `» updated_at` | string(date-time) | false | | |
|
||||
|
||||
#### Enumerated Values
|
||||
|
||||
| Property | Value(s) |
|
||||
|----------|-----------------|
|
||||
| `source` | `build`, `user` |
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
@@ -234,18 +266,21 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/secrets/{name} \
|
||||
"description": "string",
|
||||
"enabled": true,
|
||||
"env_name": "string",
|
||||
"env_replaced_by": "36faa5cb-cfc3-47a0-9c3e-df076ae8c261",
|
||||
"file_path": "string",
|
||||
"file_replaced_by": "1c5fa235-bacf-4250-9f02-952c83136140",
|
||||
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
|
||||
"name": "string",
|
||||
"source": "user",
|
||||
"updated_at": "2019-08-24T14:15:22Z"
|
||||
}
|
||||
```
|
||||
|
||||
### Responses
|
||||
|
||||
| Status | Meaning | Description | Schema |
|
||||
|--------|---------------------------------------------------------|-------------|------------------------------------------------------|
|
||||
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.UserSecret](schemas.md#codersdkusersecret) |
|
||||
| Status | Meaning | Description | Schema |
|
||||
|--------|---------------------------------------------------------|-------------|----------------------------------------------------------------|
|
||||
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.WorkspaceSecret](schemas.md#codersdkworkspacesecret) |
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
@@ -320,19 +355,22 @@ curl -X PATCH http://coder-server:8080/api/v2/users/{user}/secrets/{name} \
|
||||
"description": "string",
|
||||
"enabled": true,
|
||||
"env_name": "string",
|
||||
"env_replaced_by": "36faa5cb-cfc3-47a0-9c3e-df076ae8c261",
|
||||
"file_path": "string",
|
||||
"file_replaced_by": "1c5fa235-bacf-4250-9f02-952c83136140",
|
||||
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
|
||||
"name": "string",
|
||||
"source": "user",
|
||||
"updated_at": "2019-08-24T14:15:22Z"
|
||||
}
|
||||
```
|
||||
|
||||
### Responses
|
||||
|
||||
| Status | Meaning | Description | Schema |
|
||||
|--------|------------------------------------------------------------------|-------------|------------------------------------------------------|
|
||||
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.UserSecret](schemas.md#codersdkusersecret) |
|
||||
| 400 | [Bad Request](https://tools.ietf.org/html/rfc7231#section-6.5.1) | Bad Request | [codersdk.Response](schemas.md#codersdkresponse) |
|
||||
| 409 | [Conflict](https://tools.ietf.org/html/rfc7231#section-6.5.8) | Conflict | [codersdk.Response](schemas.md#codersdkresponse) |
|
||||
| Status | Meaning | Description | Schema |
|
||||
|--------|------------------------------------------------------------------|-------------|----------------------------------------------------------------|
|
||||
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.WorkspaceSecret](schemas.md#codersdkworkspacesecret) |
|
||||
| 400 | [Bad Request](https://tools.ietf.org/html/rfc7231#section-6.5.1) | Bad Request | [codersdk.Response](schemas.md#codersdkresponse) |
|
||||
| 409 | [Conflict](https://tools.ietf.org/html/rfc7231#section-6.5.8) | Conflict | [codersdk.Response](schemas.md#codersdkresponse) |
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
Generated
+22
-4
@@ -22,16 +22,34 @@ coder secret list [flags] [name]
|
||||
|
||||
```console
|
||||
Secret values are omitted from the output.
|
||||
|
||||
When listing all secrets inside a workspace, or with --workspace or --build-id, the workspace build's secrets are included and the "replaced by" column shows user secrets whose env var or file is taken by a workspace secret.
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
### --workspace
|
||||
|
||||
| | |
|
||||
|------|---------------------|
|
||||
| Type | <code>string</code> |
|
||||
|
||||
Include the secrets of this workspace's latest build. Defaults to the current workspace when run inside one.
|
||||
|
||||
### --build-id
|
||||
|
||||
| | |
|
||||
|------|---------------------|
|
||||
| Type | <code>string</code> |
|
||||
|
||||
Include the secrets of this workspace build. Takes precedence over --workspace.
|
||||
|
||||
### -c, --column
|
||||
|
||||
| | |
|
||||
|---------|------------------------------------------------------------------------|
|
||||
| Type | <code>[created\|name\|updated\|env\|file\|enabled\|description]</code> |
|
||||
| Default | <code>name,created,updated,env,file,enabled,description</code> |
|
||||
| | |
|
||||
|---------|---------------------------------------------------------------------------------------------|
|
||||
| Type | <code>[created\|name\|updated\|source\|env\|file\|enabled\|replaced by\|description]</code> |
|
||||
| Default | <code>name,source,created,updated,env,file,enabled,replaced by,description</code> |
|
||||
|
||||
Columns to display in table output.
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Generated
+58
-2
@@ -11721,8 +11721,8 @@ export interface UserRoles {
|
||||
|
||||
// From codersdk/usersecrets.go
|
||||
/**
|
||||
* UserSecret represents a user secret's metadata. The secret value
|
||||
* is never included in API responses.
|
||||
* UserSecret is the previous name of WorkspaceSecret, kept for backwards
|
||||
* compatibility.
|
||||
*/
|
||||
export interface UserSecret {
|
||||
readonly id: string;
|
||||
@@ -11739,6 +11739,19 @@ export interface UserSecret {
|
||||
readonly enabled: boolean;
|
||||
readonly created_at: string;
|
||||
readonly updated_at: string;
|
||||
readonly source: WorkspaceSecretSource;
|
||||
/**
|
||||
* EnvReplacedBy is the ID of the secret delivered on this secret's
|
||||
* env_name instead of it. Only set when listing secrets for a
|
||||
* workspace build.
|
||||
*/
|
||||
readonly env_replaced_by?: string;
|
||||
/**
|
||||
* FileReplacedBy is the ID of the secret delivered on this secret's
|
||||
* file_path instead of it. Only set when listing secrets for a
|
||||
* workspace build.
|
||||
*/
|
||||
readonly file_replaced_by?: string;
|
||||
}
|
||||
|
||||
// From codersdk/usersecretvalidation.go
|
||||
@@ -12773,6 +12786,41 @@ export type WorkspaceRole = "admin" | "" | "use";
|
||||
|
||||
export const WorkspaceRoles: WorkspaceRole[] = ["admin", "", "use"];
|
||||
|
||||
// From codersdk/usersecrets.go
|
||||
/**
|
||||
* WorkspaceSecret represents a secret's metadata. The secret value is never
|
||||
* included in API responses.
|
||||
*/
|
||||
export interface WorkspaceSecret {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
readonly env_name: string;
|
||||
readonly file_path: string;
|
||||
/**
|
||||
* Enabled controls whether the secret is injected into workspaces.
|
||||
* Disabled secrets remain visible and editable, but are not added
|
||||
* to the agent manifest, so they are not exposed as environment
|
||||
* variables or written to secret files.
|
||||
*/
|
||||
readonly enabled: boolean;
|
||||
readonly created_at: string;
|
||||
readonly updated_at: string;
|
||||
readonly source: WorkspaceSecretSource;
|
||||
/**
|
||||
* EnvReplacedBy is the ID of the secret delivered on this secret's
|
||||
* env_name instead of it. Only set when listing secrets for a
|
||||
* workspace build.
|
||||
*/
|
||||
readonly env_replaced_by?: string;
|
||||
/**
|
||||
* FileReplacedBy is the ID of the secret delivered on this secret's
|
||||
* file_path instead of it. Only set when listing secrets for a
|
||||
* workspace build.
|
||||
*/
|
||||
readonly file_replaced_by?: string;
|
||||
}
|
||||
|
||||
// From codersdk/workspacesecrets.go
|
||||
/**
|
||||
* WorkspaceSecretInjectionTargetRequiredDetail explains that a workspace
|
||||
@@ -12832,6 +12880,14 @@ export interface WorkspaceSecretInput {
|
||||
export const WorkspaceSecretRemovalWithFieldsDetail =
|
||||
"A null value removes the secret, so env_name, file_path, and ephemeral must not be set. Set value to set the secret."; //nolint:gosec // G101: message text, not a hardcoded credential.
|
||||
|
||||
// From codersdk/usersecrets.go
|
||||
export type WorkspaceSecretSource = "build" | "user";
|
||||
|
||||
export const WorkspaceSecretSources: WorkspaceSecretSource[] = [
|
||||
"build",
|
||||
"user",
|
||||
];
|
||||
|
||||
// From codersdk/workspacesharing.go
|
||||
/**
|
||||
* WorkspaceSharingSettings represents workspace sharing settings affecting an
|
||||
|
||||
@@ -89,6 +89,7 @@ const createSecretFromRequest = (
|
||||
enabled: request.enabled ?? true,
|
||||
created_at: "2026-05-04T00:00:00Z",
|
||||
updated_at: "2026-05-04T00:00:00Z",
|
||||
source: "user",
|
||||
});
|
||||
|
||||
const findVisibleSecretByName = (name: string): UserSecret => {
|
||||
|
||||
@@ -20,6 +20,7 @@ const existingSecrets: UserSecret[] = [
|
||||
enabled: true,
|
||||
created_at: "2026-05-04T00:00:00Z",
|
||||
updated_at: "2026-05-04T00:00:00Z",
|
||||
source: "user",
|
||||
},
|
||||
{
|
||||
id: "22222222-2222-2222-2222-222222222222",
|
||||
@@ -30,6 +31,7 @@ const existingSecrets: UserSecret[] = [
|
||||
enabled: true,
|
||||
created_at: "2026-05-04T00:00:00Z",
|
||||
updated_at: "2026-05-04T00:00:00Z",
|
||||
source: "user",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -476,6 +476,7 @@ function userSecretFromCreateRequest(
|
||||
enabled: request.enabled ?? true,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
source: "user",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user