Files
coder/codersdk/usersecretsimport.go
T
Zach 85984ff142 feat: add enable/disable support for user secrets (#27537)
Users can now disable a secret to stop it from being injected into
workspaces without deleting it, and re-enable it later. Disabled secrets
stay visible and editable everywhere they already appear.

An enabled secret must have at least one injection target; a secret with
no target can be stored only while disabled. Existing target-less secrets
are migrated to disabled to preserve current behavior.

Support spans the REST API, SDK, CLI, dashboard, and audit log.
2026-07-28 09:58:33 -06:00

369 lines
11 KiB
Go

package codersdk
import (
"encoding/json"
"errors"
"io"
"strings"
"golang.org/x/xerrors"
"gopkg.in/yaml.v3"
)
// SecretsFileFormat identifies the on-disk format of a secrets file.
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 a secrets file before parsing.
const MaxSecretsFileBytes = 1 << 20 // 1 MiB
type secretEntry struct {
key string
value string
line int
}
// ParseSecretsFile parses a secrets file into CreateUserSecretRequests.
// It checks structure and duplicate keys; per-entry validation is left to
// 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)
}
switch format {
case SecretsFileFormatEnv, SecretsFileFormatJSON, SecretsFileFormatYAML:
case "":
return nil, xerrors.New("a secrets file format is required")
default:
return nil, xerrors.Errorf("unknown secrets file format %q", format)
}
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
}
if len(entries) == 0 {
return nil, xerrors.New("no secrets found in file")
}
if len(entries) > MaxUserSecretsPerUserCount {
return nil, xerrors.Errorf("secrets file contains %d secrets, which exceeds the maximum of %d secrets per user", len(entries), MaxUserSecretsPerUserCount)
}
if err := detectDuplicateKeys(entries); err != nil {
return nil, err
}
reqs := make([]CreateUserSecretRequest, 0, len(entries))
for _, e := range entries {
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
} else {
// Keys that cannot be env-injected (reserved names, invalid
// identifiers) are imported without an injection target, so
// they must be disabled: an enabled secret always has at
// least one of env_name or file_path set. The user can add
// a target and re-enable the secret afterwards.
disabled := false
req.Enabled = &disabled
}
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 {
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 a dotenv-style file into ordered entries. It supports a
// deliberately small subset of dotenv:
// - KEY=VALUE lines, an optional "export " prefix, and full-line "#" comments.
// - Single-quoted values are literal; double-quoted values support \n, \t,
// \r, \\, and \" escapes and keep unknown escapes literal.
//
// It intentionally does NOT:
// - expand $VAR or ${VAR}. Secrets frequently contain "$", and expansion would
// silently corrupt them.
// - strip inline comments, so PASS=abc#123 keeps the trailing #123.
// - support multiline values. Use the JSON or YAML format for PEM keys or
// certs.
//
// Duplicate keys are an error (see detectDuplicateKeys); a silent last-wins
// would drop a secret.
//
// This is hand-rolled rather than using joho/godotenv or hashicorp/go-envparse
// because those expand variables and/or strip inline comments (silent secret
// corruption) and return unordered maps, so we lose source order, line numbers,
// and duplicate detection. They would also add a dependency to the public
// codersdk package.
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")
}
func parseEnvValue(rhs string, lineNum int) (string, error) {
v := strings.TrimLeft(rhs, " \t")
if v == "" {
return "", nil
}
switch v[0] {
case '"':
inner, err := doubleQuotedInner(v, lineNum)
if err != nil {
return "", err
}
return unescapeDoubleQuoted(inner), nil
case '\'':
return singleQuotedInner(v, lineNum)
default:
return strings.TrimSpace(v), nil
}
}
func doubleQuotedInner(v string, lineNum int) (string, error) {
for i := 1; i < len(v); i++ {
if v[i] != '"' || hasOddBackslashRun(v, i) {
continue
}
if strings.Trim(v[i+1:], " \t") != "" {
return "", xerrors.Errorf("line %d: unexpected data after closing double quote", lineNum)
}
return v[1:i], nil
}
return "", xerrors.Errorf("line %d: missing closing double quote", lineNum)
}
func hasOddBackslashRun(s string, before int) bool {
count := 0
for i := before - 1; i >= 0 && s[i] == '\\'; i-- {
count++
}
return count%2 == 1
}
// singleQuotedInner returns the content between the opening single
// quote at index 0 and the first closing single quote. Single quotes
// have no escape sequences, so the first quote after the opener always
// closes the value. Only whitespace may follow the closing quote.
func singleQuotedInner(v string, lineNum int) (string, error) {
for i := 1; i < len(v); i++ {
if v[i] != '\'' {
continue
}
if strings.Trim(v[i+1:], " \t") != "" {
return "", xerrors.Errorf("line %d: unexpected data after closing single quote", lineNum)
}
return v[1:i], nil
}
return "", xerrors.Errorf("line %d: missing closing single quote", lineNum)
}
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)
}
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)
}
}
if _, err := dec.Token(); err != nil {
return nil, xerrors.Errorf("invalid JSON: %w", err)
}
var extra any
if err := dec.Decode(&extra); errors.Is(err, io.EOF) {
return entries, nil
} else if err != nil {
return nil, xerrors.Errorf("invalid JSON: %w", err)
}
return nil, xerrors.New("unexpected trailing data after JSON object")
}
func parseYAMLSecrets(content string) ([]secretEntry, error) {
dec := yaml.NewDecoder(strings.NewReader(content))
var root yaml.Node
if err := dec.Decode(&root); err != nil {
if errors.Is(err, io.EOF) {
return nil, nil
}
return nil, xerrors.Errorf("invalid YAML: %w", err)
}
var extra yaml.Node
if err := dec.Decode(&extra); err == nil {
return nil, xerrors.New("YAML content must be a single document mapping secret names to string values")
} else if !errors.Is(err, io.EOF) {
return nil, xerrors.Errorf("invalid YAML: %w", err)
}
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)
for i := 0; i+1 < len(doc.Content); i += 2 {
keyNode := doc.Content[i]
valNode := doc.Content[i+1]
if keyNode.Kind != yaml.ScalarNode || (keyNode.Tag != "" && keyNode.Tag != "!!str") {
return nil, xerrors.New("YAML keys must be strings")
}
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
}