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
8 changes: 8 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,14 @@ Custom `SCAAccessService` follows SDK conventions:
- Error messages suggest the appropriate non-interactive flag (e.g., `--target/--role`, `--all`, `--yes`, `--group`, `--favorite`)
- `go-isatty` v0.0.20 is a direct dependency (promoted from indirect via survey)

## JSON Output
- `--output` / `-o` persistent flag on root command: `text` (default) or `json`
- Validated in `PersistentPreRunE`; JSON mode forces `IsTerminalFunc` to return false (non-interactive)
- `cmd/output.go` — `outputFormat` var, `isJSONOutput()`, `writeJSON(w, data)`
- `cmd/output_types.go` — JSON structs: `cloudElevationOutput`, `groupElevationJSON`, `sessionOutput`, `statusOutput`, `revocationOutput`, `favoriteOutput`, `awsCredentialOutput`
- All commands support JSON: root elevation, `env`, `status`, `revoke`, `favorites list`
- `config.Favorite` has both `yaml:"..."` and `json:"..."` struct tags

## Cache
- Eligibility responses cached in `~/.grant/cache/` as JSON files (e.g., `eligibility_azure.json`, `groups_eligibility_azure.json`)
- Default TTL: 4 hours, configurable via `cache_ttl` in `~/.grant/config.yaml` (Go duration syntax: `2h`, `30m`)
Expand Down
8 changes: 8 additions & 0 deletions cmd/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,14 @@ func runEnvWithDeps(
return fmt.Errorf("failed to parse access credentials: %w", err)
}

if isJSONOutput() {
return writeJSON(cmd.OutOrStdout(), awsCredentialOutput{
AccessKeyID: awsCreds.AccessKeyID,
SecretAccessKey: awsCreds.SecretAccessKey,
SessionToken: awsCreds.SessionToken,
})
}

fmt.Fprintf(cmd.OutOrStdout(), "export AWS_ACCESS_KEY_ID='%s'\n", awsCreds.AccessKeyID)
fmt.Fprintf(cmd.OutOrStdout(), "export AWS_SECRET_ACCESS_KEY='%s'\n", awsCreds.SecretAccessKey)
fmt.Fprintf(cmd.OutOrStdout(), "export AWS_SESSION_TOKEN='%s'\n", awsCreds.SessionToken)
Expand Down
53 changes: 53 additions & 0 deletions cmd/env_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cmd

import (
"encoding/json"
"strings"
"testing"

Expand Down Expand Up @@ -160,3 +161,55 @@ func TestNewEnvCommand_RefreshFlagRegistered(t *testing.T) {
t.Error("expected --refresh flag to be registered")
}
}

func TestEnvCommand_JSONOutput(t *testing.T) {
credsJSON := `{"aws_access_key":"ASIAXXX","aws_secret_access_key":"secret","aws_session_token":"tok"}`

authLoader := &mockAuthLoader{
token: &authmodels.IdsecToken{Token: "test-jwt"},
}
eligLister := &mockEligibilityLister{
response: &models.EligibilityResponse{
Response: []models.EligibleTarget{{
OrganizationID: "o-1", WorkspaceID: "acct-1", WorkspaceName: "AWS Mgmt",
WorkspaceType: models.WorkspaceTypeAccount,
RoleInfo: models.RoleInfo{ID: "role-1", Name: "Admin"},
}},
Total: 1,
},
}
elevSvc := &mockElevateService{
response: &models.ElevateResponse{Response: models.ElevateAccessResult{
CSP: models.CSPAWS, OrganizationID: "o-1",
Results: []models.ElevateTargetResult{{
WorkspaceID: "acct-1", RoleID: "Admin", SessionID: "sess-1",
AccessCredentials: &credsJSON,
}},
}},
}
selector := &mockTargetSelector{
target: &models.EligibleTarget{
OrganizationID: "o-1", WorkspaceID: "acct-1", WorkspaceName: "AWS Mgmt",
WorkspaceType: models.WorkspaceTypeAccount,
RoleInfo: models.RoleInfo{ID: "role-1", Name: "Admin"},
},
}

cmd := NewEnvCommandWithDeps(nil, authLoader, eligLister, elevSvc, selector, config.DefaultConfig())
// Attach to root so --output flag is available
root := newTestRootCommand()
root.AddCommand(cmd)

output, err := executeCommand(root, "env", "--provider", "aws", "--target", "AWS Mgmt", "--role", "Admin", "--output", "json")
if err != nil {
t.Fatalf("unexpected error: %v\noutput: %s", err, output)
}

var parsed awsCredentialOutput
if err := json.Unmarshal([]byte(output), &parsed); err != nil {
t.Fatalf("invalid JSON: %v\n%s", err, output)
}
if parsed.AccessKeyID != "ASIAXXX" {
t.Errorf("accessKeyId = %q, want ASIAXXX", parsed.AccessKeyID)
}
}
16 changes: 16 additions & 0 deletions cmd/favorites.go
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,22 @@ func runFavoritesList(cmd *cobra.Command, args []string) error {
return nil
}

