From c4021be3462e3a490b4680972e0b3e8c580e147e Mon Sep 17 00:00:00 2001 From: Josh Drake Date: Tue, 7 Jul 2026 00:38:31 -0500 Subject: [PATCH] feat(ca/acme/eab): add --json output to add, list, and remove The `step ca acme eab` subcommands only emitted fixed-width column text, making them awkward to script against (add.go even carried a standing TODO asking for JSON output). Add a `--json` flag to each subcommand for machine-readable output: - add: marshals the created key (including the secret) as a JSON object - list: collects all pages into a JSON array, bypassing $PAGER; the secret HMAC key is omitted, matching the table's masking - remove: emits {"provisioner","id","deleted":true} on success The shared cliEAK struct now has exported, JSON-tagged fields, and toCLI guards createdAt with an IsZero check (mirroring boundAt) so unset timestamps are dropped via omitempty. Adds the first unit tests for the package covering toCLI and the JSON contract. Co-Authored-By: Claude Opus 4.8 (1M context) --- command/ca/acme/eab/add.go | 20 ++++++- command/ca/acme/eab/eab.go | 38 +++++++----- command/ca/acme/eab/eab_test.go | 100 ++++++++++++++++++++++++++++++++ command/ca/acme/eab/list.go | 52 ++++++++++++++--- command/ca/acme/eab/remove.go | 26 ++++++++- 5 files changed, 210 insertions(+), 26 deletions(-) create mode 100644 command/ca/acme/eab/eab_test.go diff --git a/command/ca/acme/eab/add.go b/command/ca/acme/eab/add.go index 2fe70c794..d803a232b 100644 --- a/command/ca/acme/eab/add.go +++ b/command/ca/acme/eab/add.go @@ -1,6 +1,7 @@ package eab import ( + "encoding/json" "fmt" "os" @@ -20,10 +21,11 @@ func addCommand() cli.Command { Action: cli.ActionFunc(addAction), Usage: "add ACME External Account Binding Key", UsageText: `**step ca acme eab add** [] -[**--admin-cert**=] [**--admin-key**=] [**--admin-subject**=] +[**--json**] [**--admin-cert**=] [**--admin-key**=] [**--admin-subject**=] [**--admin-provisioner**=] [**--admin-password-file**=] [**--ca-url**=] [**--root**=] [**--context**=]`, Flags: []cli.Flag{ + jsonFlag, flags.AdminCert, flags.AdminKey, flags.AdminSubject, @@ -53,6 +55,11 @@ $ step ca acme eab add my_acme_provisioner Add an ACME External Account Binding Key with reference: ''' $ step ca acme eab add my_acme_provisioner my_first_eab_key +''' + +Add an ACME External Account Binding Key and output the result as JSON: +''' +$ step ca acme eab add my_acme_provisioner my_first_eab_key --json '''`, } } @@ -84,12 +91,19 @@ func addAction(ctx *cli.Context) (err error) { cliEAK := toCLI(ctx, client, eak) - // TODO(hs): JSON output, so that executing this command can be more easily automated? + if ctx.Bool("json") { + b, err := json.MarshalIndent(cliEAK, "", " ") + if err != nil { + return errors.Wrap(err, "error marshaling ACME EAB key") + } + fmt.Println(string(b)) + return nil + } out := os.Stdout format := "%-36s%-28s%-48s%s\n" fmt.Fprintf(out, format, "Key ID", "Provisioner", "Key (base64, raw url encoded)", "Reference") - fmt.Fprintf(out, format, cliEAK.id, cliEAK.provisioner, cliEAK.key, cliEAK.reference) + fmt.Fprintf(out, format, cliEAK.ID, cliEAK.Provisioner, cliEAK.Key, cliEAK.Reference) return nil } diff --git a/command/ca/acme/eab/eab.go b/command/ca/acme/eab/eab.go index b989482b6..4c0c517ca 100644 --- a/command/ca/acme/eab/eab.go +++ b/command/ca/acme/eab/eab.go @@ -16,31 +16,41 @@ import ( ) type cliEAK struct { - id string - provisioner string - reference string - key string - createdAt string - boundAt string - account string + ID string `json:"id"` + Provisioner string `json:"provisioner"` + Reference string `json:"reference"` + Key string `json:"key,omitempty"` + CreatedAt string `json:"createdAt,omitempty"` + BoundAt string `json:"boundAt,omitempty"` + Account string `json:"account,omitempty"` } func toCLI(_ *cli.Context, _ *ca.AdminClient, eak *linkedca.EABKey) *cliEAK { + createdAt := "" + if !eak.CreatedAt.AsTime().IsZero() { + createdAt = eak.CreatedAt.AsTime().Format("2006-01-02 15:04:05 -07:00") + } boundAt := "" if !eak.BoundAt.AsTime().IsZero() { boundAt = eak.BoundAt.AsTime().Format("2006-01-02 15:04:05 -07:00") } return &cliEAK{ - id: eak.Id, - provisioner: eak.Provisioner, - reference: eak.Reference, - key: base64.RawURLEncoding.Strict().EncodeToString(eak.HmacKey), - createdAt: eak.CreatedAt.AsTime().Format("2006-01-02 15:04:05 -07:00"), - boundAt: boundAt, - account: eak.Account, + ID: eak.Id, + Provisioner: eak.Provisioner, + Reference: eak.Reference, + Key: base64.RawURLEncoding.Strict().EncodeToString(eak.HmacKey), + CreatedAt: createdAt, + BoundAt: boundAt, + Account: eak.Account, } } +// jsonFlag toggles machine-readable JSON output for the eab subcommands. +var jsonFlag = cli.BoolFlag{ + Name: "json", + Usage: `Print output as a JSON object (or array) instead of the human-readable table.`, +} + // Command returns the eab subcommand. func Command() cli.Command { return cli.Command{ diff --git a/command/ca/acme/eab/eab_test.go b/command/ca/acme/eab/eab_test.go new file mode 100644 index 000000000..23e6f97cc --- /dev/null +++ b/command/ca/acme/eab/eab_test.go @@ -0,0 +1,100 @@ +package eab + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/smallstep/linkedca" +) + +func TestToCLI(t *testing.T) { + created := time.Date(2026, 7, 6, 12, 0, 0, 0, time.UTC) + bound := time.Date(2026, 7, 6, 13, 30, 0, 0, time.UTC) + + t.Run("bound key", func(t *testing.T) { + eak := &linkedca.EABKey{ + Id: "keyid", + Provisioner: "my_acme", + Reference: "my_ref", + HmacKey: []byte("secret-hmac-key"), + Account: "acct-1", + CreatedAt: timestamppb.New(created), + BoundAt: timestamppb.New(bound), + } + + got := toCLI(nil, nil, eak) + assert.Equal(t, "keyid", got.ID) + assert.Equal(t, "my_acme", got.Provisioner) + assert.Equal(t, "my_ref", got.Reference) + assert.Equal(t, "c2VjcmV0LWhtYWMta2V5", got.Key) // base64 raw-url of the hmac key + assert.Equal(t, "acct-1", got.Account) + assert.Equal(t, "2026-07-06 12:00:00 +00:00", got.CreatedAt) + assert.Equal(t, "2026-07-06 13:30:00 +00:00", got.BoundAt) + }) + + t.Run("unbound key with zero timestamps", func(t *testing.T) { + eak := &linkedca.EABKey{ + Id: "keyid", + Provisioner: "my_acme", + CreatedAt: timestamppb.New(time.Time{}), + BoundAt: timestamppb.New(time.Time{}), + } + + got := toCLI(nil, nil, eak) + // zero timestamps are represented as empty strings so they are dropped + // from JSON via omitempty. + assert.Empty(t, got.CreatedAt) + assert.Empty(t, got.BoundAt) + assert.Empty(t, got.Account) + }) +} + +func TestCLIEAK_JSON(t *testing.T) { + t.Run("add output includes key and omits empty fields", func(t *testing.T) { + eak := &cliEAK{ + ID: "keyid", + Provisioner: "my_acme", + Reference: "my_ref", + Key: "c2VjcmV0", + } + + b, err := json.Marshal(eak) + require.NoError(t, err) + + var m map[string]any + require.NoError(t, json.Unmarshal(b, &m)) + assert.Equal(t, "keyid", m["id"]) + assert.Equal(t, "my_acme", m["provisioner"]) + assert.Equal(t, "my_ref", m["reference"]) + assert.Equal(t, "c2VjcmV0", m["key"]) + assert.NotContains(t, m, "createdAt") + assert.NotContains(t, m, "boundAt") + assert.NotContains(t, m, "account") + }) + + t.Run("list output omits the secret key", func(t *testing.T) { + eak := &cliEAK{ + ID: "keyid", + Provisioner: "my_acme", + Reference: "my_ref", + Key: "", // cleared for list output + CreatedAt: "2026-07-06 12:00:00 +00:00", + Account: "acct-1", + } + + b, err := json.Marshal(eak) + require.NoError(t, err) + + var m map[string]any + require.NoError(t, json.Unmarshal(b, &m)) + assert.NotContains(t, m, "key") + assert.Equal(t, "2026-07-06 12:00:00 +00:00", m["createdAt"]) + assert.Equal(t, "acct-1", m["account"]) + assert.NotContains(t, m, "boundAt") + }) +} diff --git a/command/ca/acme/eab/list.go b/command/ca/acme/eab/list.go index 51e1bfe5b..59383a8fd 100644 --- a/command/ca/acme/eab/list.go +++ b/command/ca/acme/eab/list.go @@ -1,6 +1,7 @@ package eab import ( + "encoding/json" "fmt" "io" "os" @@ -24,11 +25,12 @@ func listCommand() cli.Command { Action: cli.ActionFunc(listAction), Usage: "list all ACME External Account Binding Keys", UsageText: `**step ca acme eab list** [] -[**--limit**=] +[**--json**] [**--limit**=] [**--admin-cert**=] [**--admin-key**=] [**--admin-subject**=] [**--admin-provisioner**=] [**--admin-password-file**=] [**--ca-url**=] [**--root**=] [**--context**=]`, Flags: []cli.Flag{ + jsonFlag, flags.Limit, flags.NoPager, flags.AdminCert, @@ -64,6 +66,11 @@ Show ACME External Account Binding Key with specific reference: ''' $ step ca acme eab list my_acme_provisioner my_reference ''' + +List all ACME External Account Binding Keys as a JSON array (keys are omitted): +''' +$ step ca acme eab list my_acme_provisioner --json +''' `, } } @@ -86,6 +93,41 @@ func listAction(ctx *cli.Context) (err error) { return errors.Wrap(err, "error creating admin client") } + // default to API paging per 100 entities + limit := uint(0) + if ctx.IsSet("limit") { + limit = ctx.Uint("limit") + } + + // JSON output collects every page into a single array and bypasses the + // $PAGER machinery, which only makes sense for the human-readable table. + if ctx.Bool("json") { + eaks := []*cliEAK{} + cursor := "" + for { + options := []ca.AdminOption{ca.WithAdminCursor(cursor), ca.WithAdminLimit(cast.Int(limit))} + eaksResponse, err := client.GetExternalAccountKeysPaginate(provisioner, reference, options...) + if err != nil { + return errors.Wrap(notImplemented(err), "error retrieving ACME EAB keys") + } + for _, k := range eaksResponse.EAKs { + cliEAK := toCLI(ctx, client, k) + cliEAK.Key = "" // never expose the secret HMAC key in list output + eaks = append(eaks, cliEAK) + } + if eaksResponse.NextCursor == "" { + break + } + cursor = eaksResponse.NextCursor + } + b, err := json.MarshalIndent(eaks, "", " ") + if err != nil { + return errors.Wrap(err, "error marshaling ACME EAB keys") + } + fmt.Println(string(b)) + return nil + } + var out io.WriteCloser var cmd *exec.Cmd @@ -121,12 +163,6 @@ func listAction(ctx *cli.Context) (err error) { out = os.Stdout } - // default to API paging per 100 entities - limit := uint(0) - if ctx.IsSet("limit") { - limit = ctx.Uint("limit") - } - cursor := "" format := "%-36s%-28s%-16s%-30s%-30s%-40s%s\n" firstIteration := true @@ -154,7 +190,7 @@ func listAction(ctx *cli.Context) (err error) { } for _, k := range eaksResponse.EAKs { cliEAK := toCLI(ctx, client, k) - _, err = fmt.Fprintf(out, format, cliEAK.id, cliEAK.provisioner, "*****", cliEAK.createdAt, cliEAK.boundAt, cliEAK.account, cliEAK.reference) + _, err = fmt.Fprintf(out, format, cliEAK.ID, cliEAK.Provisioner, "*****", cliEAK.CreatedAt, cliEAK.BoundAt, cliEAK.Account, cliEAK.Reference) if err != nil { return errors.Wrap(err, "error writing ACME EAB key to output") } diff --git a/command/ca/acme/eab/remove.go b/command/ca/acme/eab/remove.go index 857a4df1a..9280994dc 100644 --- a/command/ca/acme/eab/remove.go +++ b/command/ca/acme/eab/remove.go @@ -1,6 +1,7 @@ package eab import ( + "encoding/json" "fmt" "github.com/pkg/errors" @@ -18,10 +19,11 @@ func removeCommand() cli.Command { Action: cli.ActionFunc(removeAction), Usage: "remove an ACME EAB Key from the CA", UsageText: `**step ca acme eab remove** -[**--admin-cert**=] [**--admin-key**=] [**--admin-subject**=] +[**--json**] [**--admin-cert**=] [**--admin-key**=] [**--admin-subject**=] [**--admin-provisioner**=] [**--admin-password-file**=] [**--ca-url**=] [**--root**=] [**--context**=]`, Flags: []cli.Flag{ + jsonFlag, flags.AdminCert, flags.AdminKey, flags.AdminSubject, @@ -47,6 +49,11 @@ Remove ACME EAB Key with Key ID "zFGdKC1sHmNf3Wsx3OujY808chxwEdmr" from my_acme_ ''' $ step ca acme eab remove my_acme_provisioner zFGdKC1sHmNf3Wsx3OujY808chxwEdmr ''' + +Remove an ACME EAB Key and output the result as JSON: +''' +$ step ca acme eab remove my_acme_provisioner zFGdKC1sHmNf3Wsx3OujY808chxwEdmr --json +''' `, } } @@ -70,6 +77,23 @@ func removeAction(ctx *cli.Context) error { return errors.Wrap(notImplemented(err), "error removing ACME EAB key") } + if ctx.Bool("json") { + b, err := json.MarshalIndent(struct { + Provisioner string `json:"provisioner"` + ID string `json:"id"` + Deleted bool `json:"deleted"` + }{ + Provisioner: provisioner, + ID: keyID, + Deleted: true, + }, "", " ") + if err != nil { + return errors.Wrap(err, "error marshaling ACME EAB key") + } + fmt.Println(string(b)) + return nil + } + fmt.Println("Key was deleted successfully!") return nil