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
+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):