Files
coder/codersdk/usersecrets.go
T
dylanhuff-at-coder d5a3963167 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.
2026-07-23 14:55:34 -07:00

134 lines
4.6 KiB
Go

package codersdk
import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/google/uuid"
)
// UserSecret represents a user secret's metadata. The secret value
// is never included in API responses.
type UserSecret struct {
ID uuid.UUID `json:"id" format:"uuid"`
Name string `json:"name"`
Description string `json:"description"`
EnvName string `json:"env_name"`
FilePath string `json:"file_path"`
CreatedAt time.Time `json:"created_at" format:"date-time"`
UpdatedAt time.Time `json:"updated_at" format:"date-time"`
}
// CreateUserSecretRequest is the payload for creating a new user
// secret. Name and Value are required. All other fields are optional
// and default to empty string.
type CreateUserSecretRequest struct {
Name string `json:"name"`
Value string `json:"value"`
Description string `json:"description,omitempty"`
EnvName string `json:"env_name,omitempty"`
FilePath string `json:"file_path,omitempty"`
}
// UpdateUserSecretRequest is the payload for partially updating a
// user secret. At least one field must be non-nil. Pointer fields
// distinguish "not sent" (nil) from "set to empty string" (pointer
// to empty string).
type UpdateUserSecretRequest struct {
Value *string `json:"value,omitempty"`
Description *string `json:"description,omitempty"`
EnvName *string `json:"env_name,omitempty"`
FilePath *string `json:"file_path,omitempty"`
}
func (c *Client) CreateUserSecret(ctx context.Context, user string, req CreateUserSecretRequest) (UserSecret, error) {
res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/users/%s/secrets", user), req)
if err != nil {
return UserSecret{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusCreated {
return UserSecret{}, ReadBodyAsError(res)
}
var secret UserSecret
return secret, json.NewDecoder(res.Body).Decode(&secret)
}
func (c *Client) UserSecrets(ctx context.Context, user string) ([]UserSecret, error) {
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/secrets", user), nil)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, ReadBodyAsError(res)
}
var secrets []UserSecret
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 {
return UserSecret{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return UserSecret{}, ReadBodyAsError(res)
}
var secret UserSecret
return secret, json.NewDecoder(res.Body).Decode(&secret)
}
func (c *Client) UpdateUserSecret(ctx context.Context, user string, name string, req UpdateUserSecretRequest) (UserSecret, error) {
res, err := c.Request(ctx, http.MethodPatch, fmt.Sprintf("/api/v2/users/%s/secrets/%s", user, name), req)
if err != nil {
return UserSecret{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return UserSecret{}, ReadBodyAsError(res)
}
var secret UserSecret
return secret, json.NewDecoder(res.Body).Decode(&secret)
}
func (c *Client) DeleteUserSecret(ctx context.Context, user string, name string) error {
res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/v2/users/%s/secrets/%s", user, name), nil)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusNoContent {
return ReadBodyAsError(res)
}
return nil
}