mirror of
https://github.com/coder/coder.git
synced 2026-06-03 13:08:25 +00:00
6a200a49d3
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.
387 lines
11 KiB
Go
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),
|
|
)
|
|
}
|
|
}
|