diff --git a/CLAUDE.md b/CLAUDE.md index d40ff70..177c9a3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`) diff --git a/cmd/env.go b/cmd/env.go index 36f49c9..4e3faec 100644 --- a/cmd/env.go +++ b/cmd/env.go @@ -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) diff --git a/cmd/env_test.go b/cmd/env_test.go index cb3296b..7c21cbe 100644 --- a/cmd/env_test.go +++ b/cmd/env_test.go @@ -1,6 +1,7 @@ package cmd import ( + "encoding/json" "strings" "testing" @@ -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) + } +} diff --git a/cmd/favorites.go b/cmd/favorites.go index 8ba4d39..3edca81 100644 --- a/cmd/favorites.go +++ b/cmd/favorites.go @@ -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) diff --git a/cmd/favorites_test.go b/cmd/favorites_test.go index 2fee498..1f84714 100644 --- a/cmd/favorites_test.go +++ b/cmd/favorites_test.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "encoding/json" "errors" "path/filepath" "strings" @@ -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 }() diff --git a/cmd/output.go b/cmd/output.go new file mode 100644 index 0000000..e1f060c --- /dev/null +++ b/cmd/output.go @@ -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) +} diff --git a/cmd/output_test.go b/cmd/output_test.go new file mode 100644 index 0000000..e69f3a1 --- /dev/null +++ b/cmd/output_test.go @@ -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"]) + } +} diff --git a/cmd/output_types.go b/cmd/output_types.go new file mode 100644 index 0000000..ca7bad2 --- /dev/null +++ b/cmd/output_types.go @@ -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"` +} diff --git a/cmd/revoke.go b/cmd/revoke.go index 414b94a..7b226c1 100644 --- a/cmd/revoke.go +++ b/cmd/revoke.go @@ -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) } diff --git a/cmd/revoke_test.go b/cmd/revoke_test.go index 3688b18..64df31f 100644 --- a/cmd/revoke_test.go +++ b/cmd/revoke_test.go @@ -4,6 +4,7 @@ package cmd import ( "context" + "encoding/json" "errors" "strings" "testing" @@ -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) + } +} diff --git a/cmd/root.go b/cmd/root.go index ad93846..027f388 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -98,12 +98,19 @@ Examples: } else { sdkconfig.DisableVerboseLogging() } + if outputFormat != "text" && outputFormat != "json" { + return fmt.Errorf("invalid output format %q: must be one of: text, json", outputFormat) + } + if isJSONOutput() { + ui.IsTerminalFunc = func(fd uintptr) bool { return false } + } return nil }, RunE: runFn, } cmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "enable verbose output") + cmd.PersistentFlags().StringVarP(&outputFormat, "output", "o", "text", "Output format: text, json") cmd.Flags().StringP("provider", "p", "", "Cloud provider: azure, aws (omit to show all)") cmd.Flags().StringP("target", "t", "", "Target name (subscription, resource group, etc.)") cmd.Flags().StringP("role", "r", "", "Role name") @@ -774,6 +781,10 @@ func runElevateWithDeps( return err } + if isJSONOutput() { + return writeElevationJSON(cmd, cloudRes, groupRes) + } + if groupRes != nil { // Display group elevation result dirContext := "" @@ -809,6 +820,43 @@ func runElevateWithDeps( return nil } +// writeElevationJSON writes the elevation result as JSON. +func writeElevationJSON(cmd *cobra.Command, cloudRes *elevationResult, groupRes *groupElevationResult) error { + if groupRes != nil { + out := groupElevationJSON{ + Type: "group", + SessionID: groupRes.result.SessionID, + GroupName: groupRes.group.GroupName, + GroupID: groupRes.group.GroupID, + DirectoryID: groupRes.group.DirectoryID, + Directory: groupRes.group.DirectoryName, + } + return writeJSON(cmd.OutOrStdout(), out) + } + + out := cloudElevationOutput{ + Type: "cloud", + Provider: strings.ToLower(string(cloudRes.target.CSP)), + SessionID: cloudRes.result.SessionID, + Target: cloudRes.target.WorkspaceName, + Role: cloudRes.target.RoleInfo.Name, + } + + if cloudRes.result.AccessCredentials != nil { + awsCreds, err := models.ParseAWSCredentials(*cloudRes.result.AccessCredentials) + if err != nil { + return fmt.Errorf("failed to parse access credentials: %w", err) + } + out.Credentials = &awsCredentialOutput{ + AccessKeyID: awsCreds.AccessKeyID, + SecretAccessKey: awsCreds.SecretAccessKey, + SessionToken: awsCreds.SessionToken, + } + } + + return writeJSON(cmd.OutOrStdout(), out) +} + // findMatchingTarget finds a target by workspace name and role name (case-insensitive) func findMatchingTarget(targets []models.EligibleTarget, targetName, roleName string) *models.EligibleTarget { for i := range targets { diff --git a/cmd/root_elevate_test.go b/cmd/root_elevate_test.go index 0629651..187464c 100644 --- a/cmd/root_elevate_test.go +++ b/cmd/root_elevate_test.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "encoding/json" "errors" "strings" "testing" @@ -1724,3 +1725,149 @@ func TestRootElevate_SlowPromptTimeout(t *testing.T) { } }) } + +func TestRootElevate_JSONOutput(t *testing.T) { + now := time.Now() + expiresIn := commonmodels.IdsecRFC3339Time(now.Add(1 * time.Hour)) + + tests := []struct { + name string + setupMocks func() (*mockAuthLoader, *mockEligibilityLister, *mockElevateService, *mockUnifiedSelector, *mockGroupsEligibilityLister, *mockGroupsElevator, *config.Config) + args []string + validate func(t *testing.T, output string) + }{ + { + name: "Azure cloud elevation JSON", + setupMocks: func() (*mockAuthLoader, *mockEligibilityLister, *mockElevateService, *mockUnifiedSelector, *mockGroupsEligibilityLister, *mockGroupsElevator, *config.Config) { + return &mockAuthLoader{token: &authmodels.IdsecToken{Token: "jwt", Username: "user", ExpiresIn: expiresIn}}, + &mockEligibilityLister{response: &models.EligibilityResponse{ + Response: []models.EligibleTarget{{ + OrganizationID: "org-1", WorkspaceID: "sub-1", WorkspaceName: "Prod-EastUS", + WorkspaceType: models.WorkspaceTypeSubscription, + RoleInfo: models.RoleInfo{ID: "role-1", Name: "Contributor"}, + }}, Total: 1, + }}, + &mockElevateService{response: &models.ElevateResponse{Response: models.ElevateAccessResult{ + CSP: models.CSPAzure, OrganizationID: "org-1", + Results: []models.ElevateTargetResult{{WorkspaceID: "sub-1", RoleID: "role-1", SessionID: "sess-az"}}, + }}}, + &mockUnifiedSelector{item: &selectionItem{kind: selectionCloud, cloud: &models.EligibleTarget{ + OrganizationID: "org-1", WorkspaceID: "sub-1", WorkspaceName: "Prod-EastUS", + WorkspaceType: models.WorkspaceTypeSubscription, + RoleInfo: models.RoleInfo{ID: "role-1", Name: "Contributor"}, + }}}, + &mockGroupsEligibilityLister{listErr: errors.New("skip")}, + nil, config.DefaultConfig() + }, + args: []string{"--output", "json", "--provider", "azure"}, + validate: func(t *testing.T, output string) { + var out cloudElevationOutput + if err := json.Unmarshal([]byte(output), &out); err != nil { + t.Fatalf("invalid JSON: %v\n%s", err, output) + } + if out.Type != "cloud" { + t.Errorf("type = %q, want cloud", out.Type) + } + if out.SessionID != "sess-az" { + t.Errorf("sessionId = %q, want sess-az", out.SessionID) + } + if out.Credentials != nil { + t.Error("expected no credentials for Azure") + } + }, + }, + { + name: "AWS cloud elevation JSON with credentials", + setupMocks: func() (*mockAuthLoader, *mockEligibilityLister, *mockElevateService, *mockUnifiedSelector, *mockGroupsEligibilityLister, *mockGroupsElevator, *config.Config) { + credsJSON := `{"aws_access_key":"ASIAXXX","aws_secret_access_key":"secret","aws_session_token":"tok"}` + return &mockAuthLoader{token: &authmodels.IdsecToken{Token: "jwt", Username: "user", ExpiresIn: expiresIn}}, + &mockEligibilityLister{response: &models.EligibilityResponse{ + Response: []models.EligibleTarget{{ + OrganizationID: "o-1", WorkspaceID: "acct-1", WorkspaceName: "AWS Mgmt", + WorkspaceType: models.WorkspaceTypeAccount, + RoleInfo: models.RoleInfo{ID: "arn:role", Name: "Admin"}, + }}, Total: 1, + }}, + &mockElevateService{response: &models.ElevateResponse{Response: models.ElevateAccessResult{ + CSP: models.CSPAWS, OrganizationID: "o-1", + Results: []models.ElevateTargetResult{{ + WorkspaceID: "acct-1", RoleID: "Admin", SessionID: "sess-aws", + AccessCredentials: &credsJSON, + }}, + }}}, + &mockUnifiedSelector{item: &selectionItem{kind: selectionCloud, cloud: &models.EligibleTarget{ + OrganizationID: "o-1", WorkspaceID: "acct-1", WorkspaceName: "AWS Mgmt", + WorkspaceType: models.WorkspaceTypeAccount, + RoleInfo: models.RoleInfo{ID: "arn:role", Name: "Admin"}, + }}}, + &mockGroupsEligibilityLister{listErr: errors.New("skip")}, + nil, config.DefaultConfig() + }, + args: []string{"--output", "json", "--provider", "aws"}, + validate: func(t *testing.T, output string) { + var out cloudElevationOutput + if err := json.Unmarshal([]byte(output), &out); err != nil { + t.Fatalf("invalid JSON: %v\n%s", err, output) + } + if out.Credentials == nil { + t.Fatal("expected credentials for AWS") + } + if out.Credentials.AccessKeyID != "ASIAXXX" { + t.Errorf("accessKeyId = %q, want ASIAXXX", out.Credentials.AccessKeyID) + } + }, + }, + { + name: "Group elevation JSON", + setupMocks: func() (*mockAuthLoader, *mockEligibilityLister, *mockElevateService, *mockUnifiedSelector, *mockGroupsEligibilityLister, *mockGroupsElevator, *config.Config) { + return &mockAuthLoader{token: &authmodels.IdsecToken{Token: "jwt", Username: "user", ExpiresIn: expiresIn}}, + &mockEligibilityLister{response: &models.EligibilityResponse{ + Response: []models.EligibleTarget{{ + OrganizationID: "org-1", WorkspaceID: "sub-1", WorkspaceName: "Prod", + WorkspaceType: models.WorkspaceTypeSubscription, + RoleInfo: models.RoleInfo{ID: "r1", Name: "Reader"}, + }}, Total: 1, + }}, + nil, + nil, + &mockGroupsEligibilityLister{response: &models.GroupsEligibilityResponse{ + Response: []models.GroupsEligibleTarget{{DirectoryID: "dir1", GroupID: "grp1", GroupName: "CloudAdmins", DirectoryName: "Contoso"}}, + Total: 1, + }}, + &mockGroupsElevator{response: &models.GroupsElevateResponse{ + DirectoryID: "dir1", CSP: models.CSPAzure, + Results: []models.GroupsElevateTargetResult{{GroupID: "grp1", SessionID: "sess-grp"}}, + }}, + config.DefaultConfig() + }, + args: []string{"--output", "json", "--group", "CloudAdmins"}, + validate: func(t *testing.T, output string) { + var out groupElevationJSON + if err := json.Unmarshal([]byte(output), &out); err != nil { + t.Fatalf("invalid JSON: %v\n%s", err, output) + } + if out.Type != "group" { + t.Errorf("type = %q, want group", out.Type) + } + if out.GroupName != "CloudAdmins" { + t.Errorf("groupName = %q, want CloudAdmins", out.GroupName) + } + if out.Directory != "Contoso" { + t.Errorf("directory = %q, want Contoso", out.Directory) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + authLoader, eligLister, elevSvc, selector, groupsElig, groupsElev, cfg := tt.setupMocks() + cmd := NewRootCommandWithDeps(nil, authLoader, eligLister, elevSvc, selector, groupsElig, groupsElev, cfg) + output, err := executeCommand(cmd, tt.args...) + if err != nil { + t.Fatalf("unexpected error: %v\noutput: %s", err, output) + } + tt.validate(t, output) + }) + } +} diff --git a/cmd/root_test.go b/cmd/root_test.go index a75ed42..41c3c04 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -222,6 +222,33 @@ func TestExecuteHintOutput(t *testing.T) { } } +func TestOutputFlagValidation(t *testing.T) { + tests := []struct { + name string + args []string + wantErr bool + }{ + {"text is valid", []string{"--output", "text", "noop"}, false}, + {"json is valid", []string{"--output", "json", "noop"}, false}, + {"xml is invalid", []string{"--output", "xml", "noop"}, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + root := newTestRootCommand() + root.AddCommand(newNoOpCommand()) + + _, err := executeCommand(root, tt.args...) + if (err != nil) != tt.wantErr { + t.Errorf("error = %v, wantErr %v", err, tt.wantErr) + } + if tt.wantErr && err != nil && !strings.Contains(err.Error(), "invalid output format") { + t.Errorf("expected 'invalid output format' error, got: %v", err) + } + }) + } +} + func TestUnifiedSelector_NonTTY(t *testing.T) { original := ui.IsTerminalFunc defer func() { ui.IsTerminalFunc = original }() diff --git a/cmd/status.go b/cmd/status.go index 286379a..4a660af 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -57,13 +57,13 @@ func runStatus(cmd *cobra.Command, authLoader authLoader, sessionLister sessionL // Load authentication state token, err := authLoader.LoadAuthentication(profile, true) if err != nil { + if isJSONOutput() { + return writeJSON(cmd.OutOrStdout(), statusOutput{Authenticated: false, Sessions: []sessionOutput{}}) + } fmt.Fprintf(cmd.OutOrStdout(), "Not authenticated. Run 'grant login' first.\n") return nil } - // Display authenticated user - fmt.Fprintf(cmd.OutOrStdout(), "Authenticated as: %s\n", token.Username) - // Parse provider filter if specified provider, _ := cmd.Flags().GetString("provider") var cspFilter *scamodels.CSP @@ -91,6 +91,13 @@ func runStatus(cmd *cobra.Command, authLoader authLoader, sessionLister sessionL } } + if isJSONOutput() { + return writeStatusJSON(cmd, token.Username, data) + } + + // Display authenticated user + fmt.Fprintf(cmd.OutOrStdout(), "Authenticated as: %s\n", token.Username) + // Display sessions if len(data.sessions.Response) == 0 { fmt.Fprintf(cmd.OutOrStdout(), "\nNo active sessions.\n") @@ -131,6 +138,37 @@ func runStatus(cmd *cobra.Command, authLoader authLoader, sessionLister sessionL return nil } +// writeStatusJSON outputs the status as JSON. +func writeStatusJSON(cmd *cobra.Command, username string, data *statusData) error { + out := statusOutput{ + Authenticated: true, + Username: username, + Sessions: make([]sessionOutput, 0, len(data.sessions.Response)), + } + + for _, s := range data.sessions.Response { + so := sessionOutput{ + SessionID: s.SessionID, + Provider: strings.ToLower(string(s.CSP)), + WorkspaceID: s.WorkspaceID, + Duration: s.SessionDuration, + RoleID: s.RoleID, + } + if name, ok := data.nameMap[s.WorkspaceID]; ok { + so.WorkspaceName = name + } + if s.IsGroupSession() { + so.Type = "group" + so.GroupID = s.Target.ID + } else { + so.Type = "cloud" + } + out.Sessions = append(out.Sessions, so) + } + + return writeJSON(cmd.OutOrStdout(), out) +} + // parseProvider converts a provider string to a CSP enum func parseProvider(provider string) (scamodels.CSP, error) { switch strings.ToUpper(provider) { diff --git a/cmd/status_test.go b/cmd/status_test.go index 617b8a5..6a8f757 100644 --- a/cmd/status_test.go +++ b/cmd/status_test.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "encoding/json" "errors" "strings" "testing" @@ -757,3 +758,79 @@ func TestStatusCommandUsage(t *testing.T) { t.Errorf("expected provider flag shorthand 'p', got %q", providerFlag.Shorthand) } } + +func TestStatusCommand_JSONOutput(t *testing.T) { + now := time.Now() + expiresIn := commonmodels.IdsecRFC3339Time(now.Add(1 * time.Hour)) + + tests := []struct { + name string + auth *mockAuthLoader + sessions *mockSessionLister + elig *mockEligibilityLister + validate func(t *testing.T, output string) + }{ + { + name: "not authenticated", + auth: &mockAuthLoader{loadErr: errors.New("no token")}, + sessions: &mockSessionLister{}, + elig: &mockEligibilityLister{}, + validate: func(t *testing.T, output string) { + var out statusOutput + if err := json.Unmarshal([]byte(output), &out); err != nil { + t.Fatalf("invalid JSON: %v\n%s", err, output) + } + if out.Authenticated { + t.Error("expected authenticated=false") + } + if len(out.Sessions) != 0 { + t.Errorf("expected empty sessions, got %d", len(out.Sessions)) + } + }, + }, + { + name: "with sessions", + auth: &mockAuthLoader{token: &authmodels.IdsecToken{Token: "jwt", Username: "user@test.com", ExpiresIn: expiresIn}}, + sessions: &mockSessionLister{ + sessions: &scamodels.SessionsResponse{Response: []scamodels.SessionInfo{ + {SessionID: "s1", CSP: scamodels.CSPAzure, WorkspaceID: "sub-1", RoleID: "Contributor", SessionDuration: 3600}, + }}, + }, + elig: &mockEligibilityLister{response: &scamodels.EligibilityResponse{ + Response: []scamodels.EligibleTarget{{WorkspaceID: "sub-1", WorkspaceName: "Prod"}}, + }}, + validate: func(t *testing.T, output string) { + var out statusOutput + if err := json.Unmarshal([]byte(output), &out); err != nil { + t.Fatalf("invalid JSON: %v\n%s", err, output) + } + if !out.Authenticated { + t.Error("expected authenticated=true") + } + if out.Username != "user@test.com" { + t.Errorf("username = %q, want user@test.com", out.Username) + } + if len(out.Sessions) != 1 { + t.Fatalf("expected 1 session, got %d", len(out.Sessions)) + } + if out.Sessions[0].SessionID != "s1" { + t.Errorf("sessionId = %q, want s1", out.Sessions[0].SessionID) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := NewStatusCommandWithDeps(tt.auth, tt.sessions, tt.elig) + root := newTestRootCommand() + root.AddCommand(cmd) + + output, err := executeCommand(root, "status", "--output", "json") + if err != nil { + t.Fatalf("unexpected error: %v\noutput: %s", err, output) + } + tt.validate(t, output) + }) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index afb7a53..15eafee 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -21,12 +21,12 @@ const DefaultCacheTTL = 4 * time.Hour // Favorite represents a saved elevation target. type Favorite struct { - Type string `yaml:"type,omitempty"` // "cloud" or "groups"; empty → "cloud" - Provider string `yaml:"provider"` - Target string `yaml:"target"` - Role string `yaml:"role"` - Group string `yaml:"group,omitempty"` // Group name (groups only) - DirectoryID string `yaml:"directory_id,omitempty"` // Directory ID (groups only) + Type string `yaml:"type,omitempty" json:"type,omitempty"` + Provider string `yaml:"provider" json:"provider"` + Target string `yaml:"target" json:"target"` + Role string `yaml:"role" json:"role"` + Group string `yaml:"group,omitempty" json:"group,omitempty"` + DirectoryID string `yaml:"directory_id,omitempty" json:"directoryId,omitempty"` } // Config holds the grant application configuration.