mirror of
https://github.com/coder/coder.git
synced 2026-09-01 14:53:15 +08:00
feat: add enable/disable support for user secrets (#27537)
Users can now disable a secret to stop it from being injected into workspaces without deleting it, and re-enable it later. Disabled secrets stay visible and editable everywhere they already appear. An enabled secret must have at least one injection target; a secret with no target can be stored only while disabled. Existing target-less secrets are migrated to disabled to preserve current behavior. Support spans the REST API, SDK, CLI, dashboard, and audit log.
This commit is contained in:
+104
-4
@@ -3,6 +3,7 @@ package cli
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/cli/cliui"
|
||||
"github.com/coder/coder/v2/coderd/util/ptr"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/pretty"
|
||||
"github.com/coder/serpent"
|
||||
@@ -48,6 +50,8 @@ func (r *RootCmd) secrets() *serpent.Command {
|
||||
Children: []*serpent.Command{
|
||||
r.secretCreate(),
|
||||
r.secretUpdate(),
|
||||
r.secretEnable(),
|
||||
r.secretDisable(),
|
||||
r.secretList(),
|
||||
r.secretDelete(),
|
||||
},
|
||||
@@ -62,6 +66,7 @@ func (r *RootCmd) secretCreate() *serpent.Command {
|
||||
description string
|
||||
env string
|
||||
file string
|
||||
enabled bool
|
||||
)
|
||||
|
||||
cmd := &serpent.Command{
|
||||
@@ -96,6 +101,13 @@ func (r *RootCmd) secretCreate() *serpent.Command {
|
||||
Description: "Workspace file path where this secret will be written. Must start with ~/ or /.",
|
||||
Value: serpent.StringOf(&file),
|
||||
},
|
||||
{
|
||||
Name: "enabled",
|
||||
Flag: "enabled",
|
||||
Description: "Whether the secret is injected into workspaces. An enabled secret must set --env or --file; pass --enabled=false to store a secret without injecting it.",
|
||||
Default: "true",
|
||||
Value: serpent.BoolOf(&enabled),
|
||||
},
|
||||
},
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
client, err := r.InitClient(inv)
|
||||
@@ -114,13 +126,18 @@ func (r *RootCmd) secretCreate() *serpent.Command {
|
||||
return xerrors.New("secret value must be provided by exactly one of --value or non-interactive stdin (pipe or redirect)")
|
||||
}
|
||||
|
||||
secret, err := client.CreateUserSecret(inv.Context(), codersdk.Me, codersdk.CreateUserSecretRequest{
|
||||
req := codersdk.CreateUserSecretRequest{
|
||||
Name: inv.Args[0],
|
||||
Value: resolvedValue,
|
||||
Description: description,
|
||||
EnvName: env,
|
||||
FilePath: file,
|
||||
})
|
||||
}
|
||||
if userSetOption(inv, "enabled") {
|
||||
req.Enabled = ptr.Ref(enabled)
|
||||
}
|
||||
|
||||
secret, err := client.CreateUserSecret(inv.Context(), codersdk.Me, req)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create secret %q: %w", inv.Args[0], err)
|
||||
}
|
||||
@@ -139,13 +156,14 @@ func (r *RootCmd) secretUpdate() *serpent.Command {
|
||||
description string
|
||||
env string
|
||||
file string
|
||||
enabled bool
|
||||
)
|
||||
|
||||
cmd := &serpent.Command{
|
||||
Use: "update <name>",
|
||||
Short: "Update a secret",
|
||||
Long: strings.Join([]string{
|
||||
"At least one of --value, --description, --env, or --file must be specified.",
|
||||
"At least one of --value, --description, --env, --file, or --enabled must be specified.",
|
||||
"Provide the secret value by at most one of --value or non-interactive stdin (pipe or redirect).",
|
||||
}, " "),
|
||||
Middleware: serpent.Chain(
|
||||
@@ -176,6 +194,12 @@ func (r *RootCmd) secretUpdate() *serpent.Command {
|
||||
Description: "Workspace file path where this secret will be written. Must start with ~/ or /. Pass an empty string to clear it.",
|
||||
Value: serpent.StringOf(&file),
|
||||
},
|
||||
{
|
||||
Name: "enabled",
|
||||
Flag: "enabled",
|
||||
Description: "Whether the secret is injected into workspaces. An enabled secret must keep at least one of --env or --file; pass --enabled=false to stop injecting it without deleting it.",
|
||||
Value: serpent.BoolOf(&enabled),
|
||||
},
|
||||
},
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
client, err := r.InitClient(inv)
|
||||
@@ -200,6 +224,9 @@ func (r *RootCmd) secretUpdate() *serpent.Command {
|
||||
if userSetOption(inv, "file") {
|
||||
req.FilePath = &file
|
||||
}
|
||||
if userSetOption(inv, "enabled") {
|
||||
req.Enabled = ptr.Ref(enabled)
|
||||
}
|
||||
|
||||
secret, err := client.UpdateUserSecret(inv.Context(), codersdk.Me, inv.Args[0], req)
|
||||
if err != nil {
|
||||
@@ -295,6 +322,7 @@ type secretListRow struct {
|
||||
Updated string `json:"-" table:"updated"`
|
||||
Env string `json:"-" table:"env"`
|
||||
File string `json:"-" table:"file"`
|
||||
Enabled string `json:"-" table:"enabled"`
|
||||
Description string `json:"-" table:"description"`
|
||||
}
|
||||
|
||||
@@ -306,16 +334,88 @@ func secretListRowFromSecret(secret codersdk.UserSecret) secretListRow {
|
||||
Updated: humanize.Time(secret.UpdatedAt),
|
||||
Env: secret.EnvName,
|
||||
File: secret.FilePath,
|
||||
Enabled: strconv.FormatBool(secret.Enabled),
|
||||
Description: secret.Description,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *RootCmd) secretEnable() *serpent.Command {
|
||||
return r.secretEnabledSetter(secretEnabledStateEnabled)
|
||||
}
|
||||
|
||||
func (r *RootCmd) secretDisable() *serpent.Command {
|
||||
return r.secretEnabledSetter(secretEnabledStateDisabled)
|
||||
}
|
||||
|
||||
// secretEnabledState distinguishes the two `coder secret enable` and
|
||||
// `coder secret disable` subcommands without using a bare bool, which
|
||||
// revive's flag-parameter rule treats as a control coupling.
|
||||
type secretEnabledState int
|
||||
|
||||
const (
|
||||
secretEnabledStateEnabled secretEnabledState = iota
|
||||
secretEnabledStateDisabled
|
||||
)
|
||||
|
||||
// secretEnabledSetter builds the `coder secret enable` and `coder secret
|
||||
// disable` subcommands. Both are a one-field PATCH that flips the enabled
|
||||
// state. Disabling stops injection for new sessions but leaves the secret
|
||||
// in place so it can be re-enabled later; existing sessions keep injected
|
||||
// values until the workspace's agent manifest is refetched.
|
||||
func (r *RootCmd) secretEnabledSetter(state secretEnabledState) *serpent.Command {
|
||||
var (
|
||||
verb string
|
||||
participle string
|
||||
short string
|
||||
enabled bool
|
||||
)
|
||||
switch state {
|
||||
case secretEnabledStateEnabled:
|
||||
verb = "enable"
|
||||
participle = "Enabled"
|
||||
short = "Enable a secret so it is injected into workspaces"
|
||||
enabled = true
|
||||
case secretEnabledStateDisabled:
|
||||
verb = "disable"
|
||||
participle = "Disabled"
|
||||
short = "Disable a secret without removing it"
|
||||
enabled = false
|
||||
}
|
||||
|
||||
cmd := &serpent.Command{
|
||||
Use: fmt.Sprintf("%s <name>", verb),
|
||||
Short: short,
|
||||
Middleware: serpent.Chain(
|
||||
serpent.RequireNArgs(1),
|
||||
),
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
client, err := r.InitClient(inv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
name := inv.Args[0]
|
||||
secret, err := client.UpdateUserSecret(inv.Context(), codersdk.Me, name, codersdk.UpdateUserSecretRequest{
|
||||
Enabled: ptr.Ref(enabled),
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("%s secret %q: %w", verb, name, err)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "%s secret %s.\n", participle, cliui.Keyword(secret.Name))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (r *RootCmd) secretList() *serpent.Command {
|
||||
formatter := cliui.NewOutputFormatter(
|
||||
cliui.ChangeFormatterData(
|
||||
cliui.TableFormat(
|
||||
[]secretListRow{},
|
||||
[]string{"name", "created", "updated", "env", "file", "description"},
|
||||
[]string{"name", "created", "updated", "env", "file", "enabled", "description"},
|
||||
),
|
||||
func(data any) (any, error) {
|
||||
switch rows := data.(type) {
|
||||
|
||||
+140
-14
@@ -195,8 +195,9 @@ func TestSecretUpdate(t *testing.T) {
|
||||
|
||||
setupCtx := testutil.Context(t, testutil.WaitMedium)
|
||||
_, err := client.CreateUserSecret(setupCtx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
||||
Name: "my-secret",
|
||||
Value: "original-value",
|
||||
Name: "my-secret",
|
||||
Value: "original-value",
|
||||
EnvName: "MY_SECRET",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -224,6 +225,10 @@ func TestSecretUpdate(t *testing.T) {
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Clearing env_name and description while leaving file_path
|
||||
// keeps the secret well-formed (still has an injection
|
||||
// target). Trying to clear both env_name and file_path is
|
||||
// covered by the server-side test below.
|
||||
inv, root := clitest.New(
|
||||
t,
|
||||
"secret",
|
||||
@@ -232,7 +237,6 @@ func TestSecretUpdate(t *testing.T) {
|
||||
"--value", "rotated-secret",
|
||||
"--description", "",
|
||||
"--env", "",
|
||||
"--file", "",
|
||||
)
|
||||
output := clitest.Capture(inv)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
@@ -246,7 +250,41 @@ func TestSecretUpdate(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "", secret.Description)
|
||||
require.Equal(t, "", secret.EnvName)
|
||||
require.Equal(t, "", secret.FilePath)
|
||||
require.Equal(t, "~/.my-secret", secret.FilePath)
|
||||
})
|
||||
|
||||
t.Run("ClearingBothTargetsRejected", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, nil)
|
||||
_ = coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
setupCtx := testutil.Context(t, testutil.WaitMedium)
|
||||
_, err := client.CreateUserSecret(setupCtx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
||||
Name: "my-secret",
|
||||
Value: "original-value",
|
||||
EnvName: "MY_SECRET",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
inv, root := clitest.New(
|
||||
t,
|
||||
"secret",
|
||||
"update",
|
||||
"my-secret",
|
||||
"--env", "",
|
||||
)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
err = inv.WithContext(ctx).Run()
|
||||
require.Error(t, err)
|
||||
var sdkErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
|
||||
require.Len(t, sdkErr.Validations, 1)
|
||||
require.Equal(t, "env_name", sdkErr.Validations[0].Field)
|
||||
require.Contains(t, sdkErr.Validations[0].Detail, "at least one of env_name or file_path")
|
||||
})
|
||||
|
||||
t.Run("UpdatesValueFromEmptyFlag", func(t *testing.T) {
|
||||
@@ -257,8 +295,9 @@ func TestSecretUpdate(t *testing.T) {
|
||||
|
||||
setupCtx := testutil.Context(t, testutil.WaitMedium)
|
||||
_, err := client.CreateUserSecret(setupCtx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
||||
Name: "my-secret",
|
||||
Value: "original-value",
|
||||
Name: "my-secret",
|
||||
Value: "original-value",
|
||||
EnvName: "MY_SECRET",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -286,8 +325,9 @@ func TestSecretUpdate(t *testing.T) {
|
||||
|
||||
setupCtx := testutil.Context(t, testutil.WaitMedium)
|
||||
_, err := client.CreateUserSecret(setupCtx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
||||
Name: "my-secret",
|
||||
Value: "original-value",
|
||||
Name: "my-secret",
|
||||
Value: "original-value",
|
||||
EnvName: "MY_SECRET",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -310,8 +350,9 @@ func TestSecretUpdate(t *testing.T) {
|
||||
|
||||
setupCtx := testutil.Context(t, testutil.WaitMedium)
|
||||
_, err := client.CreateUserSecret(setupCtx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
||||
Name: "my-secret",
|
||||
Value: "original-value",
|
||||
Name: "my-secret",
|
||||
Value: "original-value",
|
||||
EnvName: "MY_SECRET",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -507,8 +548,9 @@ func TestSecretDelete(t *testing.T) {
|
||||
|
||||
setupCtx := testutil.Context(t, testutil.WaitMedium)
|
||||
_, err := client.CreateUserSecret(setupCtx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
||||
Name: "service-token",
|
||||
Value: "service-token-value",
|
||||
Name: "service-token",
|
||||
Value: "service-token-value",
|
||||
EnvName: "SERVICE_TOKEN",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -542,8 +584,9 @@ func TestSecretDelete(t *testing.T) {
|
||||
|
||||
setupCtx := testutil.Context(t, testutil.WaitMedium)
|
||||
_, err := client.CreateUserSecret(setupCtx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
||||
Name: "service-token",
|
||||
Value: "service-token-value",
|
||||
Name: "service-token",
|
||||
Value: "service-token-value",
|
||||
EnvName: "SERVICE_TOKEN",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -591,3 +634,86 @@ func TestSecretDelete(t *testing.T) {
|
||||
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
|
||||
})
|
||||
}
|
||||
|
||||
func TestSecretEnableDisable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("Disable", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, nil)
|
||||
_ = coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
setupCtx := testutil.Context(t, testutil.WaitMedium)
|
||||
created, err := client.CreateUserSecret(setupCtx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
||||
Name: "service-token",
|
||||
Value: "service-token-value",
|
||||
EnvName: "SERVICE_TOKEN",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, created.Enabled)
|
||||
|
||||
inv, root := clitest.New(t, "secret", "disable", "service-token")
|
||||
output := clitest.Capture(inv)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
err = inv.WithContext(ctx).Run()
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, output.Stdout(), "Disabled secret")
|
||||
require.Contains(t, output.Stdout(), "service-token")
|
||||
|
||||
got, err := client.UserSecretByName(setupCtx, codersdk.Me, "service-token")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, got.Enabled)
|
||||
})
|
||||
|
||||
t.Run("Enable", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, nil)
|
||||
_ = coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
setupCtx := testutil.Context(t, testutil.WaitMedium)
|
||||
disabled := false
|
||||
created, err := client.CreateUserSecret(setupCtx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
||||
Name: "service-token",
|
||||
Value: "service-token-value",
|
||||
EnvName: "SERVICE_TOKEN",
|
||||
Enabled: &disabled,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.False(t, created.Enabled)
|
||||
|
||||
inv, root := clitest.New(t, "secret", "enable", "service-token")
|
||||
output := clitest.Capture(inv)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
err = inv.WithContext(ctx).Run()
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, output.Stdout(), "Enabled secret")
|
||||
require.Contains(t, output.Stdout(), "service-token")
|
||||
|
||||
got, err := client.UserSecretByName(setupCtx, codersdk.Me, "service-token")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, got.Enabled)
|
||||
})
|
||||
|
||||
t.Run("NotFound", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, nil)
|
||||
_ = coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
inv, root := clitest.New(t, "secret", "disable", "missing-secret")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
err := inv.WithContext(ctx).Run()
|
||||
require.Error(t, err)
|
||||
var sdkErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
|
||||
})
|
||||
}
|
||||
|
||||
+6
-4
@@ -30,10 +30,12 @@ USAGE:
|
||||
$ coder secret delete api-key
|
||||
|
||||
SUBCOMMANDS:
|
||||
create Create a secret
|
||||
delete Delete a secret
|
||||
list List secrets, or show one by name
|
||||
update Update a secret
|
||||
create Create a secret
|
||||
delete Delete a secret
|
||||
disable Disable a secret without removing it
|
||||
enable Enable a secret so it is injected into workspaces
|
||||
list List secrets, or show one by name
|
||||
update Update a secret
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
|
||||
@@ -12,6 +12,11 @@ OPTIONS:
|
||||
--description string
|
||||
Set the secret description.
|
||||
|
||||
--enabled bool (default: true)
|
||||
Whether the secret is injected into workspaces. An enabled secret must
|
||||
set --env or --file; pass --enabled=false to store a secret without
|
||||
injecting it.
|
||||
|
||||
--env string
|
||||
Name of the workspace environment variable that this secret will set.
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
coder v0.0.0-devel
|
||||
|
||||
USAGE:
|
||||
coder secret disable <name>
|
||||
|
||||
Disable a secret without removing it
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
@@ -0,0 +1,9 @@
|
||||
coder v0.0.0-devel
|
||||
|
||||
USAGE:
|
||||
coder secret enable <name>
|
||||
|
||||
Enable a secret so it is injected into workspaces
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
+1
-1
@@ -10,7 +10,7 @@ USAGE:
|
||||
Secret values are omitted from the output.
|
||||
|
||||
OPTIONS:
|
||||
-c, --column [created|name|updated|env|file|description] (default: name,created,updated,env,file,description)
|
||||
-c, --column [created|name|updated|env|file|enabled|description] (default: name,created,updated,env,file,enabled,description)
|
||||
Columns to display in table output.
|
||||
|
||||
-o, --output table|json (default: table)
|
||||
|
||||
+8
-3
@@ -5,14 +5,19 @@ USAGE:
|
||||
|
||||
Update a secret
|
||||
|
||||
At least one of --value, --description, --env, or --file must be specified.
|
||||
Provide the secret value by at most one of --value or non-interactive stdin
|
||||
(pipe or redirect).
|
||||
At least one of --value, --description, --env, --file, or --enabled must be
|
||||
specified. Provide the secret value by at most one of --value or
|
||||
non-interactive stdin (pipe or redirect).
|
||||
|
||||
OPTIONS:
|
||||
--description string
|
||||
Update the secret description. Pass an empty string to clear it.
|
||||
|
||||
--enabled bool
|
||||
Whether the secret is injected into workspaces. An enabled secret must
|
||||
keep at least one of --env or --file; pass --enabled=false to stop
|
||||
injecting it without deleting it.
|
||||
|
||||
--env string
|
||||
Name of the workspace environment variable that this secret will set.
|
||||
Pass an empty string to clear it.
|
||||
|
||||
@@ -278,10 +278,11 @@ func dbAgentDevcontainersToProto(devcontainers []database.WorkspaceAgentDevconta
|
||||
func dbUserSecretsToProto(secrets []database.UserSecret) []*agentproto.WorkspaceSecret {
|
||||
ret := make([]*agentproto.WorkspaceSecret, 0, len(secrets))
|
||||
for _, s := range secrets {
|
||||
// Only include secrets that have an environment variable
|
||||
// name or file path set. Secrets with neither are not
|
||||
// injected at runtime.
|
||||
if s.EnvName == "" && s.FilePath == "" {
|
||||
// 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 = append(ret, &agentproto.WorkspaceSecret{
|
||||
|
||||
@@ -468,19 +468,20 @@ func TestGetManifest(t *testing.T) {
|
||||
mDB.EXPECT().GetWorkspaceByID(gomock.Any(), workspace.ID).Return(workspace, nil)
|
||||
|
||||
// Return a mix of secrets: env-only, file-only, both, and
|
||||
// one with neither set. The last should be filtered out.
|
||||
// one explicitly disabled. The disabled secret should be
|
||||
// filtered out.
|
||||
mDB.EXPECT().ListUserSecretsWithValues(gomock.Any(), workspace.OwnerID).Return([]database.UserSecret{
|
||||
{EnvName: "GITHUB_TOKEN", FilePath: "", Value: "ghp_xxxx"},
|
||||
{EnvName: "", FilePath: "~/.ssh/id_rsa", Value: "private-key"},
|
||||
{EnvName: "BOTH_ENV", FilePath: "/etc/both", Value: "both-val"},
|
||||
{EnvName: "", FilePath: "", Value: "stored-only"},
|
||||
{EnvName: "GITHUB_TOKEN", FilePath: "", Value: "ghp_xxxx", Enabled: true},
|
||||
{EnvName: "", FilePath: "~/.ssh/id_rsa", Value: "private-key", Enabled: true},
|
||||
{EnvName: "BOTH_ENV", FilePath: "/etc/both", Value: "both-val", Enabled: true},
|
||||
{EnvName: "DISABLED_ENV", FilePath: "", Value: "disabled-val", Enabled: false},
|
||||
}, nil)
|
||||
|
||||
got, err := api.GetManifest(context.Background(), &agentproto.GetManifestRequest{})
|
||||
require.NoError(t, err)
|
||||
|
||||
// The secret with neither env_name nor file_path should
|
||||
// be filtered out, leaving exactly 3.
|
||||
// The disabled secret should be filtered out, leaving
|
||||
// exactly 3.
|
||||
require.Len(t, got.Secrets, 3)
|
||||
require.Equal(t, "GITHUB_TOKEN", got.Secrets[0].EnvName)
|
||||
require.Equal(t, "", got.Secrets[0].FilePath)
|
||||
|
||||
Generated
+10
@@ -19269,6 +19269,9 @@ const docTemplate = `{
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"env_name": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -26044,6 +26047,9 @@ const docTemplate = `{
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"env_name": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -26652,6 +26658,10 @@ const docTemplate = `{
|
||||
"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"
|
||||
},
|
||||
|
||||
Generated
+10
@@ -17436,6 +17436,9 @@
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"env_name": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -23938,6 +23941,9 @@
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"env_name": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -24519,6 +24525,10 @@
|
||||
"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"
|
||||
},
|
||||
|
||||
Generated
+1
@@ -56,6 +56,7 @@ const (
|
||||
CheckUsageEventTypeCheck CheckConstraint = "usage_event_type_check" // usage_events
|
||||
CheckUserAIBudgetOverridesSpendLimitMicrosCheck CheckConstraint = "user_ai_budget_overrides_spend_limit_micros_check" // user_ai_budget_overrides
|
||||
CheckUserAIProviderKeysAPIKeyCheck CheckConstraint = "user_ai_provider_keys_api_key_check" // user_ai_provider_keys
|
||||
CheckUserSecretsEnabledRequiresTarget CheckConstraint = "user_secrets_enabled_requires_target" // user_secrets
|
||||
CheckUserSkillsContentSize CheckConstraint = "user_skills_content_size" // user_skills
|
||||
CheckUserSkillsDescriptionSize CheckConstraint = "user_skills_description_size" // user_skills
|
||||
CheckUserSkillsNameFormat CheckConstraint = "user_skills_name_format" // user_skills
|
||||
|
||||
@@ -2098,6 +2098,7 @@ func UserSecret(secret database.ListUserSecretsRow) codersdk.UserSecret {
|
||||
Description: secret.Description,
|
||||
EnvName: secret.EnvName,
|
||||
FilePath: secret.FilePath,
|
||||
Enabled: secret.Enabled,
|
||||
CreatedAt: secret.CreatedAt,
|
||||
UpdatedAt: secret.UpdatedAt,
|
||||
}
|
||||
@@ -2112,6 +2113,7 @@ func UserSecretFromFull(secret database.UserSecret) codersdk.UserSecret {
|
||||
Description: secret.Description,
|
||||
EnvName: secret.EnvName,
|
||||
FilePath: secret.FilePath,
|
||||
Enabled: secret.Enabled,
|
||||
CreatedAt: secret.CreatedAt,
|
||||
UpdatedAt: secret.UpdatedAt,
|
||||
}
|
||||
|
||||
@@ -1962,6 +1962,7 @@ func UserSecret(t testing.TB, db database.Store, seed database.UserSecret, mutat
|
||||
ValueKeyID: seed.ValueKeyID,
|
||||
EnvName: takeFirst(seed.EnvName, "SECRET_ENV_NAME"),
|
||||
FilePath: takeFirst(seed.FilePath, "~/secret/file/path"),
|
||||
Enabled: takeFirst(seed.Enabled, true),
|
||||
}
|
||||
for _, mut := range mutators {
|
||||
mut(¶ms)
|
||||
|
||||
Generated
+3
-1
@@ -3624,7 +3624,9 @@ CREATE TABLE user_secrets (
|
||||
file_path text DEFAULT ''::text NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
value_key_id text
|
||||
value_key_id text,
|
||||
enabled boolean DEFAULT true NOT NULL,
|
||||
CONSTRAINT user_secrets_enabled_requires_target CHECK (((NOT enabled) OR (env_name <> ''::text) OR (file_path <> ''::text)))
|
||||
);
|
||||
|
||||
CREATE TABLE user_skills (
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE user_secrets
|
||||
DROP COLUMN enabled;
|
||||
@@ -0,0 +1,30 @@
|
||||
-- Add an explicit enabled flag to user_secrets.
|
||||
--
|
||||
-- A disabled secret stays visible and editable in the management UI, CLI,
|
||||
-- and API, but is not injected into workspaces and does not satisfy any
|
||||
-- "secret present" predicate. This is the single source of truth for
|
||||
-- "not injected"; the agent manifest layer no longer skips rows based
|
||||
-- on having both env_name and file_path empty.
|
||||
--
|
||||
-- Existing rows whose env_name and file_path are both empty are flipped
|
||||
-- to enabled = false. Today those rows are silently skipped during agent
|
||||
-- manifest assembly, so flipping them preserves observable behavior
|
||||
-- while letting the manifest stop encoding the both-empty special case.
|
||||
ALTER TABLE user_secrets
|
||||
ADD COLUMN enabled BOOLEAN NOT NULL DEFAULT true;
|
||||
|
||||
UPDATE user_secrets
|
||||
SET enabled = false
|
||||
WHERE env_name = '' AND file_path = '';
|
||||
|
||||
-- Enforce the injection-target invariant in the database: an enabled
|
||||
-- secret must have at least one of env_name / file_path non-empty.
|
||||
-- Disabled secrets may have no targets (bulk imports use that state for
|
||||
-- keys that cannot be env-injected). The API also checks this on write,
|
||||
-- but the constraint is the source of truth: it closes a read-modify-write
|
||||
-- race where two concurrent PATCHes each clear a different target, both
|
||||
-- pass the API's post-state check, and serialize to an enabled row with
|
||||
-- no targets.
|
||||
ALTER TABLE user_secrets
|
||||
ADD CONSTRAINT user_secrets_enabled_requires_target
|
||||
CHECK (NOT enabled OR env_name <> '' OR file_path <> '');
|
||||
@@ -2251,3 +2251,100 @@ func TestMigration000543ChatSearchSchemaBehavior(t *testing.T) {
|
||||
"search must exclude deleted, model-only, and tool-role rows (%d %d %d)",
|
||||
toolMsg.ID, modelOnly.ID, deletedMsg.ID)
|
||||
}
|
||||
|
||||
func TestMigration000556UserSecretsEnabled(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const migrationVersion = 556
|
||||
|
||||
sqlDB := testSQLDB(t)
|
||||
|
||||
// Migrate up to the migration before the one that adds the enabled
|
||||
// column.
|
||||
next, err := migrations.Stepper(sqlDB)
|
||||
require.NoError(t, err)
|
||||
for {
|
||||
version, more, err := next()
|
||||
require.NoError(t, err)
|
||||
if !more {
|
||||
t.Fatalf("migration %d not found", migrationVersion)
|
||||
}
|
||||
if version == migrationVersion-1 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitSuperLong)
|
||||
|
||||
userID := uuid.New()
|
||||
envSecretID := uuid.New()
|
||||
fileSecretID := uuid.New()
|
||||
bothEmptySecretID := uuid.New()
|
||||
|
||||
now := time.Now().UTC().Truncate(time.Microsecond)
|
||||
|
||||
tx, err := sqlDB.BeginTx(ctx, nil)
|
||||
require.NoError(t, err)
|
||||
defer tx.Rollback()
|
||||
|
||||
fixtures := []struct {
|
||||
query string
|
||||
args []any
|
||||
}{
|
||||
{
|
||||
`INSERT INTO users (id, username, email, hashed_password, created_at, updated_at, status, rbac_roles, login_type)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||
[]any{userID, "user-secrets-enabled", "user-secrets-enabled@test.com", []byte{}, now, now, "active", pq.StringArray{}, "password"},
|
||||
},
|
||||
// env-only secret: should remain enabled after migration.
|
||||
{
|
||||
`INSERT INTO user_secrets (id, user_id, name, description, value, env_name, file_path, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||
[]any{envSecretID, userID, "env-secret", "", "v1", "ENV_SECRET", "", now, now},
|
||||
},
|
||||
// file-only secret: should remain enabled after migration.
|
||||
{
|
||||
`INSERT INTO user_secrets (id, user_id, name, description, value, env_name, file_path, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||
[]any{fileSecretID, userID, "file-secret", "", "v2", "", "/tmp/file-secret", now, now},
|
||||
},
|
||||
// Both env_name and file_path empty: silently skipped today by
|
||||
// the agent manifest layer. Should be flipped to enabled=false
|
||||
// by the migration so the behavior is preserved exactly under
|
||||
// the new "always inject when enabled" rule.
|
||||
{
|
||||
`INSERT INTO user_secrets (id, user_id, name, description, value, env_name, file_path, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||
[]any{bothEmptySecretID, userID, "both-empty", "", "v3", "", "", now, now},
|
||||
},
|
||||
}
|
||||
|
||||
for i, f := range fixtures {
|
||||
_, err := tx.ExecContext(ctx, f.query, f.args...)
|
||||
require.NoError(t, err, "fixture %d", i)
|
||||
}
|
||||
require.NoError(t, tx.Commit())
|
||||
|
||||
// Run the migration.
|
||||
version, _, err := next()
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, migrationVersion, version)
|
||||
|
||||
getEnabled := func(t *testing.T, id uuid.UUID) bool {
|
||||
t.Helper()
|
||||
var enabled bool
|
||||
err := sqlDB.QueryRowContext(ctx,
|
||||
"SELECT enabled FROM user_secrets WHERE id = $1", id,
|
||||
).Scan(&enabled)
|
||||
require.NoError(t, err)
|
||||
return enabled
|
||||
}
|
||||
|
||||
require.True(t, getEnabled(t, envSecretID),
|
||||
"env-only secret should remain enabled")
|
||||
require.True(t, getEnabled(t, fileSecretID),
|
||||
"file-only secret should remain enabled")
|
||||
require.False(t, getEnabled(t, bothEmptySecretID),
|
||||
"secret with both targets empty should be flipped to disabled "+
|
||||
"to preserve the previous implicit-skip behavior")
|
||||
}
|
||||
|
||||
Generated
+1
@@ -6244,6 +6244,7 @@ type UserSecret struct {
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
ValueKeyID sql.NullString `db:"value_key_id" json:"value_key_id"`
|
||||
Enabled bool `db:"enabled" json:"enabled"`
|
||||
}
|
||||
|
||||
type UserSkill struct {
|
||||
|
||||
@@ -8483,7 +8483,9 @@ func TestUserSecretsCRUDOperations(t *testing.T) {
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "duplicate key value")
|
||||
|
||||
// Create secret with empty env_name and file_path (should succeed)
|
||||
// Create secret with empty env_name and file_path. A target-less
|
||||
// secret must be disabled to satisfy the
|
||||
// user_secrets_enabled_requires_target constraint.
|
||||
secret2 := dbgen.UserSecret(t, db, database.UserSecret{
|
||||
UserID: testUser.ID,
|
||||
Name: "unique-test-4",
|
||||
@@ -8491,6 +8493,8 @@ func TestUserSecretsCRUDOperations(t *testing.T) {
|
||||
Value: "value2",
|
||||
EnvName: "", // Empty env_name
|
||||
FilePath: "", // Empty file_path
|
||||
}, func(params *database.CreateUserSecretParams) {
|
||||
params.Enabled = false
|
||||
})
|
||||
|
||||
// Verify both secrets exist
|
||||
@@ -8505,6 +8509,83 @@ func TestUserSecretsCRUDOperations(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestUserSecretsEnabledRequiresTargetConstraint verifies the
|
||||
// user_secrets_enabled_requires_target CHECK constraint. It is the
|
||||
// race-safe backstop for the injection-target invariant: the API's
|
||||
// post-state check can be defeated by two concurrent PATCHes that each
|
||||
// clear a different target, so the database must reject an enabled row
|
||||
// with no target.
|
||||
func TestUserSecretsEnabledRequiresTargetConstraint(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, _ := dbtestutil.NewDB(t)
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
user := dbgen.User(t, db, database.User{})
|
||||
|
||||
// A disabled secret may have no target.
|
||||
disabled, err := db.CreateUserSecret(ctx, database.CreateUserSecretParams{
|
||||
ID: uuid.New(),
|
||||
UserID: user.ID,
|
||||
Name: "disabled-no-target",
|
||||
Value: "v",
|
||||
EnvName: "",
|
||||
FilePath: "",
|
||||
Enabled: false,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Enabling a target-less secret must be rejected by the constraint.
|
||||
_, err = db.UpdateUserSecretByUserIDAndName(ctx, database.UpdateUserSecretByUserIDAndNameParams{
|
||||
UserID: user.ID,
|
||||
Name: disabled.Name,
|
||||
UpdateEnabled: true,
|
||||
Enabled: true,
|
||||
})
|
||||
require.True(t, database.IsCheckViolation(err, database.CheckUserSecretsEnabledRequiresTarget),
|
||||
"enabling a target-less secret should violate the constraint, got: %v", err)
|
||||
|
||||
// An enabled secret with both targets set.
|
||||
enabled, err := db.CreateUserSecret(ctx, database.CreateUserSecretParams{
|
||||
ID: uuid.New(),
|
||||
UserID: user.ID,
|
||||
Name: "enabled-both",
|
||||
Value: "v",
|
||||
EnvName: "ENABLED_BOTH",
|
||||
FilePath: "~/enabled-both",
|
||||
Enabled: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Clearing both targets while the secret stays enabled (the race
|
||||
// outcome) must be rejected.
|
||||
_, err = db.UpdateUserSecretByUserIDAndName(ctx, database.UpdateUserSecretByUserIDAndNameParams{
|
||||
UserID: user.ID,
|
||||
Name: enabled.Name,
|
||||
UpdateEnvName: true,
|
||||
EnvName: "",
|
||||
UpdateFilePath: true,
|
||||
FilePath: "",
|
||||
})
|
||||
require.True(t, database.IsCheckViolation(err, database.CheckUserSecretsEnabledRequiresTarget),
|
||||
"clearing both targets of an enabled secret should violate the constraint, got: %v", err)
|
||||
|
||||
// Clearing both targets and disabling in the same update is allowed.
|
||||
updated, err := db.UpdateUserSecretByUserIDAndName(ctx, database.UpdateUserSecretByUserIDAndNameParams{
|
||||
UserID: user.ID,
|
||||
Name: enabled.Name,
|
||||
UpdateEnvName: true,
|
||||
EnvName: "",
|
||||
UpdateFilePath: true,
|
||||
FilePath: "",
|
||||
UpdateEnabled: true,
|
||||
Enabled: false,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.False(t, updated.Enabled)
|
||||
require.Empty(t, updated.EnvName)
|
||||
require.Empty(t, updated.FilePath)
|
||||
}
|
||||
|
||||
// TestUserSecretsSoftDeleteTrigger verifies that a user's secrets
|
||||
// are deleted when the user is soft-deleted.
|
||||
func TestUserSecretsSoftDeleteTrigger(t *testing.T) {
|
||||
|
||||
Generated
+27
-10
@@ -29754,7 +29754,8 @@ INSERT INTO user_secrets (
|
||||
value,
|
||||
value_key_id,
|
||||
env_name,
|
||||
file_path
|
||||
file_path,
|
||||
enabled
|
||||
) VALUES (
|
||||
$1,
|
||||
$2,
|
||||
@@ -29763,8 +29764,9 @@ INSERT INTO user_secrets (
|
||||
$5,
|
||||
$6,
|
||||
$7,
|
||||
$8
|
||||
) RETURNING id, user_id, name, description, value, env_name, file_path, created_at, updated_at, value_key_id
|
||||
$8,
|
||||
$9
|
||||
) RETURNING id, user_id, name, description, value, env_name, file_path, created_at, updated_at, value_key_id, enabled
|
||||
`
|
||||
|
||||
type CreateUserSecretParams struct {
|
||||
@@ -29776,6 +29778,7 @@ type CreateUserSecretParams struct {
|
||||
ValueKeyID sql.NullString `db:"value_key_id" json:"value_key_id"`
|
||||
EnvName string `db:"env_name" json:"env_name"`
|
||||
FilePath string `db:"file_path" json:"file_path"`
|
||||
Enabled bool `db:"enabled" json:"enabled"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) CreateUserSecret(ctx context.Context, arg CreateUserSecretParams) (UserSecret, error) {
|
||||
@@ -29788,6 +29791,7 @@ func (q *sqlQuerier) CreateUserSecret(ctx context.Context, arg CreateUserSecretP
|
||||
arg.ValueKeyID,
|
||||
arg.EnvName,
|
||||
arg.FilePath,
|
||||
arg.Enabled,
|
||||
)
|
||||
var i UserSecret
|
||||
err := row.Scan(
|
||||
@@ -29801,6 +29805,7 @@ func (q *sqlQuerier) CreateUserSecret(ctx context.Context, arg CreateUserSecretP
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.ValueKeyID,
|
||||
&i.Enabled,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -29808,7 +29813,7 @@ func (q *sqlQuerier) CreateUserSecret(ctx context.Context, arg CreateUserSecretP
|
||||
const deleteUserSecretByUserIDAndName = `-- name: DeleteUserSecretByUserIDAndName :one
|
||||
DELETE FROM user_secrets
|
||||
WHERE user_id = $1 AND name = $2
|
||||
RETURNING id, user_id, name, description, value, env_name, file_path, created_at, updated_at, value_key_id
|
||||
RETURNING id, user_id, name, description, value, env_name, file_path, created_at, updated_at, value_key_id, enabled
|
||||
`
|
||||
|
||||
type DeleteUserSecretByUserIDAndNameParams struct {
|
||||
@@ -29830,12 +29835,13 @@ func (q *sqlQuerier) DeleteUserSecretByUserIDAndName(ctx context.Context, arg De
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.ValueKeyID,
|
||||
&i.Enabled,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getUserSecretByID = `-- name: GetUserSecretByID :one
|
||||
SELECT id, user_id, name, description, value, env_name, file_path, created_at, updated_at, value_key_id
|
||||
SELECT id, user_id, name, description, value, env_name, file_path, created_at, updated_at, value_key_id, enabled
|
||||
FROM user_secrets
|
||||
WHERE id = $1
|
||||
`
|
||||
@@ -29854,12 +29860,13 @@ func (q *sqlQuerier) GetUserSecretByID(ctx context.Context, id uuid.UUID) (UserS
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.ValueKeyID,
|
||||
&i.Enabled,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getUserSecretByUserIDAndName = `-- name: GetUserSecretByUserIDAndName :one
|
||||
SELECT id, user_id, name, description, value, env_name, file_path, created_at, updated_at, value_key_id
|
||||
SELECT id, user_id, name, description, value, env_name, file_path, created_at, updated_at, value_key_id, enabled
|
||||
FROM user_secrets
|
||||
WHERE user_id = $1 AND name = $2
|
||||
`
|
||||
@@ -29883,6 +29890,7 @@ func (q *sqlQuerier) GetUserSecretByUserIDAndName(ctx context.Context, arg GetUs
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.ValueKeyID,
|
||||
&i.Enabled,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -29983,7 +29991,7 @@ func (q *sqlQuerier) GetUserSecretsTelemetrySummary(ctx context.Context) (GetUse
|
||||
const listUserSecrets = `-- name: ListUserSecrets :many
|
||||
SELECT
|
||||
id, user_id, name, description,
|
||||
env_name, file_path,
|
||||
env_name, file_path, enabled,
|
||||
created_at, updated_at
|
||||
FROM user_secrets
|
||||
WHERE user_id = $1
|
||||
@@ -29997,6 +30005,7 @@ type ListUserSecretsRow struct {
|
||||
Description string `db:"description" json:"description"`
|
||||
EnvName string `db:"env_name" json:"env_name"`
|
||||
FilePath string `db:"file_path" json:"file_path"`
|
||||
Enabled bool `db:"enabled" json:"enabled"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
@@ -30019,6 +30028,7 @@ func (q *sqlQuerier) ListUserSecrets(ctx context.Context, userID uuid.UUID) ([]L
|
||||
&i.Description,
|
||||
&i.EnvName,
|
||||
&i.FilePath,
|
||||
&i.Enabled,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
@@ -30036,7 +30046,7 @@ func (q *sqlQuerier) ListUserSecrets(ctx context.Context, userID uuid.UUID) ([]L
|
||||
}
|
||||
|
||||
const listUserSecretsWithValues = `-- name: ListUserSecretsWithValues :many
|
||||
SELECT id, user_id, name, description, value, env_name, file_path, created_at, updated_at, value_key_id
|
||||
SELECT id, user_id, name, description, value, env_name, file_path, created_at, updated_at, value_key_id, enabled
|
||||
FROM user_secrets
|
||||
WHERE user_id = $1
|
||||
ORDER BY name ASC
|
||||
@@ -30065,6 +30075,7 @@ func (q *sqlQuerier) ListUserSecretsWithValues(ctx context.Context, userID uuid.
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.ValueKeyID,
|
||||
&i.Enabled,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -30087,9 +30098,10 @@ SET
|
||||
description = CASE WHEN $4::bool THEN $5 ELSE description END,
|
||||
env_name = CASE WHEN $6::bool THEN $7 ELSE env_name END,
|
||||
file_path = CASE WHEN $8::bool THEN $9 ELSE file_path END,
|
||||
enabled = CASE WHEN $10::bool THEN $11 ELSE enabled END,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE user_id = $10 AND name = $11
|
||||
RETURNING id, user_id, name, description, value, env_name, file_path, created_at, updated_at, value_key_id
|
||||
WHERE user_id = $12 AND name = $13
|
||||
RETURNING id, user_id, name, description, value, env_name, file_path, created_at, updated_at, value_key_id, enabled
|
||||
`
|
||||
|
||||
type UpdateUserSecretByUserIDAndNameParams struct {
|
||||
@@ -30102,6 +30114,8 @@ type UpdateUserSecretByUserIDAndNameParams struct {
|
||||
EnvName string `db:"env_name" json:"env_name"`
|
||||
UpdateFilePath bool `db:"update_file_path" json:"update_file_path"`
|
||||
FilePath string `db:"file_path" json:"file_path"`
|
||||
UpdateEnabled bool `db:"update_enabled" json:"update_enabled"`
|
||||
Enabled bool `db:"enabled" json:"enabled"`
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
Name string `db:"name" json:"name"`
|
||||
}
|
||||
@@ -30117,6 +30131,8 @@ func (q *sqlQuerier) UpdateUserSecretByUserIDAndName(ctx context.Context, arg Up
|
||||
arg.EnvName,
|
||||
arg.UpdateFilePath,
|
||||
arg.FilePath,
|
||||
arg.UpdateEnabled,
|
||||
arg.Enabled,
|
||||
arg.UserID,
|
||||
arg.Name,
|
||||
)
|
||||
@@ -30132,6 +30148,7 @@ func (q *sqlQuerier) UpdateUserSecretByUserIDAndName(ctx context.Context, arg Up
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.ValueKeyID,
|
||||
&i.Enabled,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ WHERE id = @id;
|
||||
-- REST API list and get endpoints.
|
||||
SELECT
|
||||
id, user_id, name, description,
|
||||
env_name, file_path,
|
||||
env_name, file_path, enabled,
|
||||
created_at, updated_at
|
||||
FROM user_secrets
|
||||
WHERE user_id = @user_id
|
||||
@@ -37,7 +37,8 @@ INSERT INTO user_secrets (
|
||||
value,
|
||||
value_key_id,
|
||||
env_name,
|
||||
file_path
|
||||
file_path,
|
||||
enabled
|
||||
) VALUES (
|
||||
@id,
|
||||
@user_id,
|
||||
@@ -46,7 +47,8 @@ INSERT INTO user_secrets (
|
||||
@value,
|
||||
@value_key_id,
|
||||
@env_name,
|
||||
@file_path
|
||||
@file_path,
|
||||
@enabled
|
||||
) RETURNING *;
|
||||
|
||||
-- name: UpdateUserSecretByUserIDAndName :one
|
||||
@@ -57,6 +59,7 @@ SET
|
||||
description = CASE WHEN @update_description::bool THEN @description ELSE description END,
|
||||
env_name = CASE WHEN @update_env_name::bool THEN @env_name ELSE env_name END,
|
||||
file_path = CASE WHEN @update_file_path::bool THEN @file_path ELSE file_path END,
|
||||
enabled = CASE WHEN @update_enabled::bool THEN @enabled ELSE enabled END,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE user_id = @user_id AND name = @name
|
||||
RETURNING *;
|
||||
|
||||
@@ -2103,6 +2103,10 @@ func TestUserSecretsTelemetry(t *testing.T) {
|
||||
}, func(p *database.CreateUserSecretParams) {
|
||||
p.EnvName = ""
|
||||
p.FilePath = ""
|
||||
// A target-less secret must be disabled to satisfy the
|
||||
// user_secrets_enabled_requires_target constraint. Disabled
|
||||
// secrets are still counted in the telemetry breakdown.
|
||||
p.Enabled = false
|
||||
})
|
||||
|
||||
_, snap := collectSnapshot(ctx, t, db, nil)
|
||||
@@ -2149,9 +2153,12 @@ func TestUserSecretsTelemetry(t *testing.T) {
|
||||
// Clear EnvName and FilePath so the unique
|
||||
// (user_id, env_name) and (user_id, file_path)
|
||||
// indexes don't collide across multiple secrets
|
||||
// for the same user.
|
||||
// for the same user. Target-less secrets must be
|
||||
// disabled to satisfy the
|
||||
// user_secrets_enabled_requires_target constraint.
|
||||
p.EnvName = ""
|
||||
p.FilePath = ""
|
||||
p.Enabled = false
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2261,6 +2268,9 @@ func TestUserSecretsTelemetry(t *testing.T) {
|
||||
}, func(p *database.CreateUserSecretParams) {
|
||||
p.EnvName = ""
|
||||
p.FilePath = ""
|
||||
// Target-less secrets must be disabled to satisfy the
|
||||
// user_secrets_enabled_requires_target constraint.
|
||||
p.Enabled = false
|
||||
})
|
||||
|
||||
clock := quartz.NewMock(t)
|
||||
|
||||
+84
-1
@@ -28,6 +28,13 @@ const (
|
||||
userSecretsEnvBytesLimitConstraint database.CheckConstraint = "user_secrets_per_user_env_bytes_limit"
|
||||
)
|
||||
|
||||
// errUserSecretInjectionTargetRequired signals that a PATCH would leave an
|
||||
// enabled secret with both env_name and file_path empty. It is returned
|
||||
// from the patchUserSecret transaction so the handler can map it to a 400.
|
||||
// Creates enforce the same invariant in
|
||||
// codersdk.ValidateCreateUserSecretRequest.
|
||||
var errUserSecretInjectionTargetRequired = xerrors.New("enabled user secret must have at least one of env_name or file_path set")
|
||||
|
||||
// @Summary Create a new user secret
|
||||
// @ID create-a-new-user-secret
|
||||
// @Security CoderSessionToken
|
||||
@@ -62,6 +69,11 @@ func (api *API) postUserSecret(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
enabled := true
|
||||
if req.Enabled != nil {
|
||||
enabled = *req.Enabled
|
||||
}
|
||||
|
||||
secret, err := api.Database.CreateUserSecret(ctx, database.CreateUserSecretParams{
|
||||
ID: uuid.New(),
|
||||
UserID: user.ID,
|
||||
@@ -71,12 +83,17 @@ func (api *API) postUserSecret(rw http.ResponseWriter, r *http.Request) {
|
||||
ValueKeyID: sql.NullString{},
|
||||
EnvName: req.EnvName,
|
||||
FilePath: req.FilePath,
|
||||
Enabled: enabled,
|
||||
})
|
||||
if err != nil {
|
||||
if validations := userSecretConflictValidationErrors(err); len(validations) > 0 {
|
||||
writeUserSecretValidationErrors(ctx, rw, http.StatusConflict, validations)
|
||||
return
|
||||
}
|
||||
if validations := userSecretInjectionTargetValidationErrors(err); len(validations) > 0 {
|
||||
writeUserSecretValidationErrors(ctx, rw, http.StatusBadRequest, validations)
|
||||
return
|
||||
}
|
||||
if resp, ok := userSecretLimitResponse(err); ok {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, resp)
|
||||
return
|
||||
@@ -155,6 +172,10 @@ func (api *API) postUserSecretsBatch(rw http.ResponseWriter, r *http.Request) {
|
||||
failedIndex := -1
|
||||
err = api.Database.InTx(func(tx database.Store) error {
|
||||
for i, sreq := range reqs {
|
||||
enabled := true
|
||||
if sreq.Enabled != nil {
|
||||
enabled = *sreq.Enabled
|
||||
}
|
||||
s, txErr := tx.CreateUserSecret(ctx, database.CreateUserSecretParams{
|
||||
ID: uuid.New(),
|
||||
UserID: user.ID,
|
||||
@@ -164,6 +185,7 @@ func (api *API) postUserSecretsBatch(rw http.ResponseWriter, r *http.Request) {
|
||||
ValueKeyID: sql.NullString{},
|
||||
EnvName: sreq.EnvName,
|
||||
FilePath: sreq.FilePath,
|
||||
Enabled: enabled,
|
||||
})
|
||||
if txErr != nil {
|
||||
failedIndex = i
|
||||
@@ -185,6 +207,15 @@ func (api *API) postUserSecretsBatch(rw http.ResponseWriter, r *http.Request) {
|
||||
writeUserSecretValidationErrors(ctx, rw, http.StatusConflict, conflicts)
|
||||
return
|
||||
}
|
||||
if validations := userSecretInjectionTargetValidationErrors(err); len(validations) > 0 {
|
||||
if index >= 0 {
|
||||
for i := range validations {
|
||||
validations[i].Field = fmt.Sprintf("secrets[%d].%s", index, validations[i].Field)
|
||||
}
|
||||
}
|
||||
writeUserSecretValidationErrors(ctx, rw, http.StatusBadRequest, validations)
|
||||
return
|
||||
}
|
||||
if resp, ok := userSecretLimitResponse(err); ok {
|
||||
if index >= 0 {
|
||||
resp.Detail = fmt.Sprintf("Entry secrets[%d] (%q): %s", index, reqs[index].Name, resp.Detail)
|
||||
@@ -319,7 +350,7 @@ func (api *API) patchUserSecret(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if req.Value == nil && req.Description == nil && req.EnvName == nil && req.FilePath == nil {
|
||||
if req.Value == nil && req.Description == nil && req.EnvName == nil && req.FilePath == nil && req.Enabled == nil {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "At least one field must be provided.",
|
||||
})
|
||||
@@ -342,6 +373,8 @@ func (api *API) patchUserSecret(rw http.ResponseWriter, r *http.Request) {
|
||||
EnvName: "",
|
||||
UpdateFilePath: req.FilePath != nil,
|
||||
FilePath: "",
|
||||
UpdateEnabled: req.Enabled != nil,
|
||||
Enabled: false,
|
||||
}
|
||||
if req.Value != nil {
|
||||
params.Value = *req.Value
|
||||
@@ -355,6 +388,9 @@ func (api *API) patchUserSecret(rw http.ResponseWriter, r *http.Request) {
|
||||
if req.FilePath != nil {
|
||||
params.FilePath = *req.FilePath
|
||||
}
|
||||
if req.Enabled != nil {
|
||||
params.Enabled = *req.Enabled
|
||||
}
|
||||
|
||||
// Pre-read the secret inside a transaction so the audit diff has both an
|
||||
// "old" and "new" snapshot.
|
||||
@@ -375,6 +411,26 @@ func (api *API) patchUserSecret(rw http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
aReq.Old = old
|
||||
|
||||
// Reject patches that would leave an enabled secret with both
|
||||
// env_name and file_path empty. Evaluated against the post-update
|
||||
// state so atomic env<->file swaps still succeed, and so targets
|
||||
// can be cleared when the same PATCH also disables the secret.
|
||||
postEnvName := old.EnvName
|
||||
if req.EnvName != nil {
|
||||
postEnvName = *req.EnvName
|
||||
}
|
||||
postFilePath := old.FilePath
|
||||
if req.FilePath != nil {
|
||||
postFilePath = *req.FilePath
|
||||
}
|
||||
postEnabled := old.Enabled
|
||||
if req.Enabled != nil {
|
||||
postEnabled = *req.Enabled
|
||||
}
|
||||
if postEnabled && postEnvName == "" && postFilePath == "" {
|
||||
return errUserSecretInjectionTargetRequired
|
||||
}
|
||||
|
||||
updated, err := tx.UpdateUserSecretByUserIDAndName(ctx, params)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("update user secret: %w", err)
|
||||
@@ -388,6 +444,17 @@ func (api *API) patchUserSecret(rw http.ResponseWriter, r *http.Request) {
|
||||
httpapi.ResourceNotFound(rw)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, errUserSecretInjectionTargetRequired) {
|
||||
writeUserSecretValidationErrors(ctx, rw, http.StatusBadRequest, []codersdk.ValidationError{{
|
||||
Field: codersdk.UserSecretEnvNameField,
|
||||
Detail: codersdk.UserSecretInjectionTargetRequiredDetail,
|
||||
}})
|
||||
return
|
||||
}
|
||||
if validations := userSecretInjectionTargetValidationErrors(err); len(validations) > 0 {
|
||||
writeUserSecretValidationErrors(ctx, rw, http.StatusBadRequest, validations)
|
||||
return
|
||||
}
|
||||
if validations := userSecretConflictValidationErrors(err); len(validations) > 0 {
|
||||
writeUserSecretValidationErrors(ctx, rw, http.StatusConflict, validations)
|
||||
return
|
||||
@@ -518,6 +585,22 @@ func userSecretLimitResponse(err error) (codersdk.Response, bool) {
|
||||
return codersdk.Response{}, false
|
||||
}
|
||||
|
||||
// userSecretInjectionTargetValidationErrors maps the
|
||||
// user_secrets_enabled_requires_target CHECK violation to a field-level
|
||||
// validation error. The database constraint is the race-safe source of
|
||||
// truth for the injection-target invariant: concurrent PATCHes can each
|
||||
// clear a different target and pass the handler's own post-state check,
|
||||
// so the constraint is what ultimately rejects an enabled target-less row.
|
||||
func userSecretInjectionTargetValidationErrors(err error) []codersdk.ValidationError {
|
||||
if database.IsCheckViolation(err, database.CheckUserSecretsEnabledRequiresTarget) {
|
||||
return []codersdk.ValidationError{{
|
||||
Field: codersdk.UserSecretEnvNameField,
|
||||
Detail: codersdk.UserSecretInjectionTargetRequiredDetail,
|
||||
}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func userSecretConflictValidationErrors(err error) []codersdk.ValidationError {
|
||||
switch {
|
||||
case database.IsUniqueViolation(err, database.UniqueUserSecretsUserNameIndex):
|
||||
|
||||
@@ -29,13 +29,24 @@ func TestUserSecretAudit(t *testing.T) {
|
||||
// collide in the shared user's secret namespace.
|
||||
return strings.ReplaceAll(t.Name(), "/", "-")
|
||||
}
|
||||
genEnvName := func(t *testing.T) string {
|
||||
// Same derivation as genSecretName, but in the
|
||||
// SCREAMING_SNAKE_CASE shape env names require. Every
|
||||
// secret needs at least one of env_name or file_path,
|
||||
// and the per-user UNIQUE index makes empty-injection
|
||||
// not an option.
|
||||
name := strings.ReplaceAll(t.Name(), "/", "_")
|
||||
name = strings.ReplaceAll(name, "-", "_")
|
||||
return strings.ToUpper(name)
|
||||
}
|
||||
|
||||
t.Run("CreateEmitsLog", func(t *testing.T) {
|
||||
auditor.ResetLogs()
|
||||
|
||||
secret, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
||||
Name: genSecretName(t),
|
||||
Value: "ghp_xxxxxxxxxxxx",
|
||||
Name: genSecretName(t),
|
||||
Value: "ghp_xxxxxxxxxxxx",
|
||||
EnvName: genEnvName(t),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -51,8 +62,9 @@ func TestUserSecretAudit(t *testing.T) {
|
||||
auditor.ResetLogs()
|
||||
|
||||
secret, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
||||
Name: genSecretName(t),
|
||||
Value: "old",
|
||||
Name: genSecretName(t),
|
||||
Value: "old",
|
||||
EnvName: genEnvName(t),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -77,8 +89,9 @@ func TestUserSecretAudit(t *testing.T) {
|
||||
auditor.ResetLogs()
|
||||
|
||||
secret, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
||||
Name: genSecretName(t),
|
||||
Value: "value",
|
||||
Name: genSecretName(t),
|
||||
Value: "value",
|
||||
EnvName: genEnvName(t),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -138,8 +151,9 @@ func TestUserSecretAudit(t *testing.T) {
|
||||
name := genSecretName(t)
|
||||
|
||||
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
||||
Name: name,
|
||||
Value: "value",
|
||||
Name: name,
|
||||
Value: "value",
|
||||
EnvName: genEnvName(t),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
// Reset to ignore the created log. We are only testing that the
|
||||
@@ -159,8 +173,9 @@ func TestUserSecretAudit(t *testing.T) {
|
||||
secretName := genSecretName(t)
|
||||
|
||||
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
||||
Name: secretName,
|
||||
Value: "value",
|
||||
Name: secretName,
|
||||
Value: "value",
|
||||
EnvName: genEnvName(t),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
// Discard the create log so the assertion below only sees audit entries
|
||||
|
||||
+217
-30
@@ -87,14 +87,16 @@ func TestPostUserSecret(t *testing.T) {
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
|
||||
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
||||
Name: "dup-secret",
|
||||
Value: "value1",
|
||||
Name: "dup-secret",
|
||||
Value: "value1",
|
||||
EnvName: "DUP_SECRET_ENV_1",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
||||
Name: "dup-secret",
|
||||
Value: "value2",
|
||||
Name: "dup-secret",
|
||||
Value: "value2",
|
||||
EnvName: "DUP_SECRET_ENV_2",
|
||||
})
|
||||
requireSecretValidationEqualsError(t, err, http.StatusConflict, "name", "name already in use")
|
||||
})
|
||||
@@ -206,6 +208,63 @@ func TestPostUserSecret(t *testing.T) {
|
||||
})
|
||||
requireSecretValidationContainsError(t, err, http.StatusBadRequest, "value", "must not exceed")
|
||||
})
|
||||
|
||||
t.Run("MissingInjectionTarget", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
|
||||
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
||||
Name: "missing-target-secret",
|
||||
Value: "value",
|
||||
})
|
||||
requireSecretValidationContainsError(t, err, http.StatusBadRequest, "env_name", "at least one of env_name or file_path")
|
||||
})
|
||||
|
||||
t.Run("DisabledByDefault", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
|
||||
disabled := false
|
||||
secret, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
||||
Name: "create-disabled",
|
||||
Value: "value",
|
||||
EnvName: "CREATE_DISABLED",
|
||||
Enabled: &disabled,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, secret.Enabled)
|
||||
})
|
||||
|
||||
t.Run("DisabledWithoutTarget", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
|
||||
// A disabled secret may omit both injection targets. Bulk
|
||||
// imports rely on this for keys that cannot be env-injected.
|
||||
disabled := false
|
||||
secret, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
||||
Name: "create-disabled-no-target",
|
||||
Value: "value",
|
||||
Enabled: &disabled,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, secret.Enabled)
|
||||
assert.Empty(t, secret.EnvName)
|
||||
assert.Empty(t, secret.FilePath)
|
||||
})
|
||||
|
||||
t.Run("EnabledByDefault", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
|
||||
secret, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
||||
Name: "create-default-enabled",
|
||||
Value: "value",
|
||||
EnvName: "CREATE_DEFAULT_ENABLED",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, secret.Enabled)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostUserSecretForbiddenForAnotherUser(t *testing.T) {
|
||||
@@ -216,8 +275,9 @@ func TestPostUserSecretForbiddenForAnotherUser(t *testing.T) {
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
|
||||
_, err := memberClient.CreateUserSecret(ctx, owner.UserID.String(), codersdk.CreateUserSecretRequest{
|
||||
Name: "forbidden",
|
||||
Value: "value",
|
||||
Name: "forbidden",
|
||||
Value: "value",
|
||||
EnvName: "FORBIDDEN",
|
||||
})
|
||||
var sdkErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
@@ -240,14 +300,16 @@ func TestGetUserSecrets(t *testing.T) {
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
|
||||
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
||||
Name: "list-secret-a",
|
||||
Value: "value-a",
|
||||
Name: "list-secret-a",
|
||||
Value: "value-a",
|
||||
EnvName: "LIST_SECRET_A",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
||||
Name: "list-secret-b",
|
||||
Value: "value-b",
|
||||
Name: "list-secret-b",
|
||||
Value: "value-b",
|
||||
EnvName: "LIST_SECRET_B",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -327,8 +389,9 @@ func TestPatchUserSecret(t *testing.T) {
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
|
||||
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
||||
Name: "patch-nofields-secret",
|
||||
Value: "my-value",
|
||||
Name: "patch-nofields-secret",
|
||||
Value: "my-value",
|
||||
EnvName: "PATCH_NOFIELDS_ENV",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -365,8 +428,9 @@ func TestPatchUserSecret(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
||||
Name: "conflict-env-2",
|
||||
Value: "value2",
|
||||
Name: "conflict-env-2",
|
||||
Value: "value2",
|
||||
FilePath: "/tmp/conflict-env-2",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -389,8 +453,9 @@ func TestPatchUserSecret(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
||||
Name: "conflict-fp-2",
|
||||
Value: "value2",
|
||||
Name: "conflict-fp-2",
|
||||
Value: "value2",
|
||||
EnvName: "CONFLICT_FP_2",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -406,8 +471,9 @@ func TestPatchUserSecret(t *testing.T) {
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
|
||||
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
||||
Name: "patch-invalid-env",
|
||||
Value: "good-value",
|
||||
Name: "patch-invalid-env",
|
||||
Value: "good-value",
|
||||
FilePath: "/tmp/patch-invalid-env",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -423,8 +489,9 @@ func TestPatchUserSecret(t *testing.T) {
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
|
||||
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
||||
Name: "patch-invalid-file-path",
|
||||
Value: "good-value",
|
||||
Name: "patch-invalid-file-path",
|
||||
Value: "good-value",
|
||||
EnvName: "PATCH_INVALID_FILE_PATH",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -440,8 +507,9 @@ func TestPatchUserSecret(t *testing.T) {
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
|
||||
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
||||
Name: "patch-invalid-val",
|
||||
Value: "good-value",
|
||||
Name: "patch-invalid-val",
|
||||
Value: "good-value",
|
||||
EnvName: "PATCH_INVALID_VAL",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -451,6 +519,121 @@ func TestPatchUserSecret(t *testing.T) {
|
||||
})
|
||||
requireSecretValidationContainsError(t, err, http.StatusBadRequest, "value", "null bytes")
|
||||
})
|
||||
|
||||
t.Run("ToggleEnabled", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
|
||||
secret, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
||||
Name: "toggle-enabled",
|
||||
Value: "value",
|
||||
EnvName: "TOGGLE_ENABLED",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, secret.Enabled)
|
||||
|
||||
disable := false
|
||||
updated, err := client.UpdateUserSecret(ctx, codersdk.Me, "toggle-enabled", codersdk.UpdateUserSecretRequest{
|
||||
Enabled: &disable,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, updated.Enabled)
|
||||
// Other fields should be unchanged.
|
||||
assert.Equal(t, "TOGGLE_ENABLED", updated.EnvName)
|
||||
|
||||
enable := true
|
||||
updated, err = client.UpdateUserSecret(ctx, codersdk.Me, "toggle-enabled", codersdk.UpdateUserSecretRequest{
|
||||
Enabled: &enable,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, updated.Enabled)
|
||||
})
|
||||
|
||||
t.Run("ClearingBothTargetsRejected", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
|
||||
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
||||
Name: "clear-both",
|
||||
Value: "value",
|
||||
EnvName: "CLEAR_BOTH_ENV",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// PATCH that clears env_name while file_path is also empty
|
||||
// should be rejected: the row stays enabled but would have no
|
||||
// injection target.
|
||||
empty := ""
|
||||
_, err = client.UpdateUserSecret(ctx, codersdk.Me, "clear-both", codersdk.UpdateUserSecretRequest{
|
||||
EnvName: &empty,
|
||||
})
|
||||
requireSecretValidationContainsError(t, err, http.StatusBadRequest, "env_name", "at least one of env_name or file_path")
|
||||
})
|
||||
|
||||
t.Run("ClearingTargetsWhileDisablingAllowed", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
|
||||
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
||||
Name: "clear-and-disable",
|
||||
Value: "value",
|
||||
EnvName: "CLEAR_AND_DISABLE",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Clearing the last target is allowed when the same PATCH also
|
||||
// disables the secret: only enabled secrets need a target.
|
||||
empty := ""
|
||||
disabled := false
|
||||
updated, err := client.UpdateUserSecret(ctx, codersdk.Me, "clear-and-disable", codersdk.UpdateUserSecretRequest{
|
||||
EnvName: &empty,
|
||||
Enabled: &disabled,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, updated.Enabled)
|
||||
assert.Empty(t, updated.EnvName)
|
||||
|
||||
// Re-enabling without restoring a target is rejected.
|
||||
enable := true
|
||||
_, err = client.UpdateUserSecret(ctx, codersdk.Me, "clear-and-disable", codersdk.UpdateUserSecretRequest{
|
||||
Enabled: &enable,
|
||||
})
|
||||
requireSecretValidationContainsError(t, err, http.StatusBadRequest, "env_name", "at least one of env_name or file_path")
|
||||
|
||||
// Re-enabling and restoring a target in one PATCH succeeds.
|
||||
envName := "CLEAR_AND_DISABLE"
|
||||
updated, err = client.UpdateUserSecret(ctx, codersdk.Me, "clear-and-disable", codersdk.UpdateUserSecretRequest{
|
||||
EnvName: &envName,
|
||||
Enabled: &enable,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, updated.Enabled)
|
||||
assert.Equal(t, "CLEAR_AND_DISABLE", updated.EnvName)
|
||||
})
|
||||
|
||||
t.Run("AtomicEnvFileSwap", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
|
||||
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
||||
Name: "atomic-swap",
|
||||
Value: "value",
|
||||
EnvName: "ATOMIC_SWAP_ENV",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Clearing env_name and setting file_path in the same PATCH must
|
||||
// succeed: the post-update row still has an injection target.
|
||||
empty := ""
|
||||
newPath := "/tmp/atomic-swap"
|
||||
updated, err := client.UpdateUserSecret(ctx, codersdk.Me, "atomic-swap", codersdk.UpdateUserSecretRequest{
|
||||
EnvName: &empty,
|
||||
FilePath: &newPath,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "", updated.EnvName)
|
||||
assert.Equal(t, "/tmp/atomic-swap", updated.FilePath)
|
||||
})
|
||||
}
|
||||
|
||||
func requireSecretValidationContainsError(t *testing.T, err error, status int, field string, detailContains string) {
|
||||
@@ -510,8 +693,9 @@ func TestUserSecretLimits(t *testing.T) {
|
||||
var firstSecret codersdk.UserSecret
|
||||
for i := 0; i < codersdk.MaxUserSecretsPerUserCount; i++ {
|
||||
s, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
||||
Name: fmt.Sprintf("count-limit-%03d", i),
|
||||
Value: "x",
|
||||
Name: fmt.Sprintf("count-limit-%03d", i),
|
||||
Value: "x",
|
||||
FilePath: fmt.Sprintf("/tmp/count-limit-%03d", i),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
if i == 0 {
|
||||
@@ -521,8 +705,9 @@ func TestUserSecretLimits(t *testing.T) {
|
||||
|
||||
// POST: the 51st secret is rejected.
|
||||
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
||||
Name: "one-too-many",
|
||||
Value: "x",
|
||||
Name: "one-too-many",
|
||||
Value: "x",
|
||||
FilePath: "/tmp/one-too-many",
|
||||
})
|
||||
requireSecretAPIError(t, err, http.StatusBadRequest, "at most")
|
||||
|
||||
@@ -537,8 +722,9 @@ func TestUserSecretLimits(t *testing.T) {
|
||||
|
||||
// Other-user isolation: the second user's budget is independent.
|
||||
_, err = otherClient.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
||||
Name: "other-user-secret",
|
||||
Value: "x",
|
||||
Name: "other-user-secret",
|
||||
Value: "x",
|
||||
FilePath: "/tmp/other-user-secret",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
})
|
||||
@@ -702,8 +888,9 @@ func TestDeleteUserSecret(t *testing.T) {
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
|
||||
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
||||
Name: "delete-me-secret",
|
||||
Value: "my-value",
|
||||
Name: "delete-me-secret",
|
||||
Value: "my-value",
|
||||
EnvName: "DELETE_ME_SECRET",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
|
||||
@@ -179,8 +179,9 @@ func TestImportUserSecretsConflict(t *testing.T) {
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
|
||||
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
||||
Name: "EXISTING",
|
||||
Value: "original",
|
||||
Name: "EXISTING",
|
||||
Value: "original",
|
||||
EnvName: "EXISTING",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
auditor.ResetLogs()
|
||||
@@ -217,8 +218,9 @@ func TestImportUserSecretsLimits(t *testing.T) {
|
||||
|
||||
for i := 0; i < codersdk.MaxUserSecretsPerUserCount-1; i++ {
|
||||
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
||||
Name: fmt.Sprintf("prefill-%03d", i),
|
||||
Value: "original",
|
||||
Name: fmt.Sprintf("prefill-%03d", i),
|
||||
Value: "original",
|
||||
FilePath: fmt.Sprintf("/tmp/prefill-%03d", i),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
+17
-5
@@ -18,30 +18,42 @@ type UserSecret struct {
|
||||
Description string `json:"description"`
|
||||
EnvName string `json:"env_name"`
|
||||
FilePath string `json:"file_path"`
|
||||
CreatedAt time.Time `json:"created_at" format:"date-time"`
|
||||
UpdatedAt time.Time `json:"updated_at" format:"date-time"`
|
||||
// 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.
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedAt time.Time `json:"created_at" format:"date-time"`
|
||||
UpdatedAt time.Time `json:"updated_at" format:"date-time"`
|
||||
}
|
||||
|
||||
// CreateUserSecretRequest is the payload for creating a new user
|
||||
// secret. Name and Value are required. All other fields are optional
|
||||
// and default to empty string.
|
||||
// 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
|
||||
// target; to keep a secret without injecting it, set Enabled to false.
|
||||
// All other fields are optional and default to empty string. Enabled
|
||||
// defaults to true when omitted.
|
||||
type CreateUserSecretRequest struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
Description string `json:"description,omitempty"`
|
||||
EnvName string `json:"env_name,omitempty"`
|
||||
FilePath string `json:"file_path,omitempty"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateUserSecretRequest is the payload for partially updating a
|
||||
// user secret. At least one field must be non-nil. Pointer fields
|
||||
// distinguish "not sent" (nil) from "set to empty string" (pointer
|
||||
// to empty string).
|
||||
// to empty string). If the post-update row is enabled it must still
|
||||
// have at least one of EnvName or FilePath non-empty; clearing both
|
||||
// targets is only allowed when the secret is (or becomes) disabled.
|
||||
type UpdateUserSecretRequest struct {
|
||||
Value *string `json:"value,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
EnvName *string `json:"env_name,omitempty"`
|
||||
FilePath *string `json:"file_path,omitempty"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
}
|
||||
|
||||
func (c *Client) CreateUserSecret(ctx context.Context, user string, req CreateUserSecretRequest) (UserSecret, error) {
|
||||
|
||||
@@ -87,6 +87,14 @@ func ParseSecretsFile(format SecretsFileFormat, content string) ([]CreateUserSec
|
||||
// so multiple empty env_names are allowed.
|
||||
if UserSecretEnvNameValid(e.key) == nil {
|
||||
req.EnvName = e.key
|
||||
} else {
|
||||
// Keys that cannot be env-injected (reserved names, invalid
|
||||
// identifiers) are imported without an injection target, so
|
||||
// they must be disabled: an enabled secret always has at
|
||||
// least one of env_name or file_path set. The user can add
|
||||
// a target and re-enable the secret afterwards.
|
||||
disabled := false
|
||||
req.Enabled = &disabled
|
||||
}
|
||||
reqs = append(reqs, req)
|
||||
}
|
||||
|
||||
@@ -406,9 +406,13 @@ func TestParseSecretsFileBestEffortEnvName(t *testing.T) {
|
||||
t.Parallel()
|
||||
reqs, err := codersdk.ParseSecretsFile(tc.format, tc.content)
|
||||
require.NoError(t, err)
|
||||
// Reserved keys cannot be env-injected, so they are imported
|
||||
// without an injection target and therefore disabled.
|
||||
disabled := false
|
||||
require.Equal(t, []codersdk.CreateUserSecretRequest{{
|
||||
Name: "PATH",
|
||||
Value: "value",
|
||||
Name: "PATH",
|
||||
Value: "value",
|
||||
Enabled: &disabled,
|
||||
}}, reqs)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -236,9 +236,27 @@ func ValidateCreateUserSecretRequest(req CreateUserSecretRequest) []ValidationEr
|
||||
if err := UserSecretFilePathValid(req.FilePath); err != nil {
|
||||
validations = append(validations, ValidationError{Field: UserSecretFilePathField, Detail: err.Error()})
|
||||
}
|
||||
// An enabled secret must have an injection target. The agent
|
||||
// manifest layer relies on this invariant so it can gate injection
|
||||
// solely on the enabled flag; "stored but not injected" is
|
||||
// expressed by enabled=false, not by clearing both targets.
|
||||
// Disabled secrets may have no target (e.g. bulk imports of keys
|
||||
// that cannot be env-injected).
|
||||
if req.EnvName == "" && req.FilePath == "" && (req.Enabled == nil || *req.Enabled) {
|
||||
validations = append(validations, ValidationError{
|
||||
Field: UserSecretEnvNameField,
|
||||
Detail: UserSecretInjectionTargetRequiredDetail,
|
||||
})
|
||||
}
|
||||
return validations
|
||||
}
|
||||
|
||||
// UserSecretInjectionTargetRequiredDetail explains the injection-target
|
||||
// invariant. It is shared by the create validator above and the PATCH
|
||||
// handler's post-state check in coderd. The value is a user-facing
|
||||
// validation message, not a credential.
|
||||
const UserSecretInjectionTargetRequiredDetail = "An enabled secret must have at least one of env_name or file_path set. To keep a secret without injecting it, set enabled to false instead of clearing both targets." //nolint:gosec // G101: message text, not a hardcoded credential.
|
||||
|
||||
// UserSecretNameValid validates a user secret name. Names are used in
|
||||
// API route path segments, so they must not include route separators.
|
||||
func UserSecretNameValid(s string) error {
|
||||
|
||||
@@ -29,13 +29,25 @@ func TestValidateCreateUserSecretRequest(t *testing.T) {
|
||||
{
|
||||
name: "MissingValue",
|
||||
req: codersdk.CreateUserSecretRequest{
|
||||
Name: "missing-value-secret",
|
||||
Name: "missing-value-secret",
|
||||
EnvName: "MISSING_VALUE_SECRET",
|
||||
},
|
||||
want: []codersdk.ValidationError{{
|
||||
Field: "value",
|
||||
Detail: "Value is required.",
|
||||
}},
|
||||
},
|
||||
{
|
||||
name: "MissingInjectionTarget",
|
||||
req: codersdk.CreateUserSecretRequest{
|
||||
Name: "missing-target-secret",
|
||||
Value: "value",
|
||||
},
|
||||
want: []codersdk.ValidationError{{
|
||||
Field: "env_name",
|
||||
Detail: codersdk.UserSecretInjectionTargetRequiredDetail,
|
||||
}},
|
||||
},
|
||||
{
|
||||
name: "MultiInvalid",
|
||||
req: codersdk.CreateUserSecretRequest{
|
||||
|
||||
@@ -43,7 +43,7 @@ We track the following resources:
|
||||
| Template<br><i>write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>active_version_id</td><td>true</td></tr><tr><td>activity_bump</td><td>true</td></tr><tr><td>allow_user_autostart</td><td>true</td></tr><tr><td>allow_user_autostop</td><td>true</td></tr><tr><td>allow_user_cancel_workspace_jobs</td><td>true</td></tr><tr><td>autostart_block_days_of_week</td><td>true</td></tr><tr><td>autostop_requirement_days_of_week</td><td>true</td></tr><tr><td>autostop_requirement_weeks</td><td>true</td></tr><tr><td>cors_behavior</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>created_by</td><td>true</td></tr><tr><td>created_by_avatar_url</td><td>false</td></tr><tr><td>created_by_name</td><td>false</td></tr><tr><td>created_by_username</td><td>false</td></tr><tr><td>default_ttl</td><td>true</td></tr><tr><td>deleted</td><td>false</td></tr><tr><td>deprecated</td><td>true</td></tr><tr><td>description</td><td>true</td></tr><tr><td>disable_module_cache</td><td>true</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>failure_ttl</td><td>true</td></tr><tr><td>group_acl</td><td>true</td></tr><tr><td>icon</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>max_port_sharing_level</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_display_name</td><td>false</td></tr><tr><td>organization_icon</td><td>false</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>organization_name</td><td>false</td></tr><tr><td>provisioner</td><td>true</td></tr><tr><td>require_active_version</td><td>true</td></tr><tr><td>time_til_autostop_notify</td><td>true</td></tr><tr><td>time_til_dormant</td><td>true</td></tr><tr><td>time_til_dormant_autodelete</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>use_classic_parameter_flow</td><td>true</td></tr><tr><td>user_acl</td><td>true</td></tr></tbody></table> |
|
||||
| TemplateVersion<br><i>create, write</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>archived</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>created_by</td><td>true</td></tr><tr><td>created_by_avatar_url</td><td>false</td></tr><tr><td>created_by_name</td><td>false</td></tr><tr><td>created_by_username</td><td>false</td></tr><tr><td>external_auth_providers</td><td>false</td></tr><tr><td>has_ai_task</td><td>false</td></tr><tr><td>has_external_agent</td><td>false</td></tr><tr><td>id</td><td>true</td></tr><tr><td>job_id</td><td>false</td></tr><tr><td>message</td><td>false</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>readme</td><td>true</td></tr><tr><td>source_example_id</td><td>false</td></tr><tr><td>template_id</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr></tbody></table> |
|
||||
| User<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>avatar_url</td><td>false</td></tr><tr><td>chat_spend_limit_micros</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>deleted</td><td>true</td></tr><tr><td>email</td><td>true</td></tr><tr><td>github_com_user_id</td><td>false</td></tr><tr><td>hashed_one_time_passcode</td><td>false</td></tr><tr><td>hashed_password</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>is_service_account</td><td>true</td></tr><tr><td>is_system</td><td>true</td></tr><tr><td>last_seen_at</td><td>false</td></tr><tr><td>login_type</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>one_time_passcode_expires_at</td><td>true</td></tr><tr><td>quiet_hours_schedule</td><td>true</td></tr><tr><td>rbac_roles</td><td>true</td></tr><tr><td>status</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>username</td><td>true</td></tr></tbody></table> |
|
||||
| UserSecret<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>created_at</td><td>false</td></tr><tr><td>description</td><td>true</td></tr><tr><td>env_name</td><td>true</td></tr><tr><td>file_path</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_id</td><td>true</td></tr><tr><td>value</td><td>true</td></tr><tr><td>value_key_id</td><td>false</td></tr></tbody></table> |
|
||||
| UserSecret<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>created_at</td><td>false</td></tr><tr><td>description</td><td>true</td></tr><tr><td>enabled</td><td>true</td></tr><tr><td>env_name</td><td>true</td></tr><tr><td>file_path</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_id</td><td>true</td></tr><tr><td>value</td><td>true</td></tr><tr><td>value_key_id</td><td>false</td></tr></tbody></table> |
|
||||
| UserSkill<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>content</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>description</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_id</td><td>true</td></tr></tbody></table> |
|
||||
| WorkspaceBuild<br><i>start, stop</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>build_number</td><td>false</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>daily_cost</td><td>false</td></tr><tr><td>deadline</td><td>false</td></tr><tr><td>has_ai_task</td><td>false</td></tr><tr><td>has_external_agent</td><td>false</td></tr><tr><td>id</td><td>false</td></tr><tr><td>initiator_by_avatar_url</td><td>false</td></tr><tr><td>initiator_by_name</td><td>false</td></tr><tr><td>initiator_by_username</td><td>false</td></tr><tr><td>initiator_id</td><td>false</td></tr><tr><td>job_id</td><td>false</td></tr><tr><td>max_deadline</td><td>false</td></tr><tr><td>notified_autostop_deadline</td><td>false</td></tr><tr><td>reason</td><td>false</td></tr><tr><td>template_version_id</td><td>true</td></tr><tr><td>template_version_preset_id</td><td>false</td></tr><tr><td>transition</td><td>false</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>workspace_id</td><td>false</td></tr></tbody></table> |
|
||||
| WorkspaceProxy<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>created_at</td><td>true</td></tr><tr><td>deleted</td><td>false</td></tr><tr><td>derp_enabled</td><td>true</td></tr><tr><td>derp_only</td><td>true</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>icon</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>region_id</td><td>true</td></tr><tr><td>token_hashed_secret</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>url</td><td>true</td></tr><tr><td>version</td><td>true</td></tr><tr><td>wildcard_hostname</td><td>true</td></tr></tbody></table> |
|
||||
|
||||
@@ -49,7 +49,8 @@ Users can view their public key in their account settings:
|
||||
User secrets are developer-managed values that Coder injects at workspace start.
|
||||
If a user secret targets the same environment variable name or file path as a
|
||||
template-provided variable or file, Coder injects the user secret into that
|
||||
workspace. User secret values are covered by
|
||||
workspace. A secret can be disabled, in which case it is stored but not injected
|
||||
until it is re-enabled. User secret values are covered by
|
||||
[Database Encryption](./database-encryption.md) when it is enabled. See the
|
||||
[User secrets guide](../../user-guides/user-secrets.md).
|
||||
|
||||
|
||||
Generated
+28
-22
@@ -5117,6 +5117,7 @@ This is required on creation to enable a user-flow of validating a template work
|
||||
```json
|
||||
{
|
||||
"description": "string",
|
||||
"enabled": true,
|
||||
"env_name": "string",
|
||||
"file_path": "string",
|
||||
"name": "string",
|
||||
@@ -5126,13 +5127,14 @@ This is required on creation to enable a user-flow of validating a template work
|
||||
|
||||
### Properties
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
|---------------|--------|----------|--------------|-------------|
|
||||
| `description` | string | false | | |
|
||||
| `env_name` | string | false | | |
|
||||
| `file_path` | string | false | | |
|
||||
| `name` | string | false | | |
|
||||
| `value` | string | false | | |
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
|---------------|---------|----------|--------------|-------------|
|
||||
| `description` | string | false | | |
|
||||
| `enabled` | boolean | false | | |
|
||||
| `env_name` | string | false | | |
|
||||
| `file_path` | string | false | | |
|
||||
| `name` | string | false | | |
|
||||
| `value` | string | false | | |
|
||||
|
||||
## codersdk.CreateUserSkillRequest
|
||||
|
||||
@@ -13833,6 +13835,7 @@ If the schedule is empty, the user will be updated to use the default schedule.|
|
||||
```json
|
||||
{
|
||||
"description": "string",
|
||||
"enabled": true,
|
||||
"env_name": "string",
|
||||
"file_path": "string",
|
||||
"value": "string"
|
||||
@@ -13841,12 +13844,13 @@ If the schedule is empty, the user will be updated to use the default schedule.|
|
||||
|
||||
### Properties
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
|---------------|--------|----------|--------------|-------------|
|
||||
| `description` | string | false | | |
|
||||
| `env_name` | string | false | | |
|
||||
| `file_path` | string | false | | |
|
||||
| `value` | string | false | | |
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
|---------------|---------|----------|--------------|-------------|
|
||||
| `description` | string | false | | |
|
||||
| `enabled` | boolean | false | | |
|
||||
| `env_name` | string | false | | |
|
||||
| `file_path` | string | false | | |
|
||||
| `value` | string | false | | |
|
||||
|
||||
## codersdk.UpdateUserSkillRequest
|
||||
|
||||
@@ -14532,6 +14536,7 @@ If the schedule is empty, the user will be updated to use the default schedule.|
|
||||
{
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"description": "string",
|
||||
"enabled": true,
|
||||
"env_name": "string",
|
||||
"file_path": "string",
|
||||
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
|
||||
@@ -14542,15 +14547,16 @@ If the schedule is empty, the user will be updated to use the default schedule.|
|
||||
|
||||
### Properties
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
|---------------|--------|----------|--------------|-------------|
|
||||
| `created_at` | string | false | | |
|
||||
| `description` | string | false | | |
|
||||
| `env_name` | string | false | | |
|
||||
| `file_path` | string | false | | |
|
||||
| `id` | string | false | | |
|
||||
| `name` | string | false | | |
|
||||
| `updated_at` | string | false | | |
|
||||
| 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.UserSkill
|
||||
|
||||
|
||||
Generated
+29
-20
@@ -28,6 +28,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/secrets \
|
||||
{
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"description": "string",
|
||||
"enabled": true,
|
||||
"env_name": "string",
|
||||
"file_path": "string",
|
||||
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
|
||||
@@ -47,16 +48,17 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/secrets \
|
||||
|
||||
Status Code **200**
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
|-----------------|-------------------|----------|--------------|-------------|
|
||||
| `[array item]` | array | false | | |
|
||||
| `» created_at` | string(date-time) | false | | |
|
||||
| `» description` | string | false | | |
|
||||
| `» 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 | | |
|
||||
| `» file_path` | string | false | | |
|
||||
| `» id` | string(uuid) | false | | |
|
||||
| `» name` | string | false | | |
|
||||
| `» updated_at` | string(date-time) | false | | |
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
@@ -79,6 +81,7 @@ curl -X POST http://coder-server:8080/api/v2/users/{user}/secrets \
|
||||
```json
|
||||
{
|
||||
"description": "string",
|
||||
"enabled": true,
|
||||
"env_name": "string",
|
||||
"file_path": "string",
|
||||
"name": "string",
|
||||
@@ -101,6 +104,7 @@ curl -X POST http://coder-server:8080/api/v2/users/{user}/secrets \
|
||||
{
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"description": "string",
|
||||
"enabled": true,
|
||||
"env_name": "string",
|
||||
"file_path": "string",
|
||||
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
|
||||
@@ -156,6 +160,7 @@ curl -X POST http://coder-server:8080/api/v2/users/{user}/secrets/batch \
|
||||
{
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"description": "string",
|
||||
"enabled": true,
|
||||
"env_name": "string",
|
||||
"file_path": "string",
|
||||
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
|
||||
@@ -178,16 +183,17 @@ curl -X POST http://coder-server:8080/api/v2/users/{user}/secrets/batch \
|
||||
|
||||
Status Code **201**
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
|-----------------|-------------------|----------|--------------|-------------|
|
||||
| `[array item]` | array | false | | |
|
||||
| `» created_at` | string(date-time) | false | | |
|
||||
| `» description` | string | false | | |
|
||||
| `» 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 | | |
|
||||
| `» file_path` | string | false | | |
|
||||
| `» id` | string(uuid) | false | | |
|
||||
| `» name` | string | false | | |
|
||||
| `» updated_at` | string(date-time) | false | | |
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
@@ -219,6 +225,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/secrets/{name} \
|
||||
{
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"description": "string",
|
||||
"enabled": true,
|
||||
"env_name": "string",
|
||||
"file_path": "string",
|
||||
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
|
||||
@@ -281,6 +288,7 @@ curl -X PATCH http://coder-server:8080/api/v2/users/{user}/secrets/{name} \
|
||||
```json
|
||||
{
|
||||
"description": "string",
|
||||
"enabled": true,
|
||||
"env_name": "string",
|
||||
"file_path": "string",
|
||||
"value": "string"
|
||||
@@ -303,6 +311,7 @@ curl -X PATCH http://coder-server:8080/api/v2/users/{user}/secrets/{name} \
|
||||
{
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"description": "string",
|
||||
"enabled": true,
|
||||
"env_name": "string",
|
||||
"file_path": "string",
|
||||
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
|
||||
|
||||
Generated
+8
-6
@@ -39,9 +39,11 @@ coder secret
|
||||
|
||||
## Subcommands
|
||||
|
||||
| Name | Purpose |
|
||||
|-------------------------------------------|-----------------------------------|
|
||||
| [<code>create</code>](./secret_create.md) | Create a secret |
|
||||
| [<code>update</code>](./secret_update.md) | Update a secret |
|
||||
| [<code>list</code>](./secret_list.md) | List secrets, or show one by name |
|
||||
| [<code>delete</code>](./secret_delete.md) | Delete a secret |
|
||||
| Name | Purpose |
|
||||
|---------------------------------------------|---------------------------------------------------|
|
||||
| [<code>create</code>](./secret_create.md) | Create a secret |
|
||||
| [<code>update</code>](./secret_update.md) | Update a secret |
|
||||
| [<code>enable</code>](./secret_enable.md) | Enable a secret so it is injected into workspaces |
|
||||
| [<code>disable</code>](./secret_disable.md) | Disable a secret without removing it |
|
||||
| [<code>list</code>](./secret_list.md) | List secrets, or show one by name |
|
||||
| [<code>delete</code>](./secret_delete.md) | Delete a secret |
|
||||
|
||||
Generated
+9
@@ -48,3 +48,12 @@ Name of the workspace environment variable that this secret will set.
|
||||
| Type | <code>string</code> |
|
||||
|
||||
Workspace file path where this secret will be written. Must start with ~/ or /.
|
||||
|
||||
### --enabled
|
||||
|
||||
| | |
|
||||
|---------|-------------------|
|
||||
| Type | <code>bool</code> |
|
||||
| Default | <code>true</code> |
|
||||
|
||||
Whether the secret is injected into workspaces. An enabled secret must set --env or --file; pass --enabled=false to store a secret without injecting it.
|
||||
|
||||
Generated
+10
@@ -0,0 +1,10 @@
|
||||
<!-- DO NOT EDIT | GENERATED CONTENT -->
|
||||
# secret disable
|
||||
|
||||
Disable a secret without removing it
|
||||
|
||||
## Usage
|
||||
|
||||
```console
|
||||
coder secret disable <name>
|
||||
```
|
||||
Generated
+10
@@ -0,0 +1,10 @@
|
||||
<!-- DO NOT EDIT | GENERATED CONTENT -->
|
||||
# secret enable
|
||||
|
||||
Enable a secret so it is injected into workspaces
|
||||
|
||||
## Usage
|
||||
|
||||
```console
|
||||
coder secret enable <name>
|
||||
```
|
||||
Generated
+4
-4
@@ -23,10 +23,10 @@ Secret values are omitted from the output.
|
||||
|
||||
### -c, --column
|
||||
|
||||
| | |
|
||||
|---------|---------------------------------------------------------------|
|
||||
| Type | <code>[created\|name\|updated\|env\|file\|description]</code> |
|
||||
| Default | <code>name,created,updated,env,file,description</code> |
|
||||
| | |
|
||||
|---------|------------------------------------------------------------------------|
|
||||
| Type | <code>[created\|name\|updated\|env\|file\|enabled\|description]</code> |
|
||||
| Default | <code>name,created,updated,env,file,enabled,description</code> |
|
||||
|
||||
Columns to display in table output.
|
||||
|
||||
|
||||
Generated
+9
-1
@@ -12,7 +12,7 @@ coder secret update [flags] <name>
|
||||
## Description
|
||||
|
||||
```console
|
||||
At least one of --value, --description, --env, or --file must be specified. Provide the secret value by at most one of --value or non-interactive stdin (pipe or redirect).
|
||||
At least one of --value, --description, --env, --file, or --enabled must be specified. Provide the secret value by at most one of --value or non-interactive stdin (pipe or redirect).
|
||||
```
|
||||
|
||||
## Options
|
||||
@@ -48,3 +48,11 @@ Name of the workspace environment variable that this secret will set. Pass an em
|
||||
| Type | <code>string</code> |
|
||||
|
||||
Workspace file path where this secret will be written. Must start with ~/ or /. Pass an empty string to clear it.
|
||||
|
||||
### --enabled
|
||||
|
||||
| | |
|
||||
|------|-------------------|
|
||||
| Type | <code>bool</code> |
|
||||
|
||||
Whether the secret is injected into workspaces. An enabled secret must keep at least one of --env or --file; pass --enabled=false to stop injecting it without deleting it.
|
||||
|
||||
@@ -11,9 +11,19 @@ Each user secret has:
|
||||
- A value, which contains the sensitive content.
|
||||
- An optional description.
|
||||
- An optional environment variable target, file target, or both.
|
||||
- An enabled flag that controls whether Coder injects the secret into your
|
||||
workspaces.
|
||||
|
||||
A secret without an environment variable target or file target is stored, but is
|
||||
not injected into workspaces.
|
||||
An enabled secret must have at least one of an environment variable target or a
|
||||
file target. To keep a secret stored without injecting it, disable it
|
||||
(`enabled = false`) instead of clearing both targets. A create or update that
|
||||
would leave an enabled secret with no target is rejected with a 400 and
|
||||
directs you to disable the secret instead.
|
||||
|
||||
Disabled secrets stay visible and editable in the CLI, REST API, and dashboard,
|
||||
but are not injected into workspaces. Secrets that predate the enabled flag and
|
||||
had no target were migrated to disabled, so they show as disabled and need a
|
||||
target before you can enable them.
|
||||
|
||||
User secrets apply to all workspaces that you own.
|
||||
|
||||
@@ -39,6 +49,12 @@ time the workspace agent reconnects to Coder, for example after the workspace
|
||||
or the agent restarts. To pick up a change to a secret while a workspace is
|
||||
running, restart the workspace.
|
||||
|
||||
Disabling a secret (`coder secret disable`) stops it from being injected from
|
||||
the next workspace start onward. Running sessions keep values that were already
|
||||
injected until the agent manifest is refetched, which happens on workspace
|
||||
restart. Disabling does not remove a file that was already written; the same
|
||||
"Coder never deletes secret files" rule below applies.
|
||||
|
||||
### Environment variable secrets
|
||||
|
||||
Coder injects environment variable secrets into every new shell, terminal,
|
||||
@@ -46,11 +62,13 @@ app, SSH session, and startup script that you start in your workspace.
|
||||
Existing shells and processes keep the environment they were given when they
|
||||
started.
|
||||
|
||||
| If you... | ...then in your workspace |
|
||||
|--------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| Create or update an env secret | The change applies after the next workspace start. Until then, your running workspace continues to use the secrets it had when it last started. |
|
||||
| Rename the env var (`--env NEW_NAME`) | After the next workspace start, new shells get `NEW_NAME` and the old name is no longer set. |
|
||||
| Clear the env target (`--env ""`) or delete the secret | After the next workspace start, the variable is no longer injected. |
|
||||
| If you... | ...then in your workspace |
|
||||
|---------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| Create or update an env secret | The change applies after the next workspace start. Until then, your running workspace continues to use the secrets it had when it last started. |
|
||||
| Rename the env var (`--env NEW_NAME`) | After the next workspace start, new shells get `NEW_NAME` and the old name is no longer set. |
|
||||
| Clear the env target (`--env ""`) | Only succeeds if the secret keeps its file target or is disabled in the same request; otherwise the request is rejected with a 400. After the next workspace start, the variable is no longer injected. |
|
||||
| Disable the secret (`coder secret disable`) | After the next workspace start, the variable is no longer injected. Running sessions keep the value until the agent manifest is refetched (workspace restart). |
|
||||
| Delete the secret | After the next workspace start, the variable is no longer injected. |
|
||||
|
||||
To pick up a change in a long-running shell or app started after a restart,
|
||||
restart that shell or app.
|
||||
@@ -62,11 +80,13 @@ starts, before any startup scripts run. New parent directories are created as
|
||||
needed. If the file already exists, Coder overwrites the contents and leaves
|
||||
the existing permissions alone.
|
||||
|
||||
| If you... | ...then in your workspace |
|
||||
|----------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------|
|
||||
| Create or update a file secret | The file is written or overwritten at the next workspace start. |
|
||||
| Change the file path (`--file NEW_PATH`) | At the next workspace start, a file is written at `NEW_PATH`. **The file at the previous path stays on disk with its old value.** |
|
||||
| Clear the file target (`--file ""`) or delete the secret | **The previously-written file stays on disk with its last value.** |
|
||||
| If you... | ...then in your workspace |
|
||||
|---------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| Create or update a file secret | The file is written or overwritten at the next workspace start. |
|
||||
| Change the file path (`--file NEW_PATH`) | At the next workspace start, a file is written at `NEW_PATH`. **The file at the previous path stays on disk with its old value.** |
|
||||
| Clear the file target (`--file ""`) | Only succeeds if the secret keeps its env target or is disabled in the same request; otherwise the request is rejected with a 400. **The previously-written file stays on disk with its last value.** |
|
||||
| Disable the secret (`coder secret disable`) | The file is no longer written at the next workspace start. **The previously-written file stays on disk with its last value.** |
|
||||
| Delete the secret | **The previously-written file stays on disk with its last value.** |
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Coder never deletes secret files it has written for you. If you remove a
|
||||
@@ -120,7 +140,10 @@ You can create, edit, and delete user secrets from the Coder dashboard:
|
||||
|
||||
From this page you can add a new secret, update an existing secret's value,
|
||||
description, or environment variable and file targets, and delete secrets you
|
||||
no longer need.
|
||||
no longer need. Each row has an enable/disable toggle that controls whether
|
||||
Coder injects the secret. A secret with no environment variable or file target
|
||||
cannot be enabled from the dashboard; the toggle is disabled with a tooltip,
|
||||
mirroring the API invariant that an enabled secret must have a target.
|
||||
|
||||
The rest of this guide shows the equivalent CLI commands. The same behaviors,
|
||||
limits, and injection rules apply whether you manage secrets from the
|
||||
@@ -194,11 +217,21 @@ want to store a trailing newline:
|
||||
echo -n "$API_KEY" | coder secret create api-key --env API_KEY
|
||||
```
|
||||
|
||||
### Create a disabled secret
|
||||
|
||||
An enabled secret must set `--env`, `--file`, or both. To store a secret
|
||||
without injecting it, pass `--enabled=false`. You can add a target and enable
|
||||
it later with `coder secret enable`.
|
||||
|
||||
```sh
|
||||
echo -n "$API_KEY" | coder secret create api-key --enabled=false
|
||||
```
|
||||
|
||||
## Update a secret
|
||||
|
||||
Use `coder secret update` to update a secret value, description, environment
|
||||
variable target, or file target. At least one of `--value`, `--description`,
|
||||
`--env`, or `--file` must be specified.
|
||||
`--env`, `--file`, or `--enabled` must be specified.
|
||||
|
||||
```sh
|
||||
# Update a secret value.
|
||||
@@ -207,10 +240,26 @@ echo -n "$NEW_API_KEY" | coder secret update api-key
|
||||
# Change the environment variable target.
|
||||
coder secret update api-key --env NEW_API_KEY
|
||||
|
||||
# Clear the file injection target while keeping the secret.
|
||||
# Clear the file injection target while keeping the secret. This only
|
||||
# succeeds because api-key still has an environment variable target; a
|
||||
# request that clears the last target of an enabled secret is rejected.
|
||||
coder secret update api-key --file ""
|
||||
```
|
||||
|
||||
### Enable and disable a secret
|
||||
|
||||
Disable a secret to stop injecting it without deleting it, then enable it again
|
||||
to resume. Enabling a secret that has no target is rejected; add a target
|
||||
first.
|
||||
|
||||
```sh
|
||||
# Stop injecting a secret without deleting it.
|
||||
coder secret disable api-key
|
||||
|
||||
# Resume injection.
|
||||
coder secret enable api-key
|
||||
```
|
||||
|
||||
## List and delete secrets
|
||||
|
||||
List, show, and delete your secrets with the `coder secret` CLI:
|
||||
@@ -227,7 +276,8 @@ coder secret delete api-key
|
||||
```
|
||||
|
||||
The list and show commands return secret metadata only. They never return the
|
||||
secret value.
|
||||
secret value. The `coder secret list` table includes an `enabled` column so you
|
||||
can see which secrets are currently injected.
|
||||
|
||||
See [How your secrets reach a workspace](#how-your-secrets-reach-a-workspace)
|
||||
for what happens to running workspaces when you delete a secret.
|
||||
|
||||
@@ -503,6 +503,7 @@ var auditableResourcesTypes = map[any]map[string]Action{
|
||||
"description": ActionTrack,
|
||||
"env_name": ActionTrack,
|
||||
"file_path": ActionTrack,
|
||||
"enabled": ActionTrack,
|
||||
|
||||
"value": ActionSecret,
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ func TestUserSecretAuditDiffRedaction(t *testing.T) {
|
||||
Name: "createDiff-target",
|
||||
Description: initialDescription,
|
||||
Value: initialValue,
|
||||
EnvName: "CREATE_DIFF_TARGET",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
|
||||
@@ -95,6 +95,8 @@ func Rotate(ctx context.Context, log slog.Logger, sqlDB *sql.DB, ciphers []Ciphe
|
||||
EnvName: "",
|
||||
UpdateFilePath: false,
|
||||
FilePath: "",
|
||||
UpdateEnabled: false,
|
||||
Enabled: false,
|
||||
}); err != nil {
|
||||
return xerrors.Errorf("rotate user secret user_id=%s name=%s: %w", uid, secret.Name, err)
|
||||
}
|
||||
@@ -307,6 +309,8 @@ func Decrypt(ctx context.Context, log slog.Logger, sqlDB *sql.DB, ciphers []Ciph
|
||||
EnvName: "",
|
||||
UpdateFilePath: false,
|
||||
FilePath: "",
|
||||
UpdateEnabled: false,
|
||||
Enabled: false,
|
||||
}); err != nil {
|
||||
return xerrors.Errorf("decrypt user secret user_id=%s name=%s: %w", uid, secret.Name, err)
|
||||
}
|
||||
|
||||
@@ -484,6 +484,7 @@ describe("api.ts", () => {
|
||||
description: "Example token for tests",
|
||||
env_name: secretName,
|
||||
file_path: "",
|
||||
enabled: true,
|
||||
created_at: "2026-05-04T00:00:00Z",
|
||||
updated_at: "2026-05-04T00:00:00Z",
|
||||
};
|
||||
|
||||
Generated
+27
-3
@@ -4310,8 +4310,11 @@ export interface CreateUserRequestWithOrgs {
|
||||
// From codersdk/usersecrets.go
|
||||
/**
|
||||
* CreateUserSecretRequest is the payload for creating a new user
|
||||
* secret. Name and Value are required. All other fields are optional
|
||||
* and default to empty string.
|
||||
* 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
|
||||
* target; to keep a secret without injecting it, set Enabled to false.
|
||||
* All other fields are optional and default to empty string. Enabled
|
||||
* defaults to true when omitted.
|
||||
*/
|
||||
export interface CreateUserSecretRequest {
|
||||
readonly name: string;
|
||||
@@ -4319,6 +4322,7 @@ export interface CreateUserSecretRequest {
|
||||
readonly description?: string;
|
||||
readonly env_name?: string;
|
||||
readonly file_path?: string;
|
||||
readonly enabled?: boolean;
|
||||
}
|
||||
|
||||
// From codersdk/userskills.go
|
||||
@@ -9958,13 +9962,16 @@ export interface UpdateUserQuietHoursScheduleRequest {
|
||||
* UpdateUserSecretRequest is the payload for partially updating a
|
||||
* user secret. At least one field must be non-nil. Pointer fields
|
||||
* distinguish "not sent" (nil) from "set to empty string" (pointer
|
||||
* to empty string).
|
||||
* to empty string). If the post-update row is enabled it must still
|
||||
* have at least one of EnvName or FilePath non-empty; clearing both
|
||||
* targets is only allowed when the secret is (or becomes) disabled.
|
||||
*/
|
||||
export interface UpdateUserSecretRequest {
|
||||
readonly value?: string;
|
||||
readonly description?: string;
|
||||
readonly env_name?: string;
|
||||
readonly file_path?: string;
|
||||
readonly enabled?: boolean;
|
||||
}
|
||||
|
||||
// From codersdk/userskills.go
|
||||
@@ -10468,6 +10475,13 @@ export interface UserSecret {
|
||||
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;
|
||||
}
|
||||
@@ -10488,6 +10502,16 @@ export const UserSecretEnvNameField = "env_name";
|
||||
*/
|
||||
export const UserSecretFilePathField = "file_path";
|
||||
|
||||
// From codersdk/usersecretvalidation.go
|
||||
/**
|
||||
* UserSecretInjectionTargetRequiredDetail explains the injection-target
|
||||
* invariant. It is shared by the create validator above and the PATCH
|
||||
* handler's post-state check in coderd. The value is a user-facing
|
||||
* validation message, not a credential.
|
||||
*/
|
||||
export const UserSecretInjectionTargetRequiredDetail =
|
||||
"An enabled secret must have at least one of env_name or file_path set. To keep a secret without injecting it, set enabled to false instead of clearing both targets."; //nolint:gosec // G101: message text, not a hardcoded credential.
|
||||
|
||||
// From codersdk/usersecretvalidation.go
|
||||
/**
|
||||
* UserSecret*Field constants are the canonical ValidationError.Field values
|
||||
|
||||
@@ -65,6 +65,26 @@ const SecretsPage: FC = () => {
|
||||
throw error;
|
||||
}
|
||||
}}
|
||||
onToggleSecretEnabled={async (secret, enabled) => {
|
||||
try {
|
||||
await updateSecretMutation.mutateAsync({
|
||||
name: secret.name,
|
||||
request: { enabled },
|
||||
});
|
||||
toast.success(
|
||||
`${enabled ? "Enabled" : "Disabled"} secret "${secret.name}".`,
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
getErrorMessage(
|
||||
error,
|
||||
`Failed to ${enabled ? "enable" : "disable"} secret.`,
|
||||
),
|
||||
{ description: getErrorDetail(error) },
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -29,6 +29,7 @@ const meta: Meta<typeof SecretsPageView> = {
|
||||
onCreateSecret: fn(),
|
||||
onUpdateSecret: fn(),
|
||||
onDeleteSecret: fn(),
|
||||
onToggleSecretEnabled: fn(),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -45,6 +46,9 @@ type UpdateSecretMock = ReturnType<
|
||||
type DeleteSecretMock = ReturnType<
|
||||
typeof fn<(secret: UserSecret) => Promise<void> | void>
|
||||
>;
|
||||
type ToggleSecretEnabledMock = ReturnType<
|
||||
typeof fn<(secret: UserSecret, enabled: boolean) => Promise<void> | void>
|
||||
>;
|
||||
|
||||
const waitForDialogToClose = async (body: ReturnType<typeof within>) => {
|
||||
await waitFor(() => {
|
||||
@@ -64,6 +68,7 @@ const createSecretFromRequest = (
|
||||
description: request.description ?? "",
|
||||
env_name: request.env_name ?? "",
|
||||
file_path: request.file_path ?? "",
|
||||
enabled: request.enabled ?? true,
|
||||
created_at: "2026-05-04T00:00:00Z",
|
||||
updated_at: "2026-05-04T00:00:00Z",
|
||||
});
|
||||
@@ -597,3 +602,69 @@ export const CreateMutationErrorDisplay: Story = {
|
||||
expectNoValueField(body);
|
||||
},
|
||||
};
|
||||
|
||||
export const ToggleEnabledSubmit: Story = {
|
||||
args: {
|
||||
onToggleSecretEnabled: fn<
|
||||
(secret: UserSecret, enabled: boolean) => Promise<void>
|
||||
>(async () => {}),
|
||||
},
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const onToggleSecretEnabled =
|
||||
args.onToggleSecretEnabled as ToggleSecretEnabledMock;
|
||||
onToggleSecretEnabled.mockClear();
|
||||
const user = userEvent.setup();
|
||||
const canvas = within(canvasElement);
|
||||
const secret = findVisibleSecretByName("EXAMPLE_TOKEN");
|
||||
|
||||
const toggle = canvas.getByRole("switch", {
|
||||
name: `Toggle secret ${secret.name}`,
|
||||
});
|
||||
await expect(toggle).toBeChecked();
|
||||
await user.click(toggle);
|
||||
|
||||
await waitFor(() => expect(onToggleSecretEnabled).toHaveBeenCalledTimes(1));
|
||||
expect(onToggleSecretEnabled).toHaveBeenCalledWith(secret, false);
|
||||
},
|
||||
};
|
||||
|
||||
export const ToggleEnabledMutationErrorDisplay: Story = {
|
||||
args: {
|
||||
onToggleSecretEnabled: fn<
|
||||
(secret: UserSecret, enabled: boolean) => Promise<void>
|
||||
>(async () => {
|
||||
throw mockApiError({ message: "Failed to disable secret." });
|
||||
}),
|
||||
},
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const onToggleSecretEnabled =
|
||||
args.onToggleSecretEnabled as ToggleSecretEnabledMock;
|
||||
onToggleSecretEnabled.mockClear();
|
||||
const user = userEvent.setup();
|
||||
const canvas = within(canvasElement);
|
||||
const secret = findVisibleSecretByName("EXAMPLE_TOKEN");
|
||||
|
||||
const toggle = canvas.getByRole("switch", {
|
||||
name: `Toggle secret ${secret.name}`,
|
||||
});
|
||||
await user.click(toggle);
|
||||
|
||||
await waitFor(() => expect(onToggleSecretEnabled).toHaveBeenCalledTimes(1));
|
||||
// Handler rejected; the parent owns the secret state so the switch
|
||||
// remains checked in this story where no state change is applied.
|
||||
await expect(toggle).toBeChecked();
|
||||
},
|
||||
};
|
||||
|
||||
export const ToggleEnabledDisabledForTargetlessSecret: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const secret = findVisibleSecretByName("SERVICE_PASSWORD");
|
||||
|
||||
const toggle = canvas.getByRole("switch", {
|
||||
name: `Toggle secret ${secret.name}`,
|
||||
});
|
||||
await expect(toggle).not.toBeChecked();
|
||||
await expect(toggle).toBeDisabled();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -36,6 +36,10 @@ type SecretsPageViewProps = {
|
||||
request: UpdateUserSecretRequest,
|
||||
) => Promise<UserSecret> | UserSecret;
|
||||
onDeleteSecret: (secret: UserSecret) => Promise<void> | void;
|
||||
onToggleSecretEnabled: (
|
||||
secret: UserSecret,
|
||||
enabled: boolean,
|
||||
) => Promise<void> | void;
|
||||
};
|
||||
|
||||
type SecretDialogState =
|
||||
@@ -55,6 +59,7 @@ export const SecretsPageView: FC<SecretsPageViewProps> = ({
|
||||
onCreateSecret,
|
||||
onUpdateSecret,
|
||||
onDeleteSecret,
|
||||
onToggleSecretEnabled,
|
||||
}) => {
|
||||
const [dialogState, setDialogState] = useState<SecretDialogState>({
|
||||
mode: "add",
|
||||
@@ -143,6 +148,7 @@ export const SecretsPageView: FC<SecretsPageViewProps> = ({
|
||||
onAddSecret={openAddSecret}
|
||||
onEditSecret={openEditSecret}
|
||||
onDeleteSecret={onDeleteSecret}
|
||||
onToggleEnabled={onToggleSecretEnabled}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "#/components/DropdownMenu/DropdownMenu";
|
||||
import { Switch } from "#/components/Switch/Switch";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -21,6 +22,11 @@ import {
|
||||
} from "#/components/Table/Table";
|
||||
import { TableEmpty } from "#/components/TableEmpty/TableEmpty";
|
||||
import { TableLoader } from "#/components/TableLoader/TableLoader";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "#/components/Tooltip/Tooltip";
|
||||
import { relativeTime } from "#/utils/time";
|
||||
|
||||
type SecretsTableProps = {
|
||||
@@ -34,6 +40,10 @@ type SecretsTableProps = {
|
||||
returnFocusElement?: HTMLElement | null,
|
||||
) => void;
|
||||
onDeleteSecret: (secret: UserSecret) => Promise<void> | void;
|
||||
onToggleEnabled: (
|
||||
secret: UserSecret,
|
||||
enabled: boolean,
|
||||
) => Promise<void> | void;
|
||||
};
|
||||
|
||||
export const SecretsTable: FC<SecretsTableProps> = ({
|
||||
@@ -44,8 +54,25 @@ export const SecretsTable: FC<SecretsTableProps> = ({
|
||||
onAddSecret,
|
||||
onEditSecret,
|
||||
onDeleteSecret,
|
||||
onToggleEnabled,
|
||||
}) => {
|
||||
const [secretToDelete, setSecretToDelete] = useState<UserSecret>();
|
||||
const [togglingSecretId, setTogglingSecretId] = useState<string | null>(null);
|
||||
|
||||
const handleToggle = (secret: UserSecret, enabled: boolean) => {
|
||||
setTogglingSecretId(secret.id);
|
||||
void Promise.resolve()
|
||||
.then(() => onToggleEnabled(secret, enabled))
|
||||
.catch(() => {
|
||||
// onToggleEnabled reports failures with a toast before rejecting.
|
||||
// Swallow the rejection here to avoid an unhandled promise rejection warning.
|
||||
})
|
||||
.finally(() => {
|
||||
setTogglingSecretId((current) =>
|
||||
current === secret.id ? null : current,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -69,12 +96,13 @@ export const SecretsTable: FC<SecretsTableProps> = ({
|
||||
<Table aria-label="User secrets">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[16%]">Name</TableHead>
|
||||
<TableHead className="w-[14%]">Environment variable</TableHead>
|
||||
<TableHead className="w-[18%]">File path</TableHead>
|
||||
<TableHead className="w-[11%]">Type</TableHead>
|
||||
<TableHead className="w-[23%]">Description</TableHead>
|
||||
<TableHead className="w-[12%]">Updated</TableHead>
|
||||
<TableHead className="w-[14%]">Name</TableHead>
|
||||
<TableHead className="w-[13%]">Environment variable</TableHead>
|
||||
<TableHead className="w-[16%]">File path</TableHead>
|
||||
<TableHead className="w-[10%]">Type</TableHead>
|
||||
<TableHead className="w-[20%]">Description</TableHead>
|
||||
<TableHead className="w-[11%]">Updated</TableHead>
|
||||
<TableHead className="w-[15%]">Enabled</TableHead>
|
||||
<TableHead className="w-[1%]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -115,6 +143,13 @@ export const SecretsTable: FC<SecretsTableProps> = ({
|
||||
<TableCell data-pixel="ignore">
|
||||
{relativeTime(secret.updated_at)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<EnabledToggle
|
||||
secret={secret}
|
||||
isPending={togglingSecretId === secret.id}
|
||||
onToggle={handleToggle}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<SecretRowActions
|
||||
secret={secret}
|
||||
@@ -160,6 +195,66 @@ const SecretTypeBadge: FC<{ secret: UserSecret }> = ({ secret }) => {
|
||||
return <Badge>not injected</Badge>;
|
||||
};
|
||||
|
||||
type EnabledToggleProps = {
|
||||
secret: UserSecret;
|
||||
isPending: boolean;
|
||||
onToggle: (secret: UserSecret, enabled: boolean) => void;
|
||||
};
|
||||
|
||||
const EnabledToggle: FC<EnabledToggleProps> = ({
|
||||
secret,
|
||||
isPending,
|
||||
onToggle,
|
||||
}) => {
|
||||
const hasTarget = Boolean(secret.env_name) || Boolean(secret.file_path);
|
||||
// An enabled secret must have at least one injection target. Prevent
|
||||
// enabling a target-less secret; the user must add a target first.
|
||||
const cannotEnable = !secret.enabled && !hasTarget;
|
||||
const label = `Toggle secret ${secret.name}`;
|
||||
const stateLabel = secret.enabled ? "Enabled" : "Disabled";
|
||||
|
||||
const control = (
|
||||
<Switch
|
||||
aria-label={label}
|
||||
checked={secret.enabled}
|
||||
disabled={isPending || cannotEnable}
|
||||
onCheckedChange={(checked) => onToggle(secret, checked)}
|
||||
/>
|
||||
);
|
||||
|
||||
if (cannotEnable) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{/*
|
||||
* Wrap the disabled Switch in a focusable span so the
|
||||
* tooltip can be triggered by keyboard and pointer.
|
||||
* biome-ignore lint/a11y/noNoninteractiveTabindex: needed to
|
||||
* surface the tooltip on a disabled control via keyboard focus.
|
||||
*/}
|
||||
<span tabIndex={0} className="inline-flex">
|
||||
{control}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Add an environment variable or file path before enabling this
|
||||
secret.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<span className="text-content-secondary text-xs">{stateLabel}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{control}
|
||||
<span className="text-content-secondary text-xs">{stateLabel}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type SecretRowActionsProps = {
|
||||
secret: UserSecret;
|
||||
onEditSecret: (
|
||||
|
||||
@@ -14,6 +14,7 @@ const existingSecrets: UserSecret[] = [
|
||||
description: "Service token",
|
||||
env_name: "SERVICE_TOKEN",
|
||||
file_path: "",
|
||||
enabled: true,
|
||||
created_at: "2026-05-04T00:00:00Z",
|
||||
updated_at: "2026-05-04T00:00:00Z",
|
||||
},
|
||||
@@ -23,6 +24,7 @@ const existingSecrets: UserSecret[] = [
|
||||
description: "",
|
||||
env_name: "SERVICE_API_KEY",
|
||||
file_path: "~/.config/service/key",
|
||||
enabled: true,
|
||||
created_at: "2026-05-04T00:00:00Z",
|
||||
updated_at: "2026-05-04T00:00:00Z",
|
||||
},
|
||||
|
||||
@@ -578,6 +578,7 @@ export const MockUserSecrets: TypesGen.UserSecret[] = [
|
||||
description: "Used by example templates.",
|
||||
env_name: "EXAMPLE_TOKEN",
|
||||
file_path: "",
|
||||
enabled: true,
|
||||
created_at: "2026-04-28T16:30:00Z",
|
||||
updated_at: "2026-04-30T16:30:00Z",
|
||||
},
|
||||
@@ -587,6 +588,7 @@ export const MockUserSecrets: TypesGen.UserSecret[] = [
|
||||
description: "Mounted as a workspace file.",
|
||||
env_name: "",
|
||||
file_path: "~/.config/example/config.json",
|
||||
enabled: true,
|
||||
created_at: "2026-04-29T16:30:00Z",
|
||||
updated_at: "2026-05-01T16:30:00Z",
|
||||
},
|
||||
@@ -596,15 +598,20 @@ export const MockUserSecrets: TypesGen.UserSecret[] = [
|
||||
description: "Available as an environment variable and file.",
|
||||
env_name: "SERVICE_API_KEY",
|
||||
file_path: "/var/run/secrets/service-api-key",
|
||||
enabled: true,
|
||||
created_at: "2026-04-30T16:30:00Z",
|
||||
updated_at: "2026-05-02T16:30:00Z",
|
||||
},
|
||||
{
|
||||
// Mirrors a pre-migration secret that had both env_name and
|
||||
// file_path empty. The migration flips such rows to
|
||||
// enabled: false, so this is the shape they have after upgrade.
|
||||
id: "secret-not-injected",
|
||||
name: "SERVICE_PASSWORD",
|
||||
description: "",
|
||||
env_name: "",
|
||||
file_path: "",
|
||||
enabled: false,
|
||||
created_at: "2026-05-01T16:30:00Z",
|
||||
updated_at: "2026-05-03T16:30:00Z",
|
||||
},
|
||||
@@ -614,6 +621,7 @@ export const MockUserSecrets: TypesGen.UserSecret[] = [
|
||||
description: "Used to exercise duplicate validation.",
|
||||
env_name: "DUPLICATE_API_KEY",
|
||||
file_path: "",
|
||||
enabled: true,
|
||||
created_at: "2026-05-01T18:30:00Z",
|
||||
updated_at: "2026-05-03T18:30:00Z",
|
||||
},
|
||||
|
||||
@@ -444,6 +444,7 @@ function userSecretFromCreateRequest(
|
||||
description: request.description ?? "",
|
||||
env_name: request.env_name ?? "",
|
||||
file_path: request.file_path ?? "",
|
||||
enabled: request.enabled ?? true,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user