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:
Zach
2026-07-28 09:58:33 -06:00
committed by GitHub
parent 3c61a9a939
commit 85984ff142
56 changed files with 1391 additions and 186 deletions
+104 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+5
View File
@@ -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.
+9
View File
@@ -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.
+9
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+5 -4
View File
@@ -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{
+8 -7
View File
@@ -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)
+10
View File
@@ -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"
},
+10
View File
@@ -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"
},
+1
View File
@@ -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
+2
View File
@@ -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,
}
+1
View File
@@ -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(&params)
+3 -1
View File
@@ -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")
}
+1
View File
@@ -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 {
+82 -1
View File
@@ -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) {
+27 -10
View File
@@ -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
}
+6 -3
View File
@@ -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 *;
+11 -1
View File
@@ -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
View File
@@ -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):
+25 -10
View File
@@ -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
View File
@@ -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)
+6 -4
View File
@@ -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
View File
@@ -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) {
+8
View File
@@ -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)
}
+6 -2
View File
@@ -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)
})
}
+18
View File
@@ -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 {
+13 -1
View File
@@ -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{
+1 -1
View File
@@ -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> |
+2 -1
View File
@@ -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).
+28 -22
View File
@@ -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
+29 -20
View File
@@ -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",
+8 -6
View File
@@ -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 |
+9
View File
@@ -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.
+10
View File
@@ -0,0 +1,10 @@
<!-- DO NOT EDIT | GENERATED CONTENT -->
# secret disable
Disable a secret without removing it
## Usage
```console
coder secret disable <name>
```
+10
View File
@@ -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>
```
+4 -4
View File
@@ -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.
+9 -1
View File
@@ -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.
+66 -16
View File
@@ -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.
+1
View File
@@ -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)
+4
View File
@@ -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)
}
+1
View File
@@ -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",
};
+27 -3
View File
@@ -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",
},
+8
View File
@@ -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",
},
+1
View File
@@ -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,
};