if isJSONOutput() {
out := make([]favoriteOutput, len(favorites))
for i, entry := range favorites {
out[i] = favoriteOutput{
Name: entry.Name,
Type: entry.ResolvedType(),
Provider: entry.Provider,
Target: entry.Target,
Role: entry.Role,
Group: entry.Group,
DirectoryID: entry.DirectoryID,
}
}
return writeJSON(cmd.OutOrStdout(), out)
}

for _, entry := range favorites {
if entry.ResolvedType() == config.FavoriteTypeGroups {
fmt.Fprintf(cmd.OutOrStdout(), "%s: groups/%s\n", entry.Name, entry.Group)
Expand Down
50 changes: 50 additions & 0 deletions cmd/favorites_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cmd

import (
"context"
"encoding/json"
"errors"
"path/filepath"
"strings"
Expand Down Expand Up @@ -1185,6 +1186,55 @@ func TestFavoritesListWithGroupFavorites(t *testing.T) {
}
}

func TestFavoritesList_JSONOutput(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.yaml")
t.Setenv("GRANT_CONFIG", configPath)

cfg := config.DefaultConfig()
_ = config.AddFavorite(cfg, "dev", config.Favorite{Provider: "azure", Target: "sub-1", Role: "Contributor"})
_ = config.AddFavorite(cfg, "grp", config.Favorite{Type: config.FavoriteTypeGroups, Provider: "azure", Group: "Admins", DirectoryID: "dir-1"})
_ = config.Save(cfg, configPath)

rootCmd := newTestRootCommand()
rootCmd.AddCommand(NewFavoritesCommand())

output, err := executeCommand(rootCmd, "favorites", "list", "--output", "json")
if err != nil {
t.Fatalf("unexpected error: %v\noutput: %s", err, output)
}

var parsed []favoriteOutput
if err := json.Unmarshal([]byte(output), &parsed); err != nil {
t.Fatalf("invalid JSON: %v\n%s", err, output)
}
if len(parsed) != 2 {
t.Fatalf("expected 2 favorites, got %d", len(parsed))
}

// Find by name
for _, f := range parsed {
switch f.Name {
case "dev":
if f.Type != "cloud" {
t.Errorf("dev type = %q, want cloud", f.Type)
}
if f.Target != "sub-1" {
t.Errorf("dev target = %q, want sub-1", f.Target)
}
case "grp":
if f.Type != "groups" {
t.Errorf("grp type = %q, want groups", f.Type)
}
if f.Group != "Admins" {
t.Errorf("grp group = %q, want Admins", f.Group)
}
default:
t.Errorf("unexpected favorite name: %q", f.Name)
}
}
}

func TestSurveyNamePrompter_NonTTY(t *testing.T) {
original := ui.IsTerminalFunc
defer func() { ui.IsTerminalFunc = original }()
Expand Down
21 changes: 21 additions & 0 deletions cmd/output.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package cmd

import (
"encoding/json"
"io"
)

// outputFormat holds the global output format flag value.
var outputFormat string

// isJSONOutput returns true when the user has requested JSON output.
func isJSONOutput() bool {
return outputFormat == "json"
}

// writeJSON encodes data as indented JSON to the given writer.
func writeJSON(w io.Writer, data any) error {
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(data)
}
56 changes: 56 additions & 0 deletions cmd/output_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package cmd

import (
"bytes"
"encoding/json"
"testing"
)

func TestIsJSONOutput(t *testing.T) {
tests := []struct {
name string
format string
want bool
}{
{"text format", "text", false},
{"json format", "json", true},
{"empty format", "", false},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
old := outputFormat
defer func() { outputFormat = old }()

outputFormat = tt.format
if got := isJSONOutput(); got != tt.want {
t.Errorf("isJSONOutput() = %v, want %v", got, tt.want)
}
})
}
}

func TestWriteJSON(t *testing.T) {
type sample struct {
Name string `json:"name"`
Count int `json:"count"`
}

var buf bytes.Buffer
err := writeJSON(&buf, sample{Name: "test", Count: 42})
if err != nil {
t.Fatalf("writeJSON() error = %v", err)
}

var parsed map[string]interface{}
if err := json.Unmarshal(buf.Bytes(), &parsed); err != nil {
t.Fatalf("output is not valid JSON: %v\ngot: %s", err, buf.String())
}

if parsed["name"] != "test" {
t.Errorf("expected name=test, got %v", parsed["name"])
}
if parsed["count"] != float64(42) {
t.Errorf("expected count=42, got %v", parsed["count"])
}
}
64 changes: 64 additions & 0 deletions cmd/output_types.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package cmd

// cloudElevationOutput is the JSON representation of a cloud elevation result.
type cloudElevationOutput struct {
Type string `json:"type"`
Provider string `json:"provider"`
SessionID string `json:"sessionId"`
Target string `json:"target"`
Role string `json:"role"`
Credentials *awsCredentialOutput `json:"credentials,omitempty"`
}

// awsCredentialOutput is the JSON representation of AWS credentials.
type awsCredentialOutput struct {
AccessKeyID string `json:"accessKeyId"`
SecretAccessKey string `json:"secretAccessKey"`
SessionToken string `json:"sessionToken"`
}

// groupElevationJSON is the JSON representation of a group elevation result.
type groupElevationJSON struct {
Type string `json:"type"`
SessionID string `json:"sessionId"`
GroupName string `json:"groupName"`
GroupID string `json:"groupId"`
DirectoryID string `json:"directoryId"`
Directory string `json:"directory,omitempty"`
}

// sessionOutput is the JSON representation of an active session.
type sessionOutput struct {
SessionID string `json:"sessionId"`
Provider string `json:"provider"`
WorkspaceID string `json:"workspaceId"`
WorkspaceName string `json:"workspaceName,omitempty"`
RoleID string `json:"roleId,omitempty"`
Duration int `json:"duration"`
Type string `json:"type"`
GroupID string `json:"groupId,omitempty"`
}

// statusOutput is the JSON representation of grant status.
type statusOutput struct {
Authenticated bool `json:"authenticated"`
Username string `json:"username,omitempty"`
Sessions []sessionOutput `json:"sessions"`
}

// revocationOutput is the JSON representation of a revocation result.
type revocationOutput struct {
SessionID string `json:"sessionId"`
Status string `json:"status"`
}

// favoriteOutput is the JSON representation of a saved favorite.
type favoriteOutput struct {
Name string `json:"name"`
Type string `json:"type"`
Provider string `json:"provider"`
Target string `json:"target,omitempty"`
Role string `json:"role,omitempty"`
Group string `json:"group,omitempty"`
DirectoryID string `json:"directoryId,omitempty"`
}
8 changes: 8 additions & 0 deletions cmd/revoke.go
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,14 @@ func runRevoke(
}

// Display results
if isJSONOutput() {
out := make([]revocationOutput, len(result.Response))
for i, r := range result.Response {
out[i] = revocationOutput{SessionID: r.SessionID, Status: r.RevocationStatus}
}
return writeJSON(cmd.OutOrStdout(), out)
}

for _, r := range result.Response {
fmt.Fprintf(cmd.OutOrStdout(), " %s: %s\n", r.SessionID, r.RevocationStatus)
}
Expand Down
46 changes: 46 additions & 0 deletions cmd/revoke_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package cmd

import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
Expand Down Expand Up @@ -712,3 +713,48 @@ func TestRevokeCommandUsage(t *testing.T) {
t.Fatal("expected --provider flag")
}
}

