feat: add bulk user secret import endpoint and SDK client (PLAT-240)#26724
Conversation
Docs previewCheck off each page once it's been reviewed. If a page changes in a later push, its checkbox clears automatically so it gets a fresh look. Pages not yet wired into the docs navigation aren't listed here. |
This stack of pull requests is managed by Graphite. Learn more about stacking. |
7e85c27 to
a0e3321
Compare
9f5cad9 to
df4f2be
Compare
Add POST /api/v2/users/{user}/secrets/batch to create many user secrets
from an uploaded env/json/yaml file. The batch is inserted in one
transaction, so any validation, uniqueness, or per-user-limit failure
rolls back everything and emits zero audit logs. Adds the codersdk
ImportUserSecrets client and the regenerated API docs.
Previously ParseSecretsFile set EnvName unconditionally to the file key for every parsed entry. This caused keys that are not valid POSIX env names (e.g. MY-TOKEN, my.key) or that are reserved (e.g. PATH) to fail ValidateCreateUserSecretRequest on the env_name field, rolling back the entire atomic batch import. Common real-world .env files typically contain such keys. Change EnvName assignment to best-effort: only set it when the key passes UserSecretEnvNameValid; otherwise leave EnvName empty. The secret is still imported, just without env injection. The env_name uniqueness index is a partial index (WHERE env_name != ''), so multiple empty env_names are fine. Update tests: - Add TestParseSecretsFileEnvBestEffortEnvName in codersdk with cases for hyphenated key (MY-TOKEN), reserved name (PATH), dot key (my.key), and a valid key (MY_TOKEN). - Remove ReservedEnvName (PATH) from the rollback failure cases in coderd/usersecretsimport_test.go; add TestImportUserSecretsReservedEnvNameBestEffort to assert the new success behavior (secret created, env_name empty).
c309123 to
9627726
Compare
Documentation CheckThis PR adds a public API endpoint ( New Documentation Needed
Automated review via Coder Agents |
nickvigilante
left a comment
There was a problem hiding this comment.
Docs LGTM! Please get another review on the Go code, but that also looked good to me, too.
| For full command details, see [`coder secret`](../reference/cli/secret.md) and | ||
| the [Secrets API reference](../reference/api/secrets.md). | ||
|
|
||
| ## Bulk import |
There was a problem hiding this comment.
Content/audience question (picking up @matifali's open thread on this file): this section lives in a user guide but reads in reference-doc register — it leads with a raw POST /api/v2/users/{user}/secrets/batch route and the codersdk.Client.ImportUserSecrets Go method. A user-guide reader is more likely to expect a CLI or UI entry point, with the wire/SDK details left to the API reference (which already documents this endpoint).
Given #26725 sits on top of this in the stack and may add the user-facing surface, a couple of options:
Reframe this around the user-facing entry point (CLI/UI) and link the API reference for the wire format, orHold this user-guide section until that surface lands, keeping only the API-reference coverage for now.
The technical content itself is accurate — formats, atomic rollback, and the empty-env_name behavior for reserved/invalid keys all match the implementation. This is purely about placement/audience.
There was a problem hiding this comment.
Content note (following up on @matifali's thread): with the upload UI in #26725 closed/stale and no CLI in the stack, this API/SDK path is currently the only way to bulk-import — so keeping it documented here is the right call, and I'd keep the section. Placement isn't a concern either, since docs IA is being restructured separately.
One optional polish for the user-guide audience: a short task-oriented lead-in (when/why you'd reach for bulk import) plus a small example payload — a few lines of env content and the matching request body — would make it read a bit less like a route-and-method pointer. Not blocking. The technical content (formats, atomic rollback, empty env_name for reserved/invalid keys) is accurate as-is.
There was a problem hiding this comment.
Oh I will be reviving #26725, it was just opened a while ago. Once this is merged that will be reopened :)
There was a problem hiding this comment.
I ended up pulling the section from this page entirely per Atif's request to keep reference docs off the user guide. I'll bring the task-oriented version (lead-in + example) back with the UI PR.
|
|
||
| err := json.NewDecoder(r.Body).Decode(value) | ||
| if err != nil { | ||
| if _, ok := errors.AsType[*http.MaxBytesError](err); ok { |
There was a problem hiding this comment.
From Coder Agents:
FYI for reviewers (not a change request): this teaches the shared Read helper to return 413 on *http.MaxBytesError, which is a strict improvement over the old behavior (an oversized body previously surfaced as 400 "Request body must be valid JSON."). Flagging only that it's cross-cutting — the new status now applies to every endpoint that wraps its body in http.MaxBytesReader, not just the new batch import. That looks intentional and it's covered by the new TestRead/BodyTooLarge case; noting it so the shared-behavior change is a conscious sign-off rather than an incidental side effect.
There was a problem hiding this comment.
Yep, intentional. 413 is the right status for an oversized body, and it only affects endpoints already using http.MaxBytesReader 👍
|
|
||
| err := json.NewDecoder(r.Body).Decode(value) | ||
| if err != nil { | ||
| if _, ok := errors.AsType[*http.MaxBytesError](err); ok { |
| // ValidateCreateUserSecretRequest. | ||
| // ValidateCreateUserSecretRequest. EnvName is set only when the key passes | ||
| // env-name validation (best-effort; keys like MY-TOKEN or PATH get an empty | ||
| // EnvName so they are still imported without env injection). |
There was a problem hiding this comment.
It won't surprise the user, right?
There was a problem hiding this comment.
Yeah, we enforce that validation across the User Secrets interfaces
| return count%2 == 1 | ||
| } | ||
|
|
||
| func quotedInner(v string, quote byte) (string, bool) { |
There was a problem hiding this comment.
This is not part of this PR, but agent flagged a bug:
Bug: single-quoted values silently swallow trailing data
quotedInner (codersdk/usersecretsimport.go:225) doesn't find the first closing quote like the double-quote path does. It just trims trailing whitespace and checks
whether the last character of the line is a quote, then strips first-and-last:
func quotedInner(v string, quote byte) (string, bool) {
trimmed := strings.TrimRight(v, " \t")
if len(trimmed) < 2 || trimmed[len(trimmed)-1] != quote {
return "", false
}
return trimmed[1 : len(trimmed)-1], true
}
Actual behavior:
┌────────────────────┬───────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Input │ Result │
├────────────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ KEY='abc' # 'note' │ value = abc' # 'note ⚠️ silent │
├────────────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ KEY='a'b' │ value = a'b ⚠️ silent │
├────────────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ KEY='a' 'b' │ value = a' 'b ⚠️ silent │
├────────────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ KEY='abc' extra │ error, but message says "missing closing single quote" (wrong; the quote is closed, the data is trailing) │
└────────────────────┴───────────────────────────────────────────────────────────────────────────────────────────────────────────┘
There was a problem hiding this comment.
I'll create a follow up PR for this shortly

Adds
POST /api/v2/users/{user}/secrets/batchandcodersdk.Client.ImportUserSecretsto 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 emptyenv_name.Part of the PLAT-240 bulk secret import stack. Reviewed and updated by Coder Agents on behalf of @dylanhuff-at-coder.