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

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

Part of the [PLAT-240 bulk secret import
stack](https://linear.app/codercom/issue/PLAT-240). Reviewed and updated
by Coder Agents on behalf of @dylanhuff-at-coder.
This commit is contained in:
dylanhuff-at-coder
2026-07-23 17:55:34 -04:00
committed by GitHub
parent 73af2ca632
commit d5a3963167
14 changed files with 872 additions and 9 deletions
+95
View File
@@ -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": [
@@ -20578,6 +20645,21 @@ const docTemplate = `{
}
}
},
"codersdk.ImportUserSecretsRequest": {
"type": "object",
"required": [
"content",
"format"
],
"properties": {
"content": {
"type": "string"
},
"format": {
"$ref": "#/definitions/codersdk.SecretsFileFormat"
}
}
},
"codersdk.InboxNotification": {
"type": "object",
"properties": {
@@ -23647,6 +23729,19 @@ const docTemplate = `{
}
}
},
"codersdk.SecretsFileFormat": {
"type": "string",
"enum": [
"env",
"json",
"yaml"
],
"x-enum-varnames": [
"SecretsFileFormatEnv",
"SecretsFileFormatJSON",
"SecretsFileFormatYAML"
]
},
"codersdk.ServerSentEvent": {
"type": "object",
"properties": {
+82
View File
@@ -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"],
@@ -18738,6 +18799,18 @@
}
}
},
"codersdk.ImportUserSecretsRequest": {
"type": "object",
"required": ["content", "format"],
"properties": {
"content": {
"type": "string"
},
"format": {
"$ref": "#/definitions/codersdk.SecretsFileFormat"
}
}
},
"codersdk.InboxNotification": {
"type": "object",
"properties": {
@@ -21682,6 +21755,15 @@
}
}
},
"codersdk.SecretsFileFormat": {
"type": "string",
"enum": ["env", "json", "yaml"],
"x-enum-varnames": [
"SecretsFileFormatEnv",
"SecretsFileFormatJSON",
"SecretsFileFormatYAML"
]
},
"codersdk.ServerSentEvent": {
"type": "object",
"properties": {
+1
View File
@@ -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)
+7
View File
@@ -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(),
+11
View File
@@ -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 {
+139
View File
@@ -81,6 +81,10 @@ func (api *API) postUserSecret(rw http.ResponseWriter, r *http.Request) {
httpapi.Write(ctx, rw, http.StatusBadRequest, resp)
return
}
if httpapi.IsUnauthorizedError(err) {
httpapi.Forbidden(rw)
return
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error creating secret.",
Detail: err.Error(),
@@ -92,6 +96,141 @@ func (api *API) postUserSecret(rw http.ResponseWriter, r *http.Request) {
httpapi.Write(ctx, rw, http.StatusCreated, db2sdk.UserSecretFromFull(secret))
}
// @Summary Import user secrets from a file
// @ID import-user-secrets-from-a-file
// @Security CoderSessionToken
// @Accept json
// @Produce json
// @Tags Secrets
// @Param user path string true "User ID, username, or me"
// @Param request body codersdk.ImportUserSecretsRequest true "Import secrets request"
// @Success 201 {array} codersdk.UserSecret
// @Failure 400 {object} codersdk.Response
// @Failure 409 {object} codersdk.Response
// @Failure 413 {object} codersdk.Response
// @Router /api/v2/users/{user}/secrets/batch [post]
func (api *API) postUserSecretsBatch(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
user := httpmw.UserParam(r)
// Cap body size before reading; worst-case JSON escaping can inflate
// a max-size file several-fold, so 8x gives comfortable headroom.
r.Body = http.MaxBytesReader(rw, r.Body, 8*codersdk.MaxSecretsFileBytes)
var req codersdk.ImportUserSecretsRequest
if !httpapi.Read(ctx, rw, r, &req) {
return
}
reqs, err := codersdk.ParseSecretsFile(req.Format, req.Content)
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Failed to parse secrets file.",
Detail: err.Error(),
})
return
}
// Validate every entry and accumulate all errors so the caller can
// fix the whole file in one round-trip. Each field is prefixed with
// the entry index, e.g. "secrets[2].env_name".
var validations []codersdk.ValidationError
for i, sreq := range reqs {
for _, v := range codersdk.ValidateCreateUserSecretRequest(sreq) {
validations = append(validations, codersdk.ValidationError{
Field: fmt.Sprintf("secrets[%d].%s", i, v.Field),
Detail: v.Detail,
})
}
}
if len(validations) > 0 {
writeUserSecretValidationErrors(ctx, rw, http.StatusBadRequest, validations)
return
}
// Insert atomically. The per-user-limit trigger fires per row, and
// any unique or limit violation aborts the whole transaction, so a
// failed import creates nothing. failedIndex records which entry
// failed so the error can be attributed to it after the rollback.
var created []database.UserSecret
failedIndex := -1
err = api.Database.InTx(func(tx database.Store) error {
for i, sreq := range reqs {
s, txErr := tx.CreateUserSecret(ctx, database.CreateUserSecretParams{
ID: uuid.New(),
UserID: user.ID,
Name: sreq.Name,
Description: sreq.Description,
Value: sreq.Value,
ValueKeyID: sql.NullString{},
EnvName: sreq.EnvName,
FilePath: sreq.FilePath,
})
if txErr != nil {
failedIndex = i
return txErr
}
created = append(created, s)
}
return nil
}, nil)
if err != nil {
index := failedIndex
if conflicts := userSecretConflictValidationErrors(err); len(conflicts) > 0 {
if index >= 0 {
for i := range conflicts {
conflicts[i].Field = fmt.Sprintf("secrets[%d].%s", index, conflicts[i].Field)
}
}
writeUserSecretValidationErrors(ctx, rw, http.StatusConflict, conflicts)
return
}
if resp, ok := userSecretLimitResponse(err); ok {
if index >= 0 {
resp.Detail = fmt.Sprintf("Entry secrets[%d] (%q): %s", index, reqs[index].Name, resp.Detail)
}
httpapi.Write(ctx, rw, http.StatusBadRequest, resp)
return
}
if httpapi.IsUnauthorizedError(err) {
httpapi.Forbidden(rw)
return
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error importing secrets.",
Detail: err.Error(),
})
return
}
// Emit audit logs only after the transaction commits so a rolled-back
// batch produces zero logs. One create log is emitted per secret
// because database.UserSecret is registered as auditable.
auditor := api.Auditor.Load()
requestID := httpmw.RequestID(r)
auditCtx := context.WithoutCancel(ctx)
for _, secret := range created {
audit.BackgroundAudit(auditCtx, &audit.BackgroundAuditParams[database.UserSecret]{
Audit: *auditor,
Log: api.Logger,
UserID: user.ID,
RequestID: requestID,
Status: http.StatusCreated,
IP: r.RemoteAddr,
UserAgent: r.UserAgent(),
Action: database.AuditActionCreate,
New: secret,
Old: database.UserSecret{},
})
}
out := make([]codersdk.UserSecret, 0, len(created))
for _, secret := range created {
out = append(out, db2sdk.UserSecretFromFull(secret))
}
httpapi.Write(ctx, rw, http.StatusCreated, out)
}
// @Summary List user secrets
// @ID list-user-secrets
// @Security CoderSessionToken
+17
View File
@@ -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)
+343
View File
@@ -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())
}
+24
View File
@@ -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 {
+12 -6
View File
@@ -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 {
+27 -3
View File
@@ -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)
})
}
}
+30
View File
@@ -7912,6 +7912,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
@@ -11374,6 +11390,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
+74
View File
@@ -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) |
<h3 id="import-user-secrets-from-a-file-responseschema">Response Schema</h3>
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
+10
View File
@@ -5459,6 +5459,16 @@ export interface IDPSyncMapping<ResourceIdType extends string> {
readonly Gets: ResourceIdType;
}
// From codersdk/usersecrets.go
/**
* ImportUserSecretsRequest is the payload for the bulk secret import
* endpoint. Content is the raw file bytes and Format selects the parser.
*/
export interface ImportUserSecretsRequest {
readonly format: SecretsFileFormat;
readonly content: string;
}
// From codersdk/inboxnotification.go
export interface InboxNotification {
readonly id: string;