mirror of
https://github.com/coder/coder.git
synced 2026-09-21 20:51:01 +08:00
## Summary `httpapi.Read` decoded request bodies with no size limit, so a single request could allocate memory without bound. This adds a 4 MiB default ceiling, leaves the endpoints that legitimately need more explicitly exempted, and counts the rejections so a limit set too tight is visible. This is the first of three PRs split out of #28048, covering the endpoints that answer in `codersdk.Response` shape. The OAuth2 decode paths (RFC 6749, RFC 7591) and the SCIM ones (RFC 7644) answer in their own error shapes and follow in separate PRs, along with the lint rule that pins the invariant. Closes PLAT-463. Remediates SEC-416 (CWE-770, CVSS 7.5) and SEC-392. ## Problem `httpapi.Read` calls `json.NewDecoder(r.Body).Decode(value)` with no ceiling, and no middleware in the chain bounds body size. The exposure is pre-authentication: login, OTP, and first-user creation all read a body before any authorization decision is reached. The existing rate limiter bounds request *rate*, which is orthogonal to the memory a single admitted request may consume. ## Fix `Read` is split into `Read` and `ReadLimit`. `ReadLimit` wraps `r.Body` in an `http.MaxBytesReader` and keeps the existing decode and validate logic; `Read` delegates to it with a new `DefaultMaxRequestBodyBytes` of 4 MiB, which covers the 124 remaining non-test callers at a single site. `http.MaxBytesReader` composes as tightest-wins, so the handlers that pre-wrapped their own bodies pass their limit to `ReadLimit` rather than wrapping, and each keeps its previous ceiling byte for byte. That matters most for the bulk secrets import at `8 * MaxSecretsFileBytes`: an unconditional wrap inside `Read` would have silently halved it to the default. `TestImportUserSecretsBodyLargerThanDefaultLimit` is the regression guard for that specific failure, and `TestMaxBytesReaderNesting` pins the composition behavior the whole requirement rests on. Every rejection site calls `httpapi.RecordRequestBodyLimit`, which names the limit that tripped on the request's existing log line and marks the request so `coderd_api_requests_too_large_total{reason="request_body"}` counts body rejections apart from the 413s coderd answers for other causes, such as agent log storage overflow. A limit set too tight for a legitimate payload therefore surfaces without waiting for a user report. The limit is a constant rather than a deployment option: an operator raising it to unblock something would reopen the vulnerability as configuration, where a security scan will not find it. A legitimate 413 is answered with a targeted `ReadLimit` on that endpoint. ## Behavior change `POST /api/v2/files` now answers 413 rather than 400 when a request body exceeds `HTTPFileMaxBytes`. It installed that bound already but reported the rejection as a read failure, which leaked the stdlib `http: request body too large` string through `Detail` and kept the largest limit in the tree off the metric. The separate 413 for an oversized expanded archive is unchanged. The task log snapshot endpoint now answers 413 rather than 400 when its 64 KiB cap is exceeded. Routing it through `ReadLimit` also changes its decode-failure message from "Failed to decode request payload." to "Request body must be valid JSON.", which is what every other endpoint answers. Its tests are updated to match both. `coderd_api_requests_too_large_total` is new, so there is no existing query to migrate. It counts the 413s coderd answers, labeled `method`, `path`, and `reason`. `reason="request_body"` is a rejection by one of the limits above; `reason="other"` is a 413 that has nothing to do with body size, such as agent log storage overflow. ## Reading this The commits are ordered to be read in sequence. Commits 1 and 2 are the security fix; commits 3 to 5 are the observability consequences, and commit 3 is the one that touches dashboards. Commit 7 documents the limit on the REST API reference index. Commits 6 and 8 add and revert an exhaustive `@Failure 413` annotation pass, which buried the fix under its regenerated swagger, and cancel out.
626 lines
20 KiB
Go
626 lines
20 KiB
Go
package coderd
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/google/uuid"
|
|
"golang.org/x/xerrors"
|
|
|
|
"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/codersdk"
|
|
)
|
|
|
|
const (
|
|
// These names are raised by the enforce_user_secrets_per_user_limits
|
|
// trigger with USING CONSTRAINT. They are not table CHECK
|
|
// constraints, so dbgen does not emit them in check_constraint.go.
|
|
userSecretsCountLimitConstraint database.CheckConstraint = "user_secrets_per_user_count_limit"
|
|
userSecretsTotalBytesLimitConstraint database.CheckConstraint = "user_secrets_per_user_total_bytes_limit"
|
|
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
|
|
// @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 validations := codersdk.ValidateCreateUserSecretRequest(req); len(validations) > 0 {
|
|
writeUserSecretValidationErrors(ctx, rw, http.StatusBadRequest, validations)
|
|
return
|
|
}
|
|
|
|
enabled := true
|
|
if req.Enabled != nil {
|
|
enabled = *req.Enabled
|
|
}
|
|
|
|
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,
|
|
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
|
|
}
|
|
if httpapi.IsUnauthorizedError(err) {
|
|
httpapi.Forbidden(rw)
|
|
return
|
|
}
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error creating secret.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
aReq.New = secret
|
|
|
|
httpapi.Write(ctx, rw, http.StatusCreated, db2sdk.UserSecretFromFull(secret))
|
|
}
|
|
|
|
// @Summary Import user secrets from a file
|
|
// @ID import-user-secrets-from-a-file
|
|
// @Security CoderSessionToken
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Tags Secrets
|
|
// @Param user path string true "User ID, username, or me"
|
|
// @Param request body codersdk.ImportUserSecretsRequest true "Import secrets request"
|
|
// @Success 201 {array} codersdk.UserSecret
|
|
// @Failure 400 {object} codersdk.Response
|
|
// @Failure 409 {object} codersdk.Response
|
|
// @Failure 413 {object} codersdk.Response "Request body exceeds 8 MiB"
|
|
// @Router /api/v2/users/{user}/secrets/batch [post]
|
|
func (api *API) postUserSecretsBatch(rw http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
user := httpmw.UserParam(r)
|
|
|
|
// Worst-case JSON escaping can inflate a max-size file several-fold, so 8x
|
|
// gives comfortable headroom. This exceeds
|
|
// httpapi.DefaultMaxRequestBodyBytes, so it must be passed to ReadLimit
|
|
// rather than wrapping r.Body here.
|
|
var req codersdk.ImportUserSecretsRequest
|
|
if !httpapi.ReadLimit(ctx, rw, r, 8*codersdk.MaxSecretsFileBytes, &req) {
|
|
return
|
|
}
|
|
|
|
reqs, err := codersdk.ParseSecretsFile(req.Format, req.Content)
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: "Failed to parse secrets file.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
// Validate every entry and accumulate all errors so the caller can
|
|
// fix the whole file in one round-trip. Each field is prefixed with
|
|
// the entry index, e.g. "secrets[2].env_name".
|
|
var validations []codersdk.ValidationError
|
|
for i, sreq := range reqs {
|
|
for _, v := range codersdk.ValidateCreateUserSecretRequest(sreq) {
|
|
validations = append(validations, codersdk.ValidationError{
|
|
Field: fmt.Sprintf("secrets[%d].%s", i, v.Field),
|
|
Detail: v.Detail,
|
|
})
|
|
}
|
|
}
|
|
if len(validations) > 0 {
|
|
writeUserSecretValidationErrors(ctx, rw, http.StatusBadRequest, validations)
|
|
return
|
|
}
|
|
|
|
// Insert atomically. The per-user-limit trigger fires per row, and
|
|
// any unique or limit violation aborts the whole transaction, so a
|
|
// failed import creates nothing. failedIndex records which entry
|
|
// failed so the error can be attributed to it after the rollback.
|
|
var created []database.UserSecret
|
|
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,
|
|
Name: sreq.Name,
|
|
Description: sreq.Description,
|
|
Value: sreq.Value,
|
|
ValueKeyID: sql.NullString{},
|
|
EnvName: sreq.EnvName,
|
|
FilePath: sreq.FilePath,
|
|
Enabled: enabled,
|
|
})
|
|
if txErr != nil {
|
|
failedIndex = i
|
|
return txErr
|
|
}
|
|
created = append(created, s)
|
|
}
|
|
return nil
|
|
}, nil)
|
|
if err != nil {
|
|
index := failedIndex
|
|
|
|
if conflicts := userSecretConflictValidationErrors(err); len(conflicts) > 0 {
|
|
if index >= 0 {
|
|
for i := range conflicts {
|
|
conflicts[i].Field = fmt.Sprintf("secrets[%d].%s", index, conflicts[i].Field)
|
|
}
|
|
}
|
|
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)
|
|
}
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, resp)
|
|
return
|
|
}
|
|
if httpapi.IsUnauthorizedError(err) {
|
|
httpapi.Forbidden(rw)
|
|
return
|
|
}
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error importing secrets.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
// Emit audit logs only after the transaction commits so a rolled-back
|
|
// batch produces zero logs. One create log is emitted per secret
|
|
// because database.UserSecret is registered as auditable.
|
|
auditor := api.Auditor.Load()
|
|
requestID := httpmw.RequestID(r)
|
|
auditCtx := context.WithoutCancel(ctx)
|
|
for _, secret := range created {
|
|
audit.BackgroundAudit(auditCtx, &audit.BackgroundAuditParams[database.UserSecret]{
|
|
Audit: *auditor,
|
|
Log: api.Logger,
|
|
UserID: user.ID,
|
|
RequestID: requestID,
|
|
Status: http.StatusCreated,
|
|
IP: r.RemoteAddr,
|
|
UserAgent: r.UserAgent(),
|
|
Action: database.AuditActionCreate,
|
|
New: secret,
|
|
Old: database.UserSecret{},
|
|
})
|
|
}
|
|
|
|
out := make([]codersdk.UserSecret, 0, len(created))
|
|
for _, secret := range created {
|
|
out = append(out, db2sdk.UserSecretFromFull(secret))
|
|
}
|
|
httpapi.Write(ctx, rw, http.StatusCreated, out)
|
|
}
|
|
|
|
// @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, codersdk.UserSecretNameField)
|
|
|
|
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, codersdk.UserSecretNameField)
|
|
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 && req.Enabled == nil {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: "At least one field must be provided.",
|
|
})
|
|
return
|
|
}
|
|
if validations := updateUserSecretValidationErrors(req); len(validations) > 0 {
|
|
writeUserSecretValidationErrors(ctx, rw, http.StatusBadRequest, validations)
|
|
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: "",
|
|
UpdateEnabled: req.Enabled != nil,
|
|
Enabled: false,
|
|
}
|
|
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
|
|
}
|
|
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.
|
|
//
|
|
// 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
|
|
|
|
// 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)
|
|
}
|
|
secret = updated
|
|
aReq.New = updated
|
|
return nil
|
|
}, nil)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
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
|
|
}
|
|
if resp, ok := userSecretLimitResponse(err); ok {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, resp)
|
|
return
|
|
}
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error updating secret.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
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, codersdk.UserSecretNameField)
|
|
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
|
|
|
|
rw.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func writeUserSecretValidationErrors(ctx context.Context, rw http.ResponseWriter, status int, validations []codersdk.ValidationError) {
|
|
httpapi.Write(ctx, rw, status, codersdk.Response{
|
|
Message: "Validation failed.",
|
|
Validations: validations,
|
|
})
|
|
}
|
|
|
|
func updateUserSecretValidationErrors(req codersdk.UpdateUserSecretRequest) []codersdk.ValidationError {
|
|
var validations []codersdk.ValidationError
|
|
if req.Value != nil {
|
|
validations = appendUserSecretValidationError(validations, codersdk.UserSecretValueField, codersdk.UserSecretValueValid(*req.Value))
|
|
}
|
|
if req.EnvName != nil {
|
|
validations = appendUserSecretValidationError(validations, codersdk.UserSecretEnvNameField, codersdk.UserSecretEnvNameValid(*req.EnvName))
|
|
}
|
|
if req.FilePath != nil {
|
|
validations = appendUserSecretValidationError(validations, codersdk.UserSecretFilePathField, codersdk.UserSecretFilePathValid(*req.FilePath))
|
|
}
|
|
return validations
|
|
}
|
|
|
|
func appendUserSecretValidationError(validations []codersdk.ValidationError, field string, err error) []codersdk.ValidationError {
|
|
if err == nil {
|
|
return validations
|
|
}
|
|
return append(validations, codersdk.ValidationError{
|
|
Field: field,
|
|
Detail: err.Error(),
|
|
})
|
|
}
|
|
|
|
// userSecretLimitResponse maps a per-user-limits trigger violation
|
|
// (raised by enforce_user_secrets_per_user_limits) to a 400. Returns
|
|
// ok=false if err is not such a violation. See
|
|
// codersdk.MaxUserSecretsPerUserCount for the rationale behind the caps.
|
|
func userSecretLimitResponse(err error) (codersdk.Response, bool) {
|
|
switch {
|
|
case database.IsCheckViolation(err, userSecretsCountLimitConstraint):
|
|
return codersdk.Response{
|
|
Message: "User secrets limit reached.",
|
|
Detail: fmt.Sprintf(
|
|
"Each user can have at most %d secrets.",
|
|
codersdk.MaxUserSecretsPerUserCount,
|
|
),
|
|
}, true
|
|
case database.IsCheckViolation(err, userSecretsTotalBytesLimitConstraint):
|
|
return codersdk.Response{
|
|
Message: "User secrets value-bytes limit reached.",
|
|
Detail: fmt.Sprintf(
|
|
"Stored bytes of your secret values exceed the per-user "+
|
|
"budget (%d bytes after encryption, if applicable). "+
|
|
"Reduce the size or number of your secrets.",
|
|
codersdk.MaxUserSecretsTotalValueBytes,
|
|
),
|
|
}, true
|
|
case database.IsCheckViolation(err, userSecretsEnvBytesLimitConstraint):
|
|
return codersdk.Response{
|
|
Message: "Environment-injected user secrets bytes limit reached.",
|
|
Detail: fmt.Sprintf(
|
|
"Stored bytes of env-injected secret values exceed the "+
|
|
"per-user budget (%d bytes after encryption, if applicable). "+
|
|
"Clear env_name on large secrets or use file_path instead.",
|
|
codersdk.MaxUserSecretValueBytes,
|
|
),
|
|
}, true
|
|
}
|
|
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):
|
|
return []codersdk.ValidationError{{
|
|
Field: codersdk.UserSecretNameField,
|
|
Detail: "name already in use",
|
|
}}
|
|
case database.IsUniqueViolation(err, database.UniqueUserSecretsUserEnvNameIndex):
|
|
return []codersdk.ValidationError{{
|
|
Field: codersdk.UserSecretEnvNameField,
|
|
Detail: "environment variable already in use",
|
|
}}
|
|
case database.IsUniqueViolation(err, database.UniqueUserSecretsUserFilePathIndex):
|
|
return []codersdk.ValidationError{{
|
|
Field: codersdk.UserSecretFilePathField,
|
|
Detail: "file path already in use",
|
|
}}
|
|
default:
|
|
return nil
|
|
}
|
|
}
|