diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 2c27365ac0797..b3ade38bce3cb 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -11027,6 +11027,73 @@ 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" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/codersdk.Response" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/codersdk.Response" + } + }, + "413": { + "description": "Request Entity Too Large", + "schema": { + "$ref": "#/definitions/codersdk.Response" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, "/api/v2/users/{user}/secrets/{name}": { "get": { "produces": [ @@ -20572,6 +20639,21 @@ const docTemplate = `{ } } }, + "codersdk.ImportUserSecretsRequest": { + "type": "object", + "required": [ + "content", + "format" + ], + "properties": { + "content": { + "type": "string" + }, + "format": { + "$ref": "#/definitions/codersdk.SecretsFileFormat" + } + } + }, "codersdk.InboxNotification": { "type": "object", "properties": { @@ -23641,6 +23723,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 7f87f2bad29c0..7be66d6598dcb 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -9780,6 +9780,67 @@ ] } }, + "/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" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/codersdk.Response" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/codersdk.Response" + } + }, + "413": { + "description": "Request Entity Too Large", + "schema": { + "$ref": "#/definitions/codersdk.Response" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, "/api/v2/users/{user}/secrets/{name}": { "get": { "produces": ["application/json"], @@ -18732,6 +18793,18 @@ } } }, + "codersdk.ImportUserSecretsRequest": { + "type": "object", + "required": ["content", "format"], + "properties": { + "content": { + "type": "string" + }, + "format": { + "$ref": "#/definitions/codersdk.SecretsFileFormat" + } + } + }, "codersdk.InboxNotification": { "type": "object", "properties": { @@ -21676,6 +21749,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 ab0332f821ac1..1eae3544b921b 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1816,6 +1816,7 @@ func New(options *Options) *API { r.Put("/gitsshkey", api.regenerateGitSSHKey) r.Route("/secrets", func(r chi.Router) { r.Post("/", api.postUserSecret) + r.Post("/batch", api.postUserSecretsBatch) r.Get("/", api.getUserSecrets) r.Route("/{name}", func(r chi.Router) { r.Get("/", api.getUserSecret) diff --git a/coderd/httpapi/httpapi.go b/coderd/httpapi/httpapi.go index ba8c91582fda8..5045190072f47 100644 --- a/coderd/httpapi/httpapi.go +++ b/coderd/httpapi/httpapi.go @@ -239,6 +239,13 @@ func Read(ctx context.Context, rw http.ResponseWriter, r *http.Request, value in err := json.NewDecoder(r.Body).Decode(value) if err != nil { + if _, ok := errors.AsType[*http.MaxBytesError](err); ok { + Write(ctx, rw, http.StatusRequestEntityTooLarge, codersdk.Response{ + Message: "Request body too large.", + Detail: err.Error(), + }) + return false + } Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Request body must be valid JSON.", Detail: err.Error(), diff --git a/coderd/httpapi/httpapi_test.go b/coderd/httpapi/httpapi_test.go index 16de82bef77d8..dca28196dc56b 100644 --- a/coderd/httpapi/httpapi_test.go +++ b/coderd/httpapi/httpapi_test.go @@ -96,6 +96,17 @@ func TestRead(t *testing.T) { require.False(t, httpapi.Read(ctx, rw, r, v)) }) + t.Run("BodyTooLarge", func(t *testing.T) { + t.Parallel() + ctx := context.Background() + rw := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/", strings.NewReader(`{"value":"too large"}`)) + r.Body = http.MaxBytesReader(rw, r.Body, 4) + var v json.RawMessage + require.False(t, httpapi.Read(ctx, rw, r, &v)) + require.Equal(t, http.StatusRequestEntityTooLarge, rw.Code) + }) + t.Run("Validate", func(t *testing.T) { t.Parallel() type toValidate struct { diff --git a/coderd/usersecrets.go b/coderd/usersecrets.go index c8cc5e32147dc..633983cb612ee 100644 --- a/coderd/usersecrets.go +++ b/coderd/usersecrets.go @@ -81,6 +81,10 @@ func (api *API) postUserSecret(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, http.StatusBadRequest, resp) return } + if httpapi.IsUnauthorizedError(err) { + httpapi.Forbidden(rw) + return + } httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error creating secret.", Detail: err.Error(), @@ -92,6 +96,141 @@ func (api *API) postUserSecret(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, http.StatusCreated, db2sdk.UserSecretFromFull(secret)) } +// @Summary Import user secrets from a file +// @ID import-user-secrets-from-a-file +// @Security CoderSessionToken +// @Accept json +// @Produce json +// @Tags Secrets +// @Param user path string true "User ID, username, or me" +// @Param request body codersdk.ImportUserSecretsRequest true "Import secrets request" +// @Success 201 {array} codersdk.UserSecret +// @Failure 400 {object} codersdk.Response +// @Failure 409 {object} codersdk.Response +// @Failure 413 {object} codersdk.Response +// @Router /api/v2/users/{user}/secrets/batch [post] +func (api *API) postUserSecretsBatch(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + user := httpmw.UserParam(r) + + // Cap body size before reading; worst-case JSON escaping can inflate + // a max-size file several-fold, so 8x gives comfortable headroom. + r.Body = http.MaxBytesReader(rw, r.Body, 8*codersdk.MaxSecretsFileBytes) + var req codersdk.ImportUserSecretsRequest + if !httpapi.Read(ctx, rw, r, &req) { + return + } + + reqs, err := codersdk.ParseSecretsFile(req.Format, req.Content) + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Failed to parse secrets file.", + Detail: err.Error(), + }) + return + } + + // Validate every entry and accumulate all errors so the caller can + // fix the whole file in one round-trip. Each field is prefixed with + // the entry index, e.g. "secrets[2].env_name". + var validations []codersdk.ValidationError + for i, sreq := range reqs { + for _, v := range codersdk.ValidateCreateUserSecretRequest(sreq) { + validations = append(validations, codersdk.ValidationError{ + Field: fmt.Sprintf("secrets[%d].%s", i, v.Field), + Detail: v.Detail, + }) + } + } + if len(validations) > 0 { + writeUserSecretValidationErrors(ctx, rw, http.StatusBadRequest, validations) + return + } + + // Insert atomically. The per-user-limit trigger fires per row, and + // any unique or limit violation aborts the whole transaction, so a + // failed import creates nothing. failedIndex records which entry + // failed so the error can be attributed to it after the rollback. + var created []database.UserSecret + failedIndex := -1 + err = api.Database.InTx(func(tx database.Store) error { + for i, sreq := range reqs { + s, txErr := tx.CreateUserSecret(ctx, database.CreateUserSecretParams{ + ID: uuid.New(), + UserID: user.ID, + Name: sreq.Name, + Description: sreq.Description, + Value: sreq.Value, + ValueKeyID: sql.NullString{}, + EnvName: sreq.EnvName, + FilePath: sreq.FilePath, + }) + if txErr != nil { + failedIndex = i + return txErr + } + created = append(created, s) + } + return nil + }, nil) + if err != nil { + index := failedIndex + + if conflicts := userSecretConflictValidationErrors(err); len(conflicts) > 0 { + if index >= 0 { + for i := range conflicts { + conflicts[i].Field = fmt.Sprintf("secrets[%d].%s", index, conflicts[i].Field) + } + } + writeUserSecretValidationErrors(ctx, rw, http.StatusConflict, conflicts) + return + } + if resp, ok := userSecretLimitResponse(err); ok { + if index >= 0 { + resp.Detail = fmt.Sprintf("Entry secrets[%d] (%q): %s", index, reqs[index].Name, resp.Detail) + } + httpapi.Write(ctx, rw, http.StatusBadRequest, resp) + return + } + if httpapi.IsUnauthorizedError(err) { + httpapi.Forbidden(rw) + return + } + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error importing secrets.", + Detail: err.Error(), + }) + return + } + + // Emit audit logs only after the transaction commits so a rolled-back + // batch produces zero logs. One create log is emitted per secret + // because database.UserSecret is registered as auditable. + auditor := api.Auditor.Load() + requestID := httpmw.RequestID(r) + auditCtx := context.WithoutCancel(ctx) + for _, secret := range created { + audit.BackgroundAudit(auditCtx, &audit.BackgroundAuditParams[database.UserSecret]{ + Audit: *auditor, + Log: api.Logger, + UserID: user.ID, + RequestID: requestID, + Status: http.StatusCreated, + IP: r.RemoteAddr, + UserAgent: r.UserAgent(), + Action: database.AuditActionCreate, + New: secret, + Old: database.UserSecret{}, + }) + } + + out := make([]codersdk.UserSecret, 0, len(created)) + for _, secret := range created { + out = append(out, db2sdk.UserSecretFromFull(secret)) + } + httpapi.Write(ctx, rw, http.StatusCreated, out) +} + // @Summary List user secrets // @ID list-user-secrets // @Security CoderSessionToken diff --git a/coderd/usersecrets_test.go b/coderd/usersecrets_test.go index f51cc4b58fdf6..4fedcb2dec3a7 100644 --- a/coderd/usersecrets_test.go +++ b/coderd/usersecrets_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/require" "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" ) @@ -207,6 +208,22 @@ func TestPostUserSecret(t *testing.T) { }) } +func TestPostUserSecretForbiddenForAnotherUser(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + owner := coderdtest.CreateFirstUser(t, client) + memberClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleAuditor()) + ctx := testutil.Context(t, testutil.WaitMedium) + + _, err := memberClient.CreateUserSecret(ctx, owner.UserID.String(), codersdk.CreateUserSecretRequest{ + Name: "forbidden", + Value: "value", + }) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusForbidden, sdkErr.StatusCode()) +} + func TestGetUserSecrets(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) diff --git a/coderd/usersecretsimport_test.go b/coderd/usersecretsimport_test.go new file mode 100644 index 0000000000000..0cddeba7a16b1 --- /dev/null +++ b/coderd/usersecretsimport_test.go @@ -0,0 +1,343 @@ +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/coderd/rbac" + "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\nPATH=c\n", + }) + require.NoError(t, err) + require.Len(t, secrets, 3) + // Valid keys are env-injected, while reserved names are imported + // without env injection. + assert.Equal(t, "ALPHA", secrets[0].Name) + assert.Equal(t, "ALPHA", secrets[0].EnvName) + assert.Equal(t, "PATH", secrets[2].Name) + assert.Empty(t, secrets[2].EnvName) + + listed, err := client.UserSecrets(ctx, codersdk.Me) + require.NoError(t, err) + names := make([]string, 0, len(listed)) + for _, s := range listed { + names = append(names, s.Name) + } + assert.ElementsMatch(t, []string{"ALPHA", "BETA", "PATH"}, names) + + // Exactly one create audit log per imported secret. + logs := auditor.AuditLogs() + require.Len(t, logs, 3) + resourceIDs := make([]string, 0, len(logs)) + resourceTargets := make([]string, 0, len(logs)) + for _, l := range logs { + assert.Equal(t, database.AuditActionCreate, l.Action) + assert.EqualValues(t, http.StatusCreated, l.StatusCode) + resourceIDs = append(resourceIDs, l.ResourceID.String()) + resourceTargets = append(resourceTargets, l.ResourceTarget) + } + assert.ElementsMatch(t, []string{ + secrets[0].ID.String(), secrets[1].ID.String(), secrets[2].ID.String(), + }, resourceIDs) + assert.ElementsMatch(t, []string{"ALPHA", "BETA", "PATH"}, resourceTargets) + }) + + t.Run("ValuesNotInResponse", func(t *testing.T) { + 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) + }) +} + +func TestImportUserSecretsForbiddenForAnotherUser(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + owner := coderdtest.CreateFirstUser(t, client) + memberClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleAuditor()) + ctx := testutil.Context(t, testutil.WaitMedium) + + _, err := memberClient.ImportUserSecrets(ctx, owner.UserID.String(), codersdk.ImportUserSecretsRequest{ + Format: codersdk.SecretsFileFormatEnv, + Content: "FORBIDDEN=value", + }) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusForbidden, sdkErr.StatusCode()) +} + +func TestImportUserSecretsBodyTooLarge(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitMedium) + + _, err := client.ImportUserSecrets(ctx, codersdk.Me, codersdk.ImportUserSecretsRequest{ + Format: codersdk.SecretsFileFormatEnv, + Content: strings.Repeat("a", 8*codersdk.MaxSecretsFileBytes), + }) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusRequestEntityTooLarge, sdkErr.StatusCode()) +} + +// TestImportUserSecretsValidationRollback verifies that a single +// invalid entry rejects the whole batch: nothing is created and no +// audit log is written. The valid sibling entry must not leak through. +func TestImportUserSecretsValidationRollback(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + badLine string + }{ + // Empty values are always invalid; this is the canonical rollback case. + {name: "EmptyValue", badLine: "EMPTY_ONE="}, + {name: "OversizedValue", badLine: "BIG=" + strings.Repeat("a", codersdk.MaxUserSecretValueBytes+1)}, + // A slash in the name is invalid regardless of env-name handling. + {name: "NameWithSlash", badLine: "bad/name=value"}, + } + for _, tc := range cases { + 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 verifies that a batch containing an +// already-existing secret name aborts entirely: the new entry is not +// created and no audit log is written. +func TestImportUserSecretsConflict(t *testing.T) { + t.Parallel() + auditor := audit.NewMock() + 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", + }) + 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 every row in the import and, because +// audit logs are emitted only after the transaction commits, write no +// import audit logs. +func TestImportUserSecretsLimits(t *testing.T) { + t.Parallel() + + t.Run("CountLimit", func(t *testing.T) { + t.Parallel() + auditor := audit.NewMock() + client := coderdtest.New(t, &coderdtest.Options{Auditor: auditor}) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + for i := 0; i < codersdk.MaxUserSecretsPerUserCount-1; i++ { + _, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ + Name: fmt.Sprintf("prefill-%03d", i), + Value: "original", + }) + require.NoError(t, err) + } + before, err := client.UserSecrets(ctx, codersdk.Me) + require.NoError(t, err) + require.Len(t, before, codersdk.MaxUserSecretsPerUserCount-1) + + auditor.ResetLogs() + _, err = client.ImportUserSecrets(ctx, codersdk.Me, codersdk.ImportUserSecretsRequest{ + Format: codersdk.SecretsFileFormatEnv, + Content: "COUNT_FIRST=x\nCOUNT_SECOND=y\n", + }) + requireSecretAPIError(t, err, http.StatusBadRequest, "secrets[1]") + + after, err := client.UserSecrets(ctx, codersdk.Me) + require.NoError(t, err) + require.Len(t, after, len(before)) + beforeNames := make([]string, 0, len(before)) + afterNames := make([]string, 0, len(after)) + for _, secret := range before { + beforeNames = append(beforeNames, secret.Name) + } + for _, secret := range after { + afterNames = append(afterNames, secret.Name) + } + assert.ElementsMatch(t, beforeNames, afterNames) + assert.Empty(t, auditor.AuditLogs()) + }) + + t.Run("EnvBytesLimit", func(t *testing.T) { + t.Parallel() + auditor := audit.NewMock() + client := coderdtest.New(t, &coderdtest.Options{Auditor: auditor}) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + // Every imported secret is env-injected, so two values that are + // each within the per-value cap can still exceed the env-bytes + // aggregate together. + content := fmt.Sprintf("ENV_A=%s\nENV_B=%s\n", + strings.Repeat("a", codersdk.MaxUserSecretValueBytes-16), + strings.Repeat("a", 1024)) + auditor.ResetLogs() + _, err := client.ImportUserSecrets(ctx, codersdk.Me, codersdk.ImportUserSecretsRequest{ + Format: codersdk.SecretsFileFormatEnv, + Content: content, + }) + requireSecretAPIError(t, err, http.StatusBadRequest, "env_name") + + listed, err := client.UserSecrets(ctx, codersdk.Me) + require.NoError(t, err) + assert.Empty(t, listed) + assert.Empty(t, auditor.AuditLogs()) + }) + + t.Run("TotalBytesLimit", func(t *testing.T) { + t.Parallel() + auditor := audit.NewMock() + client := coderdtest.New(t, &coderdtest.Options{Auditor: auditor}) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + // Pre-fill the total-bytes budget to the cap using file-only + // secrets (no env_name), which do not count against the smaller + // env budget. Creating them via CreateUserSecret directly avoids + // going through the import parser. + big := strings.Repeat("a", codersdk.MaxUserSecretValueBytes) + numBig := codersdk.MaxUserSecretsTotalValueBytes / codersdk.MaxUserSecretValueBytes + remainder := codersdk.MaxUserSecretsTotalValueBytes % codersdk.MaxUserSecretValueBytes + for i := 0; i < numBig; i++ { + _, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ + Name: fmt.Sprintf("prefill-%03d", i), + Value: big, + FilePath: fmt.Sprintf("/tmp/prefill-%03d", i), + }) + require.NoError(t, err) + } + if remainder > 0 { + _, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ + Name: "prefill-pad", + Value: strings.Repeat("a", remainder), + FilePath: "/tmp/prefill-pad", + }) + require.NoError(t, err) + } + + before, err := client.UserSecrets(ctx, codersdk.Me) + require.NoError(t, err) + + // Reset after the prefill (which legitimately emits create audit + // logs) so the assertion below only sees logs from the rolled-back + // import. + auditor.ResetLogs() + _, err = client.ImportUserSecrets(ctx, codersdk.Me, codersdk.ImportUserSecretsRequest{ + Format: codersdk.SecretsFileFormatEnv, + Content: "OVERFLOW=x", + }) + requireSecretAPIError(t, err, http.StatusBadRequest, "per-user budget") + + after, err := client.UserSecrets(ctx, codersdk.Me) + require.NoError(t, err) + assert.Len(t, after, len(before)) + assert.Empty(t, auditor.AuditLogs()) + }) +} + +func TestImportUserSecretsParseErrors(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitMedium) + + // Parse-error variety is covered by the parser unit tests; this only + // asserts the endpoint maps a parse failure to 400. + _, err := client.ImportUserSecrets(ctx, codersdk.Me, codersdk.ImportUserSecretsRequest{ + Format: codersdk.SecretsFileFormatJSON, + Content: "{not json", + }) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + assert.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) +} diff --git a/codersdk/usersecrets.go b/codersdk/usersecrets.go index 43cfd00a4f2f1..7d59d9b6d86f7 100644 --- a/codersdk/usersecrets.go +++ b/codersdk/usersecrets.go @@ -70,6 +70,30 @@ func (c *Client) UserSecrets(ctx context.Context, user string) ([]UserSecret, er return secrets, json.NewDecoder(res.Body).Decode(&secrets) } +// ImportUserSecretsRequest is the payload for the bulk secret import +// endpoint. Content is the raw file bytes and Format selects the parser. +type ImportUserSecretsRequest struct { + Format SecretsFileFormat `json:"format" validate:"required"` + Content string `json:"content" validate:"required"` +} + +// 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 index 84d9cf1810baa..75aad537e0b00 100644 --- a/codersdk/usersecretsimport.go +++ b/codersdk/usersecretsimport.go @@ -33,7 +33,9 @@ type secretEntry struct { // ParseSecretsFile parses a secrets file into CreateUserSecretRequests. // It checks structure and duplicate keys; per-entry validation is left to -// ValidateCreateUserSecretRequest. +// ValidateCreateUserSecretRequest. EnvName is set only when the key passes +// env-name validation (best-effort; keys like MY-TOKEN or PATH get an empty +// EnvName so they are still imported without env injection). func ParseSecretsFile(format SecretsFileFormat, content string) ([]CreateUserSecretRequest, error) { if len(content) > MaxSecretsFileBytes { return nil, xerrors.Errorf("secrets file exceeds the maximum allowed size of %d bytes", MaxSecretsFileBytes) @@ -80,15 +82,19 @@ func ParseSecretsFile(format SecretsFileFormat, content string) ([]CreateUserSec reqs := make([]CreateUserSecretRequest, 0, len(entries)) for _, e := range entries { - reqs = append(reqs, CreateUserSecretRequest{ - Name: e.key, - EnvName: e.key, - Value: e.value, - }) + req := CreateUserSecretRequest{Name: e.key, Value: e.value} + // env_name uses a partial unique index (WHERE env_name != ''), + // so multiple empty env_names are allowed. + if UserSecretEnvNameValid(e.key) == nil { + req.EnvName = e.key + } + reqs = append(reqs, req) } return reqs, nil } +// Duplicate keys are rejected up front (citing the line for env files) +// instead of surfacing as a per-row uniqueness violation later. func detectDuplicateKeys(entries []secretEntry) error { seen := make(map[string]struct{}, len(entries)) for _, e := range entries { diff --git a/codersdk/usersecretsimport_test.go b/codersdk/usersecretsimport_test.go index ae651c94a8071..5dea3480d9679 100644 --- a/codersdk/usersecretsimport_test.go +++ b/codersdk/usersecretsimport_test.go @@ -234,8 +234,8 @@ func TestParseSecretsFileYAMLMultiDocument(t *testing.T) { // FuzzParseSecretsFile checks two invariants: (1) the parser never panics // regardless of input (the fuzz engine catches panics automatically); (2) on // success the result is well-formed: at least one entry, at most -// MaxUserSecretsPerUserCount entries, EnvName == Name for every entry, -// and all keys unique. On error the returned slice must be nil/empty. +// MaxUserSecretsPerUserCount entries, EnvName is empty or equals Name for every +// entry, and all keys unique. On error the returned slice must be nil/empty. func FuzzParseSecretsFile(f *testing.F) { // env - valid f.Add("env", "KEY=value") @@ -295,7 +295,7 @@ func FuzzParseSecretsFile(f *testing.F) { seen := make(map[string]struct{}, len(reqs)) for _, req := range reqs { - require.Equal(t, req.Name, req.EnvName) + require.True(t, req.EnvName == "" || req.EnvName == req.Name, "EnvName must be empty or equal to Name") _, dup := seen[req.Name] require.False(t, dup, "duplicate key %q in result", req.Name) seen[req.Name] = struct{}{} @@ -370,3 +370,27 @@ func TestParseSecretsFileGeneralErrors(t *testing.T) { }) } } + +func TestParseSecretsFileBestEffortEnvName(t *testing.T) { + t.Parallel() + + cases := []struct { + format codersdk.SecretsFileFormat + content string + }{ + {format: codersdk.SecretsFileFormatEnv, content: "PATH=value"}, + {format: codersdk.SecretsFileFormatJSON, content: `{"PATH":"value"}`}, + {format: codersdk.SecretsFileFormatYAML, content: "PATH: value"}, + } + for _, tc := range cases { + t.Run(string(tc.format), func(t *testing.T) { + t.Parallel() + reqs, err := codersdk.ParseSecretsFile(tc.format, tc.content) + require.NoError(t, err) + require.Equal(t, []codersdk.CreateUserSecretRequest{{ + Name: "PATH", + Value: "value", + }}, reqs) + }) + } +} diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index ddba40ad34a87..582b864ff394b 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -7908,6 +7908,22 @@ Only certain features set these fields: - FeatureManagedAgentLimit| | `refresh` | integer | false | | | | `threshold_database` | integer | false | | | +## codersdk.ImportUserSecretsRequest + +```json +{ + "content": "string", + "format": "env" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|-----------|----------------------------------------------------------|----------|--------------|-------------| +| `content` | string | true | | | +| `format` | [codersdk.SecretsFileFormat](#codersdksecretsfileformat) | true | | | + ## codersdk.InboxNotification ```json @@ -11370,6 +11386,20 @@ Only certain features set these fields: - FeatureManagedAgentLimit| | `ssh_config_options` | object | false | | | | » `[any property]` | string | false | | | +## codersdk.SecretsFileFormat + +```json +"env" +``` + +### Properties + +#### Enumerated Values + +| Value(s) | +|-----------------------| +| `env`, `json`, `yaml` | + ## codersdk.ServerSentEvent ```json diff --git a/docs/reference/api/secrets.md b/docs/reference/api/secrets.md index 9ead8d17eb557..74b44c0ec239a 100644 --- a/docs/reference/api/secrets.md +++ b/docs/reference/api/secrets.md @@ -117,6 +117,80 @@ curl -X POST http://coder-server:8080/api/v2/users/{user}/secrets \ To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Import user secrets from a file + +### Code samples + +```sh +# Example request using curl +curl -X POST http://coder-server:8080/api/v2/users/{user}/secrets/batch \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`POST /api/v2/users/{user}/secrets/batch` + +> Body parameter + +```json +{ + "content": "string", + "format": "env" +} +``` + +### Parameters + +| Name | In | Type | Required | Description | +|--------|------|----------------------------------------------------------------------------------|----------|--------------------------| +| `user` | path | string | true | User ID, username, or me | +| `body` | body | [codersdk.ImportUserSecretsRequest](schemas.md#codersdkimportusersecretsrequest) | true | Import secrets request | + +### Example responses + +> 201 Response + +```json +[ + { + "created_at": "2019-08-24T14:15:22Z", + "description": "string", + "env_name": "string", + "file_path": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "name": "string", + "updated_at": "2019-08-24T14:15:22Z" + } +] +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|-------------------------------------------------------------------------|--------------------------|---------------------------------------------------------------| +| 201 | [Created](https://tools.ietf.org/html/rfc7231#section-6.3.2) | Created | array of [codersdk.UserSecret](schemas.md#codersdkusersecret) | +| 400 | [Bad Request](https://tools.ietf.org/html/rfc7231#section-6.5.1) | Bad Request | [codersdk.Response](schemas.md#codersdkresponse) | +| 409 | [Conflict](https://tools.ietf.org/html/rfc7231#section-6.5.8) | Conflict | [codersdk.Response](schemas.md#codersdkresponse) | +| 413 | [Payload Too Large](https://tools.ietf.org/html/rfc7231#section-6.5.11) | Request Entity Too Large | [codersdk.Response](schemas.md#codersdkresponse) | + +