mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add enable/disable support for user secrets (#27537)
Users can now disable a secret to stop it from being injected into workspaces without deleting it, and re-enable it later. Disabled secrets stay visible and editable everywhere they already appear. An enabled secret must have at least one injection target; a secret with no target can be stored only while disabled. Existing target-less secrets are migrated to disabled to preserve current behavior. Support spans the REST API, SDK, CLI, dashboard, and audit log.
This commit is contained in:
+17
-5
@@ -18,30 +18,42 @@ type UserSecret struct {
|
||||
Description string `json:"description"`
|
||||
EnvName string `json:"env_name"`
|
||||
FilePath string `json:"file_path"`
|
||||
CreatedAt time.Time `json:"created_at" format:"date-time"`
|
||||
UpdatedAt time.Time `json:"updated_at" format:"date-time"`
|
||||
// Enabled controls whether the secret is injected into workspaces.
|
||||
// Disabled secrets remain visible and editable, but are not added
|
||||
// to the agent manifest, so they are not exposed as environment
|
||||
// variables or written to secret files.
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedAt time.Time `json:"created_at" format:"date-time"`
|
||||
UpdatedAt time.Time `json:"updated_at" format:"date-time"`
|
||||
}
|
||||
|
||||
// CreateUserSecretRequest is the payload for creating a new user
|
||||
// secret. Name and Value are required. All other fields are optional
|
||||
// and default to empty string.
|
||||
// secret. Name and Value are required. An enabled secret must have at
|
||||
// least one of EnvName or FilePath non-empty so it has an injection
|
||||
// target; to keep a secret without injecting it, set Enabled to false.
|
||||
// All other fields are optional and default to empty string. Enabled
|
||||
// defaults to true when omitted.
|
||||
type CreateUserSecretRequest struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
Description string `json:"description,omitempty"`
|
||||
EnvName string `json:"env_name,omitempty"`
|
||||
FilePath string `json:"file_path,omitempty"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateUserSecretRequest is the payload for partially updating a
|
||||
// user secret. At least one field must be non-nil. Pointer fields
|
||||
// distinguish "not sent" (nil) from "set to empty string" (pointer
|
||||
// to empty string).
|
||||
// to empty string). If the post-update row is enabled it must still
|
||||
// have at least one of EnvName or FilePath non-empty; clearing both
|
||||
// targets is only allowed when the secret is (or becomes) disabled.
|
||||
type UpdateUserSecretRequest struct {
|
||||
Value *string `json:"value,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
EnvName *string `json:"env_name,omitempty"`
|
||||
FilePath *string `json:"file_path,omitempty"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
}
|
||||
|
||||
func (c *Client) CreateUserSecret(ctx context.Context, user string, req CreateUserSecretRequest) (UserSecret, error) {
|
||||
|
||||
@@ -87,6 +87,14 @@ func ParseSecretsFile(format SecretsFileFormat, content string) ([]CreateUserSec
|
||||
// so multiple empty env_names are allowed.
|
||||
if UserSecretEnvNameValid(e.key) == nil {
|
||||
req.EnvName = e.key
|
||||
} else {
|
||||
// Keys that cannot be env-injected (reserved names, invalid
|
||||
// identifiers) are imported without an injection target, so
|
||||
// they must be disabled: an enabled secret always has at
|
||||
// least one of env_name or file_path set. The user can add
|
||||
// a target and re-enable the secret afterwards.
|
||||
disabled := false
|
||||
req.Enabled = &disabled
|
||||
}
|
||||
reqs = append(reqs, req)
|
||||
}
|
||||
|
||||
@@ -406,9 +406,13 @@ func TestParseSecretsFileBestEffortEnvName(t *testing.T) {
|
||||
t.Parallel()
|
||||
reqs, err := codersdk.ParseSecretsFile(tc.format, tc.content)
|
||||
require.NoError(t, err)
|
||||
// Reserved keys cannot be env-injected, so they are imported
|
||||
// without an injection target and therefore disabled.
|
||||
disabled := false
|
||||
require.Equal(t, []codersdk.CreateUserSecretRequest{{
|
||||
Name: "PATH",
|
||||
Value: "value",
|
||||
Name: "PATH",
|
||||
Value: "value",
|
||||
Enabled: &disabled,
|
||||
}}, reqs)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -236,9 +236,27 @@ func ValidateCreateUserSecretRequest(req CreateUserSecretRequest) []ValidationEr
|
||||
if err := UserSecretFilePathValid(req.FilePath); err != nil {
|
||||
validations = append(validations, ValidationError{Field: UserSecretFilePathField, Detail: err.Error()})
|
||||
}
|
||||
// An enabled secret must have an injection target. The agent
|
||||
// manifest layer relies on this invariant so it can gate injection
|
||||
// solely on the enabled flag; "stored but not injected" is
|
||||
// expressed by enabled=false, not by clearing both targets.
|
||||
// Disabled secrets may have no target (e.g. bulk imports of keys
|
||||
// that cannot be env-injected).
|
||||
if req.EnvName == "" && req.FilePath == "" && (req.Enabled == nil || *req.Enabled) {
|
||||
validations = append(validations, ValidationError{
|
||||
Field: UserSecretEnvNameField,
|
||||
Detail: UserSecretInjectionTargetRequiredDetail,
|
||||
})
|
||||
}
|
||||
return validations
|
||||
}
|
||||
|
||||
// UserSecretInjectionTargetRequiredDetail explains the injection-target
|
||||
// invariant. It is shared by the create validator above and the PATCH
|
||||
// handler's post-state check in coderd. The value is a user-facing
|
||||
// validation message, not a credential.
|
||||
const UserSecretInjectionTargetRequiredDetail = "An enabled secret must have at least one of env_name or file_path set. To keep a secret without injecting it, set enabled to false instead of clearing both targets." //nolint:gosec // G101: message text, not a hardcoded credential.
|
||||
|
||||
// UserSecretNameValid validates a user secret name. Names are used in
|
||||
// API route path segments, so they must not include route separators.
|
||||
func UserSecretNameValid(s string) error {
|
||||
|
||||
@@ -29,13 +29,25 @@ func TestValidateCreateUserSecretRequest(t *testing.T) {
|
||||
{
|
||||
name: "MissingValue",
|
||||
req: codersdk.CreateUserSecretRequest{
|
||||
Name: "missing-value-secret",
|
||||
Name: "missing-value-secret",
|
||||
EnvName: "MISSING_VALUE_SECRET",
|
||||
},
|
||||
want: []codersdk.ValidationError{{
|
||||
Field: "value",
|
||||
Detail: "Value is required.",
|
||||
}},
|
||||
},
|
||||
{
|
||||
name: "MissingInjectionTarget",
|
||||
req: codersdk.CreateUserSecretRequest{
|
||||
Name: "missing-target-secret",
|
||||
Value: "value",
|
||||
},
|
||||
want: []codersdk.ValidationError{{
|
||||
Field: "env_name",
|
||||
Detail: codersdk.UserSecretInjectionTargetRequiredDetail,
|
||||
}},
|
||||
},
|
||||
{
|
||||
name: "MultiInvalid",
|
||||
req: codersdk.CreateUserSecretRequest{
|
||||
|
||||
Reference in New Issue
Block a user