diff --git a/internal/naisapi/gql/generated.go b/internal/naisapi/gql/generated.go index d4a068d2..dca1b6e0 100644 --- a/internal/naisapi/gql/generated.go +++ b/internal/naisapi/gql/generated.go @@ -26770,6 +26770,8 @@ func (v *RestartAppRestartApplicationRestartApplicationPayloadApplication) GetNa type SecretValueInput struct { Name string `json:"name"` Value string `json:"value"` + // Encoding of the value. Defaults to PLAIN_TEXT. Use BASE64 for binary data (certificates, keystores, etc.). + Encoding ValueEncoding `json:"encoding"` } // GetName returns SecretValueInput.Name, and is useful for accessing the field via an interface. @@ -26778,6 +26780,9 @@ func (v *SecretValueInput) GetName() string { return v.Name } // GetValue returns SecretValueInput.Value, and is useful for accessing the field via an interface. func (v *SecretValueInput) GetValue() string { return v.Value } +// GetEncoding returns SecretValueInput.Encoding, and is useful for accessing the field via an interface. +func (v *SecretValueInput) GetEncoding() ValueEncoding { return v.Encoding } + // SetRoleResponse is returned by SetRole on success. type SetRoleResponse struct { // Assign a role to a team member @@ -28674,6 +28679,21 @@ var AllValkeyTier = []ValkeyTier{ ValkeyTierHighAvailability, } +// Encoding of a secret or config value. +type ValueEncoding string + +const ( + // The value is plain text (UTF-8). + ValueEncodingPlainText ValueEncoding = "PLAIN_TEXT" + // The value is Base64-encoded binary data. + ValueEncodingBase64 ValueEncoding = "BASE64" +) + +var AllValueEncoding = []ValueEncoding{ + ValueEncodingPlainText, + ValueEncodingBase64, +} + // Input for viewing secret values. type ViewSecretValuesInput struct { // Input for viewing secret values. @@ -28728,8 +28748,10 @@ func (v *ViewSecretValuesViewSecretValuesViewSecretValuesPayload) GetValues() [] type ViewSecretValuesViewSecretValuesViewSecretValuesPayloadValuesSecretValue struct { // The name of the secret value. Name string `json:"name"` - // The secret value itself. + // The secret value itself. When encoding is BASE64, the value is Base64-encoded binary data. Value string `json:"value"` + // Encoding of the value. PLAIN_TEXT for UTF-8 text, BASE64 for binary data. + Encoding ValueEncoding `json:"encoding"` } // GetName returns ViewSecretValuesViewSecretValuesViewSecretValuesPayloadValuesSecretValue.Name, and is useful for accessing the field via an interface. @@ -28742,6 +28764,11 @@ func (v *ViewSecretValuesViewSecretValuesViewSecretValuesPayloadValuesSecretValu return v.Value } +// GetEncoding returns ViewSecretValuesViewSecretValuesViewSecretValuesPayloadValuesSecretValue.Encoding, and is useful for accessing the field via an interface. +func (v *ViewSecretValuesViewSecretValuesViewSecretValuesPayloadValuesSecretValue) GetEncoding() ValueEncoding { + return v.Encoding +} + // __AddConfigValueInput is used internally by genqlient type __AddConfigValueInput struct { Name string `json:"name"` @@ -33000,6 +33027,7 @@ mutation ViewSecretValues ($input: ViewSecretValuesInput!) { values { name value + encoding } } } diff --git a/internal/naisapi/secret.go b/internal/naisapi/secret.go index 87197e49..adc6d2a5 100644 --- a/internal/naisapi/secret.go +++ b/internal/naisapi/secret.go @@ -9,8 +9,9 @@ import ( // SecretValue represents a key-value pair from a secret type SecretValue struct { - Name string - Value string + Name string + Value string + Encoding gql.ValueEncoding } // ViewSecretValues retrieves the values of a secret. This requires team membership @@ -22,6 +23,7 @@ viewSecretValues(input: $input) { values { name value +encoding } } } @@ -45,8 +47,9 @@ value values := make([]SecretValue, len(resp.ViewSecretValues.Values)) for i, v := range resp.ViewSecretValues.Values { values[i] = SecretValue{ - Name: v.Name, - Value: v.Value, + Name: v.Name, + Value: v.Value, + Encoding: v.Encoding, } } diff --git a/internal/secret/command/flag/flag.go b/internal/secret/command/flag/flag.go index e72b8b6a..35e5f8d1 100644 --- a/internal/secret/command/flag/flag.go +++ b/internal/secret/command/flag/flag.go @@ -95,6 +95,8 @@ type Get struct { Output Output `name:"output" short:"o" usage:"Format output (table|json)."` WithValues bool `name:"with-values" usage:"Also fetch and display secret values (access is logged)."` Reason string `name:"reason" usage:"Reason for accessing secret values (min 10 chars). Used with --with-values."` + ToFile string `name:"to-file" usage:"Write a single key's value to a file (implies --with-values). Requires --key. Binary values are decoded automatically."` + Key string `name:"key" usage:"Name of the key to extract. Used with --to-file."` } func (g *Get) GetTeam() string { return string(g.Team) } @@ -117,6 +119,7 @@ type Set struct { Key string `name:"key" usage:"Name of the key to set."` Value string `name:"value" usage:"Value to set."` ValueFromStdin bool `name:"value-from-stdin" usage:"Read value from stdin."` + ValueFromFile string `name:"value-from-file" usage:"Read binary value from file (e.g. keystore.p12, cert.pem). The value is sent as BASE64-encoded."` } func (s *Set) GetTeam() string { return string(s.Team) } diff --git a/internal/secret/command/get.go b/internal/secret/command/get.go index 48990784..b82732dd 100644 --- a/internal/secret/command/get.go +++ b/internal/secret/command/get.go @@ -2,9 +2,12 @@ package command import ( "context" + "encoding/base64" "fmt" + "os" "github.com/nais/cli/internal/naisapi" + "github.com/nais/cli/internal/naisapi/gql" "github.com/nais/cli/internal/secret" "github.com/nais/cli/internal/secret/command/flag" "github.com/nais/cli/internal/validation" @@ -16,8 +19,9 @@ import ( // Entry represents a key-value pair in a secret. When values are not fetched, // the Value field is empty and omitted from JSON output. type Entry struct { - Key string `json:"key"` - Value string `json:"value,omitempty"` + Key string `json:"key"` + Value string `json:"value,omitempty"` + Encoding gql.ValueEncoding `json:"encoding,omitempty"` } type SecretDetail struct { @@ -49,10 +53,16 @@ func get(parentFlags *flag.Secret) *naistrix.Command { if err := validateArgs(args); err != nil { return err } - if f.Reason != "" && !f.WithValues { - return fmt.Errorf("--reason can only be used together with --with-values") + if f.ToFile != "" && f.Key == "" { + return fmt.Errorf("--to-file requires --key to specify which key to extract") } - if f.WithValues && f.Reason != "" && len(f.Reason) < 10 { + if f.Key != "" && f.ToFile == "" { + return fmt.Errorf("--key is only used with --to-file") + } + if f.Reason != "" && !f.WithValues && f.ToFile == "" { + return fmt.Errorf("--reason can only be used together with --with-values or --to-file") + } + if (f.WithValues || f.ToFile != "") && f.Reason != "" && len(f.Reason) < 10 { return fmt.Errorf("reason must be at least 10 characters") } return nil @@ -76,10 +86,24 @@ func get(parentFlags *flag.Secret) *naistrix.Command { Description: "Get details including secret values with reason provided inline.", Command: "my-secret --environment dev --with-values --reason \"Debugging production issue #1234\"", }, + { + Description: "Extract a binary value (e.g. keystore) to a file.", + Command: "my-secret --environment prod --key keystore.p12 --to-file ./keystore.p12 --reason \"Need keystore for local testing\"", + }, }, RunFunc: func(ctx context.Context, args *naistrix.Arguments, out *naistrix.OutputWriter) error { + opts := getOptions{ + team: f.Team, + outputFormat: f.Output, + withValues: f.WithValues || f.ToFile != "", + reason: f.Reason, + toFile: f.ToFile, + key: f.Key, + } + if providedEnvironment := string(f.Environment); providedEnvironment != "" { - return runGetCommand(ctx, args, out, f.Team, providedEnvironment, f.Output, f.WithValues, f.Reason) + opts.environment = providedEnvironment + return runGetCommand(ctx, args, out, opts) } environment, err := resolveSecretEnvironment(ctx, f.Team, args.Get("name"), string(f.Environment)) @@ -87,13 +111,24 @@ func get(parentFlags *flag.Secret) *naistrix.Command { return err } - return runGetCommand(ctx, args, out, f.Team, environment, f.Output, f.WithValues, f.Reason) + opts.environment = environment + return runGetCommand(ctx, args, out, opts) }, } } -func runGetCommand(ctx context.Context, args *naistrix.Arguments, out *naistrix.OutputWriter, team, environment string, outputFormat flag.Output, withValues bool, reason string) error { - metadata := metadataFromArgs(args, team, environment) +type getOptions struct { + team string + environment string + outputFormat flag.Output + withValues bool + reason string + toFile string + key string +} + +func runGetCommand(ctx context.Context, args *naistrix.Arguments, out *naistrix.OutputWriter, opts getOptions) error { + metadata := metadataFromArgs(args, opts.team, opts.environment) existing, err := secret.Get(ctx, metadata) if err != nil { @@ -105,7 +140,8 @@ func runGetCommand(ctx context.Context, args *naistrix.Arguments, out *naistrix. entries[i] = Entry{Key: k} } - if withValues { + if opts.withValues { + reason := opts.reason if reason == "" { pterm.Warning.Println("Viewing secret values is logged for auditing purposes.") result, err := pterm.DefaultInteractiveTextInput. @@ -125,17 +161,49 @@ func runGetCommand(ctx context.Context, args *naistrix.Arguments, out *naistrix. return fmt.Errorf("viewing secret values: %w", err) } - valueMap := make(map[string]string, len(values)) + type valueInfo struct { + value string + encoding gql.ValueEncoding + } + valueMap := make(map[string]valueInfo, len(values)) for _, v := range values { - valueMap[v.Name] = v.Value + valueMap[v.Name] = valueInfo{value: v.Value, encoding: v.Encoding} } for i := range entries { - entries[i].Value = valueMap[entries[i].Key] + if info, ok := valueMap[entries[i].Key]; ok { + entries[i].Value = info.value + entries[i].Encoding = info.encoding + } + } + + // Handle --to-file: extract a single key's value to a file + if opts.toFile != "" { + info, ok := valueMap[opts.key] + if !ok { + return fmt.Errorf("key %q not found in secret %q", opts.key, metadata.Name) + } + + var data []byte + if info.encoding == gql.ValueEncodingBase64 { + data, err = base64.StdEncoding.DecodeString(info.value) + if err != nil { + return fmt.Errorf("decoding base64 value for key %q: %w", opts.key, err) + } + } else { + data = []byte(info.value) + } + + if err := os.WriteFile(opts.toFile, data, 0o600); err != nil { + return fmt.Errorf("writing to file %q: %w", opts.toFile, err) + } + + pterm.Success.Printfln("Wrote key %q (%d bytes) to %s", opts.key, len(data), opts.toFile) + return nil } } - if outputFormat == "json" { + if opts.outputFormat == "json" { detail := SecretDetail{ Name: existing.Name, Environment: existing.TeamEnvironment.Environment.Name, @@ -164,10 +232,10 @@ func runGetCommand(ctx context.Context, args *naistrix.Arguments, out *naistrix. pterm.DefaultSection.Println("Data") if len(entries) > 0 { var data [][]string - if withValues { + if opts.withValues { secretEntries := make([]secret.Entry, len(entries)) for i, e := range entries { - secretEntries[i] = secret.Entry{Key: e.Key, Value: e.Value} + secretEntries[i] = secret.Entry{Key: e.Key, Value: e.Value, Encoding: e.Encoding} } data = secret.FormatDataWithValues(secretEntries) } else { diff --git a/internal/secret/command/set.go b/internal/secret/command/set.go index c65003fb..b756f20f 100644 --- a/internal/secret/command/set.go +++ b/internal/secret/command/set.go @@ -2,11 +2,14 @@ package command import ( "context" + "encoding/base64" "fmt" "io" "os" "strings" + "unicode/utf8" + "github.com/nais/cli/internal/naisapi/gql" "github.com/nais/cli/internal/secret" "github.com/nais/cli/internal/secret/command/flag" "github.com/nais/cli/internal/validation" @@ -35,11 +38,23 @@ func set(parentFlags *flag.Secret) *naistrix.Command { if f.Key == "" { return fmt.Errorf("--key is required") } - if f.Value == "" && !f.ValueFromStdin { - return fmt.Errorf("--value or --value-from-stdin is required") + + // Count the number of value sources provided + sources := 0 + if f.Value != "" { + sources++ + } + if f.ValueFromStdin { + sources++ + } + if f.ValueFromFile != "" { + sources++ } - if f.Value != "" && f.ValueFromStdin { - return fmt.Errorf("--value and --value-from-stdin are mutually exclusive") + if sources == 0 { + return fmt.Errorf("--value, --value-from-stdin, or --value-from-file is required") + } + if sources > 1 { + return fmt.Errorf("--value, --value-from-stdin, and --value-from-file are mutually exclusive") } return nil }, @@ -58,20 +73,46 @@ func set(parentFlags *flag.Secret) *naistrix.Command { Description: "Read value from stdin (useful for multi-line values or avoiding shell history).", Command: "my-secret --environment dev --key TLS_CERT --value-from-stdin < cert.pem", }, + { + Description: "Upload a file as a secret value. Binary files are automatically Base64-encoded.", + Command: "my-secret --environment prod --key keystore.p12 --value-from-file ./keystore.p12", + }, }, RunFunc: func(ctx context.Context, args *naistrix.Arguments, out *naistrix.OutputWriter) error { metadata := metadataFromArgs(args, f.Team, string(f.Environment)) - value := f.Value - if f.ValueFromStdin { + var value string + encoding := gql.ValueEncodingPlainText + + switch { + case f.ValueFromFile != "": + data, err := os.ReadFile(f.ValueFromFile) + if err != nil { + return fmt.Errorf("reading file %q: %w", f.ValueFromFile, err) + } + if utf8.Valid(data) { + value = string(data) + } else { + value = base64.StdEncoding.EncodeToString(data) + encoding = gql.ValueEncodingBase64 + } + case f.ValueFromStdin: data, err := io.ReadAll(os.Stdin) if err != nil { return fmt.Errorf("reading from stdin: %w", err) } value = strings.TrimSuffix(string(data), "\n") + default: + value = f.Value + } + + // Check the value size early to give a clear error message. + const maxValueSize = 1 << 20 // 1 MiB + if len(value) > maxValueSize { + return fmt.Errorf("value too large (%d bytes); maximum size is 1 MiB", len(value)) } - updated, err := secret.SetValue(ctx, metadata, f.Key, value) + updated, err := secret.SetValue(ctx, metadata, f.Key, value, encoding) if err != nil { return fmt.Errorf("setting secret value: %w", err) } diff --git a/internal/secret/secret.go b/internal/secret/secret.go index 59e29b82..a8fe240a 100644 --- a/internal/secret/secret.go +++ b/internal/secret/secret.go @@ -2,6 +2,7 @@ package secret import ( "context" + "encoding/base64" "fmt" "slices" "time" @@ -207,20 +208,20 @@ func Delete(ctx context.Context, metadata Metadata) (bool, error) { // SetValue sets a key-value pair in a secret. If the key already exists, its value is updated. // If the key does not exist, it is added. -func SetValue(ctx context.Context, metadata Metadata, key, value string) (updated bool, err error) { +func SetValue(ctx context.Context, metadata Metadata, key, value string, encoding gql.ValueEncoding) (updated bool, err error) { existing, err := Get(ctx, metadata) if err != nil { return false, fmt.Errorf("fetching secret: %w", err) } if slices.Contains(existing.Keys, key) { - return true, updateValue(ctx, metadata, key, value) + return true, updateValue(ctx, metadata, key, value, encoding) } - return false, addValue(ctx, metadata, key, value) + return false, addValue(ctx, metadata, key, value, encoding) } -func addValue(ctx context.Context, metadata Metadata, key, value string) error { +func addValue(ctx context.Context, metadata Metadata, key, value string, encoding gql.ValueEncoding) error { _ = `# @genqlient mutation AddSecretValue($name: String!, $environment: String!, $team: Slug!, $value: SecretValueInput!) { addSecretValue(input: {name: $name, environment: $environment, team: $team, value: $value}) { @@ -238,13 +239,14 @@ func addValue(ctx context.Context, metadata Metadata, key, value string) error { } _, err = gql.AddSecretValue(ctx, client, metadata.Name, metadata.EnvironmentName, metadata.TeamSlug, gql.SecretValueInput{ - Name: key, - Value: value, + Name: key, + Value: value, + Encoding: encoding, }) return err } -func updateValue(ctx context.Context, metadata Metadata, key, value string) error { +func updateValue(ctx context.Context, metadata Metadata, key, value string, encoding gql.ValueEncoding) error { _ = `# @genqlient mutation UpdateSecretValue($name: String!, $environment: String!, $team: Slug!, $value: SecretValueInput!) { updateSecretValue(input: {name: $name, environment: $environment, team: $team, value: $value}) { @@ -262,8 +264,9 @@ func updateValue(ctx context.Context, metadata Metadata, key, value string) erro } _, err = gql.UpdateSecretValue(ctx, client, metadata.Name, metadata.EnvironmentName, metadata.TeamSlug, gql.SecretValueInput{ - Name: key, - Value: value, + Name: key, + Value: value, + Encoding: encoding, }) return err } @@ -312,8 +315,9 @@ func FormatDetails(metadata Metadata, s *gql.GetSecretTeamEnvironmentSecret) [][ // Entry represents a key-value pair in a secret. When values have not been // fetched, Value is empty. type Entry struct { - Key string - Value string + Key string + Value string + Encoding gql.ValueEncoding } // FormatData formats secret keys as a key-only table for pterm rendering. @@ -328,12 +332,22 @@ func FormatData(keys []string) [][]string { } // FormatDataWithValues formats key-value pairs as a two-column table for pterm rendering. +// Binary values (BASE64 encoding) are shown as a placeholder with byte count. func FormatDataWithValues(entries []Entry) [][]string { data := [][]string{ {"Key", "Value"}, } for _, e := range entries { - data = append(data, []string{e.Key, e.Value}) + displayValue := e.Value + if e.Encoding == gql.ValueEncodingBase64 { + raw, err := base64.StdEncoding.DecodeString(e.Value) + if err == nil { + displayValue = fmt.Sprintf("", len(raw)) + } else { + displayValue = "" + } + } + data = append(data, []string{e.Key, displayValue}) } return data } diff --git a/schema.graphql b/schema.graphql index e6b82cb9..a5a185ec 100644 --- a/schema.graphql +++ b/schema.graphql @@ -6943,15 +6943,33 @@ Order secrets by the last time it was modified. LAST_MODIFIED_AT } +""" +Encoding of a secret or config value. +""" +enum ValueEncoding { +""" +The value is plain text (UTF-8). +""" + PLAIN_TEXT +""" +The value is Base64-encoded binary data. +""" + BASE64 +} + type SecretValue { """ The name of the secret value. """ name: String! """ -The secret value itself. +The secret value itself. When encoding is BASE64, the value is Base64-encoded binary data. """ value: String! +""" +Encoding of the value. PLAIN_TEXT for UTF-8 text, BASE64 for binary data. +""" + encoding: ValueEncoding! } type SecretValueAddedActivityLogEntry implements ActivityLogEntry & Node{ @@ -7003,6 +7021,10 @@ The name of the added value. input SecretValueInput { name: String! value: String! +""" +Encoding of the value. Defaults to PLAIN_TEXT. Use BASE64 for binary data (certificates, keystores, etc.). +""" + encoding: ValueEncoding = PLAIN_TEXT } type SecretValueRemovedActivityLogEntry implements ActivityLogEntry & Node{