Files
coder/coderd/usersecrets.go
T
dylanhuff-at-coder 6a200a49d3 feat: refresh dynamic parameters on secret changes (#24786)
Publishes user secret create, update, and delete events and subscribes
dynamic parameter websockets to authorized owner secret changes.

Secret changes trigger fresh renders with monotonic response IDs, with
backend tests covering subscription authorization and websocket refresh
behavior.
2026-05-06 09:27:24 -07:00

387 lines
11 KiB
Go

package coderd
import (
"context"
"database/sql"
"errors"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/coderd/audit"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/db2sdk"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/usersecretspubsub"
"github.com/coder/coder/v2/codersdk"
)
// @Summary Create a new user secret
// @ID create-a-new-user-secret
// @Security CoderSessionToken
// @Accept json
// @Produce json
// @Tags Secrets
// @Param user path string true "User ID, username, or me"
// @Param request body codersdk.CreateUserSecretRequest true "Create secret request"
// @Success 201 {object} codersdk.UserSecret
// @Router /api/v2/users/{user}/secrets [post]
func (api *API) postUserSecret(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
user = httpmw.UserParam(r)
auditor = api.Auditor.Load()
aReq, commitAudit = audit.InitRequest[database.UserSecret](rw, &audit.RequestParams{
Audit: *auditor,
Log: api.Logger,
Request: r,
Action: database.AuditActionCreate,
})
)
defer commitAudit()
var req codersdk.CreateUserSecretRequest
if !httpapi.Read(ctx, rw, r, &req) {
return
}
if req.Name == "" {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Name is required.",
})
return
}
if req.Value == "" {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Value is required.",
})
return
}
if err := codersdk.UserSecretValueValid(req.Value); err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid secret value.",
Detail: err.Error(),
})
return
}
if err := codersdk.UserSecretEnvNameValid(req.EnvName); err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid environment variable name.",
Detail: err.Error(),
})
return
}
if err := codersdk.UserSecretFilePathValid(req.FilePath); err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid file path.",
Detail: err.Error(),
})
return
}
secret, err := api.Database.CreateUserSecret(ctx, database.CreateUserSecretParams{
ID: uuid.New(),
UserID: user.ID,
Name: req.Name,
Description: req.Description,
Value: req.Value,
ValueKeyID: sql.NullString{},
EnvName: req.EnvName,
FilePath: req.FilePath,
})
if err != nil {
if database.IsUniqueViolation(err) {
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: "A secret with that name, environment variable, or file path already exists.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error creating secret.",
Detail: err.Error(),
})
return
}
aReq.New = secret
api.publishUserSecretEvent(ctx, usersecretspubsub.Event{
Kind: usersecretspubsub.EventKindCreated,
UserID: secret.UserID,
Name: secret.Name,
EnvName: secret.EnvName,
FilePath: secret.FilePath,
})
httpapi.Write(ctx, rw, http.StatusCreated, db2sdk.UserSecretFromFull(secret))
}
// @Summary List user secrets
// @ID list-user-secrets
// @Security CoderSessionToken
// @Produce json
// @Tags Secrets
// @Param user path string true "User ID, username, or me"
// @Success 200 {array} codersdk.UserSecret
// @Router /api/v2/users/{user}/secrets [get]
func (api *API) getUserSecrets(rw http.ResponseWriter, r *http.Request) { //nolint:revive // Method name matches route.
ctx := r.Context()
user := httpmw.UserParam(r)
secrets, err := api.Database.ListUserSecrets(ctx, user.ID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error listing secrets.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.UserSecrets(secrets))
}
// @Summary Get a user secret by name
// @ID get-a-user-secret-by-name
// @Security CoderSessionToken
// @Produce json
// @Tags Secrets
// @Param user path string true "User ID, username, or me"
// @Param name path string true "Secret name"
// @Success 200 {object} codersdk.UserSecret
// @Router /api/v2/users/{user}/secrets/{name} [get]
func (api *API) getUserSecret(rw http.ResponseWriter, r *http.Request) { //nolint:revive // Method name matches route.
ctx := r.Context()
user := httpmw.UserParam(r)
name := chi.URLParam(r, "name")
secret, err := api.Database.GetUserSecretByUserIDAndName(ctx, database.GetUserSecretByUserIDAndNameParams{
UserID: user.ID,
Name: name,
})
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
httpapi.ResourceNotFound(rw)
return
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching secret.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.UserSecretFromFull(secret))
}
// @Summary Update a user secret
// @ID update-a-user-secret
// @Security CoderSessionToken
// @Accept json
// @Produce json
// @Tags Secrets
// @Param user path string true "User ID, username, or me"
// @Param name path string true "Secret name"
// @Param request body codersdk.UpdateUserSecretRequest true "Update secret request"
// @Success 200 {object} codersdk.UserSecret
// @Router /api/v2/users/{user}/secrets/{name} [patch]
func (api *API) patchUserSecret(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
user = httpmw.UserParam(r)
name = chi.URLParam(r, "name")
auditor = api.Auditor.Load()
aReq, commitAudit = audit.InitRequest[database.UserSecret](rw, &audit.RequestParams{
Audit: *auditor,
Log: api.Logger,
Request: r,
Action: database.AuditActionWrite,
})
)
defer commitAudit()
var req codersdk.UpdateUserSecretRequest
if !httpapi.Read(ctx, rw, r, &req) {
return
}
if req.Value == nil && req.Description == nil && req.EnvName == nil && req.FilePath == nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "At least one field must be provided.",
})
return
}
if req.EnvName != nil {
if err := codersdk.UserSecretEnvNameValid(*req.EnvName); err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid environment variable name.",
Detail: err.Error(),
})
return
}
}
if req.FilePath != nil {
if err := codersdk.UserSecretFilePathValid(*req.FilePath); err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid file path.",
Detail: err.Error(),
})
return
}
}
if req.Value != nil {
if err := codersdk.UserSecretValueValid(*req.Value); err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid secret value.",
Detail: err.Error(),
})
return
}
}
params := database.UpdateUserSecretByUserIDAndNameParams{
UserID: user.ID,
Name: name,
UpdateValue: req.Value != nil,
Value: "",
ValueKeyID: sql.NullString{},
UpdateDescription: req.Description != nil,
Description: "",
UpdateEnvName: req.EnvName != nil,
EnvName: "",
UpdateFilePath: req.FilePath != nil,
FilePath: "",
}
if req.Value != nil {
params.Value = *req.Value
}
if req.Description != nil {
params.Description = *req.Description
}
if req.EnvName != nil {
params.EnvName = *req.EnvName
}
if req.FilePath != nil {
params.FilePath = *req.FilePath
}
// Pre-read the secret inside a transaction so the audit diff has both an
// "old" and "new" snapshot.
//
// Under read committed isolation, a concurrent writer between our SELECT
// and our UPDATE can cause the audit diff to attribute changes to us that
// we did not make. We accept this race to match other audit log diffs
// (templates, workspaces, chats, etc). In practice this should be unlikely
// to hit since a user can only modify their own secrets.
var secret database.UserSecret
err := api.Database.InTx(func(tx database.Store) error {
old, err := tx.GetUserSecretByUserIDAndName(ctx, database.GetUserSecretByUserIDAndNameParams{
UserID: user.ID,
Name: name,
})
if err != nil {
return xerrors.Errorf("fetch user secret: %w", err)
}
aReq.Old = old
updated, err := tx.UpdateUserSecretByUserIDAndName(ctx, params)
if err != nil {
return xerrors.Errorf("update user secret: %w", err)
}
secret = updated
aReq.New = updated
return nil
}, nil)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
httpapi.ResourceNotFound(rw)
return
}
if database.IsUniqueViolation(err) {
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: "Update would conflict with an existing secret.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error updating secret.",
Detail: err.Error(),
})
return
}
api.publishUserSecretEvent(ctx, usersecretspubsub.Event{
Kind: usersecretspubsub.EventKindUpdated,
UserID: secret.UserID,
Name: secret.Name,
EnvName: secret.EnvName,
FilePath: secret.FilePath,
})
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.UserSecretFromFull(secret))
}
// @Summary Delete a user secret
// @ID delete-a-user-secret
// @Security CoderSessionToken
// @Tags Secrets
// @Param user path string true "User ID, username, or me"
// @Param name path string true "Secret name"
// @Success 204
// @Router /api/v2/users/{user}/secrets/{name} [delete]
func (api *API) deleteUserSecret(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
user = httpmw.UserParam(r)
name = chi.URLParam(r, "name")
auditor = api.Auditor.Load()
aReq, commitAudit = audit.InitRequest[database.UserSecret](rw, &audit.RequestParams{
Audit: *auditor,
Log: api.Logger,
Request: r,
Action: database.AuditActionDelete,
})
)
defer commitAudit()
deleted, err := api.Database.DeleteUserSecretByUserIDAndName(ctx, database.DeleteUserSecretByUserIDAndNameParams{
UserID: user.ID,
Name: name,
})
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
httpapi.ResourceNotFound(rw)
return
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error deleting secret.",
Detail: err.Error(),
})
return
}
aReq.Old = deleted
api.publishUserSecretEvent(ctx, usersecretspubsub.Event{
Kind: usersecretspubsub.EventKindDeleted,
UserID: user.ID,
Name: name,
})
rw.WriteHeader(http.StatusNoContent)
}
func (api *API) publishUserSecretEvent(ctx context.Context, event usersecretspubsub.Event) {
if err := usersecretspubsub.Publish(api.Pubsub, event); err != nil {
api.Logger.Warn(ctx, "failed to publish user secret event",
slog.F("user_id", event.UserID),
slog.F("secret_name", event.Name),
slog.F("event_kind", event.Kind),
slog.Error(err),
)
}
}