GeezerVison is showing github.com — feat(ca/acme/eab): add --json output to add, list, and remove by joshdrake · Pull Request #1663 · smallstep/cli
feat(ca/acme/eab): add --json output to add, list, and remove
#1663
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and
privacy statement . We’ll occasionally send you account related emails.
Already on GitHub?
Sign in
to your account
Uh oh!
There was an error while loading. Please reload this page.
Show file tree
Hide file tree
Changes from all commits
Commits
File filter
Filter by extension
.go
(5)
All 1 file type selected
Viewed files
Conversations
Failed to load comments.
Loading
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Jump to file
Failed to load files.
Loading
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Hide whitespace
Diff view
Hide whitespace
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) <[email protected] >
Loading branch information
Uh oh!
There was an error while loading. Please reload this page.
commit c4021be3462e3a490b4680972e0b3e8c580e147e
There are no files selected for viewing
Original file line number
Diff line number
Diff line change
@@ -1,6 +1,7 @@
package eab
import (
"encoding/json"
"fmt"
"os"
Expand All
@@ -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** <provisioner> [<eab-key-reference>]
[**--admin-cert**=<file>] [**--admin-key**=<file>] [**--admin-subject**=<subject>]
[**--json**] [**-- admin-cert**=<file>] [**--admin-key**=<file>] [**--admin-subject**=<subject>]
[**--admin-provisioner**=<name>] [**--admin-password-file**=<file>]
[**--ca-url**=<uri>] [**--root**=<file>] [**--context**=<name>]`,
Flags: []cli.Flag{
jsonFlag,
flags.AdminCert,
flags.AdminKey,
flags.AdminSubject,
Expand Down
Expand Up
@@ -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
'''`,
}
}
Expand Down
Expand Up
@@ -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
}
Original file line number
Diff line number
Diff line change
Expand Up
@@ -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{
Expand Down
Original file line number
Diff line number
Diff line change
@@ -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")
})
}
Original file line number
Diff line number
Diff line change
@@ -1,6 +1,7 @@
package eab
import (
"encoding/json"
"fmt"
"io"
"os"
Expand All
@@ -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** <provisioner> [<eab-key-reference>]
[**--limit**=<number>]
[**--json**] [**-- limit**=<number>]
[**--admin-cert**=<file>] [**--admin-key**=<file>] [**--admin-subject**=<subject>]
[**--admin-provisioner**=<name>] [**--admin-password-file**=<file>]
[**--ca-url**=<uri>] [**--root**=<file>] [**--context**=<name>]`,
Flags: []cli.Flag{
jsonFlag,
flags.Limit,
flags.NoPager,
flags.AdminCert,
Expand Down
Expand Up
@@ -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
'''
`,
}
}
Expand All
@@ -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
Expand Down
Expand Up
@@ -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
Expand Down
Expand Up
@@ -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")
}
Expand Down
Original file line number
Diff line number
Diff line change
@@ -1,6 +1,7 @@
package eab
import (
"encoding/json"
"fmt"
"github.com/pkg/errors"
Expand All
@@ -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** <provisioner> <eab-key-id>
[**--admin-cert**=<file>] [**--admin-key**=<file>] [**--admin-subject**=<subject>]
[**--json**] [**-- admin-cert**=<file>] [**--admin-key**=<file>] [**--admin-subject**=<subject>]
[**--admin-provisioner**=<name>] [**--admin-password-file**=<file>]
[**--ca-url**=<uri>] [**--root**=<file>] [**--context**=<name>]`,
Flags: []cli.Flag{
jsonFlag,
flags.AdminCert,
flags.AdminKey,
flags.AdminSubject,
Expand All
@@ -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
'''
`,
}
}
Expand All
@@ -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
Expand Down
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.