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
+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{