Skip to content

Commit fa143d6

Browse files
authored
Merge pull request #34 from aaearon/feat/output-json
feat: add --output json global flag for machine-readable output
2 parents 1ba4f5b + 653e64a commit fa143d6

File tree

16 files changed

+676
-9
lines changed

16 files changed

+676
-9
lines changed

CLAUDE.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,14 @@ Custom `SCAAccessService` follows SDK conventions:
7070
- Error messages suggest the appropriate non-interactive flag (e.g., `--target/--role`, `--all`, `--yes`, `--group`, `--favorite`)
7171
- `go-isatty` v0.0.20 is a direct dependency (promoted from indirect via survey)
7272

73+
## JSON Output
74+
- `--output` / `-o` persistent flag on root command: `text` (default) or `json`
75+
- Validated in `PersistentPreRunE`; JSON mode forces `IsTerminalFunc` to return false (non-interactive)
76+
- `cmd/output.go``outputFormat` var, `isJSONOutput()`, `writeJSON(w, data)`
77+
- `cmd/output_types.go` — JSON structs: `cloudElevationOutput`, `groupElevationJSON`, `sessionOutput`, `statusOutput`, `revocationOutput`, `favoriteOutput`, `awsCredentialOutput`
78+
- All commands support JSON: root elevation, `env`, `status`, `revoke`, `favorites list`
79+
- `config.Favorite` has both `yaml:"..."` and `json:"..."` struct tags
80+
7381
## Cache
7482
- Eligibility responses cached in `~/.grant/cache/` as JSON files (e.g., `eligibility_azure.json`, `groups_eligibility_azure.json`)
7583
- Default TTL: 4 hours, configurable via `cache_ttl` in `~/.grant/config.yaml` (Go duration syntax: `2h`, `30m`)

cmd/env.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,14 @@ func runEnvWithDeps(
101101
return fmt.Errorf("failed to parse access credentials: %w", err)
102102
}
103103

104+
if isJSONOutput() {
105+
return writeJSON(cmd.OutOrStdout(), awsCredentialOutput{
106+
AccessKeyID: awsCreds.AccessKeyID,
107+
SecretAccessKey: awsCreds.SecretAccessKey,
108+
SessionToken: awsCreds.SessionToken,
109+
})
110+
}
111+
104112
fmt.Fprintf(cmd.OutOrStdout(), "export AWS_ACCESS_KEY_ID='%s'\n", awsCreds.AccessKeyID)
105113
fmt.Fprintf(cmd.OutOrStdout(), "export AWS_SECRET_ACCESS_KEY='%s'\n", awsCreds.SecretAccessKey)
106114
fmt.Fprintf(cmd.OutOrStdout(), "export AWS_SESSION_TOKEN='%s'\n", awsCreds.SessionToken)

cmd/env_test.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package cmd
22

33
import (
4+
"encoding/json"
45
"strings"
56
"testing"
67

@@ -160,3 +161,55 @@ func TestNewEnvCommand_RefreshFlagRegistered(t *testing.T) {
160161
t.Error("expected --refresh flag to be registered")
161162
}
162163
}
164+
165+
func TestEnvCommand_JSONOutput(t *testing.T) {
166+
credsJSON := `{"aws_access_key":"ASIAXXX","aws_secret_access_key":"secret","aws_session_token":"tok"}`
167+
168+
authLoader := &mockAuthLoader{
169+
token: &authmodels.IdsecToken{Token: "test-jwt"},
170+
}
171+
eligLister := &mockEligibilityLister{
172+
response: &models.EligibilityResponse{
173+
Response: []models.EligibleTarget{{
174+
OrganizationID: "o-1", WorkspaceID: "acct-1", WorkspaceName: "AWS Mgmt",
175+
WorkspaceType: models.WorkspaceTypeAccount,
176+
RoleInfo: models.RoleInfo{ID: "role-1", Name: "Admin"},
177+
}},
178+
Total: 1,
179+
},
180+
}
181+
elevSvc := &mockElevateService{
182+
response: &models.ElevateResponse{Response: models.ElevateAccessResult{
183+
CSP: models.CSPAWS, OrganizationID: "o-1",
184+
Results: []models.ElevateTargetResult{{
185+
WorkspaceID: "acct-1", RoleID: "Admin", SessionID: "sess-1",
186+
AccessCredentials: &credsJSON,
187+
}},
188+
}},
189+
}
190+
selector := &mockTargetSelector{
191+
target: &models.EligibleTarget{
192+
OrganizationID: "o-1", WorkspaceID: "acct-1", WorkspaceName: "AWS Mgmt",
193+
WorkspaceType: models.WorkspaceTypeAccount,
194+
RoleInfo: models.RoleInfo{ID: "role-1", Name: "Admin"},
195+
},
196+
}
197+
198+
cmd := NewEnvCommandWithDeps(nil, authLoader, eligLister, elevSvc, selector, config.DefaultConfig())
199+
// Attach to root so --output flag is available
200+
root := newTestRootCommand()
201+
root.AddCommand(cmd)
202+
203+
output, err := executeCommand(root, "env", "--provider", "aws", "--target", "AWS Mgmt", "--role", "Admin", "--output", "json")
204+
if err != nil {
205+
t.Fatalf("unexpected error: %v\noutput: %s", err, output)
206+
}
207+
208+
var parsed awsCredentialOutput
209+
if err := json.Unmarshal([]byte(output), &parsed); err != nil {
210+
t.Fatalf("invalid JSON: %v\n%s", err, output)
211+
}
212+
if parsed.AccessKeyID != "ASIAXXX" {
213+
t.Errorf("accessKeyId = %q, want ASIAXXX", parsed.AccessKeyID)
214+
}
215+
}

