From 54cde3ee349140bb80774230bd91dd3e5004bc09 Mon Sep 17 00:00:00 2001 From: Dylan Huff Date: Tue, 23 Jun 2026 18:11:57 +0000 Subject: [PATCH 01/10] feat: add atomic bulk user secret import endpoint and parser (PLAT-240) Add POST /api/v2/users/{user}/secrets/batch to create many user secrets from an uploaded env/json/yaml file. The import is atomic: any validation, uniqueness, or per-user-limit failure rolls back the whole batch and emits zero audit logs. The file parser (codersdk.ParseSecretsFile) and the shared per-entry validator (codersdk.ValidateCreateUserSecretRequest) live in codersdk so a future `coder secret` CLI can reuse them with no changes. --- coderd/apidoc/docs.go | 73 ++++++ coderd/apidoc/swagger.json | 63 +++++ coderd/coderd.go | 1 + coderd/usersecrets.go | 163 +++++++++++- coderd/usersecretsimport_test.go | 281 +++++++++++++++++++++ codersdk/usersecrets.go | 17 ++ codersdk/usersecretsimport.go | 390 +++++++++++++++++++++++++++++ codersdk/usersecretsimport_test.go | 264 +++++++++++++++++++ codersdk/usersecretvalidation.go | 39 +++ docs/reference/api/schemas.md | 30 +++ docs/reference/api/secrets.md | 71 ++++++ site/src/api/typesGenerated.ts | 64 +++++ 12 files changed, 1443 insertions(+), 13 deletions(-) create mode 100644 coderd/usersecretsimport_test.go create mode 100644 codersdk/usersecretsimport.go create mode 100644 codersdk/usersecretsimport_test.go diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 4fc7a0c1bcd12..83c17bf5c3003 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -10793,6 +10793,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": [ @@ -20125,6 +20174,17 @@ const docTemplate = `{ } } }, + "codersdk.ImportUserSecretsRequest": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "format": { + "$ref": "#/definitions/codersdk.SecretsFileFormat" + } + } + }, "codersdk.InboxNotification": { "type": "object", "properties": { @@ -23143,6 +23203,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 994cb2402adb1..aa9841b9aaa6c 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -9568,6 +9568,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"], @@ -18314,6 +18357,17 @@ } } }, + "codersdk.ImportUserSecretsRequest": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "format": { + "$ref": "#/definitions/codersdk.SecretsFileFormat" + } + } + }, "codersdk.InboxNotification": { "type": "object", "properties": { @@ -21207,6 +21261,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 2688b55c033b4..4c997c8f01207 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1742,6 +1742,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 eed2570fa5904..9b6d7e0add491 100644 --- a/coderd/usersecrets.go +++ b/coderd/usersecrets.go @@ -97,6 +97,151 @@ func (api *API) postUserSecret(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, http.StatusCreated, db2sdk.UserSecretFromFull(secret)) } +// userSecretBatchError carries the index of the entry whose insert +// failed inside the import transaction so the handler can attribute +// the underlying violation to the right entry after the transaction +// rolls back. It unwraps to the underlying database error so the +// existing conflict and limit mappers keep working. +type userSecretBatchError struct { + index int + err error +} + +func (e *userSecretBatchError) Error() string { return e.err.Error() } +func (e *userSecretBatchError) Unwrap() error { return e.err } + +// @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 instead of one error at a + // time. 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. + var created []database.UserSecret + err = api.Database.InTx(func(tx database.Store) error { + // Reset on entry so a transaction retry does not accumulate rows + // from a previous attempt. + created = created[:0] + 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 { + return &userSecretBatchError{index: i, err: txErr} + } + created = append(created, s) + } + return nil + }, nil) + if err != nil { + index := -1 + underlying := err + var batchErr *userSecretBatchError + if errors.As(err, &batchErr) { + index = batchErr.index + underlying = batchErr.err + } + + if conflicts := userSecretConflictValidationErrors(underlying); 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(underlying); 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. A rolled-back + // batch produces zero rows and must produce zero audit logs, so this + // runs strictly on the success path. 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 @@ -322,20 +467,12 @@ func writeUserSecretValidationErrors(ctx context.Context, rw http.ResponseWriter }) } +// createUserSecretValidationErrors validates a create request by +// delegating to the shared codersdk validator so the single-create +// handler, the batch import handler, and a future CLI all enforce the +// same rules. func createUserSecretValidationErrors(req codersdk.CreateUserSecretRequest) []codersdk.ValidationError { - var validations []codersdk.ValidationError - validations = appendUserSecretValidationError(validations, userSecretNameField, codersdk.UserSecretNameValid(req.Name)) - if req.Value == "" { - validations = append(validations, codersdk.ValidationError{ - Field: userSecretValueField, - Detail: "Value is required.", - }) - } else { - validations = appendUserSecretValidationError(validations, userSecretValueField, codersdk.UserSecretValueValid(req.Value)) - } - validations = appendUserSecretValidationError(validations, userSecretEnvNameField, codersdk.UserSecretEnvNameValid(req.EnvName)) - validations = appendUserSecretValidationError(validations, userSecretFilePathField, codersdk.UserSecretFilePathValid(req.FilePath)) - return validations + return codersdk.ValidateCreateUserSecretRequest(req) } func updateUserSecretValidationErrors(req codersdk.UpdateUserSecretRequest) []codersdk.ValidationError { diff --git a/coderd/usersecretsimport_test.go b/coderd/usersecretsimport_test.go new file mode 100644 index 0000000000000..77a54419a433d --- /dev/null +++ b/coderd/usersecretsimport_test.go @@ -0,0 +1,281 @@ +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. +func TestImportUserSecretsLimits(t *testing.T) { + t.Parallel() + + t.Run("CountLimit", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = 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) + } + _, err := client.ImportUserSecrets(ctx, codersdk.Me, codersdk.ImportUserSecretsRequest{ + Format: codersdk.SecretsFileFormatEnv, + Content: sb.String(), + }) + requireSecretAPIError(t, err, http.StatusBadRequest, "at most") + + listed, err := client.UserSecrets(ctx, codersdk.Me) + require.NoError(t, err) + assert.Empty(t, listed) + }) + + t.Run("EnvBytesLimit", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = 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)) + _, 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) + }) + + t.Run("TotalBytesLimit", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = 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) + + _, 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)) + }) +} + +func TestImportUserSecretsParseErrors(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + cases := []struct { + name string + req codersdk.ImportUserSecretsRequest + }{ + {name: "Empty", req: codersdk.ImportUserSecretsRequest{Format: codersdk.SecretsFileFormatEnv, Content: ""}}, + {name: "MalformedJSON", req: codersdk.ImportUserSecretsRequest{Format: codersdk.SecretsFileFormatJSON, Content: "{not json"}}, + {name: "UnknownFormat", req: codersdk.ImportUserSecretsRequest{Format: "toml", Content: "A=1"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitMedium) + _, err := client.ImportUserSecrets(ctx, codersdk.Me, tc.req) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + assert.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) + }) + } +} diff --git a/codersdk/usersecrets.go b/codersdk/usersecrets.go index 43cfd00a4f2f1..371990d24d843 100644 --- a/codersdk/usersecrets.go +++ b/codersdk/usersecrets.go @@ -70,6 +70,23 @@ func (c *Client) UserSecrets(ctx context.Context, user string) ([]UserSecret, er return secrets, json.NewDecoder(res.Body).Decode(&secrets) } +// 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/codersdk/usersecretsimport.go b/codersdk/usersecretsimport.go new file mode 100644 index 0000000000000..b7fd8ce3a50a1 --- /dev/null +++ b/codersdk/usersecretsimport.go @@ -0,0 +1,390 @@ +package codersdk + +import ( + "encoding/json" + "errors" + "io" + "strings" + + "golang.org/x/xerrors" + "gopkg.in/yaml.v3" +) + +// SecretsFileFormat identifies the on-disk format of an uploaded +// secrets file. It is shared by the HTTP import endpoint and is +// intended to be reused by a future `coder secret` CLI without change. +type SecretsFileFormat string + +const ( + // SecretsFileFormatEnv is a dotenv-style file of KEY=VALUE lines. + SecretsFileFormatEnv SecretsFileFormat = "env" + // SecretsFileFormatJSON is a flat JSON object of string values. + SecretsFileFormatJSON SecretsFileFormat = "json" + // SecretsFileFormatYAML is a flat YAML mapping of string values. + SecretsFileFormatYAML SecretsFileFormat = "yaml" +) + +// MaxSecretsFileBytes bounds the raw size of an uploaded secrets file +// before any parsing happens. It is a defensive limit against +// decompression-style and resource-exhaustion attacks (huge files, +// deeply nested YAML, "billion laughs"). 1 MiB is far larger than any +// realistic secrets file: the per-user value budget is only 200 KiB +// (MaxUserSecretsTotalValueBytes), so a valid import can never need +// more than a few hundred KiB of content. +const MaxSecretsFileBytes = 1 << 20 // 1 MiB + +// ImportUserSecretsRequest is the payload for the bulk secret import +// endpoint. Content is the raw file contents and Format selects the +// parser used to interpret it. +type ImportUserSecretsRequest struct { + Format SecretsFileFormat `json:"format"` + Content string `json:"content"` +} + +// secretEntry is one parsed (key, value) pair in source order. line is +// 1-based and only meaningful for the env format (it is 0 for JSON and +// the key node's line for YAML); it is used to make duplicate-key and +// syntax errors point at the offending line. +type secretEntry struct { + key string + value string + line int +} + +// ParseSecretsFile parses an uploaded secrets file into a slice of +// CreateUserSecretRequest in source order. It performs only structural +// parsing and intra-file duplicate detection; per-entry validation +// (name/value/env_name/file_path rules, size and reserved-name checks) +// is left to ValidateCreateUserSecretRequest so the two concerns stay +// reusable independently (e.g. by a future CLI). +// +// Mapping is identical for every format: each KEY:VALUE pair becomes +// CreateUserSecretRequest{Name: KEY, EnvName: KEY, Value: VALUE} with +// Description and FilePath left empty. This targets the primary +// env-injection use case (upload a dotenv file, get the same names as +// environment variables). Because Name == EnvName == KEY and FilePath +// is always empty, a single duplicate-KEY check is sufficient to cover +// intra-file duplicate names, env_names, and file_paths at once. +func ParseSecretsFile(format SecretsFileFormat, content string) ([]CreateUserSecretRequest, error) { + // Reject oversized content before parsing so a malicious or + // accidental huge upload cannot drive the parser at all. + if len(content) > MaxSecretsFileBytes { + return nil, xerrors.Errorf("secrets file exceeds the maximum allowed size of %d bytes", MaxSecretsFileBytes) + } + + switch format { + case SecretsFileFormatEnv, SecretsFileFormatJSON, SecretsFileFormatYAML: + // Recognized format; fall through to parsing. + case "": + return nil, xerrors.New("a secrets file format is required") + default: + return nil, xerrors.Errorf("unknown secrets file format %q", format) + } + + // Treat an empty or whitespace-only file uniformly across formats. + if strings.TrimSpace(content) == "" { + return nil, xerrors.New("no secrets found in file") + } + + var ( + entries []secretEntry + err error + ) + switch format { + case SecretsFileFormatEnv: + entries, err = parseEnvSecrets(content) + case SecretsFileFormatJSON: + entries, err = parseJSONSecrets(content) + case SecretsFileFormatYAML: + entries, err = parseYAMLSecrets(content) + } + if err != nil { + return nil, err + } + + // An env file of only comments, or an empty JSON/YAML object, parses + // successfully but yields nothing to import. + if len(entries) == 0 { + return nil, xerrors.New("no secrets found in file") + } + + if err := detectDuplicateKeys(entries); err != nil { + return nil, err + } + + reqs := make([]CreateUserSecretRequest, 0, len(entries)) + for _, e := range entries { + reqs = append(reqs, CreateUserSecretRequest{ + Name: e.key, + EnvName: e.key, + Value: e.value, + }) + } + return reqs, nil +} + +// detectDuplicateKeys runs a single ordered scan for repeated keys +// across all formats. Because the flat mapping sets Name == EnvName == +// KEY, a duplicate key would otherwise surface later as a confusing +// per-row uniqueness violation; catching it here gives a clear, +// up-front error that cites the key (and the line for env files). +func detectDuplicateKeys(entries []secretEntry) error { + seen := make(map[string]struct{}, len(entries)) + for _, e := range entries { + if _, ok := seen[e.key]; ok { + if e.line > 0 { + return xerrors.Errorf("duplicate key %q on line %d", e.key, e.line) + } + return xerrors.Errorf("duplicate key %q", e.key) + } + seen[e.key] = struct{}{} + } + return nil +} + +// parseEnvSecrets parses dotenv-style content into ordered entries. +// +// Rules: +// - CRLF is normalized to LF and a leading UTF-8 BOM is stripped. +// Lines are 1-based for error messages. +// - Blank lines and full-line comments (first non-whitespace char is +// '#') are skipped. +// - An optional leading "export " prefix (the word export followed by +// whitespace) is stripped. +// - The line is split on the FIRST '='. A non-blank, non-comment line +// without '=' is an error citing the line. '=' characters after the +// first are kept as part of the value. +// - The key is the trimmed left side. +// - Value handling by the first non-whitespace char of the right side: +// - '"': double-quoted. The value runs to the matching closing '"' +// at the right-trimmed end, with a small set of escapes +// interpreted: \n \t \r \\ \". A missing closing quote is an error. +// - '\”: single-quoted, literal (no escapes) to the matching closing +// '\” at the right-trimmed end. A missing closing quote is an error. +// - otherwise unquoted: the value is the right side trimmed of +// surrounding whitespace. An inline '#' is NOT treated as a comment; +// it is kept literally. Silently truncating a secret value at '#' +// would be a dangerous footgun, so users who need a trailing '#' or +// spaces simply get them; those who want them stripped can quote. +// - Non-ASCII / Unicode bytes are preserved as-is. +func parseEnvSecrets(content string) ([]secretEntry, error) { + content = strings.ReplaceAll(content, "\r\n", "\n") + content = strings.TrimPrefix(content, "\ufeff") + + var entries []secretEntry + for i, raw := range strings.Split(content, "\n") { + lineNum := i + 1 + + if t := strings.TrimSpace(raw); t == "" || strings.HasPrefix(t, "#") { + continue + } + + work := stripExportPrefix(strings.TrimLeft(raw, " \t")) + + eq := strings.IndexByte(work, '=') + if eq < 0 { + return nil, xerrors.Errorf("line %d: expected KEY=VALUE but found no '='", lineNum) + } + + key := strings.TrimSpace(work[:eq]) + if key == "" { + return nil, xerrors.Errorf("line %d: missing key before '='", lineNum) + } + + value, err := parseEnvValue(work[eq+1:], lineNum) + if err != nil { + return nil, err + } + entries = append(entries, secretEntry{key: key, value: value, line: lineNum}) + } + return entries, nil +} + +// stripExportPrefix removes a leading "export " (the word export +// followed by whitespace). A line like "export=foo" is left untouched +// so the key becomes "export". +func stripExportPrefix(s string) string { + const kw = "export" + if !strings.HasPrefix(s, kw) { + return s + } + rest := s[len(kw):] + if rest == "" || (rest[0] != ' ' && rest[0] != '\t') { + return s + } + return strings.TrimLeft(rest, " \t") +} + +// parseEnvValue interprets the right-hand side of an env assignment. +func parseEnvValue(rhs string, lineNum int) (string, error) { + v := strings.TrimLeft(rhs, " \t") + if v == "" { + return "", nil + } + switch v[0] { + case '"': + return parseDoubleQuotedEnvValue(v, lineNum) + case '\'': + return parseSingleQuotedEnvValue(v, lineNum) + default: + // Unquoted: trim surrounding whitespace, keep '#' literally. + return strings.TrimSpace(v), nil + } +} + +// parseDoubleQuotedEnvValue extracts a double-quoted value and +// interprets the permitted escape sequences. +func parseDoubleQuotedEnvValue(v string, lineNum int) (string, error) { + inner, ok := quotedInner(v, '"') + if !ok { + return "", xerrors.Errorf("line %d: missing closing double quote", lineNum) + } + return unescapeDoubleQuoted(inner), nil +} + +// parseSingleQuotedEnvValue extracts a single-quoted value verbatim; +// single quotes perform no escape processing. +func parseSingleQuotedEnvValue(v string, lineNum int) (string, error) { + inner, ok := quotedInner(v, '\'') + if !ok { + return "", xerrors.Errorf("line %d: missing closing single quote", lineNum) + } + return inner, nil +} + +// quotedInner returns the content between the opening quote (v[0]) and +// the matching closing quote, which must be the last character after +// right-trimming whitespace. ok is false when no closing quote is found. +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 +} + +// unescapeDoubleQuoted interprets the escapes permitted inside a +// double-quoted env value: \n \t \r \\ \". Any other backslash +// sequence, or a trailing backslash, is preserved literally. +func unescapeDoubleQuoted(s string) string { + if !strings.Contains(s, "\\") { + return s + } + buf := make([]byte, 0, len(s)) + for i := 0; i < len(s); i++ { + c := s[i] + if c != '\\' || i == len(s)-1 { + buf = append(buf, c) + continue + } + switch next := s[i+1]; next { + case 'n': + buf = append(buf, '\n') + case 't': + buf = append(buf, '\t') + case 'r': + buf = append(buf, '\r') + case '\\': + buf = append(buf, '\\') + case '"': + buf = append(buf, '"') + default: + buf = append(buf, '\\', next) + } + i++ + } + return string(buf) +} + +// parseJSONSecrets parses a flat JSON object of string values into +// ordered entries. A token-based decoder is used so that source order +// is preserved, duplicate keys are observable (and rejected by the +// shared duplicate check), and non-string or nested values are +// rejected with clear errors. +func parseJSONSecrets(content string) ([]secretEntry, error) { + dec := json.NewDecoder(strings.NewReader(content)) + + tok, err := dec.Token() + if err != nil { + return nil, xerrors.Errorf("invalid JSON: %w", err) + } + if delim, ok := tok.(json.Delim); !ok || delim != '{' { + return nil, xerrors.New("JSON content must be an object mapping secret names to string values") + } + + var entries []secretEntry + for dec.More() { + keyTok, err := dec.Token() + if err != nil { + return nil, xerrors.Errorf("invalid JSON: %w", err) + } + key, ok := keyTok.(string) + if !ok { + return nil, xerrors.New("invalid JSON object key") + } + + valTok, err := dec.Token() + if err != nil { + return nil, xerrors.Errorf("invalid JSON: %w", err) + } + switch val := valTok.(type) { + case string: + entries = append(entries, secretEntry{key: key, value: val}) + case json.Delim: + return nil, xerrors.Errorf("value for key %q must be a string, not a nested object or array", key) + default: + return nil, xerrors.Errorf("value for key %q must be a string", key) + } + } + + // Consume the closing brace, then ensure nothing follows the + // top-level object. + if _, err := dec.Token(); err != nil { + return nil, xerrors.Errorf("invalid JSON: %w", err) + } + if _, err := dec.Token(); !errors.Is(err, io.EOF) { + return nil, xerrors.New("unexpected trailing data after JSON object") + } + + return entries, nil +} + +// parseYAMLSecrets parses a flat YAML mapping of string values into +// ordered entries. The top level must be a mapping and every value +// must be a scalar string node. Non-string scalars (numbers, booleans, +// null) are rejected so a secret value is never silently type-coerced; +// users who want such a value must quote it. Nested mappings and +// sequences are rejected. Duplicate keys are preserved by the node +// decoder and caught by the shared duplicate check. +func parseYAMLSecrets(content string) ([]secretEntry, error) { + var root yaml.Node + if err := yaml.Unmarshal([]byte(content), &root); err != nil { + return nil, xerrors.Errorf("invalid YAML: %w", err) + } + + // An empty document or comments-only file decodes to a zero node. + if root.Kind == 0 || len(root.Content) == 0 { + return nil, nil + } + + doc := root.Content[0] + if doc.Kind != yaml.MappingNode { + return nil, xerrors.New("YAML content must be a mapping of secret names to string values") + } + + entries := make([]secretEntry, 0, len(doc.Content)/2) + // Mapping node content alternates key, value, key, value, ... + for i := 0; i+1 < len(doc.Content); i += 2 { + keyNode := doc.Content[i] + valNode := doc.Content[i+1] + + if valNode.Kind != yaml.ScalarNode { + return nil, xerrors.Errorf("value for key %q must be a string, not a nested mapping or sequence", keyNode.Value) + } + if valNode.Tag != "" && valNode.Tag != "!!str" { + return nil, xerrors.Errorf("value for key %q must be a string (quote the value if it is numeric or boolean)", keyNode.Value) + } + entries = append(entries, secretEntry{key: keyNode.Value, value: valNode.Value, line: keyNode.Line}) + } + return entries, nil +} diff --git a/codersdk/usersecretsimport_test.go b/codersdk/usersecretsimport_test.go new file mode 100644 index 0000000000000..9e7442e33a478 --- /dev/null +++ b/codersdk/usersecretsimport_test.go @@ -0,0 +1,264 @@ +package codersdk_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/codersdk" +) + +// TestParseSecretsFileEnv covers the dotenv parsing rules end-to-end: +// comments, blank lines, the export prefix, single and double quotes, +// double-quote escapes, '=' inside a value, surrounding whitespace, an +// inline '#' kept literally, and non-ASCII values. It also asserts the +// flat mapping invariant Name == EnvName == KEY and Value == VALUE. +func TestParseSecretsFileEnv(t *testing.T) { + t.Parallel() + + content := strings.Join([]string{ + "# full-line comment", + " # indented full-line comment", + "", + " ", + "export EXPORTED=exported-value", + "PLAIN=plain-value", + "WITH_SPACES= trimmed ", + `DQUOTED="double quoted"`, + `DQ_ESCAPES="a\nb\tc\\d\"e"`, + `SQUOTED='literal \n no escape'`, + "EQ_IN_VALUE=a=b=c", + "HASH=value # kept literal", + "UNICODE=héllo 世界 café", + "EMPTY_VAL=", + }, "\n") + + reqs, err := codersdk.ParseSecretsFile(codersdk.SecretsFileFormatEnv, content) + require.NoError(t, err) + + want := []codersdk.CreateUserSecretRequest{ + {Name: "EXPORTED", EnvName: "EXPORTED", Value: "exported-value"}, + {Name: "PLAIN", EnvName: "PLAIN", Value: "plain-value"}, + {Name: "WITH_SPACES", EnvName: "WITH_SPACES", Value: "trimmed"}, + {Name: "DQUOTED", EnvName: "DQUOTED", Value: "double quoted"}, + {Name: "DQ_ESCAPES", EnvName: "DQ_ESCAPES", Value: "a\nb\tc\\d\"e"}, + {Name: "SQUOTED", EnvName: "SQUOTED", Value: `literal \n no escape`}, + {Name: "EQ_IN_VALUE", EnvName: "EQ_IN_VALUE", Value: "a=b=c"}, + {Name: "HASH", EnvName: "HASH", Value: "value # kept literal"}, + {Name: "UNICODE", EnvName: "UNICODE", Value: "héllo 世界 café"}, + {Name: "EMPTY_VAL", EnvName: "EMPTY_VAL", Value: ""}, + } + require.Equal(t, want, reqs) +} + +// TestParseSecretsFileEnvCRLFAndBOM verifies CRLF normalization and BOM +// stripping. +func TestParseSecretsFileEnvCRLFAndBOM(t *testing.T) { + t.Parallel() + + content := "\ufeffKEY1=val1\r\nKEY2=val2\r\n" + reqs, err := codersdk.ParseSecretsFile(codersdk.SecretsFileFormatEnv, content) + require.NoError(t, err) + require.Equal(t, []codersdk.CreateUserSecretRequest{ + {Name: "KEY1", EnvName: "KEY1", Value: "val1"}, + {Name: "KEY2", EnvName: "KEY2", Value: "val2"}, + }, reqs) +} + +func TestParseSecretsFileEnvErrors(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + content string + errMsg string + }{ + {name: "NoEquals", content: "NOEQUALS", errMsg: "no '='"}, + {name: "MissingKey", content: "=value", errMsg: "missing key"}, + {name: "UnterminatedDouble", content: `KEY="oops`, errMsg: "missing closing double quote"}, + {name: "UnterminatedSingle", content: `KEY='oops`, errMsg: "missing closing single quote"}, + {name: "DuplicateKey", content: "DUP=a\nDUP=b", errMsg: "duplicate key"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + _, err := codersdk.ParseSecretsFile(codersdk.SecretsFileFormatEnv, tt.content) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errMsg) + }) + } +} + +// TestParseSecretsFileEnvDuplicateCitesLine confirms the duplicate-key +// error reports the offending line for the env format. +func TestParseSecretsFileEnvDuplicateCitesLine(t *testing.T) { + t.Parallel() + + _, err := codersdk.ParseSecretsFile(codersdk.SecretsFileFormatEnv, "DUP=a\nDUP=b") + require.Error(t, err) + assert.Contains(t, err.Error(), "duplicate key") + assert.Contains(t, err.Error(), "line 2") +} + +func TestParseSecretsFileJSON(t *testing.T) { + t.Parallel() + + reqs, err := codersdk.ParseSecretsFile(codersdk.SecretsFileFormatJSON, `{"A":"1","B":"two","C":"a=b#c"}`) + require.NoError(t, err) + require.Equal(t, []codersdk.CreateUserSecretRequest{ + {Name: "A", EnvName: "A", Value: "1"}, + {Name: "B", EnvName: "B", Value: "two"}, + {Name: "C", EnvName: "C", Value: "a=b#c"}, + }, reqs) +} + +func TestParseSecretsFileJSONErrors(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + content string + errMsg string + }{ + {name: "Malformed", content: `{"A":`, errMsg: "invalid JSON"}, + {name: "NonObjectArray", content: `["a","b"]`, errMsg: "must be an object"}, + {name: "NonObjectScalar", content: `"just a string"`, errMsg: "must be an object"}, + {name: "NumberValue", content: `{"A":1}`, errMsg: "must be a string"}, + {name: "BoolValue", content: `{"A":true}`, errMsg: "must be a string"}, + {name: "NullValue", content: `{"A":null}`, errMsg: "must be a string"}, + {name: "NestedObject", content: `{"A":{"x":"y"}}`, errMsg: "nested object or array"}, + {name: "NestedArray", content: `{"A":["x"]}`, errMsg: "nested object or array"}, + {name: "DuplicateKey", content: `{"DUP":"a","DUP":"b"}`, errMsg: "duplicate key"}, + {name: "TrailingData", content: `{"A":"1"} {"B":"2"}`, errMsg: "trailing data"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + _, err := codersdk.ParseSecretsFile(codersdk.SecretsFileFormatJSON, tt.content) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errMsg) + }) + } +} + +func TestParseSecretsFileYAML(t *testing.T) { + t.Parallel() + + content := strings.Join([]string{ + "# a comment", + "A: one", + `B: "two"`, + "C: 'a=b#c'", + }, "\n") + reqs, err := codersdk.ParseSecretsFile(codersdk.SecretsFileFormatYAML, content) + require.NoError(t, err) + require.Equal(t, []codersdk.CreateUserSecretRequest{ + {Name: "A", EnvName: "A", Value: "one"}, + {Name: "B", EnvName: "B", Value: "two"}, + {Name: "C", EnvName: "C", Value: "a=b#c"}, + }, reqs) +} + +func TestParseSecretsFileYAMLErrors(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + content string + errMsg string + }{ + {name: "Malformed", content: "A: [unclosed", errMsg: "invalid YAML"}, + {name: "NonMappingScalar", content: "just a scalar", errMsg: "must be a mapping"}, + {name: "NonMappingSequence", content: "- a\n- b", errMsg: "must be a mapping"}, + {name: "NestedMapping", content: "OUTER:\n inner: x", errMsg: "nested mapping or sequence"}, + {name: "SequenceValue", content: "LIST:\n - a\n - b", errMsg: "nested mapping or sequence"}, + {name: "IntValue", content: "PORT: 8080", errMsg: "must be a string"}, + {name: "BoolValue", content: "FLAG: true", errMsg: "must be a string"}, + {name: "DuplicateKey", content: "DUP: a\nDUP: b", errMsg: "duplicate key"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + _, err := codersdk.ParseSecretsFile(codersdk.SecretsFileFormatYAML, tt.content) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errMsg) + }) + } +} + +func TestParseSecretsFileGeneralErrors(t *testing.T) { + t.Parallel() + + t.Run("UnknownFormat", func(t *testing.T) { + t.Parallel() + _, err := codersdk.ParseSecretsFile("toml", "A=1") + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown secrets file format") + }) + + t.Run("EmptyFormat", func(t *testing.T) { + t.Parallel() + _, err := codersdk.ParseSecretsFile("", "A=1") + require.Error(t, err) + assert.Contains(t, err.Error(), "format is required") + }) + + t.Run("Oversized", func(t *testing.T) { + t.Parallel() + content := strings.Repeat("a", codersdk.MaxSecretsFileBytes+1) + _, err := codersdk.ParseSecretsFile(codersdk.SecretsFileFormatEnv, content) + require.Error(t, err) + assert.Contains(t, err.Error(), "maximum allowed size") + }) + + emptyCases := []struct { + name string + format codersdk.SecretsFileFormat + content string + }{ + {name: "EnvEmpty", format: codersdk.SecretsFileFormatEnv, content: ""}, + {name: "EnvWhitespace", format: codersdk.SecretsFileFormatEnv, content: " \n\t\n"}, + {name: "EnvAllComments", format: codersdk.SecretsFileFormatEnv, content: "# one\n# two\n"}, + {name: "JSONEmptyObject", format: codersdk.SecretsFileFormatJSON, content: "{}"}, + {name: "YAMLEmpty", format: codersdk.SecretsFileFormatYAML, content: ""}, + {name: "YAMLCommentsOnly", format: codersdk.SecretsFileFormatYAML, content: "# nothing here\n"}, + } + for _, tt := range emptyCases { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + _, err := codersdk.ParseSecretsFile(tt.format, tt.content) + require.Error(t, err) + assert.Contains(t, err.Error(), "no secrets found") + }) + } +} + +// TestParseSecretsFileMappingEquivalence asserts the documented flat +// mapping (Name == EnvName == KEY, FilePath empty) holds for every +// format, which is what makes a single duplicate-KEY check cover +// duplicate names, env_names, and file_paths at once. +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) + } +} diff --git a/codersdk/usersecretvalidation.go b/codersdk/usersecretvalidation.go index d43626e8e495f..ca473c68b7492 100644 --- a/codersdk/usersecretvalidation.go +++ b/codersdk/usersecretvalidation.go @@ -209,6 +209,45 @@ var ( } ) +// JSON field names for user secret validation errors. They match the +// CreateUserSecretRequest struct tags and are shared by the HTTP +// handlers (single create and batch import) and any future CLI so the +// error fields stay consistent across entry points. +const ( + UserSecretNameField = "name" + UserSecretValueField = "value" + UserSecretEnvNameField = "env_name" + UserSecretFilePathField = "file_path" +) + +// ValidateCreateUserSecretRequest validates a single create-secret +// request and returns field-level ValidationErrors keyed by JSON field +// name. It is the transport-agnostic validation reused by the HTTP +// handlers and by a future `coder secret` CLI. +// +// The "value is required" rule lives here rather than in +// UserSecretValueValid because an empty value is syntactically valid +// (no null bytes, within the size cap) but is not permitted at create +// time. Keeping it here means every create path enforces it identically. +func ValidateCreateUserSecretRequest(req CreateUserSecretRequest) []ValidationError { + var validations []ValidationError + if err := UserSecretNameValid(req.Name); err != nil { + validations = append(validations, ValidationError{Field: UserSecretNameField, Detail: err.Error()}) + } + if req.Value == "" { + validations = append(validations, ValidationError{Field: UserSecretValueField, Detail: "Value is required."}) + } else if err := UserSecretValueValid(req.Value); err != nil { + validations = append(validations, ValidationError{Field: UserSecretValueField, Detail: err.Error()}) + } + if err := UserSecretEnvNameValid(req.EnvName); err != nil { + validations = append(validations, ValidationError{Field: UserSecretEnvNameField, Detail: err.Error()}) + } + if err := UserSecretFilePathValid(req.FilePath); err != nil { + validations = append(validations, ValidationError{Field: UserSecretFilePathField, Detail: err.Error()}) + } + return validations +} + // UserSecretNameValid validates a user secret name. Names are used in // API route path segments, so they must not include route separators. func UserSecretNameValid(s string) error { diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 054c8bd71c920..a9f45f33296bf 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -7678,6 +7678,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 @@ -11092,6 +11108,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 cd1ee75e82476..4074a0ff7676d 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 + +```shell +# 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 72a042a884cd6..0edfa4e30988b 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -5144,6 +5144,17 @@ export interface IDPSyncMapping { readonly Gets: ResourceIdType; } +// From codersdk/usersecretsimport.go +/** + * ImportUserSecretsRequest is the payload for the bulk secret import + * endpoint. Content is the raw file contents and Format selects the + * parser used to interpret it. + */ +export interface ImportUserSecretsRequest { + readonly format: SecretsFileFormat; + readonly content: string; +} + // From codersdk/inboxnotification.go export interface InboxNotification { readonly id: string; @@ -5453,6 +5464,18 @@ export const MaxChatFileIDs = 50; */ export const MaxChatFileSizeBytes = 10485760; +// From codersdk/usersecretsimport.go +/** + * MaxSecretsFileBytes bounds the raw size of an uploaded secrets file + * before any parsing happens. It is a defensive limit against + * decompression-style and resource-exhaustion attacks (huge files, + * deeply nested YAML, "billion laughs"). 1 MiB is far larger than any + * realistic secrets file: the per-user value budget is only 200 KiB + * (MaxUserSecretsTotalValueBytes), so a valid import can never need + * more than a few hundred KiB of content. + */ +export const MaxSecretsFileBytes = 1048576; // 1 MiB + // From codersdk/usersecretvalidation.go /** * MaxUserSecretEnvNameLength caps the length of an env_name when one @@ -7516,6 +7539,11 @@ export interface STUNReport { readonly Error: string | null; } +// From codersdk/usersecretsimport.go +export type SecretsFileFormat = "env" | "json" | "yaml"; + +export const SecretsFileFormats: SecretsFileFormat[] = ["env", "json", "yaml"]; + // From serpent/serpent.go /** * Annotations is an arbitrary key-mapping used to extend the Option and Command types. @@ -9747,6 +9775,42 @@ export interface UserSecret { readonly updated_at: string; } +// From codersdk/usersecretvalidation.go +/** + * JSON field names for user secret validation errors. They match the + * CreateUserSecretRequest struct tags and are shared by the HTTP + * handlers (single create and batch import) and any future CLI so the + * error fields stay consistent across entry points. + */ +export const UserSecretEnvNameField = "env_name"; + +// From codersdk/usersecretvalidation.go +/** + * JSON field names for user secret validation errors. They match the + * CreateUserSecretRequest struct tags and are shared by the HTTP + * handlers (single create and batch import) and any future CLI so the + * error fields stay consistent across entry points. + */ +export const UserSecretFilePathField = "file_path"; + +// From codersdk/usersecretvalidation.go +/** + * JSON field names for user secret validation errors. They match the + * CreateUserSecretRequest struct tags and are shared by the HTTP + * handlers (single create and batch import) and any future CLI so the + * error fields stay consistent across entry points. + */ +export const UserSecretNameField = "name"; + +// From codersdk/usersecretvalidation.go +/** + * JSON field names for user secret validation errors. They match the + * CreateUserSecretRequest struct tags and are shared by the HTTP + * handlers (single create and batch import) and any future CLI so the + * error fields stay consistent across entry points. + */ +export const UserSecretValueField = "value"; + // From codersdk/userskills.go /** * UserSkill represents a user skill with its raw Markdown content. From e730d95aba7ac08a56f22ecaced5eeddc6e2e278 Mon Sep 17 00:00:00 2001 From: Dylan Huff Date: Tue, 23 Jun 2026 19:02:04 +0000 Subject: [PATCH 02/10] feat(site): add bulk secret import upload to Add secret dialog (PLAT-240) --- site/src/api/api.ts | 12 ++ site/src/api/queries/userSecrets.ts | 12 ++ .../SecretsPage/DividerWithText.tsx | 17 +++ .../SecretsPage/SecretDialog.tsx | 117 +++++++++++++++++- .../SecretsPage/SecretsPage.tsx | 13 ++ .../SecretsPage/SecretsPageView.stories.tsx | 109 +++++++++++++++- .../SecretsPage/SecretsPageView.tsx | 4 + .../SecretsPage/secretForm.test.ts | 26 ++++ .../SecretsPage/secretForm.ts | 21 ++++ site/src/testHelpers/entities.ts | 23 ++++ site/src/testHelpers/handlers.ts | 7 ++ 11 files changed, 358 insertions(+), 3 deletions(-) create mode 100644 site/src/pages/UserSettingsPage/SecretsPage/DividerWithText.tsx diff --git a/site/src/api/api.ts b/site/src/api/api.ts index ed8e78e1dde7d..5c352453f8733 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -1858,6 +1858,18 @@ class ApiMethods { ); }; + importUserSecrets = async ( + userId: string, + request: TypesGen.ImportUserSecretsRequest, + ): Promise => { + const response = await this.axios.post( + `/api/v2/users/${encodeURIComponent(userId)}/secrets/batch`, + request, + ); + + return response.data; + }; + getWorkspaceBuilds = async ( workspaceId: string, req?: TypesGen.WorkspaceBuildsRequest, diff --git a/site/src/api/queries/userSecrets.ts b/site/src/api/queries/userSecrets.ts index 40463d7851070..340e791c9149d 100644 --- a/site/src/api/queries/userSecrets.ts +++ b/site/src/api/queries/userSecrets.ts @@ -50,3 +50,15 @@ export const deleteUserSecret = (queryClient: QueryClient, userId: string) => { }, }; }; + +export const importUserSecrets = (queryClient: QueryClient, userId: string) => { + return { + mutationFn: (request: TypesGen.ImportUserSecretsRequest) => + API.importUserSecrets(userId, request), + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: userSecretsKey(userId), + }); + }, + }; +}; diff --git a/site/src/pages/UserSettingsPage/SecretsPage/DividerWithText.tsx b/site/src/pages/UserSettingsPage/SecretsPage/DividerWithText.tsx new file mode 100644 index 0000000000000..37420ff9f0135 --- /dev/null +++ b/site/src/pages/UserSettingsPage/SecretsPage/DividerWithText.tsx @@ -0,0 +1,17 @@ +import type { FC, PropsWithChildren } from "react"; + +// DividerWithText renders a horizontal rule with a centered label. It is a +// dialog-sized variant of the shared license divider, kept local to the +// secrets feature so it can use compact typography without affecting other +// consumers. +export const DividerWithText: FC = ({ children }) => { + return ( +
+
+ + {children} + +
+
+ ); +}; diff --git a/site/src/pages/UserSettingsPage/SecretsPage/SecretDialog.tsx b/site/src/pages/UserSettingsPage/SecretsPage/SecretDialog.tsx index 4e6709ed715f7..7e943fe1eb679 100644 --- a/site/src/pages/UserSettingsPage/SecretsPage/SecretDialog.tsx +++ b/site/src/pages/UserSettingsPage/SecretsPage/SecretDialog.tsx @@ -1,11 +1,19 @@ import { type FormikTouched, useFormik } from "formik"; import { type FC, type ReactNode, useState } from "react"; +import { + type FieldError, + getErrorMessage, + isApiError, + isApiErrorResponse, +} from "#/api/errors"; import type { CreateUserSecretRequest, + ImportUserSecretsRequest, UpdateUserSecretRequest, UserSecret, } from "#/api/typesGenerated"; -import { Alert, AlertDescription } from "#/components/Alert/Alert"; +import { Alert, AlertDescription, AlertTitle } from "#/components/Alert/Alert"; +import { ErrorAlert } from "#/components/Alert/ErrorAlert"; import { Button } from "#/components/Button/Button"; import { Dialog, @@ -14,6 +22,7 @@ import { DialogHeader, DialogTitle, } from "#/components/Dialog/Dialog"; +import { FileUpload } from "#/components/FileUpload/FileUpload"; import { FormField } from "#/components/FormField/FormField"; import { Input } from "#/components/Input/Input"; import { Label } from "#/components/Label/Label"; @@ -21,11 +30,13 @@ import { Spinner } from "#/components/Spinner/Spinner"; import { Textarea } from "#/components/Textarea/Textarea"; import { cn } from "#/utils/cn"; import { getFormHelpers } from "#/utils/formUtils"; +import { DividerWithText } from "./DividerWithText"; import { buildCreateUserSecretRequest, buildUpdateUserSecretRequest, getCreateSecretRequiredFieldErrors, mapSecretApiErrorToFormErrors, + secretsFileFormatFromFilename, type SecretFieldErrors, type SecretFormValues, } from "./secretForm"; @@ -43,6 +54,7 @@ type SecretDialogProps = { name: string, request: UpdateUserSecretRequest, ) => Promise | UserSecret; + onImportSecrets: (request: ImportUserSecretsRequest) => Promise; }; const emptyValues: SecretFormValues = { @@ -64,6 +76,7 @@ export const SecretDialog: FC = ({ onClose, onCreateSecret, onUpdateSecret, + onImportSecrets, }) => { const isEdit = Boolean(secret); const initialValues = secret @@ -76,6 +89,9 @@ export const SecretDialog: FC = ({ } : emptyValues; const [clearValueRequested, setClearValueRequested] = useState(false); + const [importFile, setImportFile] = useState(undefined); + const [isImporting, setIsImporting] = useState(false); + const [importError, setImportError] = useState(undefined); const form = useFormik({ initialValues, @@ -111,17 +127,52 @@ export const SecretDialog: FC = ({ const closeDialog = () => { setClearValueRequested(false); + setImportFile(undefined); + setImportError(undefined); + setIsImporting(false); form.resetForm(); onClose(); }; + const handleImportFile = (file: File) => { + setImportError(undefined); + setImportFile(file); + + const format = secretsFileFormatFromFilename(file.name); + if (!format) { + setImportError({ + message: "Unsupported file type. Import a .env, .json, or .yml file.", + }); + return; + } + + setIsImporting(true); + const reader = new FileReader(); + reader.onload = async () => { + const content = typeof reader.result === "string" ? reader.result : ""; + try { + await onImportSecrets({ format, content }); + closeDialog(); + } catch (error) { + setImportError(error); + } finally { + setIsImporting(false); + } + }; + reader.onerror = () => { + setImportError({ message: "Failed to read the selected file." }); + setIsImporting(false); + }; + reader.readAsText(file); + }; + const request = secret ? buildUpdateUserSecretRequest(secret, form.values, { clearValue: clearValueRequested, }) : undefined; const hasUpdate = request ? Object.keys(request).length > 0 : false; - const isBusy = isSubmitting || form.isSubmitting; + const isBusy = isSubmitting || form.isSubmitting || isImporting; const confirmDisabled = isBusy || !form.isValid || (secret ? !hasUpdate : !form.dirty); const getFieldHelpers = getFormHelpers(form); @@ -193,6 +244,25 @@ export const SecretDialog: FC = ({ ) : ( <> +
+ { + setImportFile(undefined); + setImportError(undefined); + }} + removeLabel="Remove file" + title="Import secrets from a file" + description="Import a single or multiple secrets at once with a .env, .json, or .yml file." + extensions={["env", "json", "yaml", "yml"]} + /> + {importError !== undefined && ( + + )} +
+ or add individually [field, true]), ) as FormikTouched; } + +type ImportSecretsErrorProps = { + error: unknown; +}; + +// ImportSecretsError surfaces bulk import failures. Per-entry validation +// errors are listed with the field path (for example secrets[1].env_name) +// so the user can see which entry failed; other failures fall back to the +// shared ErrorAlert for the message and detail. +const ImportSecretsError: FC = ({ error }) => { + const validations = getImportSecretValidations(error); + if (validations.length === 0) { + return ; + } + + return ( + + + {getErrorMessage(error, "Failed to import secrets.")} + + +
    + {validations.map((validation) => ( +
  • + {validation.field} + {validation.detail} +
  • + ))} +
+
+
+ ); +}; + +function getImportSecretValidations(error: unknown): FieldError[] { + if (isApiError(error)) { + return error.response.data.validations ?? []; + } + if (isApiErrorResponse(error)) { + return error.validations ?? []; + } + return []; +} diff --git a/site/src/pages/UserSettingsPage/SecretsPage/SecretsPage.tsx b/site/src/pages/UserSettingsPage/SecretsPage/SecretsPage.tsx index fa74cfd4a5d01..4c3f016038274 100644 --- a/site/src/pages/UserSettingsPage/SecretsPage/SecretsPage.tsx +++ b/site/src/pages/UserSettingsPage/SecretsPage/SecretsPage.tsx @@ -5,6 +5,7 @@ import { getErrorDetail, getErrorMessage } from "#/api/errors"; import { createUserSecret, deleteUserSecret, + importUserSecrets, updateUserSecret, userSecrets, } from "#/api/queries/userSecrets"; @@ -25,6 +26,9 @@ const SecretsPage: FC = () => { const deleteSecretMutation = useMutation( deleteUserSecret(queryClient, me.id), ); + const importSecretsMutation = useMutation( + importUserSecrets(queryClient, me.id), + ); return ( { toast.success(`Updated secret "${secret.name}" successfully.`); return secret; }} + onImportSecrets={async (request) => { + const secrets = await importSecretsMutation.mutateAsync(request); + toast.success( + `Imported ${secrets.length} secret${ + secrets.length === 1 ? "" : "s" + } successfully.`, + ); + return secrets; + }} onDeleteSecret={async (secret) => { try { await deleteSecretMutation.mutateAsync(secret.name); diff --git a/site/src/pages/UserSettingsPage/SecretsPage/SecretsPageView.stories.tsx b/site/src/pages/UserSettingsPage/SecretsPage/SecretsPageView.stories.tsx index 0485a6ca942d3..eaf7f98103c55 100644 --- a/site/src/pages/UserSettingsPage/SecretsPage/SecretsPageView.stories.tsx +++ b/site/src/pages/UserSettingsPage/SecretsPage/SecretsPageView.stories.tsx @@ -2,10 +2,15 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, fn, userEvent, waitFor, within } from "storybook/test"; import type { CreateUserSecretRequest, + ImportUserSecretsRequest, UpdateUserSecretRequest, UserSecret, } from "#/api/typesGenerated"; -import { MockUserSecrets, mockApiError } from "#/testHelpers/entities"; +import { + MockImportedUserSecrets, + MockUserSecrets, + mockApiError, +} from "#/testHelpers/entities"; import { SAVED_SECRET_VALUE_DISPLAY } from "./SecretDialog"; import { SecretsPageView } from "./SecretsPageView"; @@ -26,6 +31,7 @@ const meta: Meta = { onRefresh: fn(), onCreateSecret: fn(), onUpdateSecret: fn(), + onImportSecrets: fn(), onDeleteSecret: fn(), }, }; @@ -43,6 +49,9 @@ type UpdateSecretMock = ReturnType< type DeleteSecretMock = ReturnType< typeof fn<(secret: UserSecret) => Promise | void> >; +type ImportSecretsMock = ReturnType< + typeof fn<(request: ImportUserSecretsRequest) => Promise> +>; const waitForDialogToClose = async (body: ReturnType) => { await waitFor(() => { @@ -595,3 +604,101 @@ export const CreateMutationErrorDisplay: Story = { expectNoValueField(body); }, }; + +export const ImportSecretsFromFileSubmit: Story = { + args: { + onImportSecrets: fn< + (request: ImportUserSecretsRequest) => Promise + >(async () => MockImportedUserSecrets), + }, + play: async ({ canvasElement, args }) => { + const onImportSecrets = args.onImportSecrets as ImportSecretsMock; + onImportSecrets.mockClear(); + const user = userEvent.setup({ applyAccept: false }); + const canvas = within(canvasElement); + const body = within(canvasElement.ownerDocument.body); + + await user.click(canvas.getByRole("button", { name: "Add secret" })); + const dialog = within(await body.findByRole("dialog")); + await user.upload( + dialog.getByTestId("file-upload"), + new File(["A=1\nB=2"], "secrets.env", { type: "text/plain" }), + ); + + await waitFor(() => expect(onImportSecrets).toHaveBeenCalledTimes(1)); + expect(onImportSecrets).toHaveBeenCalledWith({ + format: "env", + content: "A=1\nB=2", + }); + await waitForDialogToClose(body); + }, +}; + +export const ImportSecretsValidationError: Story = { + args: { + onImportSecrets: fn< + (request: ImportUserSecretsRequest) => Promise + >(async () => { + throw mockApiError({ + message: "Validation failed.", + validations: [ + { + field: "secrets[1].env_name", + detail: "PATH is a reserved environment variable name", + }, + ], + }); + }), + }, + play: async ({ canvasElement, args }) => { + const onImportSecrets = args.onImportSecrets as ImportSecretsMock; + onImportSecrets.mockClear(); + const user = userEvent.setup({ applyAccept: false }); + const canvas = within(canvasElement); + const body = within(canvasElement.ownerDocument.body); + + await user.click(canvas.getByRole("button", { name: "Add secret" })); + const dialog = within(await body.findByRole("dialog")); + await user.upload( + dialog.getByTestId("file-upload"), + new File(["PATH=/usr/bin"], "secrets.env", { type: "text/plain" }), + ); + + await waitFor(() => expect(onImportSecrets).toHaveBeenCalledTimes(1)); + await expect(await dialog.findByText("secrets[1].env_name")).toBeVisible(); + await expect( + dialog.getByText("PATH is a reserved environment variable name"), + ).toBeVisible(); + await expect( + dialog.getByRole("heading", { name: "Add secret" }), + ).toBeVisible(); + }, +}; + +export const ImportSecretsUnsupportedFile: Story = { + args: { + onImportSecrets: fn< + (request: ImportUserSecretsRequest) => Promise + >(async () => MockImportedUserSecrets), + }, + play: async ({ canvasElement, args }) => { + const onImportSecrets = args.onImportSecrets as ImportSecretsMock; + onImportSecrets.mockClear(); + const user = userEvent.setup({ applyAccept: false }); + const canvas = within(canvasElement); + const body = within(canvasElement.ownerDocument.body); + + await user.click(canvas.getByRole("button", { name: "Add secret" })); + const dialog = within(await body.findByRole("dialog")); + await user.upload( + dialog.getByTestId("file-upload"), + new File(["not a secret"], "bad.txt", { type: "text/plain" }), + ); + + const importError = await dialog.findByText( + "Unsupported file type. Import a .env, .json, or .yml file.", + ); + await waitFor(() => expect(importError).toBeVisible()); + expect(onImportSecrets).not.toHaveBeenCalled(); + }, +}; diff --git a/site/src/pages/UserSettingsPage/SecretsPage/SecretsPageView.tsx b/site/src/pages/UserSettingsPage/SecretsPage/SecretsPageView.tsx index 0886900770d21..8d0729a949af8 100644 --- a/site/src/pages/UserSettingsPage/SecretsPage/SecretsPageView.tsx +++ b/site/src/pages/UserSettingsPage/SecretsPage/SecretsPageView.tsx @@ -2,6 +2,7 @@ import { PlusIcon, RefreshCwIcon } from "lucide-react"; import { type FC, useRef, useState } from "react"; import type { CreateUserSecretRequest, + ImportUserSecretsRequest, UpdateUserSecretRequest, UserSecret, } from "#/api/typesGenerated"; @@ -36,6 +37,7 @@ type SecretsPageViewProps = { name: string, request: UpdateUserSecretRequest, ) => Promise | UserSecret; + onImportSecrets: (request: ImportUserSecretsRequest) => Promise; onDeleteSecret: (secret: UserSecret) => Promise | void; }; @@ -55,6 +57,7 @@ export const SecretsPageView: FC = ({ onRefresh, onCreateSecret, onUpdateSecret, + onImportSecrets, onDeleteSecret, }) => { const [dialogState, setDialogState] = useState({ @@ -127,6 +130,7 @@ export const SecretsPageView: FC = ({ onClose={closeSecretDialog} onCreateSecret={onCreateSecret} onUpdateSecret={onUpdateSecret} + onImportSecrets={onImportSecrets} /> {getSecretsError ? : undefined} diff --git a/site/src/pages/UserSettingsPage/SecretsPage/secretForm.test.ts b/site/src/pages/UserSettingsPage/SecretsPage/secretForm.test.ts index f45415bdfa028..1f9dab21ec645 100644 --- a/site/src/pages/UserSettingsPage/SecretsPage/secretForm.test.ts +++ b/site/src/pages/UserSettingsPage/SecretsPage/secretForm.test.ts @@ -5,6 +5,7 @@ import { buildUpdateUserSecretRequest, getCreateSecretRequiredFieldErrors, mapSecretApiErrorToFormErrors, + secretsFileFormatFromFilename, } from "./secretForm"; const existingSecrets: UserSecret[] = [ @@ -119,6 +120,31 @@ describe("payload builders", () => { }); }); +describe("secretsFileFormatFromFilename", () => { + it.each([ + ["a.env", "env"], + [".env", "env"], + ["prod.env", "env"], + ["config.json", "json"], + ["values.yaml", "yaml"], + ["values.yml", "yaml"], + ["CONFIG.JSON", "json"], + ["Values.YML", "yaml"], + ["secrets.ENV", "env"], + ])("maps %s to the %s format", (filename, format) => { + expect(secretsFileFormatFromFilename(filename)).toBe(format); + }); + + it.each([ + ["foo.txt"], + ["noextension"], + ["archive.tar.gz"], + [""], + ])("returns undefined for unsupported filename %s", (filename) => { + expect(secretsFileFormatFromFilename(filename)).toBeUndefined(); + }); +}); + describe("mapSecretApiErrorToFormErrors", () => { it("maps structured API validation errors to fields", () => { expect( diff --git a/site/src/pages/UserSettingsPage/SecretsPage/secretForm.ts b/site/src/pages/UserSettingsPage/SecretsPage/secretForm.ts index 4b242ea4a0dc3..3772bd58ff753 100644 --- a/site/src/pages/UserSettingsPage/SecretsPage/secretForm.ts +++ b/site/src/pages/UserSettingsPage/SecretsPage/secretForm.ts @@ -6,6 +6,7 @@ import { } from "#/api/errors"; import type { CreateUserSecretRequest, + SecretsFileFormat, UpdateUserSecretRequest, UserSecret, } from "#/api/typesGenerated"; @@ -27,6 +28,26 @@ interface SecretFormErrors { formError?: string; } +// secretsFileFormatFromFilename derives the import file format from a +// filename extension. The server performs the actual parsing; the client +// only selects the format so it can send the raw file contents. Returns +// undefined when the extension is not a supported secrets file format. +export const secretsFileFormatFromFilename = ( + filename: string, +): SecretsFileFormat | undefined => { + const lowerName = filename.toLowerCase(); + if (lowerName.endsWith(".env")) { + return "env"; + } + if (lowerName.endsWith(".json")) { + return "json"; + } + if (lowerName.endsWith(".yaml") || lowerName.endsWith(".yml")) { + return "yaml"; + } + return undefined; +}; + export const getCreateSecretRequiredFieldErrors = ( values: Pick, ): SecretFieldErrors => { diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 7f18fc9b067e0..dcd095a5bf5fd 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -619,6 +619,29 @@ export const MockUserSecrets: TypesGen.UserSecret[] = [ }, ]; +// MockImportedUserSecrets represents the value-free result returned by the +// bulk secret import endpoint. +export const MockImportedUserSecrets: TypesGen.UserSecret[] = [ + { + id: "imported-database-url", + name: "DATABASE_URL", + description: "Imported from a secrets file.", + env_name: "DATABASE_URL", + file_path: "", + created_at: "2026-05-04T00:00:00Z", + updated_at: "2026-05-04T00:00:00Z", + }, + { + id: "imported-api-token", + name: "API_TOKEN", + description: "Imported from a secrets file.", + env_name: "API_TOKEN", + file_path: "", + created_at: "2026-05-04T00:00:00Z", + updated_at: "2026-05-04T00:00:00Z", + }, +]; + export const MockTasksTabVisible: boolean = false; export const MockOrganizationMember: TypesGen.OrganizationMemberWithUserData = { diff --git a/site/src/testHelpers/handlers.ts b/site/src/testHelpers/handlers.ts index c8a3ca7c04360..a226f32ee35e6 100644 --- a/site/src/testHelpers/handlers.ts +++ b/site/src/testHelpers/handlers.ts @@ -4,6 +4,7 @@ import { HttpResponse, http } from "msw"; import type { CreateUserSecretRequest, CreateWorkspaceBuildRequest, + ImportUserSecretsRequest, UpdateUserSecretRequest, UserSecret, } from "#/api/typesGenerated"; @@ -218,6 +219,12 @@ export const handlers = [ status: 201, }); }), + http.post("/api/v2/users/:userId/secrets/batch", async ({ request }) => { + // The server parses and validates the contents; the mock returns a + // deterministic, value-free set of imported secrets on success. + const _body = (await request.json()) as ImportUserSecretsRequest; + return HttpResponse.json(M.MockImportedUserSecrets, { status: 201 }); + }), http.patch( "/api/v2/users/:userId/secrets/:name", async ({ request, params }) => { From 5e4fee25083dfb6d784d54d0dbc3c87b65c28147 Mon Sep 17 00:00:00 2001 From: Dylan Huff Date: Tue, 23 Jun 2026 19:09:23 +0000 Subject: [PATCH 03/10] test(codersdk): add YAML alias-bomb regression for secret import (PLAT-240) --- codersdk/usersecretsimport_test.go | 51 ++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/codersdk/usersecretsimport_test.go b/codersdk/usersecretsimport_test.go index 9e7442e33a478..213c9ef816f77 100644 --- a/codersdk/usersecretsimport_test.go +++ b/codersdk/usersecretsimport_test.go @@ -1,6 +1,7 @@ package codersdk_test import ( + "fmt" "strings" "testing" @@ -188,6 +189,56 @@ func TestParseSecretsFileYAMLErrors(t *testing.T) { } } +// TestParseSecretsFileYAMLAliasBomb is a regression guard against +// YAML alias-expansion ("billion laughs") resource exhaustion. Two +// properties keep an alias bomb cheap: yaml.v3 decodes into a +// yaml.Node without resolving aliases (so nothing expands in memory), +// and the parser only accepts scalar string values, so any sequence, +// mapping, or alias node at the top level is rejected outright. The +// inputs below stay well under MaxSecretsFileBytes yet would expand +// to an enormous structure if aliases were ever resolved; each must +// return a parse error quickly rather than hang or exhaust memory. +func TestParseSecretsFileYAMLAliasBomb(t *testing.T) { + t.Parallel() + + // Classic nested alias bomb: each anchor references the previous one + // nine times, so resolving the last alias would expand to 9^9 nodes. + var bomb strings.Builder + bomb.WriteString("a: &a \"lol\"\n") + prev := "a" + for i := 0; i < 9; i++ { + cur := fmt.Sprintf("l%d", i) + bomb.WriteString(cur + ": &" + cur + " [") + for j := 0; j < 9; j++ { + if j > 0 { + bomb.WriteByte(',') + } + bomb.WriteString("*" + prev) + } + bomb.WriteString("]\n") + prev = cur + } + + cases := []struct { + name string + content string + }{ + {name: "NestedSequences", content: bomb.String()}, + // Top-level value is an alias node (not a scalar), which must be + // rejected even though the anchor it points at is a scalar. + {name: "AliasToScalar", content: "a: &a \"x\"\nb: *a\n"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + require.Less(t, len(tc.content), codersdk.MaxSecretsFileBytes) + _, err := codersdk.ParseSecretsFile(codersdk.SecretsFileFormatYAML, tc.content) + require.Error(t, err) + assert.Contains(t, err.Error(), "must be a string") + }) + } +} + func TestParseSecretsFileGeneralErrors(t *testing.T) { t.Parallel() From c63787b8fd16e88c169d51600a5b9f5ed1c53d28 Mon Sep 17 00:00:00 2001 From: Dylan Huff Date: Tue, 23 Jun 2026 19:22:32 +0000 Subject: [PATCH 04/10] fix(codersdk): reject multi-document YAML in secret import (PLAT-240) ParseSecretsFile parsed YAML with yaml.Unmarshal, which decodes only the first document and silently discards everything after a '---' separator. A multi-document secrets file would import the first document's secrets and drop the rest while still returning a success response, so secrets could go missing without warning. Decode with a yaml.Decoder and reject any subsequent content-bearing document, mirroring the JSON parser's trailing-data rejection. A bare trailing '---' (or comments-only tail) decodes to a null document and is still allowed. --- codersdk/usersecretsimport.go | 49 ++++++++++++++++++++++++++++-- codersdk/usersecretsimport_test.go | 41 +++++++++++++++++++++++-- 2 files changed, 86 insertions(+), 4 deletions(-) diff --git a/codersdk/usersecretsimport.go b/codersdk/usersecretsimport.go index b7fd8ce3a50a1..49ae21df53fb2 100644 --- a/codersdk/usersecretsimport.go +++ b/codersdk/usersecretsimport.go @@ -355,13 +355,41 @@ func parseJSONSecrets(content string) ([]secretEntry, error) { // null) are rejected so a secret value is never silently type-coerced; // users who want such a value must quote it. Nested mappings and // sequences are rejected. Duplicate keys are preserved by the node -// decoder and caught by the shared duplicate check. +// decoder and caught by the shared duplicate check. A multi-document +// stream is rejected (rather than silently importing only the first +// document) so no secrets are dropped without warning. func parseYAMLSecrets(content string) ([]secretEntry, error) { + dec := yaml.NewDecoder(strings.NewReader(content)) + var root yaml.Node - if err := yaml.Unmarshal([]byte(content), &root); err != nil { + if err := dec.Decode(&root); err != nil { + // An empty document or comments-only file decodes to nothing. + if errors.Is(err, io.EOF) { + return nil, nil + } return nil, xerrors.Errorf("invalid YAML: %w", err) } + // Reject any additional documents. yaml.Unmarshal reads only the + // first document and silently drops the rest, which would lose + // secrets without warning; mirror the JSON parser's trailing-data + // rejection instead. A bare trailing "---" separator (or a + // comments-only tail) decodes to a null document that carries no + // secrets and is allowed. + for { + var extra yaml.Node + err := dec.Decode(&extra) + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return nil, xerrors.Errorf("invalid YAML: %w", err) + } + if yamlDocumentHasContent(extra) { + return nil, xerrors.New("YAML content must be a single document mapping secret names to string values") + } + } + // An empty document or comments-only file decodes to a zero node. if root.Kind == 0 || len(root.Content) == 0 { return nil, nil @@ -388,3 +416,20 @@ func parseYAMLSecrets(content string) ([]secretEntry, error) { } return entries, nil } + +// yamlDocumentHasContent reports whether a decoded YAML document node +// carries data. A bare trailing "---" separator, or a comments-only +// tail, decodes to a document whose only child is a null scalar; that +// loses no secrets and is allowed. Any other node (a mapping, sequence, +// or non-null scalar) is a real second document that would otherwise be +// dropped, so the caller rejects it. +func yamlDocumentHasContent(doc yaml.Node) bool { + if doc.Kind == 0 || len(doc.Content) == 0 { + return false + } + child := doc.Content[0] + if child.Kind == yaml.ScalarNode && child.Tag == "!!null" { + return false + } + return true +} diff --git a/codersdk/usersecretsimport_test.go b/codersdk/usersecretsimport_test.go index 213c9ef816f77..164cfdd552b9d 100644 --- a/codersdk/usersecretsimport_test.go +++ b/codersdk/usersecretsimport_test.go @@ -14,8 +14,10 @@ import ( // TestParseSecretsFileEnv covers the dotenv parsing rules end-to-end: // comments, blank lines, the export prefix, single and double quotes, // double-quote escapes, '=' inside a value, surrounding whitespace, an -// inline '#' kept literally, and non-ASCII values. It also asserts the -// flat mapping invariant Name == EnvName == KEY and Value == VALUE. +// inline '#' kept literally, non-ASCII values, "export" as part of a +// key name (not the prefix), and a value that is exactly '='. It also +// asserts the flat mapping invariant Name == EnvName == KEY and +// Value == VALUE. func TestParseSecretsFileEnv(t *testing.T) { t.Parallel() @@ -33,6 +35,8 @@ func TestParseSecretsFileEnv(t *testing.T) { "EQ_IN_VALUE=a=b=c", "HASH=value # kept literal", "UNICODE=héllo 世界 café", + "exportFOO=literal-key", + "EQ_ONLY_VALUE==", "EMPTY_VAL=", }, "\n") @@ -49,6 +53,8 @@ func TestParseSecretsFileEnv(t *testing.T) { {Name: "EQ_IN_VALUE", EnvName: "EQ_IN_VALUE", Value: "a=b=c"}, {Name: "HASH", EnvName: "HASH", Value: "value # kept literal"}, {Name: "UNICODE", EnvName: "UNICODE", Value: "héllo 世界 café"}, + {Name: "exportFOO", EnvName: "exportFOO", Value: "literal-key"}, + {Name: "EQ_ONLY_VALUE", EnvName: "EQ_ONLY_VALUE", Value: "="}, {Name: "EMPTY_VAL", EnvName: "EMPTY_VAL", Value: ""}, } require.Equal(t, want, reqs) @@ -239,6 +245,37 @@ func TestParseSecretsFileYAMLAliasBomb(t *testing.T) { } } +// TestParseSecretsFileYAMLMultiDocument verifies that a multi-document +// YAML stream is rejected rather than silently importing only the first +// document and dropping the rest. A bare trailing "---" separator with +// no content is harmless and must still parse. +func TestParseSecretsFileYAMLMultiDocument(t *testing.T) { + t.Parallel() + + t.Run("SecondMappingRejected", func(t *testing.T) { + t.Parallel() + _, err := codersdk.ParseSecretsFile(codersdk.SecretsFileFormatYAML, "A: \"1\"\n---\nB: \"2\"\n") + require.Error(t, err) + assert.Contains(t, err.Error(), "single document") + }) + + t.Run("SecondScalarRejected", func(t *testing.T) { + t.Parallel() + _, err := codersdk.ParseSecretsFile(codersdk.SecretsFileFormatYAML, "A: \"1\"\n---\nplain\n") + require.Error(t, err) + assert.Contains(t, err.Error(), "single document") + }) + + t.Run("TrailingSeparatorAllowed", func(t *testing.T) { + t.Parallel() + reqs, err := codersdk.ParseSecretsFile(codersdk.SecretsFileFormatYAML, "A: \"1\"\n---\n") + require.NoError(t, err) + require.Equal(t, []codersdk.CreateUserSecretRequest{ + {Name: "A", EnvName: "A", Value: "1"}, + }, reqs) + }) +} + func TestParseSecretsFileGeneralErrors(t *testing.T) { t.Parallel() From 196d2d15398cd6f9f1cb64e20d5dfae6d672391e Mon Sep 17 00:00:00 2001 From: Dylan Huff Date: Tue, 23 Jun 2026 19:29:53 +0000 Subject: [PATCH 05/10] refactor(site/src/testHelpers): drop unused body read in secret import mock --- site/src/testHelpers/handlers.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/site/src/testHelpers/handlers.ts b/site/src/testHelpers/handlers.ts index a226f32ee35e6..6739f6e4b3efe 100644 --- a/site/src/testHelpers/handlers.ts +++ b/site/src/testHelpers/handlers.ts @@ -4,7 +4,6 @@ import { HttpResponse, http } from "msw"; import type { CreateUserSecretRequest, CreateWorkspaceBuildRequest, - ImportUserSecretsRequest, UpdateUserSecretRequest, UserSecret, } from "#/api/typesGenerated"; @@ -219,10 +218,9 @@ export const handlers = [ status: 201, }); }), - http.post("/api/v2/users/:userId/secrets/batch", async ({ request }) => { + http.post("/api/v2/users/:userId/secrets/batch", () => { // The server parses and validates the contents; the mock returns a // deterministic, value-free set of imported secrets on success. - const _body = (await request.json()) as ImportUserSecretsRequest; return HttpResponse.json(M.MockImportedUserSecrets, { status: 201 }); }), http.patch( From fb8df47568274c7583a6ba0f8f27114613481a71 Mon Sep 17 00:00:00 2001 From: Dylan Huff Date: Tue, 23 Jun 2026 19:35:48 +0000 Subject: [PATCH 06/10] refactor(codersdk): inline single-use env quote helpers (PLAT-240) --- codersdk/usersecretsimport.go | 35 +++++++++++++---------------------- 1 file changed, 13 insertions(+), 22 deletions(-) diff --git a/codersdk/usersecretsimport.go b/codersdk/usersecretsimport.go index 49ae21df53fb2..86745b2e47208 100644 --- a/codersdk/usersecretsimport.go +++ b/codersdk/usersecretsimport.go @@ -223,35 +223,26 @@ func parseEnvValue(rhs string, lineNum int) (string, error) { } switch v[0] { case '"': - return parseDoubleQuotedEnvValue(v, lineNum) + // Double-quoted: runs to the matching closing quote, with the + // permitted escape sequences interpreted. + inner, ok := quotedInner(v, '"') + if !ok { + return "", xerrors.Errorf("line %d: missing closing double quote", lineNum) + } + return unescapeDoubleQuoted(inner), nil case '\'': - return parseSingleQuotedEnvValue(v, lineNum) + // Single-quoted: verbatim, no escape processing. + inner, ok := quotedInner(v, '\'') + if !ok { + return "", xerrors.Errorf("line %d: missing closing single quote", lineNum) + } + return inner, nil default: // Unquoted: trim surrounding whitespace, keep '#' literally. return strings.TrimSpace(v), nil } } -// parseDoubleQuotedEnvValue extracts a double-quoted value and -// interprets the permitted escape sequences. -func parseDoubleQuotedEnvValue(v string, lineNum int) (string, error) { - inner, ok := quotedInner(v, '"') - if !ok { - return "", xerrors.Errorf("line %d: missing closing double quote", lineNum) - } - return unescapeDoubleQuoted(inner), nil -} - -// parseSingleQuotedEnvValue extracts a single-quoted value verbatim; -// single quotes perform no escape processing. -func parseSingleQuotedEnvValue(v string, lineNum int) (string, error) { - inner, ok := quotedInner(v, '\'') - if !ok { - return "", xerrors.Errorf("line %d: missing closing single quote", lineNum) - } - return inner, nil -} - // quotedInner returns the content between the opening quote (v[0]) and // the matching closing quote, which must be the last character after // right-trimming whitespace. ok is false when no closing quote is found. From 640e2268de9112a193f4f9e9955902b36fc51e58 Mon Sep 17 00:00:00 2001 From: Dylan Huff Date: Tue, 23 Jun 2026 19:46:56 +0000 Subject: [PATCH 07/10] test: fill user secret import coverage gaps and assert no audit logs on limit rollback (PLAT-240) --- coderd/usersecretsimport_test.go | 51 +++++++++++++++++++++++++++--- codersdk/usersecretsimport_test.go | 29 +++++++++++++---- 2 files changed, 70 insertions(+), 10 deletions(-) diff --git a/coderd/usersecretsimport_test.go b/coderd/usersecretsimport_test.go index 77a54419a433d..93d9cb2279b9d 100644 --- a/coderd/usersecretsimport_test.go +++ b/coderd/usersecretsimport_test.go @@ -161,13 +161,16 @@ func TestImportUserSecretsConflict(t *testing.T) { } // TestImportUserSecretsLimits exercises each per-user cap. A cap -// tripped mid-batch must roll back the entire import. +// 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() - client := coderdtest.New(t, nil) + auditor := audit.NewMock() + client := coderdtest.New(t, &coderdtest.Options{Auditor: auditor}) _ = coderdtest.CreateFirstUser(t, client) ctx := testutil.Context(t, testutil.WaitLong) @@ -175,6 +178,7 @@ func TestImportUserSecretsLimits(t *testing.T) { 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(), @@ -184,11 +188,13 @@ func TestImportUserSecretsLimits(t *testing.T) { 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() - client := coderdtest.New(t, nil) + auditor := audit.NewMock() + client := coderdtest.New(t, &coderdtest.Options{Auditor: auditor}) _ = coderdtest.CreateFirstUser(t, client) ctx := testutil.Context(t, testutil.WaitLong) @@ -198,6 +204,7 @@ func TestImportUserSecretsLimits(t *testing.T) { 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, @@ -207,11 +214,13 @@ func TestImportUserSecretsLimits(t *testing.T) { 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() - client := coderdtest.New(t, nil) + auditor := audit.NewMock() + client := coderdtest.New(t, &coderdtest.Options{Auditor: auditor}) _ = coderdtest.CreateFirstUser(t, client) ctx := testutil.Context(t, testutil.WaitLong) @@ -243,6 +252,10 @@ func TestImportUserSecretsLimits(t *testing.T) { 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", @@ -252,6 +265,7 @@ func TestImportUserSecretsLimits(t *testing.T) { after, err := client.UserSecrets(ctx, codersdk.Me) require.NoError(t, err) assert.Len(t, after, len(before)) + assert.Empty(t, auditor.AuditLogs()) }) } @@ -279,3 +293,32 @@ func TestImportUserSecretsParseErrors(t *testing.T) { }) } } + +// TestImportUserSecretsDuplicateWithinFile verifies a file that repeats +// a key is rejected at parse time, before any row is inserted: the +// endpoint returns 400, no secrets are created, and no audit log is +// 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 and nothing is audited because the duplicate + // is caught before the transaction runs. + listed, err := client.UserSecrets(ctx, codersdk.Me) + require.NoError(t, err) + assert.Empty(t, listed) + assert.Empty(t, auditor.AuditLogs()) +} diff --git a/codersdk/usersecretsimport_test.go b/codersdk/usersecretsimport_test.go index 164cfdd552b9d..0474b72a7a2fa 100644 --- a/codersdk/usersecretsimport_test.go +++ b/codersdk/usersecretsimport_test.go @@ -12,12 +12,12 @@ import ( ) // TestParseSecretsFileEnv covers the dotenv parsing rules end-to-end: -// comments, blank lines, the export prefix, single and double quotes, -// double-quote escapes, '=' inside a value, surrounding whitespace, an -// inline '#' kept literally, non-ASCII values, "export" as part of a -// key name (not the prefix), and a value that is exactly '='. It also -// asserts the flat mapping invariant Name == EnvName == KEY and -// Value == VALUE. +// comments, blank lines, the export prefix (with a space or a tab), +// single and double quotes, double-quote escapes, '=' inside a value, +// surrounding whitespace and tabs, an inline '#' kept literally, +// non-ASCII values, "export" as part of a key name (not the prefix), +// and a value that is exactly '='. It also asserts the flat mapping +// invariant Name == EnvName == KEY and Value == VALUE. func TestParseSecretsFileEnv(t *testing.T) { t.Parallel() @@ -38,6 +38,8 @@ func TestParseSecretsFileEnv(t *testing.T) { "exportFOO=literal-key", "EQ_ONLY_VALUE==", "EMPTY_VAL=", + "TABBED=\t tab trimmed \t", + "export\tTAB_EXPORT=via-tab", }, "\n") reqs, err := codersdk.ParseSecretsFile(codersdk.SecretsFileFormatEnv, content) @@ -56,6 +58,8 @@ func TestParseSecretsFileEnv(t *testing.T) { {Name: "exportFOO", EnvName: "exportFOO", Value: "literal-key"}, {Name: "EQ_ONLY_VALUE", EnvName: "EQ_ONLY_VALUE", Value: "="}, {Name: "EMPTY_VAL", EnvName: "EMPTY_VAL", Value: ""}, + {Name: "TABBED", EnvName: "TABBED", Value: "tab trimmed"}, + {Name: "TAB_EXPORT", EnvName: "TAB_EXPORT", Value: "via-tab"}, } require.Equal(t, want, reqs) } @@ -109,6 +113,18 @@ func TestParseSecretsFileEnvDuplicateCitesLine(t *testing.T) { assert.Contains(t, err.Error(), "line 2") } +// TestParseSecretsFileEnvMissingEqualsCitesLine confirms the missing +// '=' error reports the offending line for the env format, not just +// line 1. +func TestParseSecretsFileEnvMissingEqualsCitesLine(t *testing.T) { + t.Parallel() + + _, err := codersdk.ParseSecretsFile(codersdk.SecretsFileFormatEnv, "OK=value\nNOEQUALS\n") + require.Error(t, err) + assert.Contains(t, err.Error(), "no '='") + assert.Contains(t, err.Error(), "line 2") +} + func TestParseSecretsFileJSON(t *testing.T) { t.Parallel() @@ -183,6 +199,7 @@ func TestParseSecretsFileYAMLErrors(t *testing.T) { {name: "SequenceValue", content: "LIST:\n - a\n - b", errMsg: "nested mapping or sequence"}, {name: "IntValue", content: "PORT: 8080", errMsg: "must be a string"}, {name: "BoolValue", content: "FLAG: true", errMsg: "must be a string"}, + {name: "NullValue", content: "KEY: null", errMsg: "must be a string"}, {name: "DuplicateKey", content: "DUP: a\nDUP: b", errMsg: "duplicate key"}, } for _, tt := range tests { From 38b59d0a504d344b7440d3c246d68e0b9d8ff595 Mon Sep 17 00:00:00 2001 From: Dylan Huff Date: Tue, 23 Jun 2026 19:51:38 +0000 Subject: [PATCH 08/10] chore(site/src/pages/UserSettingsPage/SecretsPage): format secret import ordering (PLAT-240) --- site/src/pages/UserSettingsPage/SecretsPage/SecretDialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/pages/UserSettingsPage/SecretsPage/SecretDialog.tsx b/site/src/pages/UserSettingsPage/SecretsPage/SecretDialog.tsx index 7e943fe1eb679..d8a920a6121c6 100644 --- a/site/src/pages/UserSettingsPage/SecretsPage/SecretDialog.tsx +++ b/site/src/pages/UserSettingsPage/SecretsPage/SecretDialog.tsx @@ -36,9 +36,9 @@ import { buildUpdateUserSecretRequest, getCreateSecretRequiredFieldErrors, mapSecretApiErrorToFormErrors, - secretsFileFormatFromFilename, type SecretFieldErrors, type SecretFormValues, + secretsFileFormatFromFilename, } from "./secretForm"; type SecretDialogProps = { From 680341b03c9ff0ff98469f753b05ccf0a6c3b789 Mon Sep 17 00:00:00 2001 From: Dylan Huff Date: Tue, 23 Jun 2026 19:53:22 +0000 Subject: [PATCH 09/10] fix(codersdk): handle strings.Builder write errors in secret import test (PLAT-240) --- codersdk/usersecretsimport_test.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/codersdk/usersecretsimport_test.go b/codersdk/usersecretsimport_test.go index 0474b72a7a2fa..911dc6bc3c3e2 100644 --- a/codersdk/usersecretsimport_test.go +++ b/codersdk/usersecretsimport_test.go @@ -227,18 +227,18 @@ func TestParseSecretsFileYAMLAliasBomb(t *testing.T) { // Classic nested alias bomb: each anchor references the previous one // nine times, so resolving the last alias would expand to 9^9 nodes. var bomb strings.Builder - bomb.WriteString("a: &a \"lol\"\n") + _, _ = bomb.WriteString("a: &a \"lol\"\n") prev := "a" for i := 0; i < 9; i++ { cur := fmt.Sprintf("l%d", i) - bomb.WriteString(cur + ": &" + cur + " [") + _, _ = bomb.WriteString(cur + ": &" + cur + " [") for j := 0; j < 9; j++ { if j > 0 { - bomb.WriteByte(',') + _ = bomb.WriteByte(',') } - bomb.WriteString("*" + prev) + _, _ = bomb.WriteString("*" + prev) } - bomb.WriteString("]\n") + _, _ = bomb.WriteString("]\n") prev = cur } From 92ee07bedf9ed3fdaac5f62f460c5c9bd689d2f9 Mon Sep 17 00:00:00 2001 From: Dylan Huff Date: Thu, 25 Jun 2026 17:44:14 +0000 Subject: [PATCH 10/10] test(site/src/pages/UserSettingsPage/SecretsPage): wait for import validation visibility --- .../SecretsPage/SecretsPageView.stories.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/site/src/pages/UserSettingsPage/SecretsPage/SecretsPageView.stories.tsx b/site/src/pages/UserSettingsPage/SecretsPage/SecretsPageView.stories.tsx index eaf7f98103c55..6c06727b9773a 100644 --- a/site/src/pages/UserSettingsPage/SecretsPage/SecretsPageView.stories.tsx +++ b/site/src/pages/UserSettingsPage/SecretsPage/SecretsPageView.stories.tsx @@ -665,10 +665,12 @@ export const ImportSecretsValidationError: Story = { ); await waitFor(() => expect(onImportSecrets).toHaveBeenCalledTimes(1)); - await expect(await dialog.findByText("secrets[1].env_name")).toBeVisible(); - await expect( - dialog.getByText("PATH is a reserved environment variable name"), - ).toBeVisible(); + await waitFor(() => { + expect(dialog.getByText("secrets[1].env_name")).toBeVisible(); + expect( + dialog.getByText("PATH is a reserved environment variable name"), + ).toBeVisible(); + }); await expect( dialog.getByRole("heading", { name: "Add secret" }), ).toBeVisible();