feat: enforce per-user limits on user_secrets (#25588)

Add a Postgres trigger and matching codersdk constants that cap each
user's secrets in four dimensions: count (50), total stored value bytes
(200 KiB), env-injected stored value bytes (24 KiB), and env name length
(256 bytes). Without these caps a user could overflow the 4 MiB DRPC
agent manifest, the ~32 KiB Windows process env
block, or Linux/macOS ARG_MAX at workspace start. The trigger is the
source of truth on aggregates; the handler maps its check_violation
error into a 400 that names the per-user budget in stored
(post-encryption) bytes. A handler test exercises off-by-one at each cap
across POST and PATCH, plus per-user budget isolation.

Generated with help from Coder Agents.
This commit is contained in:
Zach
2026-05-26 14:42:31 -06:00
committed by GitHub
parent d3155e1cab
commit 47ac4b309a
9 changed files with 672 additions and 28 deletions
+54
View File
@@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"errors"
"fmt"
"net/http"
"github.com/go-chi/chi/v5"
@@ -23,6 +24,13 @@ const (
userSecretValueField = "value"
userSecretEnvNameField = "env_name"
userSecretFilePathField = "file_path"
// 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"
)
// @Summary Create a new user secret
@@ -74,6 +82,10 @@ func (api *API) postUserSecret(rw http.ResponseWriter, r *http.Request) {
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 creating secret.",
Detail: err.Error(),
@@ -246,6 +258,10 @@ func (api *API) patchUserSecret(rw http.ResponseWriter, r *http.Request) {
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(),
@@ -346,6 +362,44 @@ func appendUserSecretValidationError(validations []codersdk.ValidationError, fie
})
}
// 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
}
func userSecretConflictValidationErrors(err error) []codersdk.ValidationError {
switch {
case database.IsUniqueViolation(err, database.UniqueUserSecretsUserNameIndex):