From 7994034e6417c8c2cef560910386bc30e701a5e3 Mon Sep 17 00:00:00 2001 From: Dylan Huff Date: Thu, 25 Jun 2026 19:14:10 +0000 Subject: [PATCH 1/9] feat: add bulk user secret import endpoint and SDK client (PLAT-240) 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. --- coderd/apidoc/docs.go | 73 +++++++ coderd/apidoc/swagger.json | 63 +++++++ coderd/coderd.go | 1 + coderd/usersecrets.go | 128 +++++++++++++ coderd/usersecretsimport_test.go | 313 +++++++++++++++++++++++++++++++ codersdk/usersecrets.go | 24 +++ docs/reference/api/schemas.md | 30 +++ docs/reference/api/secrets.md | 71 +++++++ site/src/api/typesGenerated.ts | 10 + 9 files changed, 713 insertions(+) create mode 100644 coderd/usersecretsimport_test.go diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 2c27365ac07..f3bc4465b8f 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -11027,6 +11027,55 @@ const docTemplate = `{ ] } }, + "/api/v2/users/{user}/secrets/batch": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Secrets" + ], + "summary": "Import user secrets from a file", + "operationId": "import-user-secrets-from-a-file", + "parameters": [ + { + "type": "string", + "description": "User ID, username, or me", + "name": "user", + "in": "path", + "required": true + }, + { + "description": "Import secrets request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.ImportUserSecretsRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.UserSecret" + } + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, "/api/v2/users/{user}/secrets/{name}": { "get": { "produces": [ @@ -20572,6 +20621,17 @@ const docTemplate = `{ } } }, + "codersdk.ImportUserSecretsRequest": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "format": { + "$ref": "#/definitions/codersdk.SecretsFileFormat" + } + } + }, "codersdk.InboxNotification": { "type": "object", "properties": { @@ -23641,6 +23701,19 @@ const docTemplate = `{ } } }, + "codersdk.SecretsFileFormat": { + "type": "string", + "enum": [ + "env", + "json", + "yaml" + ], + "x-enum-varnames": [ + "SecretsFileFormatEnv", + "SecretsFileFormatJSON", + "SecretsFileFormatYAML" + ] + }, "codersdk.ServerSentEvent": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 7f87f2bad29..f0fb91fa33a 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -9780,6 +9780,49 @@ ] } }, + "/api/v2/users/{user}/secrets/batch": { + "post": { + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Secrets"], + "summary": "Import user secrets from a file", + "operationId": "import-user-secrets-from-a-file", + "parameters": [ + { + "type": "string", + "description": "User ID, username, or me", + "name": "user", + "in": "path", + "required": true + }, + { + "description": "Import secrets request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.ImportUserSecretsRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.UserSecret" + } + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, "/api/v2/users/{user}/secrets/{name}": { "get": { "produces": ["application/json"], @@ -18732,6 +18775,17 @@ } } }, + "codersdk.ImportUserSecretsRequest": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "format": { + "$ref": "#/definitions/codersdk.SecretsFileFormat" + } + } + }, "codersdk.InboxNotification": { "type": "object", "properties": { @@ -21676,6 +21730,15 @@ } } }, + "codersdk.SecretsFileFormat": { + "type": "string", + "enum": ["env", "json", "yaml"], + "x-enum-varnames": [ + "SecretsFileFormatEnv", + "SecretsFileFormatJSON", + "SecretsFileFormatYAML" + ] + }, "codersdk.ServerSentEvent": { "type": "object", "properties": { diff --git a/coderd/coderd.go b/coderd/coderd.go index ab0332f821a..1eae3544b92 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1816,6 +1816,7 @@ func New(options *Options) *API { r.Put("/gitsshkey", api.regenerateGitSSHKey) r.Route("/secrets", func(r chi.Router) { r.Post("/", api.postUserSecret) + r.Post("/batch", api.postUserSecretsBatch) r.Get("/", api.getUserSecrets) r.Route("/{name}", func(r chi.Router) { r.Get("/", api.getUserSecret) diff --git a/coderd/usersecrets.go b/coderd/usersecrets.go index c8cc5e32147..63b06f2e4cb 100644 --- a/coderd/usersecrets.go +++ b/coderd/usersecrets.go @@ -92,6 +92,134 @@ 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 +// @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) + + 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 { + // Reset on entry so a transaction retry does not accumulate state + // from a previous attempt. + created = created[:0] + failedIndex = -1 + 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 + } + 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) + for _, secret := range created { + audit.BackgroundAudit(ctx, &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 diff --git a/coderd/usersecretsimport_test.go b/coderd/usersecretsimport_test.go new file mode 100644 index 00000000000..d18a0deafde --- /dev/null +++ b/coderd/usersecretsimport_test.go @@ -0,0 +1,313 @@ +package coderd_test + +import ( + "fmt" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/audit" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" +) + +func TestImportUserSecrets(t *testing.T) { + t.Parallel() + + t.Run("Success", func(t *testing.T) { + t.Parallel() + auditor := audit.NewMock() + client := coderdtest.New(t, &coderdtest.Options{Auditor: auditor}) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitMedium) + auditor.ResetLogs() + + secrets, err := client.ImportUserSecrets(ctx, codersdk.Me, codersdk.ImportUserSecretsRequest{ + Format: codersdk.SecretsFileFormatEnv, + Content: "ALPHA=a\nBETA=b\nGAMMA=c\n", + }) + require.NoError(t, err) + require.Len(t, secrets, 3) + // The flat mapping sets env_name to the key for every entry. + assert.Equal(t, "ALPHA", secrets[0].Name) + assert.Equal(t, "ALPHA", secrets[0].EnvName) + + listed, err := client.UserSecrets(ctx, codersdk.Me) + require.NoError(t, err) + names := make([]string, 0, len(listed)) + for _, s := range listed { + names = append(names, s.Name) + } + assert.ElementsMatch(t, []string{"ALPHA", "BETA", "GAMMA"}, names) + + // Exactly one create audit log per imported secret. + logs := auditor.AuditLogs() + require.Len(t, logs, 3) + for _, l := range logs { + assert.Equal(t, database.AuditActionCreate, l.Action) + assert.EqualValues(t, http.StatusCreated, l.StatusCode) + } + }) + + t.Run("ValuesNotInResponse", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitMedium) + + const secretValue = "super-secret-sentinel-value-123" + res, err := client.Request(ctx, http.MethodPost, + fmt.Sprintf("/api/v2/users/%s/secrets/batch", codersdk.Me), + codersdk.ImportUserSecretsRequest{ + Format: codersdk.SecretsFileFormatEnv, + Content: "LEAKY=" + secretValue, + }) + require.NoError(t, err) + defer res.Body.Close() + require.Equal(t, http.StatusCreated, res.StatusCode) + body, err := io.ReadAll(res.Body) + require.NoError(t, err) + assert.NotContains(t, string(body), secretValue) + }) +} + +// TestImportUserSecretsValidationRollback verifies that a single +// invalid entry rejects the whole batch: nothing is created and no +// audit log is written. The valid sibling entry must not leak through. +func TestImportUserSecretsValidationRollback(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + badLine string + }{ + {name: "ReservedEnvName", badLine: "PATH=whatever"}, + {name: "EmptyValue", badLine: "EMPTY_ONE="}, + {name: "OversizedValue", badLine: "BIG=" + strings.Repeat("a", codersdk.MaxUserSecretValueBytes+1)}, + {name: "NameWithSlash", badLine: "bad/name=value"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + auditor := audit.NewMock() + client := coderdtest.New(t, &coderdtest.Options{Auditor: auditor}) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitMedium) + auditor.ResetLogs() + + _, err := client.ImportUserSecrets(ctx, codersdk.Me, codersdk.ImportUserSecretsRequest{ + Format: codersdk.SecretsFileFormatEnv, + Content: "GOOD_ENTRY=fine\n" + tc.badLine, + }) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + assert.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) + // Errors are attributed to the offending entry (index 1). + require.NotEmpty(t, sdkErr.Validations) + for _, v := range sdkErr.Validations { + assert.Truef(t, strings.HasPrefix(v.Field, "secrets[1]."), + "unexpected field %q", v.Field) + } + + listed, err := client.UserSecrets(ctx, codersdk.Me) + require.NoError(t, err) + assert.Empty(t, listed) + + assert.Empty(t, auditor.AuditLogs()) + }) + } +} + +// TestImportUserSecretsConflict imports a batch that reuses the name of +// an already-existing secret. The conflict aborts the whole batch, so +// the other (new) entry is not created and no audit log is written. +func TestImportUserSecretsConflict(t *testing.T) { + t.Parallel() + auditor := audit.NewMock() + client := coderdtest.New(t, &coderdtest.Options{Auditor: auditor}) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitMedium) + + _, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ + Name: "EXISTING", + Value: "original", + }) + require.NoError(t, err) + auditor.ResetLogs() + + _, err = client.ImportUserSecrets(ctx, codersdk.Me, codersdk.ImportUserSecretsRequest{ + Format: codersdk.SecretsFileFormatEnv, + Content: "BRANDNEW=x\nEXISTING=collision", + }) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + assert.Equal(t, http.StatusConflict, sdkErr.StatusCode()) + validation := requireSecretValidation(t, err, http.StatusConflict, "secrets[1].name") + assert.Equal(t, "name already in use", validation.Detail) + + // Only the pre-existing secret should remain; BRANDNEW must not be created. + listed, err := client.UserSecrets(ctx, codersdk.Me) + require.NoError(t, err) + require.Len(t, listed, 1) + assert.Equal(t, "EXISTING", listed[0].Name) + + assert.Empty(t, auditor.AuditLogs()) +} + +// TestImportUserSecretsLimits exercises each per-user cap. A cap +// tripped mid-batch must roll back the entire import: zero rows are +// created and, because audit logs are emitted only after the +// transaction commits, zero audit logs are written. +func TestImportUserSecretsLimits(t *testing.T) { + t.Parallel() + + t.Run("CountLimit", func(t *testing.T) { + t.Parallel() + auditor := audit.NewMock() + client := coderdtest.New(t, &coderdtest.Options{Auditor: auditor}) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + var sb strings.Builder + for i := 0; i < codersdk.MaxUserSecretsPerUserCount+1; i++ { + fmt.Fprintf(&sb, "COUNT_%03d=x\n", i) + } + auditor.ResetLogs() + _, err := client.ImportUserSecrets(ctx, codersdk.Me, codersdk.ImportUserSecretsRequest{ + Format: codersdk.SecretsFileFormatEnv, + Content: sb.String(), + }) + requireSecretAPIError(t, err, http.StatusBadRequest, "exceeds") + + listed, err := client.UserSecrets(ctx, codersdk.Me) + require.NoError(t, err) + assert.Empty(t, listed) + assert.Empty(t, auditor.AuditLogs()) + }) + + t.Run("EnvBytesLimit", func(t *testing.T) { + t.Parallel() + auditor := audit.NewMock() + client := coderdtest.New(t, &coderdtest.Options{Auditor: auditor}) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + // Every imported secret is env-injected, so two values that are + // each within the per-value cap can still exceed the env-bytes + // aggregate together. + content := fmt.Sprintf("ENV_A=%s\nENV_B=%s\n", + strings.Repeat("a", codersdk.MaxUserSecretValueBytes-16), + strings.Repeat("a", 1024)) + auditor.ResetLogs() + _, err := client.ImportUserSecrets(ctx, codersdk.Me, codersdk.ImportUserSecretsRequest{ + Format: codersdk.SecretsFileFormatEnv, + Content: content, + }) + requireSecretAPIError(t, err, http.StatusBadRequest, "env_name") + + listed, err := client.UserSecrets(ctx, codersdk.Me) + require.NoError(t, err) + assert.Empty(t, listed) + assert.Empty(t, auditor.AuditLogs()) + }) + + t.Run("TotalBytesLimit", func(t *testing.T) { + t.Parallel() + auditor := audit.NewMock() + client := coderdtest.New(t, &coderdtest.Options{Auditor: auditor}) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + // Pre-fill the total-bytes budget to the cap using file-only + // secrets, which do not count against the smaller env budget. + // The import parser always sets env_name, so a file-only secret + // is the only way to load the total budget without first + // tripping the env budget. + big := strings.Repeat("a", codersdk.MaxUserSecretValueBytes) + numBig := codersdk.MaxUserSecretsTotalValueBytes / codersdk.MaxUserSecretValueBytes + remainder := codersdk.MaxUserSecretsTotalValueBytes % codersdk.MaxUserSecretValueBytes + for i := 0; i < numBig; i++ { + _, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ + Name: fmt.Sprintf("prefill-%03d", i), + Value: big, + FilePath: fmt.Sprintf("/tmp/prefill-%03d", i), + }) + require.NoError(t, err) + } + if remainder > 0 { + _, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ + Name: "prefill-pad", + Value: strings.Repeat("a", remainder), + FilePath: "/tmp/prefill-pad", + }) + require.NoError(t, err) + } + + before, err := client.UserSecrets(ctx, codersdk.Me) + require.NoError(t, err) + + // Reset after the prefill (which legitimately emits create audit + // logs) so the assertion below only sees logs from the rolled-back + // import. + auditor.ResetLogs() + _, err = client.ImportUserSecrets(ctx, codersdk.Me, codersdk.ImportUserSecretsRequest{ + Format: codersdk.SecretsFileFormatEnv, + Content: "OVERFLOW=x", + }) + requireSecretAPIError(t, err, http.StatusBadRequest, "per-user budget") + + after, err := client.UserSecrets(ctx, codersdk.Me) + require.NoError(t, err) + assert.Len(t, after, len(before)) + assert.Empty(t, auditor.AuditLogs()) + }) +} + +func TestImportUserSecretsParseErrors(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitMedium) + + // Parse-error variety is covered by the parser unit tests; this only + // asserts the endpoint maps a parse failure to 400. + _, err := client.ImportUserSecrets(ctx, codersdk.Me, codersdk.ImportUserSecretsRequest{ + Format: codersdk.SecretsFileFormatJSON, + Content: "{not json", + }) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + assert.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) +} + +// TestImportUserSecretsDuplicateWithinFile verifies a repeated key is +// rejected at parse time: 400, no rows created, no audit log written. +func TestImportUserSecretsDuplicateWithinFile(t *testing.T) { + t.Parallel() + auditor := audit.NewMock() + client := coderdtest.New(t, &coderdtest.Options{Auditor: auditor}) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitMedium) + auditor.ResetLogs() + + _, err := client.ImportUserSecrets(ctx, codersdk.Me, codersdk.ImportUserSecretsRequest{ + Format: codersdk.SecretsFileFormatEnv, + Content: "DUP=a\nDUP=b\n", + }) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + assert.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) + assert.Contains(t, sdkErr.Response.Detail, "duplicate key") + + // Nothing is inserted or audited; the duplicate is caught pre-tx. + listed, err := client.UserSecrets(ctx, codersdk.Me) + require.NoError(t, err) + assert.Empty(t, listed) + assert.Empty(t, auditor.AuditLogs()) +} diff --git a/codersdk/usersecrets.go b/codersdk/usersecrets.go index 43cfd00a4f2..993da2dbc1f 100644 --- a/codersdk/usersecrets.go +++ b/codersdk/usersecrets.go @@ -70,6 +70,30 @@ func (c *Client) UserSecrets(ctx context.Context, user string) ([]UserSecret, er return secrets, json.NewDecoder(res.Body).Decode(&secrets) } +// ImportUserSecretsRequest is the payload for the bulk secret import +// endpoint. Content is the raw file bytes and Format selects the parser. +type ImportUserSecretsRequest struct { + Format SecretsFileFormat `json:"format"` + Content string `json:"content"` +} + +// ImportUserSecrets parses the supplied file content and creates the +// resulting secrets atomically: either all secrets are created or, if +// any entry fails validation, uniqueness, or a per-user limit, none +// are. It returns the created secrets' metadata (never their values). +func (c *Client) ImportUserSecrets(ctx context.Context, user string, req ImportUserSecretsRequest) ([]UserSecret, error) { + res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/users/%s/secrets/batch", user), req) + if err != nil { + return nil, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusCreated { + return nil, ReadBodyAsError(res) + } + var secrets []UserSecret + return secrets, json.NewDecoder(res.Body).Decode(&secrets) +} + func (c *Client) UserSecretByName(ctx context.Context, user string, name string) (UserSecret, error) { res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/secrets/%s", user, name), nil) if err != nil { diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index ddba40ad34a..101e5ddf62b 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -7908,6 +7908,22 @@ Only certain features set these fields: - FeatureManagedAgentLimit| | `refresh` | integer | false | | | | `threshold_database` | integer | false | | | +## codersdk.ImportUserSecretsRequest + +```json +{ + "content": "string", + "format": "env" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|-----------|----------------------------------------------------------|----------|--------------|-------------| +| `content` | string | false | | | +| `format` | [codersdk.SecretsFileFormat](#codersdksecretsfileformat) | false | | | + ## codersdk.InboxNotification ```json @@ -11370,6 +11386,20 @@ Only certain features set these fields: - FeatureManagedAgentLimit| | `ssh_config_options` | object | false | | | | » `[any property]` | string | false | | | +## codersdk.SecretsFileFormat + +```json +"env" +``` + +### Properties + +#### Enumerated Values + +| Value(s) | +|-----------------------| +| `env`, `json`, `yaml` | + ## codersdk.ServerSentEvent ```json diff --git a/docs/reference/api/secrets.md b/docs/reference/api/secrets.md index 9ead8d17eb5..c8d88de614a 100644 --- a/docs/reference/api/secrets.md +++ b/docs/reference/api/secrets.md @@ -117,6 +117,77 @@ curl -X POST http://coder-server:8080/api/v2/users/{user}/secrets \ To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Import user secrets from a file + +### Code samples + +```sh +# Example request using curl +curl -X POST http://coder-server:8080/api/v2/users/{user}/secrets/batch \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`POST /api/v2/users/{user}/secrets/batch` + +> Body parameter + +```json +{ + "content": "string", + "format": "env" +} +``` + +### Parameters + +| Name | In | Type | Required | Description | +|--------|------|----------------------------------------------------------------------------------|----------|--------------------------| +| `user` | path | string | true | User ID, username, or me | +| `body` | body | [codersdk.ImportUserSecretsRequest](schemas.md#codersdkimportusersecretsrequest) | true | Import secrets request | + +### Example responses + +> 201 Response + +```json +[ + { + "created_at": "2019-08-24T14:15:22Z", + "description": "string", + "env_name": "string", + "file_path": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "name": "string", + "updated_at": "2019-08-24T14:15:22Z" + } +] +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|--------------------------------------------------------------|-------------|---------------------------------------------------------------| +| 201 | [Created](https://tools.ietf.org/html/rfc7231#section-6.3.2) | Created | array of [codersdk.UserSecret](schemas.md#codersdkusersecret) | + +

Response Schema

+ +Status Code **201** + +| Name | Type | Required | Restrictions | Description | +|-----------------|-------------------|----------|--------------|-------------| +| `[array item]` | array | false | | | +| `» created_at` | string(date-time) | false | | | +| `» description` | string | false | | | +| `» env_name` | string | false | | | +| `» file_path` | string | false | | | +| `» id` | string(uuid) | false | | | +| `» name` | string | false | | | +| `» updated_at` | string(date-time) | false | | | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Get a user secret by name ### Code samples diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 8a4c22d8dbb..bd91ef0c518 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -5452,6 +5452,16 @@ export interface IDPSyncMapping { readonly Gets: ResourceIdType; } +// From codersdk/usersecrets.go +/** + * ImportUserSecretsRequest is the payload for the bulk secret import + * endpoint. Content is the raw file bytes and Format selects the parser. + */ +export interface ImportUserSecretsRequest { + readonly format: SecretsFileFormat; + readonly content: string; +} + // From codersdk/inboxnotification.go export interface InboxNotification { readonly id: string; From 467b8220933f08de685ebedff4972b96f50b4183 Mon Sep 17 00:00:00 2001 From: Dylan Huff Date: Thu, 16 Jul 2026 18:13:31 +0000 Subject: [PATCH 2/9] fix(codersdk): make env_name best-effort in secrets file import 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). --- coderd/usersecretsimport_test.go | 55 ++++++++++++++++--- codersdk/usersecretsimport.go | 24 ++++++--- codersdk/usersecretsimport_test.go | 84 ++++++++++++++++++++++++++++++ 3 files changed, 151 insertions(+), 12 deletions(-) diff --git a/coderd/usersecretsimport_test.go b/coderd/usersecretsimport_test.go index d18a0deafde..97d7afb4703 100644 --- a/coderd/usersecretsimport_test.go +++ b/coderd/usersecretsimport_test.go @@ -87,9 +87,10 @@ func TestImportUserSecretsValidationRollback(t *testing.T) { name string badLine string }{ - {name: "ReservedEnvName", badLine: "PATH=whatever"}, + // Empty values are always invalid; this is the canonical rollback case. {name: "EmptyValue", badLine: "EMPTY_ONE="}, {name: "OversizedValue", badLine: "BIG=" + strings.Repeat("a", codersdk.MaxUserSecretValueBytes+1)}, + // A slash in the name is invalid regardless of env-name handling. {name: "NameWithSlash", badLine: "bad/name=value"}, } for _, tc := range cases { @@ -124,7 +125,50 @@ func TestImportUserSecretsValidationRollback(t *testing.T) { } } -// TestImportUserSecretsConflict imports a batch that reuses the name of +// TestImportUserSecretsReservedEnvNameBestEffort verifies that a +// reserved env name (e.g. PATH) no longer causes the whole batch to +// roll back. With best-effort env injection the secret is created +// successfully with an empty env_name, and the rest of the batch is +// unaffected. +func TestImportUserSecretsReservedEnvNameBestEffort(t *testing.T) { + t.Parallel() + auditor := audit.NewMock() + client := coderdtest.New(t, &coderdtest.Options{Auditor: auditor}) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitMedium) + auditor.ResetLogs() + + secrets, err := client.ImportUserSecrets(ctx, codersdk.Me, codersdk.ImportUserSecretsRequest{ + Format: codersdk.SecretsFileFormatEnv, + Content: "GOOD_ENTRY=fine\nPATH=whatever", + }) + require.NoError(t, err) + require.Len(t, secrets, 2) + + // The PATH secret must be created, but without env injection. + var pathSecret *codersdk.UserSecret + for i := range secrets { + if secrets[i].Name == "PATH" { + pathSecret = &secrets[i] + break + } + } + require.NotNilf(t, pathSecret, "PATH secret not found in response") + assert.Equal(t, "", pathSecret.EnvName, "reserved env name must be left empty") + + // Both secrets should be in the list. + listed, err := client.UserSecrets(ctx, codersdk.Me) + require.NoError(t, err) + names := make([]string, 0, len(listed)) + for _, s := range listed { + names = append(names, s.Name) + } + assert.ElementsMatch(t, []string{"GOOD_ENTRY", "PATH"}, names) + + // Both creates must be audited. + assert.Len(t, auditor.AuditLogs(), 2) +} + // an already-existing secret. The conflict aborts the whole batch, so // the other (new) entry is not created and no audit log is written. func TestImportUserSecretsConflict(t *testing.T) { @@ -225,10 +269,9 @@ func TestImportUserSecretsLimits(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) // Pre-fill the total-bytes budget to the cap using file-only - // secrets, which do not count against the smaller env budget. - // The import parser always sets env_name, so a file-only secret - // is the only way to load the total budget without first - // tripping the env budget. + // secrets (no env_name), which do not count against the smaller + // env budget. Creating them via CreateUserSecret directly avoids + // going through the import parser. big := strings.Repeat("a", codersdk.MaxUserSecretValueBytes) numBig := codersdk.MaxUserSecretsTotalValueBytes / codersdk.MaxUserSecretValueBytes remainder := codersdk.MaxUserSecretsTotalValueBytes % codersdk.MaxUserSecretValueBytes diff --git a/codersdk/usersecretsimport.go b/codersdk/usersecretsimport.go index 84d9cf1810b..a43db4170f9 100644 --- a/codersdk/usersecretsimport.go +++ b/codersdk/usersecretsimport.go @@ -33,7 +33,9 @@ type secretEntry struct { // ParseSecretsFile parses a secrets file into CreateUserSecretRequests. // It checks structure and duplicate keys; per-entry validation is left to -// 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). func ParseSecretsFile(format SecretsFileFormat, content string) ([]CreateUserSecretRequest, error) { if len(content) > MaxSecretsFileBytes { return nil, xerrors.Errorf("secrets file exceeds the maximum allowed size of %d bytes", MaxSecretsFileBytes) @@ -80,15 +82,25 @@ func ParseSecretsFile(format SecretsFileFormat, content string) ([]CreateUserSec reqs := make([]CreateUserSecretRequest, 0, len(entries)) for _, e := range entries { - reqs = append(reqs, CreateUserSecretRequest{ - Name: e.key, - EnvName: e.key, - Value: e.value, - }) + req := CreateUserSecretRequest{Name: e.key, Value: e.value} + // Best-effort env injection: only set EnvName when the key is a + // valid POSIX env variable name and not reserved. Keys such as + // "MY-TOKEN" (hyphens) or "PATH" (reserved) are silently imported + // without env injection rather than failing the entire batch. + // The env_name uniqueness index is a partial index + // (WHERE env_name != ''), so multiple empty env_names are fine. + if UserSecretEnvNameValid(e.key) == nil { + req.EnvName = e.key + } + reqs = append(reqs, req) } return reqs, nil } +// detectDuplicateKeys scans for repeated keys in source order. Because +// the flat mapping sets Name == KEY (and EnvName == KEY when valid), +// catching duplicates here gives a clear up-front error (citing the +// line for env files) instead of a later per-row uniqueness violation. func detectDuplicateKeys(entries []secretEntry) error { seen := make(map[string]struct{}, len(entries)) for _, e := range entries { diff --git a/codersdk/usersecretsimport_test.go b/codersdk/usersecretsimport_test.go index ae651c94a80..9c516c57abf 100644 --- a/codersdk/usersecretsimport_test.go +++ b/codersdk/usersecretsimport_test.go @@ -370,3 +370,87 @@ func TestParseSecretsFileGeneralErrors(t *testing.T) { }) } } + +// TestParseSecretsFileEnvBestEffortEnvName verifies the best-effort +// env_name behavior: keys that fail env-name validation (hyphens, dots, +// reserved names) get an empty EnvName, while valid keys still get +// EnvName set to the key. +func TestParseSecretsFileEnvBestEffortEnvName(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + line string + wantName string + wantEnvName string + wantValue string + }{ + { + name: "HyphenatedKey", + line: "MY-TOKEN=secretval", + wantName: "MY-TOKEN", + wantEnvName: "", // hyphen is not valid in POSIX env names + wantValue: "secretval", + }, + { + name: "ReservedName", + line: "PATH=whatever", + wantName: "PATH", + wantEnvName: "", // PATH is a reserved env name + wantValue: "whatever", + }, + { + name: "DotInKey", + line: "my.key=dotvalue", + wantName: "my.key", + wantEnvName: "", // dot is not valid in POSIX env names + wantValue: "dotvalue", + }, + { + name: "ValidKey", + line: "MY_TOKEN=goodval", + wantName: "MY_TOKEN", + wantEnvName: "MY_TOKEN", // valid POSIX env name + wantValue: "goodval", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + reqs, err := codersdk.ParseSecretsFile(codersdk.SecretsFileFormatEnv, tc.line) + require.NoError(t, err) + require.Len(t, reqs, 1) + assert.Equal(t, tc.wantName, reqs[0].Name) + assert.Equal(t, tc.wantEnvName, reqs[0].EnvName) + assert.Equal(t, tc.wantValue, reqs[0].Value) + }) + } +} + +// TestParseSecretsFileMappingEquivalence asserts that for valid env-name +// keys the Name and EnvName are both set to KEY (and FilePath is empty). +// Keys that fail env-name validation are covered by +// TestParseSecretsFileEnvBestEffortEnvName. +func TestParseSecretsFileMappingEquivalence(t *testing.T) { + t.Parallel() + + cases := []struct { + format codersdk.SecretsFileFormat + content string + }{ + {codersdk.SecretsFileFormatEnv, "FOO=bar"}, + {codersdk.SecretsFileFormatJSON, `{"FOO":"bar"}`}, + {codersdk.SecretsFileFormatYAML, "FOO: bar"}, + } + for _, tc := range cases { + reqs, err := codersdk.ParseSecretsFile(tc.format, tc.content) + require.NoErrorf(t, err, "format %s", tc.format) + require.Lenf(t, reqs, 1, "format %s", tc.format) + got := reqs[0] + assert.Equal(t, "FOO", got.Name) + assert.Equal(t, "FOO", got.EnvName) + assert.Equal(t, "bar", got.Value) + assert.Empty(t, got.FilePath) + assert.Empty(t, got.Description) + } +} From 9627726d7b355c65d110da88822c0cf73ba91e2e Mon Sep 17 00:00:00 2001 From: Dylan Huff Date: Tue, 21 Jul 2026 22:02:44 +0000 Subject: [PATCH 3/9] fix(coderd): cap secret import body size and address self-review findings --- coderd/usersecrets.go | 7 +++---- coderd/usersecretsimport_test.go | 8 +++----- codersdk/usersecretsimport.go | 14 ++++---------- codersdk/usersecretsimport_test.go | 27 +++++++++++++++------------ 4 files changed, 25 insertions(+), 31 deletions(-) diff --git a/coderd/usersecrets.go b/coderd/usersecrets.go index 63b06f2e4cb..1e725000f16 100644 --- a/coderd/usersecrets.go +++ b/coderd/usersecrets.go @@ -106,6 +106,9 @@ 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 @@ -144,10 +147,6 @@ func (api *API) postUserSecretsBatch(rw http.ResponseWriter, r *http.Request) { var created []database.UserSecret failedIndex := -1 err = api.Database.InTx(func(tx database.Store) error { - // Reset on entry so a transaction retry does not accumulate state - // from a previous attempt. - created = created[:0] - failedIndex = -1 for i, sreq := range reqs { s, txErr := tx.CreateUserSecret(ctx, database.CreateUserSecretParams{ ID: uuid.New(), diff --git a/coderd/usersecretsimport_test.go b/coderd/usersecretsimport_test.go index 97d7afb4703..1276e07a8fb 100644 --- a/coderd/usersecretsimport_test.go +++ b/coderd/usersecretsimport_test.go @@ -169,8 +169,9 @@ func TestImportUserSecretsReservedEnvNameBestEffort(t *testing.T) { assert.Len(t, auditor.AuditLogs(), 2) } -// an already-existing secret. The conflict aborts the whole batch, so -// the other (new) entry is not created and no audit log is written. +// TestImportUserSecretsConflict verifies that a batch containing an +// already-existing secret name aborts entirely: the new entry is not +// created and no audit log is written. func TestImportUserSecretsConflict(t *testing.T) { t.Parallel() auditor := audit.NewMock() @@ -189,9 +190,6 @@ func TestImportUserSecretsConflict(t *testing.T) { Format: codersdk.SecretsFileFormatEnv, Content: "BRANDNEW=x\nEXISTING=collision", }) - var sdkErr *codersdk.Error - require.ErrorAs(t, err, &sdkErr) - assert.Equal(t, http.StatusConflict, sdkErr.StatusCode()) validation := requireSecretValidation(t, err, http.StatusConflict, "secrets[1].name") assert.Equal(t, "name already in use", validation.Detail) diff --git a/codersdk/usersecretsimport.go b/codersdk/usersecretsimport.go index a43db4170f9..75aad537e0b 100644 --- a/codersdk/usersecretsimport.go +++ b/codersdk/usersecretsimport.go @@ -83,12 +83,8 @@ func ParseSecretsFile(format SecretsFileFormat, content string) ([]CreateUserSec reqs := make([]CreateUserSecretRequest, 0, len(entries)) for _, e := range entries { req := CreateUserSecretRequest{Name: e.key, Value: e.value} - // Best-effort env injection: only set EnvName when the key is a - // valid POSIX env variable name and not reserved. Keys such as - // "MY-TOKEN" (hyphens) or "PATH" (reserved) are silently imported - // without env injection rather than failing the entire batch. - // The env_name uniqueness index is a partial index - // (WHERE env_name != ''), so multiple empty env_names are fine. + // env_name uses a partial unique index (WHERE env_name != ''), + // so multiple empty env_names are allowed. if UserSecretEnvNameValid(e.key) == nil { req.EnvName = e.key } @@ -97,10 +93,8 @@ func ParseSecretsFile(format SecretsFileFormat, content string) ([]CreateUserSec return reqs, nil } -// detectDuplicateKeys scans for repeated keys in source order. Because -// the flat mapping sets Name == KEY (and EnvName == KEY when valid), -// catching duplicates here gives a clear up-front error (citing the -// line for env files) instead of a later per-row uniqueness violation. +// Duplicate keys are rejected up front (citing the line for env files) +// instead of surfacing as a per-row uniqueness violation later. func detectDuplicateKeys(entries []secretEntry) error { seen := make(map[string]struct{}, len(entries)) for _, e := range entries { diff --git a/codersdk/usersecretsimport_test.go b/codersdk/usersecretsimport_test.go index 9c516c57abf..42d5090fba2 100644 --- a/codersdk/usersecretsimport_test.go +++ b/codersdk/usersecretsimport_test.go @@ -234,8 +234,8 @@ func TestParseSecretsFileYAMLMultiDocument(t *testing.T) { // FuzzParseSecretsFile checks two invariants: (1) the parser never panics // regardless of input (the fuzz engine catches panics automatically); (2) on // success the result is well-formed: at least one entry, at most -// MaxUserSecretsPerUserCount entries, EnvName == Name for every entry, -// and all keys unique. On error the returned slice must be nil/empty. +// MaxUserSecretsPerUserCount entries, EnvName is empty or equals Name for every +// entry, and all keys unique. On error the returned slice must be nil/empty. func FuzzParseSecretsFile(f *testing.F) { // env - valid f.Add("env", "KEY=value") @@ -295,7 +295,7 @@ func FuzzParseSecretsFile(f *testing.F) { seen := make(map[string]struct{}, len(reqs)) for _, req := range reqs { - require.Equal(t, req.Name, req.EnvName) + require.True(t, req.EnvName == "" || req.EnvName == req.Name, "EnvName must be empty or equal to Name") _, dup := seen[req.Name] require.False(t, dup, "duplicate key %q in result", req.Name) seen[req.Name] = struct{}{} @@ -443,14 +443,17 @@ func TestParseSecretsFileMappingEquivalence(t *testing.T) { {codersdk.SecretsFileFormatYAML, "FOO: bar"}, } for _, tc := range cases { - reqs, err := codersdk.ParseSecretsFile(tc.format, tc.content) - require.NoErrorf(t, err, "format %s", tc.format) - require.Lenf(t, reqs, 1, "format %s", tc.format) - got := reqs[0] - assert.Equal(t, "FOO", got.Name) - assert.Equal(t, "FOO", got.EnvName) - assert.Equal(t, "bar", got.Value) - assert.Empty(t, got.FilePath) - assert.Empty(t, got.Description) + t.Run(string(tc.format), func(t *testing.T) { + t.Parallel() + reqs, err := codersdk.ParseSecretsFile(tc.format, tc.content) + require.NoError(t, err) + require.Len(t, reqs, 1) + got := reqs[0] + assert.Equal(t, "FOO", got.Name) + assert.Equal(t, "FOO", got.EnvName) + assert.Equal(t, "bar", got.Value) + assert.Empty(t, got.FilePath) + assert.Empty(t, got.Description) + }) } } From f585634d53a40134e3ae57a061230a984ef4ad88 Mon Sep 17 00:00:00 2001 From: Dylan Huff Date: Wed, 22 Jul 2026 15:22:17 +0000 Subject: [PATCH 4/9] fix: harden user secret imports --- coderd/apidoc/docs.go | 10 ++ coderd/apidoc/swagger.json | 7 ++ coderd/httpapi/httpapi.go | 7 ++ coderd/httpapi/httpapi_test.go | 11 ++ coderd/usersecrets.go | 12 ++- coderd/usersecrets_test.go | 17 ++++ coderd/usersecretsimport_test.go | 157 ++++++++++++++--------------- codersdk/usersecrets.go | 4 +- codersdk/usersecretsimport_test.go | 79 ++------------- docs/reference/api/schemas.md | 4 +- docs/reference/api/secrets.md | 7 +- 11 files changed, 152 insertions(+), 163 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index f3bc4465b8f..6ed61ceb042 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -11067,6 +11067,12 @@ const docTemplate = `{ "$ref": "#/definitions/codersdk.UserSecret" } } + }, + "413": { + "description": "Request Entity Too Large", + "schema": { + "$ref": "#/definitions/codersdk.Response" + } } }, "security": [ @@ -20623,6 +20629,10 @@ const docTemplate = `{ }, "codersdk.ImportUserSecretsRequest": { "type": "object", + "required": [ + "content", + "format" + ], "properties": { "content": { "type": "string" diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index f0fb91fa33a..e8cf27dbe71 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -9814,6 +9814,12 @@ "$ref": "#/definitions/codersdk.UserSecret" } } + }, + "413": { + "description": "Request Entity Too Large", + "schema": { + "$ref": "#/definitions/codersdk.Response" + } } }, "security": [ @@ -18777,6 +18783,7 @@ }, "codersdk.ImportUserSecretsRequest": { "type": "object", + "required": ["content", "format"], "properties": { "content": { "type": "string" diff --git a/coderd/httpapi/httpapi.go b/coderd/httpapi/httpapi.go index ba8c91582fd..5045190072f 100644 --- a/coderd/httpapi/httpapi.go +++ b/coderd/httpapi/httpapi.go @@ -239,6 +239,13 @@ func Read(ctx context.Context, rw http.ResponseWriter, r *http.Request, value in err := json.NewDecoder(r.Body).Decode(value) if err != nil { + if _, ok := errors.AsType[*http.MaxBytesError](err); ok { + Write(ctx, rw, http.StatusRequestEntityTooLarge, codersdk.Response{ + Message: "Request body too large.", + Detail: err.Error(), + }) + return false + } Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Request body must be valid JSON.", Detail: err.Error(), diff --git a/coderd/httpapi/httpapi_test.go b/coderd/httpapi/httpapi_test.go index 16de82bef77..dca28196dc5 100644 --- a/coderd/httpapi/httpapi_test.go +++ b/coderd/httpapi/httpapi_test.go @@ -96,6 +96,17 @@ func TestRead(t *testing.T) { require.False(t, httpapi.Read(ctx, rw, r, v)) }) + t.Run("BodyTooLarge", func(t *testing.T) { + t.Parallel() + ctx := context.Background() + rw := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/", strings.NewReader(`{"value":"too large"}`)) + r.Body = http.MaxBytesReader(rw, r.Body, 4) + var v json.RawMessage + require.False(t, httpapi.Read(ctx, rw, r, &v)) + require.Equal(t, http.StatusRequestEntityTooLarge, rw.Code) + }) + t.Run("Validate", func(t *testing.T) { t.Parallel() type toValidate struct { diff --git a/coderd/usersecrets.go b/coderd/usersecrets.go index 1e725000f16..cda39d24a51 100644 --- a/coderd/usersecrets.go +++ b/coderd/usersecrets.go @@ -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(), @@ -101,6 +105,7 @@ func (api *API) postUserSecret(rw http.ResponseWriter, r *http.Request) { // @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 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() @@ -185,6 +190,10 @@ func (api *API) postUserSecretsBatch(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 importing secrets.", Detail: err.Error(), @@ -197,8 +206,9 @@ func (api *API) postUserSecretsBatch(rw http.ResponseWriter, r *http.Request) { // 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(ctx, &audit.BackgroundAuditParams[database.UserSecret]{ + audit.BackgroundAudit(auditCtx, &audit.BackgroundAuditParams[database.UserSecret]{ Audit: *auditor, Log: api.Logger, UserID: user.ID, diff --git a/coderd/usersecrets_test.go b/coderd/usersecrets_test.go index f51cc4b58fd..4fedcb2dec3 100644 --- a/coderd/usersecrets_test.go +++ b/coderd/usersecrets_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/require" "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" ) @@ -207,6 +208,22 @@ func TestPostUserSecret(t *testing.T) { }) } +func TestPostUserSecretForbiddenForAnotherUser(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + owner := coderdtest.CreateFirstUser(t, client) + memberClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleAuditor()) + ctx := testutil.Context(t, testutil.WaitMedium) + + _, err := memberClient.CreateUserSecret(ctx, owner.UserID.String(), codersdk.CreateUserSecretRequest{ + Name: "forbidden", + Value: "value", + }) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusForbidden, sdkErr.StatusCode()) +} + func TestGetUserSecrets(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) diff --git a/coderd/usersecretsimport_test.go b/coderd/usersecretsimport_test.go index 1276e07a8fb..0cddeba7a16 100644 --- a/coderd/usersecretsimport_test.go +++ b/coderd/usersecretsimport_test.go @@ -13,6 +13,7 @@ import ( "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" ) @@ -30,13 +31,16 @@ func TestImportUserSecrets(t *testing.T) { secrets, err := client.ImportUserSecrets(ctx, codersdk.Me, codersdk.ImportUserSecretsRequest{ Format: codersdk.SecretsFileFormatEnv, - Content: "ALPHA=a\nBETA=b\nGAMMA=c\n", + Content: "ALPHA=a\nBETA=b\nPATH=c\n", }) require.NoError(t, err) require.Len(t, secrets, 3) - // The flat mapping sets env_name to the key for every entry. + // Valid keys are env-injected, while reserved names are imported + // without env injection. assert.Equal(t, "ALPHA", secrets[0].Name) assert.Equal(t, "ALPHA", secrets[0].EnvName) + assert.Equal(t, "PATH", secrets[2].Name) + assert.Empty(t, secrets[2].EnvName) listed, err := client.UserSecrets(ctx, codersdk.Me) require.NoError(t, err) @@ -44,15 +48,23 @@ func TestImportUserSecrets(t *testing.T) { for _, s := range listed { names = append(names, s.Name) } - assert.ElementsMatch(t, []string{"ALPHA", "BETA", "GAMMA"}, names) + assert.ElementsMatch(t, []string{"ALPHA", "BETA", "PATH"}, names) // Exactly one create audit log per imported secret. logs := auditor.AuditLogs() require.Len(t, logs, 3) + resourceIDs := make([]string, 0, len(logs)) + resourceTargets := make([]string, 0, len(logs)) for _, l := range logs { assert.Equal(t, database.AuditActionCreate, l.Action) assert.EqualValues(t, http.StatusCreated, l.StatusCode) + resourceIDs = append(resourceIDs, l.ResourceID.String()) + resourceTargets = append(resourceTargets, l.ResourceTarget) } + assert.ElementsMatch(t, []string{ + secrets[0].ID.String(), secrets[1].ID.String(), secrets[2].ID.String(), + }, resourceIDs) + assert.ElementsMatch(t, []string{"ALPHA", "BETA", "PATH"}, resourceTargets) }) t.Run("ValuesNotInResponse", func(t *testing.T) { @@ -77,6 +89,37 @@ func TestImportUserSecrets(t *testing.T) { }) } +func TestImportUserSecretsForbiddenForAnotherUser(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + owner := coderdtest.CreateFirstUser(t, client) + memberClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleAuditor()) + ctx := testutil.Context(t, testutil.WaitMedium) + + _, err := memberClient.ImportUserSecrets(ctx, owner.UserID.String(), codersdk.ImportUserSecretsRequest{ + Format: codersdk.SecretsFileFormatEnv, + Content: "FORBIDDEN=value", + }) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusForbidden, sdkErr.StatusCode()) +} + +func TestImportUserSecretsBodyTooLarge(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitMedium) + + _, err := client.ImportUserSecrets(ctx, codersdk.Me, codersdk.ImportUserSecretsRequest{ + Format: codersdk.SecretsFileFormatEnv, + Content: strings.Repeat("a", 8*codersdk.MaxSecretsFileBytes), + }) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusRequestEntityTooLarge, sdkErr.StatusCode()) +} + // TestImportUserSecretsValidationRollback verifies that a single // invalid entry rejects the whole batch: nothing is created and no // audit log is written. The valid sibling entry must not leak through. @@ -125,50 +168,6 @@ func TestImportUserSecretsValidationRollback(t *testing.T) { } } -// TestImportUserSecretsReservedEnvNameBestEffort verifies that a -// reserved env name (e.g. PATH) no longer causes the whole batch to -// roll back. With best-effort env injection the secret is created -// successfully with an empty env_name, and the rest of the batch is -// unaffected. -func TestImportUserSecretsReservedEnvNameBestEffort(t *testing.T) { - t.Parallel() - auditor := audit.NewMock() - client := coderdtest.New(t, &coderdtest.Options{Auditor: auditor}) - _ = coderdtest.CreateFirstUser(t, client) - ctx := testutil.Context(t, testutil.WaitMedium) - auditor.ResetLogs() - - secrets, err := client.ImportUserSecrets(ctx, codersdk.Me, codersdk.ImportUserSecretsRequest{ - Format: codersdk.SecretsFileFormatEnv, - Content: "GOOD_ENTRY=fine\nPATH=whatever", - }) - require.NoError(t, err) - require.Len(t, secrets, 2) - - // The PATH secret must be created, but without env injection. - var pathSecret *codersdk.UserSecret - for i := range secrets { - if secrets[i].Name == "PATH" { - pathSecret = &secrets[i] - break - } - } - require.NotNilf(t, pathSecret, "PATH secret not found in response") - assert.Equal(t, "", pathSecret.EnvName, "reserved env name must be left empty") - - // Both secrets should be in the list. - listed, err := client.UserSecrets(ctx, codersdk.Me) - require.NoError(t, err) - names := make([]string, 0, len(listed)) - for _, s := range listed { - names = append(names, s.Name) - } - assert.ElementsMatch(t, []string{"GOOD_ENTRY", "PATH"}, names) - - // Both creates must be audited. - assert.Len(t, auditor.AuditLogs(), 2) -} - // TestImportUserSecretsConflict verifies that a batch containing an // already-existing secret name aborts entirely: the new entry is not // created and no audit log is written. @@ -203,9 +202,9 @@ func TestImportUserSecretsConflict(t *testing.T) { } // TestImportUserSecretsLimits exercises each per-user cap. A cap -// tripped mid-batch must roll back the entire import: zero rows are -// created and, because audit logs are emitted only after the -// transaction commits, zero audit logs are written. +// tripped mid-batch must roll back every row in the import and, because +// audit logs are emitted only after the transaction commits, write no +// import audit logs. func TestImportUserSecretsLimits(t *testing.T) { t.Parallel() @@ -216,20 +215,36 @@ func TestImportUserSecretsLimits(t *testing.T) { _ = coderdtest.CreateFirstUser(t, client) ctx := testutil.Context(t, testutil.WaitLong) - var sb strings.Builder - for i := 0; i < codersdk.MaxUserSecretsPerUserCount+1; i++ { - fmt.Fprintf(&sb, "COUNT_%03d=x\n", i) + for i := 0; i < codersdk.MaxUserSecretsPerUserCount-1; i++ { + _, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ + Name: fmt.Sprintf("prefill-%03d", i), + Value: "original", + }) + require.NoError(t, err) } + before, err := client.UserSecrets(ctx, codersdk.Me) + require.NoError(t, err) + require.Len(t, before, codersdk.MaxUserSecretsPerUserCount-1) + auditor.ResetLogs() - _, err := client.ImportUserSecrets(ctx, codersdk.Me, codersdk.ImportUserSecretsRequest{ + _, err = client.ImportUserSecrets(ctx, codersdk.Me, codersdk.ImportUserSecretsRequest{ Format: codersdk.SecretsFileFormatEnv, - Content: sb.String(), + Content: "COUNT_FIRST=x\nCOUNT_SECOND=y\n", }) - requireSecretAPIError(t, err, http.StatusBadRequest, "exceeds") + requireSecretAPIError(t, err, http.StatusBadRequest, "secrets[1]") - listed, err := client.UserSecrets(ctx, codersdk.Me) + after, err := client.UserSecrets(ctx, codersdk.Me) require.NoError(t, err) - assert.Empty(t, listed) + require.Len(t, after, len(before)) + beforeNames := make([]string, 0, len(before)) + afterNames := make([]string, 0, len(after)) + for _, secret := range before { + beforeNames = append(beforeNames, secret.Name) + } + for _, secret := range after { + afterNames = append(afterNames, secret.Name) + } + assert.ElementsMatch(t, beforeNames, afterNames) assert.Empty(t, auditor.AuditLogs()) }) @@ -326,29 +341,3 @@ func TestImportUserSecretsParseErrors(t *testing.T) { require.ErrorAs(t, err, &sdkErr) assert.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) } - -// TestImportUserSecretsDuplicateWithinFile verifies a repeated key is -// rejected at parse time: 400, no rows created, no audit log written. -func TestImportUserSecretsDuplicateWithinFile(t *testing.T) { - t.Parallel() - auditor := audit.NewMock() - client := coderdtest.New(t, &coderdtest.Options{Auditor: auditor}) - _ = coderdtest.CreateFirstUser(t, client) - ctx := testutil.Context(t, testutil.WaitMedium) - auditor.ResetLogs() - - _, err := client.ImportUserSecrets(ctx, codersdk.Me, codersdk.ImportUserSecretsRequest{ - Format: codersdk.SecretsFileFormatEnv, - Content: "DUP=a\nDUP=b\n", - }) - var sdkErr *codersdk.Error - require.ErrorAs(t, err, &sdkErr) - assert.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) - assert.Contains(t, sdkErr.Response.Detail, "duplicate key") - - // Nothing is inserted or audited; the duplicate is caught pre-tx. - listed, err := client.UserSecrets(ctx, codersdk.Me) - require.NoError(t, err) - assert.Empty(t, listed) - assert.Empty(t, auditor.AuditLogs()) -} diff --git a/codersdk/usersecrets.go b/codersdk/usersecrets.go index 993da2dbc1f..7d59d9b6d86 100644 --- a/codersdk/usersecrets.go +++ b/codersdk/usersecrets.go @@ -73,8 +73,8 @@ func (c *Client) UserSecrets(ctx context.Context, user string) ([]UserSecret, er // ImportUserSecretsRequest is the payload for the bulk secret import // endpoint. Content is the raw file bytes and Format selects the parser. type ImportUserSecretsRequest struct { - Format SecretsFileFormat `json:"format"` - Content string `json:"content"` + Format SecretsFileFormat `json:"format" validate:"required"` + Content string `json:"content" validate:"required"` } // ImportUserSecrets parses the supplied file content and creates the diff --git a/codersdk/usersecretsimport_test.go b/codersdk/usersecretsimport_test.go index 42d5090fba2..5dea3480d96 100644 --- a/codersdk/usersecretsimport_test.go +++ b/codersdk/usersecretsimport_test.go @@ -371,89 +371,26 @@ func TestParseSecretsFileGeneralErrors(t *testing.T) { } } -// TestParseSecretsFileEnvBestEffortEnvName verifies the best-effort -// env_name behavior: keys that fail env-name validation (hyphens, dots, -// reserved names) get an empty EnvName, while valid keys still get -// EnvName set to the key. -func TestParseSecretsFileEnvBestEffortEnvName(t *testing.T) { - t.Parallel() - - cases := []struct { - name string - line string - wantName string - wantEnvName string - wantValue string - }{ - { - name: "HyphenatedKey", - line: "MY-TOKEN=secretval", - wantName: "MY-TOKEN", - wantEnvName: "", // hyphen is not valid in POSIX env names - wantValue: "secretval", - }, - { - name: "ReservedName", - line: "PATH=whatever", - wantName: "PATH", - wantEnvName: "", // PATH is a reserved env name - wantValue: "whatever", - }, - { - name: "DotInKey", - line: "my.key=dotvalue", - wantName: "my.key", - wantEnvName: "", // dot is not valid in POSIX env names - wantValue: "dotvalue", - }, - { - name: "ValidKey", - line: "MY_TOKEN=goodval", - wantName: "MY_TOKEN", - wantEnvName: "MY_TOKEN", // valid POSIX env name - wantValue: "goodval", - }, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - reqs, err := codersdk.ParseSecretsFile(codersdk.SecretsFileFormatEnv, tc.line) - require.NoError(t, err) - require.Len(t, reqs, 1) - assert.Equal(t, tc.wantName, reqs[0].Name) - assert.Equal(t, tc.wantEnvName, reqs[0].EnvName) - assert.Equal(t, tc.wantValue, reqs[0].Value) - }) - } -} - -// TestParseSecretsFileMappingEquivalence asserts that for valid env-name -// keys the Name and EnvName are both set to KEY (and FilePath is empty). -// Keys that fail env-name validation are covered by -// TestParseSecretsFileEnvBestEffortEnvName. -func TestParseSecretsFileMappingEquivalence(t *testing.T) { +func TestParseSecretsFileBestEffortEnvName(t *testing.T) { t.Parallel() cases := []struct { format codersdk.SecretsFileFormat content string }{ - {codersdk.SecretsFileFormatEnv, "FOO=bar"}, - {codersdk.SecretsFileFormatJSON, `{"FOO":"bar"}`}, - {codersdk.SecretsFileFormatYAML, "FOO: bar"}, + {format: codersdk.SecretsFileFormatEnv, content: "PATH=value"}, + {format: codersdk.SecretsFileFormatJSON, content: `{"PATH":"value"}`}, + {format: codersdk.SecretsFileFormatYAML, content: "PATH: value"}, } for _, tc := range cases { t.Run(string(tc.format), func(t *testing.T) { t.Parallel() reqs, err := codersdk.ParseSecretsFile(tc.format, tc.content) require.NoError(t, err) - require.Len(t, reqs, 1) - got := reqs[0] - assert.Equal(t, "FOO", got.Name) - assert.Equal(t, "FOO", got.EnvName) - assert.Equal(t, "bar", got.Value) - assert.Empty(t, got.FilePath) - assert.Empty(t, got.Description) + require.Equal(t, []codersdk.CreateUserSecretRequest{{ + Name: "PATH", + Value: "value", + }}, reqs) }) } } diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 101e5ddf62b..582b864ff39 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -7921,8 +7921,8 @@ Only certain features set these fields: - FeatureManagedAgentLimit| | Name | Type | Required | Restrictions | Description | |-----------|----------------------------------------------------------|----------|--------------|-------------| -| `content` | string | false | | | -| `format` | [codersdk.SecretsFileFormat](#codersdksecretsfileformat) | false | | | +| `content` | string | true | | | +| `format` | [codersdk.SecretsFileFormat](#codersdksecretsfileformat) | true | | | ## codersdk.InboxNotification diff --git a/docs/reference/api/secrets.md b/docs/reference/api/secrets.md index c8d88de614a..ea2685a170a 100644 --- a/docs/reference/api/secrets.md +++ b/docs/reference/api/secrets.md @@ -167,9 +167,10 @@ curl -X POST http://coder-server:8080/api/v2/users/{user}/secrets/batch \ ### Responses -| Status | Meaning | Description | Schema | -|--------|--------------------------------------------------------------|-------------|---------------------------------------------------------------| -| 201 | [Created](https://tools.ietf.org/html/rfc7231#section-6.3.2) | Created | array of [codersdk.UserSecret](schemas.md#codersdkusersecret) | +| Status | Meaning | Description | Schema | +|--------|-------------------------------------------------------------------------|--------------------------|---------------------------------------------------------------| +| 201 | [Created](https://tools.ietf.org/html/rfc7231#section-6.3.2) | Created | array of [codersdk.UserSecret](schemas.md#codersdkusersecret) | +| 413 | [Payload Too Large](https://tools.ietf.org/html/rfc7231#section-6.5.11) | Request Entity Too Large | [codersdk.Response](schemas.md#codersdkresponse) |

Response Schema

From 85e34e393201fca0b41d803ae72c1c3c49936d88 Mon Sep 17 00:00:00 2001 From: Dylan Huff Date: Wed, 22 Jul 2026 18:12:42 +0000 Subject: [PATCH 5/9] docs(docs/user-guides): document bulk user secret import --- docs/user-guides/user-secrets.md | 67 ++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/docs/user-guides/user-secrets.md b/docs/user-guides/user-secrets.md index d0b4d5d5209..cd812758762 100644 --- a/docs/user-guides/user-secrets.md +++ b/docs/user-guides/user-secrets.md @@ -238,3 +238,70 @@ for what happens to running workspaces when you delete a secret. For full command details, see [`coder secret`](../reference/cli/secret.md) and the [Secrets API reference](../reference/api/secrets.md). + +## Bulk import + +Import many secrets at once by uploading a file to the batch API endpoint. This +is useful when you want to move an existing `.env`, JSON, or YAML file of values +into Coder in a single request. + +The endpoint is `POST /api/v2/users/{user}/secrets/batch`. The request body is +JSON with two fields: + +- `format`: one of `env`, `json`, or `yaml`. +- `content`: the raw file contents, as a string. + +The supported formats are: + +- `env`: dotenv-style `KEY=VALUE` lines. +- `json`: a flat JSON object of string values, for example + `{"API_KEY":"...","DB_URL":"..."}`. +- `yaml`: a flat YAML mapping of string values. Multi-document YAML is + supported. + +The following example imports two secrets from an inline env-format file: + +```sh +curl -X POST http://coder-server:8080/api/v2/users/me/secrets/batch \ + -H "Coder-Session-Token: $CODER_SESSION_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"format":"env","content":"API_KEY=abc123\nDB_URL=postgres://localhost/db\n"}' +``` + +The same operation is available in the Go SDK as +`codersdk.Client.ImportUserSecrets`. For the full request and response schema, +refer to the "Import user secrets from a file" operation in the +[Secrets API reference](../reference/api/secrets.md). + +### How keys become secrets + +Each key in the file becomes a secret whose name is the key. When the key is a +valid environment variable name, Coder also sets it as the secret's environment +variable target so the value is injected into your workspaces, exactly like a +secret created individually. Refer to +[How your secrets reach a workspace](#how-your-secrets-reach-a-workspace) for +the injection details. + +Keys that are not valid environment variable names (for example `MY-TOKEN`) or +reserved names (for example `PATH`) are still imported, but with an empty +environment variable target. Like any secret without an environment variable or +file target, these are stored but not injected. + +### All-or-nothing + +Imports are atomic. If any entry fails validation, reuses a name that is already +in use, or would exceed a per-user limit, Coder rolls back the whole batch and +creates nothing. Fix the reported entry and retry the full file. + +Secret values are never returned in the response and are omitted from audit +logs. On success, the response is an array of secret metadata (`id`, `name`, +`description`, `env_name`, `file_path`, `created_at`, and `updated_at`), the +same shape returned when you create a single secret. + +### Import limits + +A single file may contain at most 50 secrets, and the file itself must be no +larger than 1 MiB. Requests whose raw body exceeds the endpoint cap return +HTTP 413. Imported secrets also count against the same per-user budgets +described in [Limits](#limits), so a batch that would push you over those +limits is rejected and nothing is created. From b9e2a4148da0d087d418840b5562ef24e4d5e4c4 Mon Sep 17 00:00:00 2001 From: Dylan Huff Date: Wed, 22 Jul 2026 23:55:31 +0000 Subject: [PATCH 6/9] docs(docs/user-guides): correct bulk import guidance --- docs/user-guides/user-secrets.md | 69 ++++---------------------------- 1 file changed, 8 insertions(+), 61 deletions(-) diff --git a/docs/user-guides/user-secrets.md b/docs/user-guides/user-secrets.md index cd812758762..abc21fd7567 100644 --- a/docs/user-guides/user-secrets.md +++ b/docs/user-guides/user-secrets.md @@ -241,67 +241,14 @@ the [Secrets API reference](../reference/api/secrets.md). ## Bulk import -Import many secrets at once by uploading a file to the batch API endpoint. This -is useful when you want to move an existing `.env`, JSON, or YAML file of values -into Coder in a single request. +Use `POST /api/v2/users/{user}/secrets/batch` or `codersdk.Client.ImportUserSecrets` to import multiple secrets from dotenv-style `KEY=VALUE` content, a flat JSON object, or a flat YAML mapping. +Set the request `format` to `env`, `json`, or `yaml`, and pass the file contents in `content`. -The endpoint is `POST /api/v2/users/{user}/secrets/batch`. The request body is -JSON with two fields: +The import is atomic. +If any secret fails validation, conflicts with another secret name, or exceeds a per-user limit, Coder rolls back the entire import and creates no secrets. -- `format`: one of `env`, `json`, or `yaml`. -- `content`: the raw file contents, as a string. +Coder sets `env_name` to the imported key when the key is a valid environment variable name. +Keys that can't be injected as environment variables, such as `MY-TOKEN` or the reserved name `PATH`, are still imported with an empty `env_name`. +These secrets are stored but aren't injected into workspaces unless you later add a valid environment variable or file target. -The supported formats are: - -- `env`: dotenv-style `KEY=VALUE` lines. -- `json`: a flat JSON object of string values, for example - `{"API_KEY":"...","DB_URL":"..."}`. -- `yaml`: a flat YAML mapping of string values. Multi-document YAML is - supported. - -The following example imports two secrets from an inline env-format file: - -```sh -curl -X POST http://coder-server:8080/api/v2/users/me/secrets/batch \ - -H "Coder-Session-Token: $CODER_SESSION_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"format":"env","content":"API_KEY=abc123\nDB_URL=postgres://localhost/db\n"}' -``` - -The same operation is available in the Go SDK as -`codersdk.Client.ImportUserSecrets`. For the full request and response schema, -refer to the "Import user secrets from a file" operation in the -[Secrets API reference](../reference/api/secrets.md). - -### How keys become secrets - -Each key in the file becomes a secret whose name is the key. When the key is a -valid environment variable name, Coder also sets it as the secret's environment -variable target so the value is injected into your workspaces, exactly like a -secret created individually. Refer to -[How your secrets reach a workspace](#how-your-secrets-reach-a-workspace) for -the injection details. - -Keys that are not valid environment variable names (for example `MY-TOKEN`) or -reserved names (for example `PATH`) are still imported, but with an empty -environment variable target. Like any secret without an environment variable or -file target, these are stored but not injected. - -### All-or-nothing - -Imports are atomic. If any entry fails validation, reuses a name that is already -in use, or would exceed a per-user limit, Coder rolls back the whole batch and -creates nothing. Fix the reported entry and retry the full file. - -Secret values are never returned in the response and are omitted from audit -logs. On success, the response is an array of secret metadata (`id`, `name`, -`description`, `env_name`, `file_path`, `created_at`, and `updated_at`), the -same shape returned when you create a single secret. - -### Import limits - -A single file may contain at most 50 secrets, and the file itself must be no -larger than 1 MiB. Requests whose raw body exceeds the endpoint cap return -HTTP 413. Imported secrets also count against the same per-user budgets -described in [Limits](#limits), so a batch that would push you over those -limits is rejected and nothing is created. +For request and response details, refer to the [Secrets API reference](../reference/api/secrets.md#import-user-secrets-from-a-file). From b220dcfb447a56ee941533593cd7d38b138751f7 Mon Sep 17 00:00:00 2001 From: Dylan Huff Date: Thu, 23 Jul 2026 15:42:08 +0000 Subject: [PATCH 7/9] docs: document 400 and 409 responses for bulk secret import --- coderd/apidoc/docs.go | 12 ++++++++++++ coderd/apidoc/swagger.json | 12 ++++++++++++ coderd/usersecrets.go | 2 ++ docs/reference/api/secrets.md | 2 ++ 4 files changed, 28 insertions(+) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 6ed61ceb042..b3ade38bce3 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -11068,6 +11068,18 @@ const docTemplate = `{ } } }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/codersdk.Response" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/codersdk.Response" + } + }, "413": { "description": "Request Entity Too Large", "schema": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index e8cf27dbe71..7be66d6598d 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -9815,6 +9815,18 @@ } } }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/codersdk.Response" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/codersdk.Response" + } + }, "413": { "description": "Request Entity Too Large", "schema": { diff --git a/coderd/usersecrets.go b/coderd/usersecrets.go index cda39d24a51..633983cb612 100644 --- a/coderd/usersecrets.go +++ b/coderd/usersecrets.go @@ -105,6 +105,8 @@ func (api *API) postUserSecret(rw http.ResponseWriter, r *http.Request) { // @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) { diff --git a/docs/reference/api/secrets.md b/docs/reference/api/secrets.md index ea2685a170a..74b44c0ec23 100644 --- a/docs/reference/api/secrets.md +++ b/docs/reference/api/secrets.md @@ -170,6 +170,8 @@ curl -X POST http://coder-server:8080/api/v2/users/{user}/secrets/batch \ | Status | Meaning | Description | Schema | |--------|-------------------------------------------------------------------------|--------------------------|---------------------------------------------------------------| | 201 | [Created](https://tools.ietf.org/html/rfc7231#section-6.3.2) | Created | array of [codersdk.UserSecret](schemas.md#codersdkusersecret) | +| 400 | [Bad Request](https://tools.ietf.org/html/rfc7231#section-6.5.1) | Bad Request | [codersdk.Response](schemas.md#codersdkresponse) | +| 409 | [Conflict](https://tools.ietf.org/html/rfc7231#section-6.5.8) | Conflict | [codersdk.Response](schemas.md#codersdkresponse) | | 413 | [Payload Too Large](https://tools.ietf.org/html/rfc7231#section-6.5.11) | Request Entity Too Large | [codersdk.Response](schemas.md#codersdkresponse) |

Response Schema

From 2a18f8799859ae04febf86ff0e3de2b0eb54ae31 Mon Sep 17 00:00:00 2001 From: Dylan Huff Date: Thu, 23 Jul 2026 15:42:14 +0000 Subject: [PATCH 8/9] docs(docs/user-guides): add lead-in and example to bulk import section --- docs/user-guides/user-secrets.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/user-guides/user-secrets.md b/docs/user-guides/user-secrets.md index abc21fd7567..dfc681e4dcc 100644 --- a/docs/user-guides/user-secrets.md +++ b/docs/user-guides/user-secrets.md @@ -241,9 +241,29 @@ the [Secrets API reference](../reference/api/secrets.md). ## Bulk import +If you already keep secrets in a dotenv file, a flat JSON object, or a flat +YAML mapping, you can import them all in one request instead of creating each +secret individually. + Use `POST /api/v2/users/{user}/secrets/batch` or `codersdk.Client.ImportUserSecrets` to import multiple secrets from dotenv-style `KEY=VALUE` content, a flat JSON object, or a flat YAML mapping. Set the request `format` to `env`, `json`, or `yaml`, and pass the file contents in `content`. +For example, to import this dotenv file: + +```sh +API_KEY=abc123 +DATABASE_URL=postgres://user:pass@db.internal/app +``` + +Send it as the request body: + +```json +{ + "format": "env", + "content": "API_KEY=abc123\nDATABASE_URL=postgres://user:pass@db.internal/app" +} +``` + The import is atomic. If any secret fails validation, conflicts with another secret name, or exceeds a per-user limit, Coder rolls back the entire import and creates no secrets. From ea62f7115f2c2b566eeea867d42e3d2797944e8e Mon Sep 17 00:00:00 2001 From: Dylan Huff Date: Thu, 23 Jul 2026 15:55:14 +0000 Subject: [PATCH 9/9] docs(docs/user-guides): remove bulk import reference docs from user guide --- docs/user-guides/user-secrets.md | 34 -------------------------------- 1 file changed, 34 deletions(-) diff --git a/docs/user-guides/user-secrets.md b/docs/user-guides/user-secrets.md index dfc681e4dcc..d0b4d5d5209 100644 --- a/docs/user-guides/user-secrets.md +++ b/docs/user-guides/user-secrets.md @@ -238,37 +238,3 @@ for what happens to running workspaces when you delete a secret. For full command details, see [`coder secret`](../reference/cli/secret.md) and the [Secrets API reference](../reference/api/secrets.md). - -## Bulk import - -If you already keep secrets in a dotenv file, a flat JSON object, or a flat -YAML mapping, you can import them all in one request instead of creating each -secret individually. - -Use `POST /api/v2/users/{user}/secrets/batch` or `codersdk.Client.ImportUserSecrets` to import multiple secrets from dotenv-style `KEY=VALUE` content, a flat JSON object, or a flat YAML mapping. -Set the request `format` to `env`, `json`, or `yaml`, and pass the file contents in `content`. - -For example, to import this dotenv file: - -```sh -API_KEY=abc123 -DATABASE_URL=postgres://user:pass@db.internal/app -``` - -Send it as the request body: - -```json -{ - "format": "env", - "content": "API_KEY=abc123\nDATABASE_URL=postgres://user:pass@db.internal/app" -} -``` - -The import is atomic. -If any secret fails validation, conflicts with another secret name, or exceeds a per-user limit, Coder rolls back the entire import and creates no secrets. - -Coder sets `env_name` to the imported key when the key is a valid environment variable name. -Keys that can't be injected as environment variables, such as `MY-TOKEN` or the reserved name `PATH`, are still imported with an empty `env_name`. -These secrets are stored but aren't injected into workspaces unless you later add a valid environment variable or file target. - -For request and response details, refer to the [Secrets API reference](../reference/api/secrets.md#import-user-secrets-from-a-file).