diff --git a/CLAUDE.md b/CLAUDE.md index 177c9a3..0895018 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,6 +55,7 @@ Custom `SCAAccessService` follows SDK conventions: - `spf13/cobra` for CLI framework - `Iilun/survey/v2` for interactive prompts - `grant env` — performs elevation, outputs only `export` statements (no human text); usage: `eval $(grant env --provider aws)`; supports `--refresh` +- `grant list` — list eligible targets and groups without triggering elevation; supports `--provider`, `--groups`, `--refresh`, `--output json`; used by LLMs to discover available targets programmatically - `grant revoke` — revoke sessions: direct (`grant revoke `), `--all`, or interactive multi-select; `--yes` skips confirmation - `grant update` — self-update binary via GitHub Releases (`rhysd/go-github-selfupdate`); guards against dev builds - `--groups` flag on root command shows only Entra ID groups in the interactive selector diff --git a/cmd/commands.go b/cmd/commands.go index 83b6b0a..eb4aae9 100644 --- a/cmd/commands.go +++ b/cmd/commands.go @@ -11,5 +11,6 @@ func init() { NewEnvCommand(), NewRevokeCommand(), NewUpdateCommand(), + NewListCommand(), ) } diff --git a/cmd/list.go b/cmd/list.go new file mode 100644 index 0000000..06de2b4 --- /dev/null +++ b/cmd/list.go @@ -0,0 +1,204 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/aaearon/grant-cli/internal/config" + "github.com/aaearon/grant-cli/internal/sca/models" + "github.com/aaearon/grant-cli/internal/ui" + "github.com/spf13/cobra" +) + +// listOutput is the JSON representation of the list command output. +type listOutput struct { + Cloud []listCloudTarget `json:"cloud"` + Groups []listGroupTarget `json:"groups"` +} + +// listCloudTarget is a single cloud eligible target in JSON output. +type listCloudTarget struct { + Provider string `json:"provider"` + Target string `json:"target"` + WorkspaceID string `json:"workspaceId"` + WorkspaceType string `json:"workspaceType"` + Role string `json:"role"` + RoleID string `json:"roleId"` +} + +// listGroupTarget is a single group eligible target in JSON output. +type listGroupTarget struct { + GroupName string `json:"groupName"` + GroupID string `json:"groupId"` + DirectoryID string `json:"directoryId"` + Directory string `json:"directory,omitempty"` +} + +// newListCommand creates the list cobra command with the given RunE function. +func newListCommand(runFn func(*cobra.Command, []string) error) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List eligible targets and groups", + Long: `List all eligible cloud targets and Entra ID groups without triggering elevation. + +Use this command to discover what you can elevate to. Supports both text +and JSON output for programmatic consumption. + +Examples: + # List all eligible targets (cloud + groups) + grant list + + # List only cloud targets for a specific provider + grant list --provider azure + + # List only Entra ID groups + grant list --groups + + # JSON output for programmatic use + grant list --output json + + # Bypass eligibility cache + grant list --refresh`, + SilenceErrors: true, + SilenceUsage: true, + RunE: runFn, + } + + cmd.Flags().StringP("provider", "p", "", "Cloud provider: azure, aws (omit to show all)") + cmd.Flags().Bool("groups", false, "Show only Entra ID groups") + cmd.Flags().Bool("refresh", false, "Bypass eligibility cache and fetch fresh data") + + cmd.MarkFlagsMutuallyExclusive("groups", "provider") + + return cmd +} + +// NewListCommand creates the production list command. +func NewListCommand() *cobra.Command { + return newListCommand(func(cmd *cobra.Command, args []string) error { + ispAuth, svc, _, err := bootstrapSCAService() + if err != nil { + return err + } + + cfg, _, err := config.LoadDefaultWithPath() + if err != nil { + return err + } + + refresh, _ := cmd.Flags().GetBool("refresh") + cachedLister := buildCachedLister(cfg, refresh, svc, svc) + + return runList(cmd, ispAuth, cachedLister, cachedLister) + }) +} + +// NewListCommandWithDeps creates a list command with injected dependencies for testing. +func NewListCommandWithDeps(auth authLoader, eligLister eligibilityLister, groupsElig groupsEligibilityLister) *cobra.Command { + return newListCommand(func(cmd *cobra.Command, args []string) error { + return runList(cmd, auth, eligLister, groupsElig) + }) +} + +func runList( + cmd *cobra.Command, + auth authLoader, + eligLister eligibilityLister, + groupsElig groupsEligibilityLister, +) error { + // Check authentication + _, err := auth.LoadAuthentication(nil, true) + if err != nil { + return fmt.Errorf("not authenticated, run 'grant login' first: %w", err) + } + + provider, _ := cmd.Flags().GetString("provider") + groupsOnly, _ := cmd.Flags().GetBool("groups") + + ctx, cancel := context.WithTimeout(context.Background(), apiTimeout) + defer cancel() + + var cloudTargets []models.EligibleTarget + var groups []models.GroupsEligibleTarget + + // Fetch cloud targets (unless --groups) + if !groupsOnly { + cloudTargets, err = fetchEligibility(ctx, eligLister, provider) + if err != nil { + log.Info("cloud eligibility fetch failed: %v", err) + } + } + + // Fetch groups (unless --provider is set) + if provider == "" { + groups, err = fetchGroupsEligibility(ctx, groupsElig, eligLister) + if err != nil { + log.Info("groups eligibility fetch failed: %v", err) + } + } + + if len(cloudTargets) == 0 && len(groups) == 0 { + return errors.New("no eligible targets or groups found, check your SCA policies") + } + + if isJSONOutput() { + return writeListJSON(cmd, cloudTargets, groups) + } + + writeListText(cmd, cloudTargets, groups) + return nil +} + +// writeListJSON outputs the list as JSON. +func writeListJSON(cmd *cobra.Command, cloudTargets []models.EligibleTarget, groups []models.GroupsEligibleTarget) error { + out := listOutput{ + Cloud: make([]listCloudTarget, 0, len(cloudTargets)), + Groups: make([]listGroupTarget, 0, len(groups)), + } + + for _, t := range cloudTargets { + out.Cloud = append(out.Cloud, listCloudTarget{ + Provider: strings.ToLower(string(t.CSP)), + Target: t.WorkspaceName, + WorkspaceID: t.WorkspaceID, + WorkspaceType: strings.ToLower(string(t.WorkspaceType)), + Role: t.RoleInfo.Name, + RoleID: t.RoleInfo.ID, + }) + } + + for _, g := range groups { + out.Groups = append(out.Groups, listGroupTarget{ + GroupName: g.GroupName, + GroupID: g.GroupID, + DirectoryID: g.DirectoryID, + Directory: g.DirectoryName, + }) + } + + return writeJSON(cmd.OutOrStdout(), out) +} + +// writeListText outputs the list as formatted text. +func writeListText(cmd *cobra.Command, cloudTargets []models.EligibleTarget, groups []models.GroupsEligibleTarget) { + if len(cloudTargets) > 0 { + fmt.Fprintln(cmd.OutOrStdout(), "Cloud targets:") + options := ui.BuildOptions(cloudTargets) + for _, opt := range options { + fmt.Fprintf(cmd.OutOrStdout(), " %s\n", opt) + } + } + + if len(groups) > 0 { + if len(cloudTargets) > 0 { + fmt.Fprintln(cmd.OutOrStdout()) + } + fmt.Fprintln(cmd.OutOrStdout(), "Groups:") + options := ui.BuildGroupOptions(groups) + for _, opt := range options { + fmt.Fprintf(cmd.OutOrStdout(), " %s\n", opt) + } + } +} diff --git a/cmd/list_test.go b/cmd/list_test.go new file mode 100644 index 0000000..357dc23 --- /dev/null +++ b/cmd/list_test.go @@ -0,0 +1,271 @@ +package cmd + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/aaearon/grant-cli/internal/sca/models" + authmodels "github.com/cyberark/idsec-sdk-golang/pkg/models/auth" +) + +func TestListCommand(t *testing.T) { + tests := []struct { + name string + auth *mockAuthLoader + eligLister *mockEligibilityLister + groupsElig *mockGroupsEligibilityLister + args []string + wantContain []string + wantErr bool + wantErrStr string + }{ + { + name: "not authenticated", + auth: &mockAuthLoader{loadErr: errors.New("no token")}, + eligLister: &mockEligibilityLister{}, + groupsElig: &mockGroupsEligibilityLister{}, + args: []string{}, + wantErr: true, + wantErrStr: "not authenticated", + }, + { + name: "list cloud targets text", + auth: &mockAuthLoader{token: &authmodels.IdsecToken{Token: "jwt"}}, + eligLister: &mockEligibilityLister{response: &models.EligibilityResponse{ + Response: []models.EligibleTarget{ + { + WorkspaceID: "sub-1", WorkspaceName: "Prod-EastUS", + WorkspaceType: models.WorkspaceTypeSubscription, + RoleInfo: models.RoleInfo{ID: "r1", Name: "Contributor"}, + }, + }, + Total: 1, + }}, + groupsElig: &mockGroupsEligibilityLister{listErr: errors.New("skip")}, + args: []string{"--provider", "azure"}, + wantContain: []string{ + "Subscription: Prod-EastUS / Role: Contributor", + }, + wantErr: false, + }, + { + name: "list cloud targets JSON", + auth: &mockAuthLoader{token: &authmodels.IdsecToken{Token: "jwt"}}, + eligLister: &mockEligibilityLister{response: &models.EligibilityResponse{ + Response: []models.EligibleTarget{ + { + WorkspaceID: "sub-1", WorkspaceName: "Prod-EastUS", + WorkspaceType: models.WorkspaceTypeSubscription, + RoleInfo: models.RoleInfo{ID: "r1", Name: "Contributor"}, + }, + }, + Total: 1, + }}, + groupsElig: &mockGroupsEligibilityLister{listErr: errors.New("skip")}, + args: []string{"--provider", "azure", "--output", "json"}, + wantErr: false, + }, + { + name: "groups only", + auth: &mockAuthLoader{token: &authmodels.IdsecToken{Token: "jwt"}}, + eligLister: &mockEligibilityLister{response: &models.EligibilityResponse{Response: []models.EligibleTarget{{WorkspaceID: "sub-1", WorkspaceName: "Prod", WorkspaceType: models.WorkspaceTypeSubscription, RoleInfo: models.RoleInfo{ID: "r1", Name: "Reader"}}}, Total: 1}}, + groupsElig: &mockGroupsEligibilityLister{response: &models.GroupsEligibilityResponse{ + Response: []models.GroupsEligibleTarget{ + {DirectoryID: "dir1", GroupID: "grp1", GroupName: "Engineering"}, + }, + Total: 1, + }}, + args: []string{"--groups"}, + wantContain: []string{ + "Engineering", + }, + wantErr: false, + }, + { + name: "provider filter", + auth: &mockAuthLoader{token: &authmodels.IdsecToken{Token: "jwt"}}, + eligLister: &mockEligibilityLister{response: &models.EligibilityResponse{ + Response: []models.EligibleTarget{ + { + WorkspaceID: "acct-1", WorkspaceName: "AWS Sandbox", + WorkspaceType: models.WorkspaceTypeAccount, + RoleInfo: models.RoleInfo{ID: "r1", Name: "Admin"}, + }, + }, + Total: 1, + }}, + groupsElig: &mockGroupsEligibilityLister{listErr: errors.New("skip")}, + args: []string{"--provider", "aws"}, + wantContain: []string{ + "Account: AWS Sandbox / Role: Admin", + }, + wantErr: false, + }, + { + name: "no eligible targets", + auth: &mockAuthLoader{token: &authmodels.IdsecToken{Token: "jwt"}}, + eligLister: &mockEligibilityLister{listErr: errors.New("no eligible targets found")}, + groupsElig: &mockGroupsEligibilityLister{listErr: errors.New("no groups")}, + args: []string{}, + wantErr: true, + wantErrStr: "no eligible", + }, + { + name: "cloud and groups merged", + auth: &mockAuthLoader{token: &authmodels.IdsecToken{Token: "jwt"}}, + eligLister: &mockEligibilityLister{response: &models.EligibilityResponse{ + Response: []models.EligibleTarget{ + {WorkspaceID: "sub-1", WorkspaceName: "Prod", WorkspaceType: models.WorkspaceTypeSubscription, RoleInfo: models.RoleInfo{ID: "r1", Name: "Reader"}}, + }, + Total: 1, + }}, + groupsElig: &mockGroupsEligibilityLister{response: &models.GroupsEligibilityResponse{ + Response: []models.GroupsEligibleTarget{ + {DirectoryID: "dir1", GroupID: "grp1", GroupName: "CloudAdmins"}, + }, + Total: 1, + }}, + args: []string{}, + wantContain: []string{ + "Subscription: Prod / Role: Reader", + "CloudAdmins", + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := NewListCommandWithDeps(tt.auth, tt.eligLister, tt.groupsElig) + root := newTestRootCommand() + root.AddCommand(cmd) + + args := append([]string{"list"}, tt.args...) + output, err := executeCommand(root, args...) + + if (err != nil) != tt.wantErr { + t.Fatalf("error = %v, wantErr %v\noutput: %s", err, tt.wantErr, output) + } + if tt.wantErr && tt.wantErrStr != "" && !strings.Contains(output, tt.wantErrStr) { + t.Errorf("expected error containing %q, got:\n%s", tt.wantErrStr, output) + } + for _, want := range tt.wantContain { + if !strings.Contains(output, want) { + t.Errorf("output missing %q\ngot:\n%s", want, output) + } + } + }) + } +} + +func TestListCommand_JSONOutput(t *testing.T) { + auth := &mockAuthLoader{token: &authmodels.IdsecToken{Token: "jwt"}} + eligLister := &mockEligibilityLister{ + listFunc: func(ctx context.Context, csp models.CSP) (*models.EligibilityResponse, error) { + if csp == models.CSPAzure { + return &models.EligibilityResponse{ + Response: []models.EligibleTarget{{ + WorkspaceID: "sub-1", WorkspaceName: "Prod-EastUS", + WorkspaceType: models.WorkspaceTypeSubscription, + RoleInfo: models.RoleInfo{ID: "r1", Name: "Contributor"}, + }}, + Total: 1, + }, nil + } + return &models.EligibilityResponse{}, nil + }, + } + groupsElig := &mockGroupsEligibilityLister{response: &models.GroupsEligibilityResponse{ + Response: []models.GroupsEligibleTarget{ + {DirectoryID: "dir1", GroupID: "grp1", GroupName: "Engineering", DirectoryName: "Contoso"}, + }, + Total: 1, + }} + + cmd := NewListCommandWithDeps(auth, eligLister, groupsElig) + root := newTestRootCommand() + root.AddCommand(cmd) + + output, err := executeCommand(root, "list", "--output", "json") + if err != nil { + t.Fatalf("unexpected error: %v\noutput: %s", err, output) + } + + var parsed listOutput + if err := json.Unmarshal([]byte(output), &parsed); err != nil { + t.Fatalf("invalid JSON: %v\n%s", err, output) + } + + if len(parsed.Cloud) != 1 { + t.Fatalf("expected 1 cloud target, got %d", len(parsed.Cloud)) + } + if parsed.Cloud[0].Target != "Prod-EastUS" { + t.Errorf("cloud target = %q, want Prod-EastUS", parsed.Cloud[0].Target) + } + if parsed.Cloud[0].Role != "Contributor" { + t.Errorf("cloud role = %q, want Contributor", parsed.Cloud[0].Role) + } + + if len(parsed.Groups) != 1 { + t.Fatalf("expected 1 group, got %d", len(parsed.Groups)) + } + if parsed.Groups[0].GroupName != "Engineering" { + t.Errorf("group name = %q, want Engineering", parsed.Groups[0].GroupName) + } + if parsed.Groups[0].Directory != "Contoso" { + t.Errorf("directory = %q, want Contoso", parsed.Groups[0].Directory) + } +} + +func TestListCommand_GroupsOnlyJSON(t *testing.T) { + auth := &mockAuthLoader{token: &authmodels.IdsecToken{Token: "jwt"}} + eligLister := &mockEligibilityLister{response: &models.EligibilityResponse{ + Response: []models.EligibleTarget{{WorkspaceID: "sub-1", WorkspaceName: "Prod", WorkspaceType: models.WorkspaceTypeSubscription, RoleInfo: models.RoleInfo{ID: "r1", Name: "Reader"}}}, + Total: 1, + }} + groupsElig := &mockGroupsEligibilityLister{response: &models.GroupsEligibilityResponse{ + Response: []models.GroupsEligibleTarget{ + {DirectoryID: "dir1", GroupID: "grp1", GroupName: "Admins"}, + }, + Total: 1, + }} + + cmd := NewListCommandWithDeps(auth, eligLister, groupsElig) + root := newTestRootCommand() + root.AddCommand(cmd) + + output, err := executeCommand(root, "list", "--groups", "--output", "json") + if err != nil { + t.Fatalf("unexpected error: %v\noutput: %s", err, output) + } + + var parsed listOutput + if err := json.Unmarshal([]byte(output), &parsed); err != nil { + t.Fatalf("invalid JSON: %v\n%s", err, output) + } + + if len(parsed.Cloud) != 0 { + t.Errorf("expected no cloud targets with --groups, got %d", len(parsed.Cloud)) + } + if len(parsed.Groups) != 1 { + t.Fatalf("expected 1 group, got %d", len(parsed.Groups)) + } +} + +func TestListCommand_MutualExclusivity(t *testing.T) { + auth := &mockAuthLoader{token: &authmodels.IdsecToken{Token: "jwt"}} + eligLister := &mockEligibilityLister{} + groupsElig := &mockGroupsEligibilityLister{} + + cmd := NewListCommandWithDeps(auth, eligLister, groupsElig) + root := newTestRootCommand() + root.AddCommand(cmd) + + _, err := executeCommand(root, "list", "--groups", "--provider", "aws") + if err == nil { + t.Fatal("expected error for --groups + --provider") + } +}