cmd/favorites.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,22 @@ func runFavoritesList(cmd *cobra.Command, args []string) error {
440440
return nil
441441
}
442442

443+
if isJSONOutput() {
444+
out := make([]favoriteOutput, len(favorites))
445+
for i, entry := range favorites {
446+
out[i] = favoriteOutput{
447+
Name: entry.Name,
448+
Type: entry.ResolvedType(),
449+
Provider: entry.Provider,
450+
Target: entry.Target,
451+
Role: entry.Role,
452+
Group: entry.Group,
453+
DirectoryID: entry.DirectoryID,
454+
}
455+
}
456+
return writeJSON(cmd.OutOrStdout(), out)
457+
}
458+
443459
for _, entry := range favorites {
444460
if entry.ResolvedType() == config.FavoriteTypeGroups {
445461
fmt.Fprintf(cmd.OutOrStdout(), "%s: groups/%s\n", entry.Name, entry.Group)

cmd/favorites_test.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package cmd
22

33
import (
44
"context"
5+
"encoding/json"
56
"errors"
67
"path/filepath"
78
"strings"
@@ -1185,6 +1186,55 @@ func TestFavoritesListWithGroupFavorites(t *testing.T) {
11851186
}
11861187
}
11871188

1189+
func TestFavoritesList_JSONOutput(t *testing.T) {
1190+
tmpDir := t.TempDir()
1191+
configPath := filepath.Join(tmpDir, "config.yaml")
1192+
t.Setenv("GRANT_CONFIG", configPath)
1193+
1194+
cfg := config.DefaultConfig()
1195+
_ = config.AddFavorite(cfg, "dev", config.Favorite{Provider: "azure", Target: "sub-1", Role: "Contributor"})
1196+
_ = config.AddFavorite(cfg, "grp", config.Favorite{Type: config.FavoriteTypeGroups, Provider: "azure", Group: "Admins", DirectoryID: "dir-1"})
1197+
_ = config.Save(cfg, configPath)
1198+
1199+
rootCmd := newTestRootCommand()
1200+
rootCmd.AddCommand(NewFavoritesCommand())
1201+
1202+
output, err := executeCommand(rootCmd, "favorites", "list", "--output", "json")
1203+
if err != nil {
1204+
t.Fatalf("unexpected error: %v\noutput: %s", err, output)
1205+
}
1206+
1207+
var parsed []favoriteOutput
1208+
if err := json.Unmarshal([]byte(output), &parsed); err != nil {
1209+
t.Fatalf("invalid JSON: %v\n%s", err, output)
1210+
}
1211+
if len(parsed) != 2 {
1212+
t.Fatalf("expected 2 favorites, got %d", len(parsed))
1213+
}
1214+
1215+
// Find by name
1216+
for _, f := range parsed {
1217+
switch f.Name {
1218+
case "dev":
1219+
if f.Type != "cloud" {
1220+
t.Errorf("dev type = %q, want cloud", f.Type)
1221+
}
1222+
if f.Target != "sub-1" {
1223+
t.Errorf("dev target = %q, want sub-1", f.Target)
1224+
}
1225+
case "grp":
1226+
if f.Type != "groups" {
1227+
t.Errorf("grp type = %q, want groups", f.Type)
1228+
}
1229+
if f.Group != "Admins" {
1230+
t.Errorf("grp group = %q, want Admins", f.Group)
1231+
}
1232+
default:
1233+
t.Errorf("unexpected favorite name: %q", f.Name)
1234+
}
1235+
}
1236+
}
1237+
11881238
func TestSurveyNamePrompter_NonTTY(t *testing.T) {
11891239
original := ui.IsTerminalFunc
11901240
defer func() { ui.IsTerminalFunc = original }()

cmd/output.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package cmd
2+
3+
import (
4+
"encoding/json"
5+
"io"
6+
)
7+
8+
// outputFormat holds the global output format flag value.
9+
var outputFormat string
10+
11+
// isJSONOutput returns true when the user has requested JSON output.
12+
func isJSONOutput() bool {
13+
return outputFormat == "json"
14+
}
15+
16+
// writeJSON encodes data as indented JSON to the given writer.
17+
func writeJSON(w io.Writer, data any) error {
18+
enc := json.NewEncoder(w)
19+
enc.SetIndent("", " ")
20+
return enc.Encode(data)
21+
}

cmd/output_test.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package cmd
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"testing"
7+
)
8+
9+
func TestIsJSONOutput(t *testing.T) {
10+
tests := []struct {
11+
name string
12+
format string
13+
want bool
14+
}{
15+
{"text format", "text", false},
16+
{"json format", "json", true},
17+
{"empty format", "", false},
18+
}
19+
20+
for _, tt := range tests {
21+
t.Run(tt.name, func(t *testing.T) {
22+
old := outputFormat
23+
defer func() { outputFormat = old }()
24+
25+
outputFormat = tt.format
26+
if got := isJSONOutput(); got != tt.want {
27+
t.Errorf("isJSONOutput() = %v, want %v", got, tt.want)
28+
}
29+
})
30+
}
31+
}
32+
33+
func TestWriteJSON(t *testing.T) {
34+
type sample struct {
35+
Name string `json:"name"`
36+
Count int `json:"count"`
37+
}
38+
39+
var buf bytes.Buffer
40+
err := writeJSON(&buf, sample{Name: "test", Count: 42})
41+
if err != nil {
42+
t.Fatalf("writeJSON() error = %v", err)
43+
}
44+
45+
var parsed map[string]interface{}
46+
if err := json.Unmarshal(buf.Bytes(), &parsed); err != nil {
47+
t.Fatalf("output is not valid JSON: %v\ngot: %s", err, buf.String())
48+
}
49+
50+
if parsed["name"] != "test" {
51+
t.Errorf("expected name=test, got %v", parsed["name"])
52+
}
53+
if parsed["count"] != float64(42) {
54+
t.Errorf("expected count=42, got %v", parsed["count"])
55+
}
56+
}

cmd/output_types.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package cmd
2+
3+
// cloudElevationOutput is the JSON representation of a cloud elevation result.
4+
type cloudElevationOutput struct {
5+
Type string `json:"type"`
6+
Provider string `json:"provider"`
7+
SessionID string `json:"sessionId"`
8+
Target string `json:"target"`
9+
Role string `json:"role"`
10+
Credentials *awsCredentialOutput `json:"credentials,omitempty"`
11+
}
12+
13+
// awsCredentialOutput is the JSON representation of AWS credentials.
14+
type awsCredentialOutput struct {
15+
AccessKeyID string `json:"accessKeyId"`
16+
SecretAccessKey string `json:"secretAccessKey"`
17+
SessionToken string `json:"sessionToken"`
18+
}
19+
20+
// groupElevationJSON is the JSON representation of a group elevation result.
21+
type groupElevationJSON struct {
22+
Type string `json:"type"`
23+
SessionID string `json:"sessionId"`
24+
GroupName string `json:"groupName"`
25+
GroupID string `json:"groupId"`
26+
DirectoryID string `json:"directoryId"`
27+
Directory string `json:"directory,omitempty"`
28+
}
29+
30+
// sessionOutput is the JSON representation of an active session.
31+
type sessionOutput struct {
32+
SessionID string `json:"sessionId"`
33+
Provider string `json:"provider"`
34+
WorkspaceID string `json:"workspaceId"`
35+
WorkspaceName string `json:"workspaceName,omitempty"`
36+
RoleID string `json:"roleId,omitempty"`
37+
Duration int `json:"duration"`
38+
Type string `json:"type"`
39+
GroupID string `json:"groupId,omitempty"`
40+
}
41+
42+
// statusOutput is the JSON representation of grant status.
43+
type statusOutput struct {
44+
Authenticated bool `json:"authenticated"`
45+
Username string `json:"username,omitempty"`
46+
Sessions []sessionOutput `json:"sessions"`
47+
}
48+
49+
// revocationOutput is the JSON representation of a revocation result.
50+
type revocationOutput struct {
51+
SessionID string `json:"sessionId"`
52+
Status string `json:"status"`
53+
}
54+
55+
// favoriteOutput is the JSON representation of a saved favorite.
56+
type favoriteOutput struct {
57+
Name string `json:"name"`
58+
Type string `json:"type"`
59+
Provider string `json:"provider"`
60+
Target string `json:"target,omitempty"`
61+
Role string `json:"role,omitempty"`
62+
Group string `json:"group,omitempty"`
63+
DirectoryID string `json:"directoryId,omitempty"`
64+
}

cmd/revoke.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,14 @@ func runRevoke(
200200
}
201201

202202
// Display results
203+
if isJSONOutput() {
204+
out := make([]revocationOutput, len(result.Response))
205+
for i, r := range result.Response {
206+
out[i] = revocationOutput{SessionID: r.SessionID, Status: r.RevocationStatus}
207+
}
208+
return writeJSON(cmd.OutOrStdout(), out)
209+
}
210+
203211
for _, r := range result.Response {
204212
fmt.Fprintf(cmd.OutOrStdout(), " %s: %s\n", r.SessionID, r.RevocationStatus)
205213
}

cmd/revoke_test.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ package cmd
44

55
import (
66
"context"
7+
"encoding/json"
78
"errors"
89
"strings"
910
"testing"
@@ -712,3 +713,48 @@ func TestRevokeCommandUsage(t *testing.T) {
712713
t.Fatal("expected --provider flag")
713714
}
714715
}
716+
717+
func TestRevokeCommand_JSONOutput(t *testing.T) {
718+
now := time.Now()
719+
expiresIn := commonmodels.IdsecRFC3339Time(now.Add(1 * time.Hour))
720+
721+
auth := &mockAuthLoader{token: &authmodels.IdsecToken{Token: "jwt", Username: "user", ExpiresIn: expiresIn}}
722+
sessions := &mockSessionLister{sessions: &scamodels.SessionsResponse{
723+
Response: []scamodels.SessionInfo{
724+
{SessionID: "s1", CSP: scamodels.CSPAzure, WorkspaceID: "sub-1", RoleID: "Contributor", SessionDuration: 3600},
725+
},
726+
}}
727+
elig := &mockEligibilityLister{}
728+
revoker := &mockSessionRevoker{response: &scamodels.RevokeResponse{
729+
Response: []scamodels.RevocationResult{
730+
{SessionID: "s1", RevocationStatus: "Revoked"},
731+
},
732+
}}
733+
selector := &mockSessionSelector{sessions: []scamodels.SessionInfo{
734+
{SessionID: "s1"},
735+
}}
736+
confirmer := &mockConfirmPrompter{confirmed: true}
737+
738+
cmd := NewRevokeCommandWithDeps(auth, sessions, elig, revoker, selector, confirmer)
739+
root := newTestRootCommand()
740+
root.AddCommand(cmd)
741+
742+
output, err := executeCommand(root, "revoke", "s1", "--yes", "--output", "json")
743+
if err != nil {
744+
t.Fatalf("unexpected error: %v\noutput: %s", err, output)
745+
}
746+
747+
var parsed []revocationOutput
748+
if err := json.Unmarshal([]byte(output), &parsed); err != nil {
749+
t.Fatalf("invalid JSON: %v\n%s", err, output)
750+
}
751+
if len(parsed) != 1 {
752+
t.Fatalf("expected 1 result, got %d", len(parsed))
753+
}
754+
if parsed[0].SessionID != "s1" {
755+
t.Errorf("sessionId = %q, want s1", parsed[0].SessionID)
756+
}
757+
if parsed[0].Status != "Revoked" {
758+
t.Errorf("status = %q, want Revoked", parsed[0].Status)
759+
}
760+
}

0 commit comments

Comments
 (0)