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:
+84
-1
@@ -28,6 +28,13 @@ const (
|
||||
userSecretsEnvBytesLimitConstraint database.CheckConstraint = "user_secrets_per_user_env_bytes_limit"
|
||||
)
|
||||
|
||||
// errUserSecretInjectionTargetRequired signals that a PATCH would leave an
|
||||
// enabled secret with both env_name and file_path empty. It is returned
|
||||
// from the patchUserSecret transaction so the handler can map it to a 400.
|
||||
// Creates enforce the same invariant in
|
||||
// codersdk.ValidateCreateUserSecretRequest.
|
||||
var errUserSecretInjectionTargetRequired = xerrors.New("enabled user secret must have at least one of env_name or file_path set")
|
||||
|
||||
// @Summary Create a new user secret
|
||||
// @ID create-a-new-user-secret
|
||||
// @Security CoderSessionToken
|
||||
@@ -62,6 +69,11 @@ func (api *API) postUserSecret(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
enabled := true
|
||||
if req.Enabled != nil {
|
||||
enabled = *req.Enabled
|
||||
}
|
||||
|
||||
secret, err := api.Database.CreateUserSecret(ctx, database.CreateUserSecretParams{
|
||||
ID: uuid.New(),
|
||||
UserID: user.ID,
|
||||
@@ -71,12 +83,17 @@ func (api *API) postUserSecret(rw http.ResponseWriter, r *http.Request) {
|
||||
ValueKeyID: sql.NullString{},
|
||||
EnvName: req.EnvName,
|
||||
FilePath: req.FilePath,
|
||||
Enabled: enabled,
|
||||
})
|
||||
if err != nil {
|
||||
if validations := userSecretConflictValidationErrors(err); len(validations) > 0 {
|
||||
writeUserSecretValidationErrors(ctx, rw, http.StatusConflict, validations)
|
||||
return
|
||||
}
|
||||
if validations := userSecretInjectionTargetValidationErrors(err); len(validations) > 0 {
|
||||
writeUserSecretValidationErrors(ctx, rw, http.StatusBadRequest, validations)
|
||||
return
|
||||
}
|
||||
if resp, ok := userSecretLimitResponse(err); ok {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, resp)
|
||||
return
|
||||
@@ -155,6 +172,10 @@ func (api *API) postUserSecretsBatch(rw http.ResponseWriter, r *http.Request) {
|
||||
failedIndex := -1
|
||||
err = api.Database.InTx(func(tx database.Store) error {
|
||||
for i, sreq := range reqs {
|
||||
enabled := true
|
||||
if sreq.Enabled != nil {
|
||||
enabled = *sreq.Enabled
|
||||
}
|
||||
s, txErr := tx.CreateUserSecret(ctx, database.CreateUserSecretParams{
|
||||
ID: uuid.New(),
|
||||
UserID: user.ID,
|
||||
@@ -164,6 +185,7 @@ func (api *API) postUserSecretsBatch(rw http.ResponseWriter, r *http.Request) {
|
||||
ValueKeyID: sql.NullString{},
|
||||
EnvName: sreq.EnvName,
|
||||
FilePath: sreq.FilePath,
|
||||
Enabled: enabled,
|
||||
})
|
||||
if txErr != nil {
|
||||
failedIndex = i
|
||||
@@ -185,6 +207,15 @@ func (api *API) postUserSecretsBatch(rw http.ResponseWriter, r *http.Request) {
|
||||
writeUserSecretValidationErrors(ctx, rw, http.StatusConflict, conflicts)
|
||||
return
|
||||
}
|
||||
if validations := userSecretInjectionTargetValidationErrors(err); len(validations) > 0 {
|
||||
if index >= 0 {
|
||||
for i := range validations {
|
||||
validations[i].Field = fmt.Sprintf("secrets[%d].%s", index, validations[i].Field)
|
||||
}
|
||||
}
|
||||
writeUserSecretValidationErrors(ctx, rw, http.StatusBadRequest, validations)
|
||||
return
|
||||
}
|
||||
if resp, ok := userSecretLimitResponse(err); ok {
|
||||
if index >= 0 {
|
||||
resp.Detail = fmt.Sprintf("Entry secrets[%d] (%q): %s", index, reqs[index].Name, resp.Detail)
|
||||
@@ -319,7 +350,7 @@ func (api *API) patchUserSecret(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if req.Value == nil && req.Description == nil && req.EnvName == nil && req.FilePath == nil {
|
||||
if req.Value == nil && req.Description == nil && req.EnvName == nil && req.FilePath == nil && req.Enabled == nil {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "At least one field must be provided.",
|
||||
})
|
||||
@@ -342,6 +373,8 @@ func (api *API) patchUserSecret(rw http.ResponseWriter, r *http.Request) {
|
||||
EnvName: "",
|
||||
UpdateFilePath: req.FilePath != nil,
|
||||
FilePath: "",
|
||||
UpdateEnabled: req.Enabled != nil,
|
||||
Enabled: false,
|
||||
}
|
||||
if req.Value != nil {
|
||||
params.Value = *req.Value
|
||||
@@ -355,6 +388,9 @@ func (api *API) patchUserSecret(rw http.ResponseWriter, r *http.Request) {
|
||||
if req.FilePath != nil {
|
||||
params.FilePath = *req.FilePath
|
||||
}
|
||||
if req.Enabled != nil {
|
||||
params.Enabled = *req.Enabled
|
||||
}
|
||||
|
||||
// Pre-read the secret inside a transaction so the audit diff has both an
|
||||
// "old" and "new" snapshot.
|
||||
@@ -375,6 +411,26 @@ func (api *API) patchUserSecret(rw http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
aReq.Old = old
|
||||
|
||||
// Reject patches that would leave an enabled secret with both
|
||||
// env_name and file_path empty. Evaluated against the post-update
|
||||
// state so atomic env<->file swaps still succeed, and so targets
|
||||
// can be cleared when the same PATCH also disables the secret.
|
||||
postEnvName := old.EnvName
|
||||
if req.EnvName != nil {
|
||||
postEnvName = *req.EnvName
|
||||
}
|
||||
postFilePath := old.FilePath
|
||||
if req.FilePath != nil {
|
||||
postFilePath = *req.FilePath
|
||||
}
|
||||
postEnabled := old.Enabled
|
||||
if req.Enabled != nil {
|
||||
postEnabled = *req.Enabled
|
||||
}
|
||||
if postEnabled && postEnvName == "" && postFilePath == "" {
|
||||
return errUserSecretInjectionTargetRequired
|
||||
}
|
||||
|
||||
updated, err := tx.UpdateUserSecretByUserIDAndName(ctx, params)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("update user secret: %w", err)
|
||||
@@ -388,6 +444,17 @@ func (api *API) patchUserSecret(rw http.ResponseWriter, r *http.Request) {
|
||||
httpapi.ResourceNotFound(rw)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, errUserSecretInjectionTargetRequired) {
|
||||
writeUserSecretValidationErrors(ctx, rw, http.StatusBadRequest, []codersdk.ValidationError{{
|
||||
Field: codersdk.UserSecretEnvNameField,
|
||||
Detail: codersdk.UserSecretInjectionTargetRequiredDetail,
|
||||
}})
|
||||
return
|
||||
}
|
||||
if validations := userSecretInjectionTargetValidationErrors(err); len(validations) > 0 {
|
||||
writeUserSecretValidationErrors(ctx, rw, http.StatusBadRequest, validations)
|
||||
return
|
||||
}
|
||||
if validations := userSecretConflictValidationErrors(err); len(validations) > 0 {
|
||||
writeUserSecretValidationErrors(ctx, rw, http.StatusConflict, validations)
|
||||
return
|
||||
@@ -518,6 +585,22 @@ func userSecretLimitResponse(err error) (codersdk.Response, bool) {
|
||||
return codersdk.Response{}, false
|
||||
}
|
||||
|
||||
// userSecretInjectionTargetValidationErrors maps the
|
||||
// user_secrets_enabled_requires_target CHECK violation to a field-level
|
||||
// validation error. The database constraint is the race-safe source of
|
||||
// truth for the injection-target invariant: concurrent PATCHes can each
|
||||
// clear a different target and pass the handler's own post-state check,
|
||||
// so the constraint is what ultimately rejects an enabled target-less row.
|
||||
func userSecretInjectionTargetValidationErrors(err error) []codersdk.ValidationError {
|
||||
if database.IsCheckViolation(err, database.CheckUserSecretsEnabledRequiresTarget) {
|
||||
return []codersdk.ValidationError{{
|
||||
Field: codersdk.UserSecretEnvNameField,
|
||||
Detail: codersdk.UserSecretInjectionTargetRequiredDetail,
|
||||
}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func userSecretConflictValidationErrors(err error) []codersdk.ValidationError {
|
||||
switch {
|
||||
case database.IsUniqueViolation(err, database.UniqueUserSecretsUserNameIndex):
|
||||
|
||||
Reference in New Issue
Block a user