Skip to content

Commit 1b97566

Browse files
committed
feat: add grant list command for programmatic target discovery
New command lists eligible cloud targets and Entra ID groups without triggering elevation. Supports --provider, --groups, --refresh flags and --output json for machine-readable output. Enables LLM agents to discover available targets non-interactively, then construct direct elevation commands with --target/--role flags.
1 parent 653e64a commit 1b97566

File tree

4 files changed

+477
-0
lines changed

4 files changed

+477
-0
lines changed

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ Custom `SCAAccessService` follows SDK conventions:
5555
- `spf13/cobra` for CLI framework
5656
- `Iilun/survey/v2` for interactive prompts
5757
- `grant env` — performs elevation, outputs only `export` statements (no human text); usage: `eval $(grant env --provider aws)`; supports `--refresh`
58+
- `grant list` — list eligible targets and groups without triggering elevation; supports `--provider`, `--groups`, `--refresh`, `--output json`; used by LLMs to discover available targets programmatically
5859
- `grant revoke` — revoke sessions: direct (`grant revoke <id>`), `--all`, or interactive multi-select; `--yes` skips confirmation
5960
- `grant update` — self-update binary via GitHub Releases (`rhysd/go-github-selfupdate`); guards against dev builds
6061
- `--groups` flag on root command shows only Entra ID groups in the interactive selector

cmd/commands.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,6 @@ func init() {
1111
NewEnvCommand(),
1212
NewRevokeCommand(),
1313
NewUpdateCommand(),
14+
NewListCommand(),
1415
)
1516
}

