Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion internal/naisapi/gql/generated.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 7 additions & 4 deletions internal/naisapi/secret.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -22,6 +23,7 @@ viewSecretValues(input: $input) {
values {
name
value
encoding
}
}
}
Expand All @@ -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,
}
}

Expand Down
3 changes: 3 additions & 0 deletions internal/secret/command/flag/flag.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand All @@ -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) }
Expand Down
100 changes: 84 additions & 16 deletions internal/secret/command/get.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -76,24 +86,49 @@ 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))
if err != nil {
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 {
Expand All @@ -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.
Expand All @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
55 changes: 48 additions & 7 deletions internal/secret/command/set.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
},
Expand All @@ -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)
}
Expand Down
Loading
Loading