mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user