func TestRevokeCommand_JSONOutput(t *testing.T) {
now := time.Now()
expiresIn := commonmodels.IdsecRFC3339Time(now.Add(1 * time.Hour))

auth := &mockAuthLoader{token: &authmodels.IdsecToken{Token: "jwt", Username: "user", ExpiresIn: expiresIn}}
sessions := &mockSessionLister{sessions: &scamodels.SessionsResponse{
Response: []scamodels.SessionInfo{
{SessionID: "s1", CSP: scamodels.CSPAzure, WorkspaceID: "sub-1", RoleID: "Contributor", SessionDuration: 3600},
},
}}
elig := &mockEligibilityLister{}
revoker := &mockSessionRevoker{response: &scamodels.RevokeResponse{
Response: []scamodels.RevocationResult{
{SessionID: "s1", RevocationStatus: "Revoked"},
},
}}
selector := &mockSessionSelector{sessions: []scamodels.SessionInfo{
{SessionID: "s1"},
}}
confirmer := &mockConfirmPrompter{confirmed: true}

cmd := NewRevokeCommandWithDeps(auth, sessions, elig, revoker, selector, confirmer)
root := newTestRootCommand()
root.AddCommand(cmd)

output, err := executeCommand(root, "revoke", "s1", "--yes", "--output", "json")
if err != nil {
t.Fatalf("unexpected error: %v\noutput: %s", err, output)
}

var parsed []revocationOutput
if err := json.Unmarshal([]byte(output), &parsed); err != nil {
t.Fatalf("invalid JSON: %v\n%s", err, output)
}
if len(parsed) != 1 {
t.Fatalf("expected 1 result, got %d", len(parsed))
}
if parsed[0].SessionID != "s1" {
t.Errorf("sessionId = %q, want s1", parsed[0].SessionID)
}
if parsed[0].Status != "Revoked" {
t.Errorf("status = %q, want Revoked", parsed[0].Status)
}
}
Loading