cmd/list.go

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"strings"
8+
9+
"github.com/aaearon/grant-cli/internal/config"
10+
"github.com/aaearon/grant-cli/internal/sca/models"
11+
"github.com/aaearon/grant-cli/internal/ui"
12+
"github.com/spf13/cobra"
13+
)
14+
15+
// listOutput is the JSON representation of the list command output.
16+
type listOutput struct {
17+
Cloud []listCloudTarget `json:"cloud"`
18+
Groups []listGroupTarget `json:"groups"`
19+
}
20+
21+
// listCloudTarget is a single cloud eligible target in JSON output.
22+
type listCloudTarget struct {
23+
Provider string `json:"provider"`
24+
Target string `json:"target"`
25+
WorkspaceID string `json:"workspaceId"`
26+
WorkspaceType string `json:"workspaceType"`
27+
Role string `json:"role"`
28+
RoleID string `json:"roleId"`
29+
}
30+
31+
// listGroupTarget is a single group eligible target in JSON output.
32+
type listGroupTarget struct {
33+
GroupName string `json:"groupName"`
34+
GroupID string `json:"groupId"`
35+
DirectoryID string `json:"directoryId"`
36+
Directory string `json:"directory,omitempty"`
37+
}
38+
39+
// newListCommand creates the list cobra command with the given RunE function.
40+
func newListCommand(runFn func(*cobra.Command, []string) error) *cobra.Command {
41+
cmd := &cobra.Command{
42+
Use: "list",
43+
Short: "List eligible targets and groups",
44+
Long: `List all eligible cloud targets and Entra ID groups without triggering elevation.
45+
46+
Use this command to discover what you can elevate to. Supports both text
47+
and JSON output for programmatic consumption.
48+
49+
Examples:
50+
# List all eligible targets (cloud + groups)
51+
grant list
52+
53+
# List only cloud targets for a specific provider
54+
grant list --provider azure
55+
56+
# List only Entra ID groups
57+
grant list --groups
58+
59+
# JSON output for programmatic use
60+
grant list --output json
61+
62+
# Bypass eligibility cache
63+
grant list --refresh`,
64+
SilenceErrors: true,
65+
SilenceUsage: true,
66+
RunE: runFn,
67+
}
68+
69+
cmd.Flags().StringP("provider", "p", "", "Cloud provider: azure, aws (omit to show all)")
70+
cmd.Flags().Bool("groups", false, "Show only Entra ID groups")
71+
cmd.Flags().Bool("refresh", false, "Bypass eligibility cache and fetch fresh data")
72+
73+
cmd.MarkFlagsMutuallyExclusive("groups", "provider")
74+
75+
return cmd
76+
}
77+
78+
// NewListCommand creates the production list command.
79+
func NewListCommand() *cobra.Command {
80+
return newListCommand(func(cmd *cobra.Command, args []string) error {
81+
ispAuth, svc, _, err := bootstrapSCAService()
82+
if err != nil {
83+
return err
84+
}
85+
86+
cfg, _, err := config.LoadDefaultWithPath()
87+
if err != nil {
88+
return err
89+
}
90+
91+
refresh, _ := cmd.Flags().GetBool("refresh")
92+
cachedLister := buildCachedLister(cfg, refresh, svc, svc)
93+
94+
return runList(cmd, ispAuth, cachedLister, cachedLister)
95+
})
96+
}
97+
98+
// NewListCommandWithDeps creates a list command with injected dependencies for testing.
99+
func NewListCommandWithDeps(auth authLoader, eligLister eligibilityLister, groupsElig groupsEligibilityLister) *cobra.Command {
100+
return newListCommand(func(cmd *cobra.Command, args []string) error {
101+
return runList(cmd, auth, eligLister, groupsElig)
102+
})
103+
}
104+
105+
func runList(
106+
cmd *cobra.Command,
107+
auth authLoader,
108+
eligLister eligibilityLister,
109+
groupsElig groupsEligibilityLister,
110+
) error {
111+
// Check authentication
112+
_, err := auth.LoadAuthentication(nil, true)
113+
if err != nil {
114+
return fmt.Errorf("not authenticated, run 'grant login' first: %w", err)
115+
}
116+
117+
provider, _ := cmd.Flags().GetString("provider")
118+
groupsOnly, _ := cmd.Flags().GetBool("groups")
119+
120+
ctx, cancel := context.WithTimeout(context.Background(), apiTimeout)
121+
defer cancel()
122+
123+
var cloudTargets []models.EligibleTarget
124+
var groups []models.GroupsEligibleTarget
125+
126+
// Fetch cloud targets (unless --groups)
127+
if !groupsOnly {
128+
cloudTargets, err = fetchEligibility(ctx, eligLister, provider)
129+
if err != nil {
130+
log.Info("cloud eligibility fetch failed: %v", err)
131+
}
132+
}
133+
134+
// Fetch groups (unless --provider is set)
135+
if provider == "" {
136+
groups, err = fetchGroupsEligibility(ctx, groupsElig, eligLister)
137+
if err != nil {
138+
log.Info("groups eligibility fetch failed: %v", err)
139+
}
140+
}
141+
142+
if len(cloudTargets) == 0 && len(groups) == 0 {
143+
return errors.New("no eligible targets or groups found, check your SCA policies")
144+
}
145+
146+
if isJSONOutput() {
147+
return writeListJSON(cmd, cloudTargets, groups)
148+
}
149+
150+
writeListText(cmd, cloudTargets, groups)
151+
return nil
152+
}
153+
154+
// writeListJSON outputs the list as JSON.
155+
func writeListJSON(cmd *cobra.Command, cloudTargets []models.EligibleTarget, groups []models.GroupsEligibleTarget) error {
156+
out := listOutput{
157+
Cloud: make([]listCloudTarget, 0, len(cloudTargets)),
158+
Groups: make([]listGroupTarget, 0, len(groups)),
159+
}
160+
161+
for _, t := range cloudTargets {
162+
out.Cloud = append(out.Cloud, listCloudTarget{
163+
Provider: strings.ToLower(string(t.CSP)),
164+
Target: t.WorkspaceName,
165+
WorkspaceID: t.WorkspaceID,
166+
WorkspaceType: strings.ToLower(string(t.WorkspaceType)),
167+
Role: t.RoleInfo.Name,
168+
RoleID: t.RoleInfo.ID,
169+
})
170+
}
171+
172+
for _, g := range groups {
173+
out.Groups = append(out.Groups, listGroupTarget{
174+
GroupName: g.GroupName,
175+
GroupID: g.GroupID,
176+
DirectoryID: g.DirectoryID,
177+
Directory: g.DirectoryName,
178+
})
179+
}
180+
181+
return writeJSON(cmd.OutOrStdout(), out)
182+
}
183+
184+
// writeListText outputs the list as formatted text.
185+
func writeListText(cmd *cobra.Command, cloudTargets []models.EligibleTarget, groups []models.GroupsEligibleTarget) {
186+
if len(cloudTargets) > 0 {
187+
fmt.Fprintln(cmd.OutOrStdout(), "Cloud targets:")
188+
options := ui.BuildOptions(cloudTargets)
189+
for _, opt := range options {
190+
fmt.Fprintf(cmd.OutOrStdout(), " %s\n", opt)
191+
}
192+
}
193+
194+
if len(groups) > 0 {
195+
if len(cloudTargets) > 0 {
196+
fmt.Fprintln(cmd.OutOrStdout())
197+
}
198+
fmt.Fprintln(cmd.OutOrStdout(), "Groups:")
199+
options := ui.BuildGroupOptions(groups)
200+
for _, opt := range options {
201+
fmt.Fprintf(cmd.OutOrStdout(), " %s\n", opt)
202+
}
203+
}
204+
}

0 commit comments

Comments
 (0)