feat: add bulk user secret import endpoint and SDK client (PLAT-240) (#26724)

Adds `POST /api/v2/users/{user}/secrets/batch` and
`codersdk.Client.ImportUserSecrets` to import env, JSON, or YAML secrets
atomically. The endpoint validates each entry, rolls back the full batch
on conflicts or limits, omits secret values from responses and audit
logs, and imports keys that cannot be injected as environment variables
with an empty `env_name`.

Part of the [PLAT-240 bulk secret import
stack](https://linear.app/codercom/issue/PLAT-240). Reviewed and updated
by Coder Agents on behalf of @dylanhuff-at-coder.
This commit is contained in:
dylanhuff-at-coder
2026-07-23 14:55:34 -07:00
committed by GitHub
parent 73af2ca632
commit d5a3963167
14 changed files with 872 additions and 9 deletions
+139
View File
@@ -81,6 +81,10 @@ func (api *API) postUserSecret(rw http.ResponseWriter, r *http.Request) {
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(),
@@ -92,6 +96,141 @@ func (api *API) postUserSecret(rw http.ResponseWriter, r *http.Request) {
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
// @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)
// Cap body size before reading; worst-case JSON escaping can inflate
// a max-size file several-fold, so 8x gives comfortable headroom.
r.Body = http.MaxBytesReader(rw, r.Body, 8*codersdk.MaxSecretsFileBytes)
var req codersdk.ImportUserSecretsRequest
if !httpapi.Read(ctx, rw, r, &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 {
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,
})
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 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