diff --git a/CHANGELOG.md b/CHANGELOG.md index 81b7584..e8a6508 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,9 @@ All notable changes to this project will be documented in this file. - Local file-based eligibility cache (`~/.grant/cache/`) with 4-hour default TTL — skips API roundtrip on subsequent runs - `--refresh` flag on `grant` and `grant env` to bypass the eligibility cache and fetch fresh data - `cache_ttl` config option in `~/.grant/config.yaml` to customize cache TTL (e.g., `cache_ttl: 2h`) -- `grant groups` command for Entra ID group membership elevation with interactive, direct (`--group`), and favorite (`--favorite`) modes -- `grant --favorite ` now detects group-type favorites and redirects users to `grant groups --favorite ` +- `--groups` flag on root command to show only Entra ID groups in the interactive selector +- `--group` / `-g` flag on root command for direct group membership elevation (`grant --group "Cloud Admins"`) +- `grant --favorite ` now handles both cloud and group favorites directly - `grant revoke` command for session revocation with three modes: direct (by session ID), `--all`, and interactive (multi-select); works with both cloud and group sessions - `--yes`/`-y` flag on `grant revoke` to skip confirmation for scripting - `--provider`/`-p` flag on `grant revoke --all` and interactive mode to filter by cloud provider @@ -18,7 +19,9 @@ All notable changes to this project will be documented in this file. ### Changed -- Eligibility caching now covers all commands (`grant status`, `grant revoke`, `grant groups`, `grant favorites add`) — previously only `grant` and `grant env` used the cache +- `grant favorites add` interactive selector now shows both cloud roles and Entra ID groups in a unified list (previously cloud-only) +- Group membership elevation merged into root command — `grant` interactive selector shows both cloud roles and Entra ID groups in a unified list +- Eligibility caching now covers all commands (`grant status`, `grant revoke`, `grant favorites add`) — previously only `grant` and `grant env` used the cache - `grant status` now fetches sessions and eligibility data concurrently, reducing wall-clock time by ~2s - `grant revoke` interactive mode now fetches workspace names concurrently across CSPs @@ -27,13 +30,14 @@ All notable changes to this project will be documented in this file. - `grant revoke` now rejects `--provider` in direct mode (session IDs are already explicit) - `grant status` session formatting reuses shared `ui.FormatSessionOption` instead of duplicated logic - `buildWorkspaceNameMap` moved to shared `cmd/helpers.go` to eliminate cross-command dependency -- `grant groups --favorite` now verifies DirectoryID from the favorite, preventing wrong-group elevation when multiple directories have identically-named groups -- `grant groups` interactive selector sorts a local copy of groups, fixing wrong-group selection when display strings collide +- Group favorites now verify DirectoryID, preventing wrong-group elevation when multiple directories have identically-named groups - `grant status` now resolves directory names for group sessions via `buildDirectoryNameMap` -- `grant groups` subcommand no longer sets `SilenceErrors`/`SilenceUsage`, matching other subcommand patterns -- Removed dead code in `TestGroupsCommandFavoriteMode` and consolidated `NewGroupsCommandWithDeps`/`NewGroupsCommandWithDepsAndConfig` into a single test constructor - `buildDirectoryNameMap` now handles nil eligibility response gracefully +### Removed + +- `grant groups` subcommand — functionality absorbed into the root command with `--groups` and `--group` flags + ## [0.2.1] - 2026-02-18 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 19fc7af..1fb1d4d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,7 +56,9 @@ Custom `SCAAccessService` follows SDK conventions: - `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 revoke` — revoke sessions: direct (`grant revoke `), `--all`, or interactive multi-select; `--yes` skips confirmation -- `grant groups` — Entra ID group membership elevation: interactive or direct (`--group "name"`); always targets Azure; uses separate API endpoints (`/eligibility/groups`, `/elevate/groups`) +- `--groups` flag on root command shows only Entra ID groups in the interactive selector +- `--group` / `-g` flag on root command for direct group membership elevation (`grant --group "Cloud Admins"`) +- Root command unified selector shows both cloud roles and Entra ID groups; groups use `/eligibility/groups` and `/elevate/groups` API endpoints - Multi-CSP: omitting `--provider` fetches eligibility from all supported CSPs and merges results - `--refresh` bypasses eligibility cache on `grant` and `grant env` - `fetchEligibility()` and `resolveTargetCSP()` in `cmd/root.go` — shared by root, env, and favorites @@ -67,8 +69,8 @@ Custom `SCAAccessService` follows SDK conventions: - `--refresh` flag on `grant` and `grant env` bypasses cache reads but still writes fresh data - `internal/cache/cache.go` — generic `Store` with `Get[T]`/`Set[T]`, injectable clock for testing - `internal/cache/cached_eligibility.go` — `CachedEligibilityLister` decorator implementing `eligibilityLister` + `groupsEligibilityLister` -- `buildCachedLister()` in `cmd/root.go` — shared factory used by all commands (root, env, status, revoke, groups, favorites add) -- Commands without `--refresh` (status, revoke, groups, favorites add) always pass `refresh: false` — they use eligibility for display only +- `buildCachedLister()` in `cmd/root.go` — shared factory used by all commands (root, env, status, revoke, favorites add) +- Commands without `--refresh` (status, revoke, favorites add) always pass `refresh: false` — they use eligibility for display only - Cache failures (read/write) silently fall through to the live API ## Verbose / Logging diff --git a/README.md b/README.md index ac74b16..3190758 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ A CLI tool for elevating cloud permissions (Azure, AWS) via CyberArk Secure Clou - Direct elevation with target and role flags - AWS credential export via `grant env` for shell integration - Favorites management for frequently used roles -- Entra ID group membership elevation via `grant groups` +- Entra ID group membership elevation via `grant --group` or `grant --groups` - Session revocation via `grant revoke` - Session status monitoring - Local eligibility cache with configurable TTL @@ -43,8 +43,8 @@ eval $(grant env --provider aws) grant --favorite prod-contrib # Elevate Entra ID group membership -grant groups -grant groups --group "Cloud Admins" +grant --groups +grant --group "Cloud Admins" # Check active sessions grant status @@ -172,6 +172,8 @@ Running `grant` with no subcommand requests JIT (just-in-time) permission elevat - `--target, -t` — Target name (subscription, resource group, account, etc.) - `--role, -r` — Role name (e.g., "Contributor", "Reader", "AdministratorAccess") - `--favorite, -f` — Use a saved favorite alias (combines provider, target, and role) +- `--groups` — Show only Entra ID groups in the interactive selector +- `--group, -g` — Group name for direct group membership elevation **Target matching:** - Matches by workspace name (case-insensitive, partial match) diff --git a/cmd/commands.go b/cmd/commands.go index 9570f9f..4b77285 100644 --- a/cmd/commands.go +++ b/cmd/commands.go @@ -10,6 +10,5 @@ func init() { NewFavoritesCommand(), NewEnvCommand(), NewRevokeCommand(), - NewGroupsCommand(), ) } diff --git a/cmd/favorites.go b/cmd/favorites.go index c8d8dd1..b3ac916 100644 --- a/cmd/favorites.go +++ b/cmd/favorites.go @@ -34,13 +34,8 @@ Workflow: return cmd } -// NewFavoritesCommandWithDeps creates the favorites command with injected dependencies for testing -func NewFavoritesCommandWithDeps(eligLister eligibilityLister, sel targetSelector, prompter namePrompter) *cobra.Command { - return NewFavoritesCommandWithAllDeps(eligLister, sel, prompter, nil, nil) -} - // NewFavoritesCommandWithAllDeps creates the favorites command with all injected dependencies including groups -func NewFavoritesCommandWithAllDeps(eligLister eligibilityLister, sel targetSelector, prompter namePrompter, groupsElig groupsEligibilityLister, groupSel groupSelector) *cobra.Command { +func NewFavoritesCommandWithAllDeps(eligLister eligibilityLister, sel unifiedSelector, prompter namePrompter, groupsElig groupsEligibilityLister) *cobra.Command { cmd := &cobra.Command{ Use: "favorites", Short: "Manage saved elevation favorites", @@ -56,7 +51,7 @@ Workflow: } cmd.AddCommand(newFavoritesAddCommandWithRunner(func(c *cobra.Command, args []string) error { - return runFavoritesAddWithDeps(c, args, eligLister, sel, prompter, nil, groupsElig, groupSel) + return runFavoritesAddWithDeps(c, args, eligLister, sel, prompter, nil, groupsElig) })) cmd.AddCommand(newFavoritesListCommand()) cmd.AddCommand(newFavoritesRemoveCommand()) @@ -126,11 +121,11 @@ func runFavoritesAddProduction(cmd *cobra.Command, args []string) error { if favType == config.FavoriteTypeGroups { if group != "" { // Non-interactive groups mode: no auth needed - return runFavoritesAddWithDeps(cmd, args, nil, nil, nil, nil, nil, nil) + return runFavoritesAddWithDeps(cmd, args, nil, nil, nil, nil, nil) } } else if target != "" && role != "" { // Non-interactive cloud mode: no auth needed - return runFavoritesAddWithDeps(cmd, args, nil, nil, nil, nil, nil, nil) + return runFavoritesAddWithDeps(cmd, args, nil, nil, nil, nil, nil) } // Interactive path: load config early for fast-fail duplicate check @@ -153,13 +148,13 @@ func runFavoritesAddProduction(cmd *cobra.Command, args []string) error { cachedLister := buildCachedLister(cfg, false, scaService, scaService) - return runFavoritesAddWithDeps(cmd, args, cachedLister, &uiSelector{}, &surveyNamePrompter{}, cfg, cachedLister, &uiGroupSelector{}) + return runFavoritesAddWithDeps(cmd, args, cachedLister, &uiUnifiedSelector{}, &surveyNamePrompter{}, cfg, cachedLister) } // runFavoritesAddWithDeps contains the core logic for favorites add. // When eligLister and sel are nil, it uses the non-interactive flag path. // If preloadedCfg is non-nil, it is used instead of loading from disk. -func runFavoritesAddWithDeps(cmd *cobra.Command, args []string, eligLister eligibilityLister, sel targetSelector, prompter namePrompter, preloadedCfg *config.Config, groupsElig groupsEligibilityLister, groupSel groupSelector) error { +func runFavoritesAddWithDeps(cmd *cobra.Command, args []string, eligLister eligibilityLister, sel unifiedSelector, prompter namePrompter, preloadedCfg *config.Config, groupsElig groupsEligibilityLister) error { // Read flags provider, _ := cmd.Flags().GetString("provider") target, _ := cmd.Flags().GetString("target") @@ -224,12 +219,13 @@ func runFavoritesAddWithDeps(cmd *cobra.Command, args []string, eligLister eligi // Groups flow if favType == config.FavoriteTypeGroups { - return addGroupFavorite(cmd, name, group, cfg, cfgPath, groupsElig, groupSel, prompter) + return addGroupFavorite(cmd, name, group, cfg, cfgPath, groupsElig, sel, prompter) } // Cloud flow var fav config.Favorite if target != "" && role != "" { + // Non-interactive: target and role specified via flags fav.Target = target fav.Role = role fav.Provider = provider @@ -237,6 +233,7 @@ func runFavoritesAddWithDeps(cmd *cobra.Command, args []string, eligLister eligi fav.Provider = cfg.DefaultProvider } } else { + // Interactive: unified selector showing cloud targets and groups ctx, cancel := context.WithTimeout(context.Background(), apiTimeout) defer cancel() @@ -245,19 +242,46 @@ func runFavoritesAddWithDeps(cmd *cobra.Command, args []string, eligLister eligi return err } - selectedTarget, err := sel.SelectTarget(allTargets) + var items []selectionItem + for i := range allTargets { + items = append(items, selectionItem{kind: selectionCloud, cloud: &allTargets[i]}) + } + + // Fetch groups eligibility (best-effort) + if groupsElig != nil { + eligResp, gErr := groupsElig.ListGroupsEligibility(ctx, scamodels.CSPAzure) + if gErr == nil && len(eligResp.Response) > 0 { + for i := range eligResp.Response { + items = append(items, selectionItem{kind: selectionGroup, group: &eligResp.Response[i]}) + } + } + } + + if len(items) == 0 { + return fmt.Errorf("no eligible targets or groups found") + } + + selected, err := sel.SelectItem(items) if err != nil { - return fmt.Errorf("target selection failed: %w", err) + return fmt.Errorf("selection failed: %w", err) } - resolveTargetCSP(selectedTarget, allTargets, provider) - if provider != "" { - fav.Provider = provider - } else { - fav.Provider = strings.ToLower(string(selectedTarget.CSP)) + switch selected.kind { + case selectionCloud: + resolveTargetCSP(selected.cloud, allTargets, provider) + if provider != "" { + fav.Provider = provider + } else { + fav.Provider = strings.ToLower(string(selected.cloud.CSP)) + } + fav.Target = selected.cloud.WorkspaceName + fav.Role = selected.cloud.RoleInfo.Name + case selectionGroup: + fav.Type = config.FavoriteTypeGroups + fav.Provider = "azure" + fav.Group = selected.group.GroupName + fav.DirectoryID = selected.group.DirectoryID } - fav.Target = selectedTarget.WorkspaceName - fav.Role = selectedTarget.RoleInfo.Name if name == "" { name, err = prompter.PromptName() @@ -276,12 +300,17 @@ func runFavoritesAddWithDeps(cmd *cobra.Command, args []string, eligLister eligi if err := config.Save(cfg, cfgPath); err != nil { return fmt.Errorf("failed to save config: %w", err) } - fmt.Fprintf(cmd.OutOrStdout(), "Added favorite %q: %s/%s/%s\n", name, fav.Provider, fav.Target, fav.Role) + + if fav.ResolvedType() == config.FavoriteTypeGroups { + fmt.Fprintf(cmd.OutOrStdout(), "Added favorite %q: groups/%s\n", name, fav.Group) + } else { + fmt.Fprintf(cmd.OutOrStdout(), "Added favorite %q: %s/%s/%s\n", name, fav.Provider, fav.Target, fav.Role) + } return nil } // addGroupFavorite handles the --type groups flow for favorites add. -func addGroupFavorite(cmd *cobra.Command, name, group string, cfg *config.Config, cfgPath string, groupsElig groupsEligibilityLister, groupSel groupSelector, prompter namePrompter) error { +func addGroupFavorite(cmd *cobra.Command, name, group string, cfg *config.Config, cfgPath string, groupsElig groupsEligibilityLister, sel unifiedSelector, prompter namePrompter) error { var fav config.Favorite fav.Type = config.FavoriteTypeGroups fav.Provider = "azure" @@ -290,7 +319,7 @@ func addGroupFavorite(cmd *cobra.Command, name, group string, cfg *config.Config // Non-interactive: group specified via flag fav.Group = group } else { - // Interactive: select from eligible groups + // Interactive: select from eligible groups via unified selector ctx, cancel := context.WithTimeout(context.Background(), apiTimeout) defer cancel() @@ -302,13 +331,18 @@ func addGroupFavorite(cmd *cobra.Command, name, group string, cfg *config.Config return fmt.Errorf("no eligible groups found") } - selected, err := groupSel.SelectGroup(eligResp.Response) + var items []selectionItem + for i := range eligResp.Response { + items = append(items, selectionItem{kind: selectionGroup, group: &eligResp.Response[i]}) + } + + selected, err := sel.SelectItem(items) if err != nil { return fmt.Errorf("group selection failed: %w", err) } - fav.Group = selected.GroupName - fav.DirectoryID = selected.DirectoryID + fav.Group = selected.group.GroupName + fav.DirectoryID = selected.group.DirectoryID if name == "" { name, err = prompter.PromptName() diff --git a/cmd/favorites_test.go b/cmd/favorites_test.go index 2d07b33..eb1b443 100644 --- a/cmd/favorites_test.go +++ b/cmd/favorites_test.go @@ -415,14 +415,15 @@ func TestFavoritesAddInteractiveMode(t *testing.T) { name string setupConfig func(string) eligLister eligibilityLister - selector targetSelector + groupsElig groupsEligibilityLister + selector unifiedSelector namePrompter namePrompter args []string wantContain []string wantErr bool }{ { - name: "success - selects target from eligibility", + name: "success - selects cloud target from eligibility", setupConfig: func(path string) { cfg := config.DefaultConfig() _ = config.Save(cfg, path) @@ -435,8 +436,8 @@ func TestFavoritesAddInteractiveMode(t *testing.T) { return &models.EligibilityResponse{}, nil }, }, - selector: &mockTargetSelector{ - target: &twoTargets[0], + selector: &mockUnifiedSelector{ + item: &selectionItem{kind: selectionCloud, cloud: &twoTargets[0]}, }, args: []string{"myfav"}, wantContain: []string{"Added favorite", "myfav", "azure/Prod-EastUS/Contributor"}, @@ -454,8 +455,8 @@ func TestFavoritesAddInteractiveMode(t *testing.T) { Total: 2, }, }, - selector: &mockTargetSelector{ - target: &twoTargets[0], + selector: &mockUnifiedSelector{ + item: &selectionItem{kind: selectionCloud, cloud: &twoTargets[0]}, }, args: []string{"myfav", "--provider", "azure"}, wantContain: []string{"Added favorite", "myfav", "azure/Prod-EastUS/Contributor"}, @@ -475,13 +476,66 @@ func TestFavoritesAddInteractiveMode(t *testing.T) { return &models.EligibilityResponse{}, nil }, }, - selector: &mockTargetSelector{ - target: &twoTargets[1], + selector: &mockUnifiedSelector{ + item: &selectionItem{kind: selectionCloud, cloud: &twoTargets[1]}, }, args: []string{"myfav"}, wantContain: []string{"Added favorite", "myfav", "azure/Dev-WestEU/Reader"}, wantErr: false, }, + { + name: "success - selects group from unified list", + setupConfig: func(path string) { + cfg := config.DefaultConfig() + _ = config.Save(cfg, path) + }, + eligLister: &mockEligibilityLister{ + listFunc: func(_ context.Context, csp models.CSP) (*models.EligibilityResponse, error) { + if csp == models.CSPAzure { + return &models.EligibilityResponse{Response: twoTargets, Total: 2}, nil + } + return &models.EligibilityResponse{}, nil + }, + }, + groupsElig: &mockGroupsEligibilityLister{ + response: &models.GroupsEligibilityResponse{ + Response: []models.GroupsEligibleTarget{ + {DirectoryID: "dir-1", GroupID: "grp-1", GroupName: "Engineering"}, + }, + Total: 1, + }, + }, + selector: &mockUnifiedSelector{ + selectFunc: func(items []selectionItem) (*selectionItem, error) { + // Verify both cloud and group items are present + hasCloud, hasGroup := false, false + for _, item := range items { + if item.kind == selectionCloud { + hasCloud = true + } + if item.kind == selectionGroup { + hasGroup = true + } + } + if !hasCloud { + return nil, errors.New("expected cloud items in unified selector") + } + if !hasGroup { + return nil, errors.New("expected group items in unified selector") + } + // Select the group + for i := range items { + if items[i].kind == selectionGroup { + return &items[i], nil + } + } + return nil, errors.New("no group item found") + }, + }, + args: []string{"my-grp-fav"}, + wantContain: []string{"Added favorite", "my-grp-fav", "groups/Engineering"}, + wantErr: false, + }, { name: "eligibility fetch fails", setupConfig: func(path string) { @@ -491,24 +545,23 @@ func TestFavoritesAddInteractiveMode(t *testing.T) { eligLister: &mockEligibilityLister{ listErr: errors.New("API error: unauthorized"), }, - selector: &mockTargetSelector{}, + selector: &mockUnifiedSelector{}, args: []string{"myfav", "--provider", "azure"}, wantContain: []string{"failed to fetch eligible targets"}, wantErr: true, }, { - name: "no eligible targets", + name: "no eligible targets or groups", setupConfig: func(path string) { cfg := config.DefaultConfig() _ = config.Save(cfg, path) }, eligLister: &mockEligibilityLister{ - response: &models.EligibilityResponse{ - Response: []models.EligibleTarget{}, - Total: 0, + listFunc: func(_ context.Context, csp models.CSP) (*models.EligibilityResponse, error) { + return &models.EligibilityResponse{Response: []models.EligibleTarget{}, Total: 0}, nil }, }, - selector: &mockTargetSelector{}, + selector: &mockUnifiedSelector{}, args: []string{"myfav"}, wantContain: []string{"no eligible"}, wantErr: true, @@ -520,16 +573,18 @@ func TestFavoritesAddInteractiveMode(t *testing.T) { _ = config.Save(cfg, path) }, eligLister: &mockEligibilityLister{ - response: &models.EligibilityResponse{ - Response: twoTargets, - Total: 2, + listFunc: func(_ context.Context, csp models.CSP) (*models.EligibilityResponse, error) { + if csp == models.CSPAzure { + return &models.EligibilityResponse{Response: twoTargets, Total: 2}, nil + } + return &models.EligibilityResponse{}, nil }, }, - selector: &mockTargetSelector{ + selector: &mockUnifiedSelector{ selectErr: errors.New("user cancelled"), }, args: []string{"myfav"}, - wantContain: []string{"target selection failed"}, + wantContain: []string{"selection failed"}, wantErr: true, }, { @@ -575,8 +630,8 @@ func TestFavoritesAddInteractiveMode(t *testing.T) { return &models.EligibilityResponse{}, nil }, }, - selector: &mockTargetSelector{ - target: &twoTargets[0], + selector: &mockUnifiedSelector{ + item: &selectionItem{kind: selectionCloud, cloud: &twoTargets[0]}, }, namePrompter: &mockNamePrompter{name: "my-fav"}, args: []string{}, @@ -595,13 +650,15 @@ func TestFavoritesAddInteractiveMode(t *testing.T) { _ = config.Save(cfg, path) }, eligLister: &mockEligibilityLister{ - response: &models.EligibilityResponse{ - Response: twoTargets, - Total: 2, + listFunc: func(_ context.Context, csp models.CSP) (*models.EligibilityResponse, error) { + if csp == models.CSPAzure { + return &models.EligibilityResponse{Response: twoTargets, Total: 2}, nil + } + return &models.EligibilityResponse{}, nil }, }, - selector: &mockTargetSelector{ - target: &twoTargets[0], + selector: &mockUnifiedSelector{ + item: &selectionItem{kind: selectionCloud, cloud: &twoTargets[0]}, }, namePrompter: &mockNamePrompter{name: "existing"}, args: []string{}, @@ -615,13 +672,15 @@ func TestFavoritesAddInteractiveMode(t *testing.T) { _ = config.Save(cfg, path) }, eligLister: &mockEligibilityLister{ - response: &models.EligibilityResponse{ - Response: twoTargets, - Total: 2, + listFunc: func(_ context.Context, csp models.CSP) (*models.EligibilityResponse, error) { + if csp == models.CSPAzure { + return &models.EligibilityResponse{Response: twoTargets, Total: 2}, nil + } + return &models.EligibilityResponse{}, nil }, }, - selector: &mockTargetSelector{ - target: &twoTargets[0], + selector: &mockUnifiedSelector{ + item: &selectionItem{kind: selectionCloud, cloud: &twoTargets[0]}, }, namePrompter: &mockNamePrompter{promptErr: errors.New("user cancelled")}, args: []string{}, @@ -651,7 +710,7 @@ func TestFavoritesAddInteractiveMode(t *testing.T) { tt.setupConfig(configPath) rootCmd := newTestRootCommand() - favCmd := NewFavoritesCommandWithDeps(tt.eligLister, tt.selector, tt.namePrompter) + favCmd := NewFavoritesCommandWithAllDeps(tt.eligLister, tt.selector, tt.namePrompter, tt.groupsElig) rootCmd.AddCommand(favCmd) cmdArgs := append([]string{"favorites", "add"}, tt.args...) @@ -672,14 +731,14 @@ func TestFavoritesAddInteractiveMode(t *testing.T) { func TestFavoritesAddGroupFavorite(t *testing.T) { tests := []struct { - name string - setupConfig func(string) - groupsElig groupsEligibilityLister - groupSel groupSelector - namePrompter namePrompter - args []string - wantContain []string - wantErr bool + name string + setupConfig func(string) + groupsElig groupsEligibilityLister + selector unifiedSelector + namePrompter namePrompter + args []string + wantContain []string + wantErr bool }{ { name: "non-interactive - group via flags", @@ -732,7 +791,7 @@ func TestFavoritesAddGroupFavorite(t *testing.T) { wantErr: true, }, { - name: "interactive - selects from eligible groups", + name: "interactive - selects from eligible groups via unified selector", setupConfig: func(path string) { cfg := config.DefaultConfig() _ = config.Save(cfg, path) @@ -745,8 +804,16 @@ func TestFavoritesAddGroupFavorite(t *testing.T) { Total: 1, }, }, - groupSel: &mockGroupSelector{ - group: &models.GroupsEligibleTarget{DirectoryID: "dir-1", GroupID: "grp-1", GroupName: "Engineering"}, + selector: &mockUnifiedSelector{ + selectFunc: func(items []selectionItem) (*selectionItem, error) { + // Verify only group items are present (--type groups) + for _, item := range items { + if item.kind != selectionGroup { + return nil, errors.New("expected only group items for --type groups") + } + } + return &items[0], nil + }, }, args: []string{"my-grp", "--type", "groups"}, wantContain: []string{"Added favorite", "my-grp", "groups/Engineering"}, @@ -773,7 +840,7 @@ func TestFavoritesAddGroupFavorite(t *testing.T) { tt.setupConfig(configPath) rootCmd := newTestRootCommand() - favCmd := NewFavoritesCommandWithAllDeps(nil, nil, tt.namePrompter, tt.groupsElig, tt.groupSel) + favCmd := NewFavoritesCommandWithAllDeps(nil, tt.selector, tt.namePrompter, tt.groupsElig) rootCmd.AddCommand(favCmd) cmdArgs := append([]string{"favorites", "add"}, tt.args...) @@ -801,7 +868,7 @@ func TestFavoritesAddGroupPersistence(t *testing.T) { _ = config.Save(cfg, configPath) rootCmd := newTestRootCommand() - favCmd := NewFavoritesCommandWithAllDeps(nil, nil, nil, nil, nil) + favCmd := NewFavoritesCommandWithAllDeps(nil, nil, nil, nil) rootCmd.AddCommand(favCmd) _, err := executeCommand(rootCmd, "favorites", "add", "grp-fav", "--type", "groups", "--group", "DevOps") @@ -859,10 +926,12 @@ func TestFavoritesAdd_CachedEligibility(t *testing.T) { store := cache.NewStore(filepath.Join(tmpDir, "cache"), 4*time.Hour) cachedLister := cache.NewCachedEligibilityLister(innerCloud, nil, store, false, nil) - selector := &mockTargetSelector{target: &azureTargets[0]} + selector := &mockUnifiedSelector{ + item: &selectionItem{kind: selectionCloud, cloud: &azureTargets[0]}, + } rootCmd := newTestRootCommand() - favCmd := NewFavoritesCommandWithAllDeps(cachedLister, selector, nil, nil, nil) + favCmd := NewFavoritesCommandWithAllDeps(cachedLister, selector, nil, nil) rootCmd.AddCommand(favCmd) output, err := executeCommand(rootCmd, "favorites", "add", "myfav") diff --git a/cmd/groups.go b/cmd/groups.go deleted file mode 100644 index c4dc023..0000000 --- a/cmd/groups.go +++ /dev/null @@ -1,221 +0,0 @@ -package cmd - -import ( - "context" - "fmt" - "strings" - - "github.com/aaearon/grant-cli/internal/config" - scamodels "github.com/aaearon/grant-cli/internal/sca/models" - "github.com/aaearon/grant-cli/internal/ui" - sdkmodels "github.com/cyberark/idsec-sdk-golang/pkg/models" - "github.com/spf13/cobra" -) - -// uiGroupSelector wraps ui.SelectGroup to implement groupSelector -type uiGroupSelector struct{} - -func (s *uiGroupSelector) SelectGroup(groups []scamodels.GroupsEligibleTarget) (*scamodels.GroupsEligibleTarget, error) { - return ui.SelectGroup(groups) -} - -// newGroupsCommand creates the groups cobra command with the given RunE function. -func newGroupsCommand(runFn func(*cobra.Command, []string) error) *cobra.Command { - cmd := &cobra.Command{ - Use: "groups", - Short: "Request temporary Entra ID group membership", - Long: `Request temporary Entra ID group membership via CyberArk Secure Cloud Access (SCA). - -Three execution modes: -1. Interactive mode (no flags): Select group interactively -2. Direct mode (--group): Directly specify group name -3. Favorite mode (--favorite): Use a saved favorite - -Examples: - # Interactive selection - grant groups - - # Direct selection - grant groups --group "Cloud Admins" - - # Favorite mode - grant groups --favorite my-group`, - RunE: runFn, - } - - cmd.Flags().StringP("group", "g", "", "Group name for direct mode") - cmd.Flags().StringP("favorite", "f", "", "Use a saved favorite (see 'grant favorites list')") - - return cmd -} - -// NewGroupsCommand creates the production groups command. -func NewGroupsCommand() *cobra.Command { - return newGroupsCommand(func(cmd *cobra.Command, args []string) error { - ispAuth, svc, profile, err := bootstrapSCAService() - if err != nil { - return err - } - - cfg, _, err := config.LoadDefaultWithPath() - if err != nil { - return err - } - - cachedLister := buildCachedLister(cfg, false, svc, svc) - - return runGroups(cmd, ispAuth, cachedLister, cachedLister, svc, &uiGroupSelector{}, profile, cfg) - }) -} - -// NewGroupsCommandWithDeps creates a groups command with injected dependencies for testing. -func NewGroupsCommandWithDeps( - profile *sdkmodels.IdsecProfile, - auth authLoader, - cloudElig eligibilityLister, - groupsElig groupsEligibilityLister, - elevator groupsElevator, - selector groupSelector, - cfg *config.Config, -) *cobra.Command { - return newGroupsCommand(func(cmd *cobra.Command, args []string) error { - return runGroups(cmd, auth, cloudElig, groupsElig, elevator, selector, profile, cfg) - }) -} - -func runGroups( - cmd *cobra.Command, - auth authLoader, - cloudElig eligibilityLister, - groupsElig groupsEligibilityLister, - elevator groupsElevator, - selector groupSelector, - profile *sdkmodels.IdsecProfile, - cfg *config.Config, -) error { - groupFlag, _ := cmd.Flags().GetString("group") - favoriteFlag, _ := cmd.Flags().GetString("favorite") - - var favDirectoryID string - - if favoriteFlag != "" { - if cfg == nil { - var err error - cfg, _, err = config.LoadDefaultWithPath() - if err != nil { - return err - } - } - - fav, err := config.GetFavorite(cfg, favoriteFlag) - if err != nil { - return fmt.Errorf("favorite %q not found, run 'grant favorites list'", favoriteFlag) - } - - if fav.ResolvedType() != config.FavoriteTypeGroups { - return fmt.Errorf("favorite %q is a cloud favorite; use 'grant --favorite %s' instead", favoriteFlag, favoriteFlag) - } - - groupFlag = fav.Group - favDirectoryID = fav.DirectoryID - } - - // Check authentication - _, err := auth.LoadAuthentication(profile, true) - if err != nil { - return fmt.Errorf("not authenticated, run 'grant login' first: %w", err) - } - - ctx, cancel := context.WithTimeout(context.Background(), apiTimeout) - defer cancel() - - // Fetch groups eligibility (always Azure for Entra ID) - eligResp, err := groupsElig.ListGroupsEligibility(ctx, scamodels.CSPAzure) - if err != nil { - return fmt.Errorf("failed to fetch eligible groups: %w", err) - } - - if len(eligResp.Response) == 0 { - return fmt.Errorf("no eligible groups found, check your SCA policies") - } - - // Resolve directory names from cloud eligibility (best-effort) - dirNameMap := buildDirectoryNameMap(ctx, cloudElig, cmd.ErrOrStderr()) - for i := range eligResp.Response { - if name, ok := dirNameMap[eligResp.Response[i].DirectoryID]; ok { - eligResp.Response[i].DirectoryName = name - } - } - - // Resolve group - var selectedGroup *scamodels.GroupsEligibleTarget - - if groupFlag != "" { - // Direct mode (or favorite-resolved) - selectedGroup = findMatchingGroup(eligResp.Response, groupFlag, favDirectoryID) - if selectedGroup == nil { - if favDirectoryID != "" { - return fmt.Errorf("group %q not found in directory %q, run 'grant groups' to see available options", groupFlag, favDirectoryID) - } - return fmt.Errorf("group %q not found, run 'grant groups' to see available options", groupFlag) - } - } else { - // Interactive mode - selectedGroup, err = selector.SelectGroup(eligResp.Response) - if err != nil { - return fmt.Errorf("group selection failed: %w", err) - } - } - - // Build elevation request - req := &scamodels.GroupsElevateRequest{ - DirectoryID: selectedGroup.DirectoryID, - CSP: scamodels.CSPAzure, - Targets: []scamodels.GroupsElevateTarget{ - {GroupID: selectedGroup.GroupID}, - }, - } - - // Execute elevation - elevateResp, err := elevator.ElevateGroups(ctx, req) - if err != nil { - return fmt.Errorf("elevation request failed: %w", err) - } - - // Check results - if len(elevateResp.Results) == 0 { - return fmt.Errorf("elevation failed: no results returned") - } - - result := elevateResp.Results[0] - if result.ErrorInfo != nil { - return fmt.Errorf("elevation failed: %s - %s\n%s", - result.ErrorInfo.Code, - result.ErrorInfo.Message, - result.ErrorInfo.Description) - } - - // Display success - dirContext := "" - if selectedGroup.DirectoryName != "" { - dirContext = fmt.Sprintf(" in %s", selectedGroup.DirectoryName) - } - fmt.Fprintf(cmd.OutOrStdout(), "Elevated to group %s%s\n", selectedGroup.GroupName, dirContext) - fmt.Fprintf(cmd.OutOrStdout(), " Session ID: %s\n", result.SessionID) - - return nil -} - -// findMatchingGroup finds a group by name (case-insensitive). -// If directoryID is non-empty, only matches groups in that directory. -func findMatchingGroup(groups []scamodels.GroupsEligibleTarget, name string, directoryID string) *scamodels.GroupsEligibleTarget { - for i := range groups { - if strings.EqualFold(groups[i].GroupName, name) { - if directoryID != "" && groups[i].DirectoryID != directoryID { - continue - } - return &groups[i] - } - } - return nil -} diff --git a/cmd/groups_test.go b/cmd/groups_test.go deleted file mode 100644 index 757ebf2..0000000 --- a/cmd/groups_test.go +++ /dev/null @@ -1,705 +0,0 @@ -// NOTE: Do not use t.Parallel() in cmd/ tests due to package-level state -// (verbose, passedArgValidation) that is mutated during test execution. -package cmd - -import ( - "context" - "errors" - "strings" - "testing" - "time" - - "github.com/aaearon/grant-cli/internal/cache" - "github.com/aaearon/grant-cli/internal/config" - scamodels "github.com/aaearon/grant-cli/internal/sca/models" - authmodels "github.com/cyberark/idsec-sdk-golang/pkg/models/auth" - commonmodels "github.com/cyberark/idsec-sdk-golang/pkg/models/common" -) - -func TestGroupsCommand(t *testing.T) { - now := time.Now() - expiresIn := commonmodels.IdsecRFC3339Time(now.Add(1 * time.Hour)) - - // newEligibleGroups returns a fresh copy to avoid mutation bleeding between tests - // (runGroups sets DirectoryName in-place on the response slice). - newEligibleGroups := func() *scamodels.GroupsEligibilityResponse { - return &scamodels.GroupsEligibilityResponse{ - Response: []scamodels.GroupsEligibleTarget{ - {DirectoryID: "dir1", GroupID: "grp1", GroupName: "Engineering"}, - {DirectoryID: "dir1", GroupID: "grp2", GroupName: "DevOps"}, - }, - Total: 2, - } - } - - // Cloud eligibility with DIRECTORY-type entry for name resolution - cloudEligWithDir := &scamodels.EligibilityResponse{ - Response: []scamodels.EligibleTarget{ - { - OrganizationID: "dir1", - WorkspaceID: "dir1", - WorkspaceName: "Contoso", - WorkspaceType: scamodels.WorkspaceTypeDirectory, - }, - }, - Total: 1, - } - - // Empty cloud eligibility (graceful degradation) - emptyCloudElig := &scamodels.EligibilityResponse{Response: []scamodels.EligibleTarget{}, Total: 0} - - tests := []struct { - name string - args []string - setupAuth func() *mockAuthLoader - setupCloudElig func() *mockEligibilityLister - setupElig func() *mockGroupsEligibilityLister - setupElevator func() *mockGroupsElevator - setupSelector func() *mockGroupSelector - wantContain []string - wantErr bool - }{ - { - name: "not authenticated", - args: []string{}, - setupAuth: func() *mockAuthLoader { - return &mockAuthLoader{loadErr: errNotAuthenticated} - }, - setupCloudElig: func() *mockEligibilityLister { return &mockEligibilityLister{} }, - setupElig: func() *mockGroupsEligibilityLister { return &mockGroupsEligibilityLister{} }, - setupElevator: func() *mockGroupsElevator { return &mockGroupsElevator{} }, - setupSelector: func() *mockGroupSelector { return &mockGroupSelector{} }, - wantContain: []string{"not authenticated"}, - wantErr: true, - }, - { - name: "no eligible groups", - args: []string{}, - setupAuth: func() *mockAuthLoader { - return &mockAuthLoader{ - token: &authmodels.IdsecToken{Token: "jwt", Username: "user@example.com", ExpiresIn: expiresIn}, - } - }, - setupCloudElig: func() *mockEligibilityLister { - return &mockEligibilityLister{response: emptyCloudElig} - }, - setupElig: func() *mockGroupsEligibilityLister { - return &mockGroupsEligibilityLister{ - response: &scamodels.GroupsEligibilityResponse{Response: []scamodels.GroupsEligibleTarget{}, Total: 0}, - } - }, - setupElevator: func() *mockGroupsElevator { return &mockGroupsElevator{} }, - setupSelector: func() *mockGroupSelector { return &mockGroupSelector{} }, - wantContain: []string{"no eligible groups"}, - wantErr: true, - }, - { - name: "interactive mode - success with directory name", - args: []string{}, - setupAuth: func() *mockAuthLoader { - return &mockAuthLoader{ - token: &authmodels.IdsecToken{Token: "jwt", Username: "user@example.com", ExpiresIn: expiresIn}, - } - }, - setupCloudElig: func() *mockEligibilityLister { - return &mockEligibilityLister{response: cloudEligWithDir} - }, - setupElig: func() *mockGroupsEligibilityLister { - return &mockGroupsEligibilityLister{response: newEligibleGroups()} - }, - setupElevator: func() *mockGroupsElevator { - return &mockGroupsElevator{ - response: &scamodels.GroupsElevateResponse{ - DirectoryID: "dir1", - CSP: scamodels.CSPAzure, - Results: []scamodels.GroupsElevateTargetResult{ - {GroupID: "grp1", SessionID: "sess1"}, - }, - }, - } - }, - setupSelector: func() *mockGroupSelector { - return &mockGroupSelector{ - group: &scamodels.GroupsEligibleTarget{DirectoryID: "dir1", DirectoryName: "Contoso", GroupID: "grp1", GroupName: "Engineering"}, - } - }, - wantContain: []string{"Elevated to group Engineering in Contoso", "Session ID: sess1"}, - wantErr: false, - }, - { - name: "success without directory name (graceful degradation)", - args: []string{"--group", "Engineering"}, - setupAuth: func() *mockAuthLoader { - return &mockAuthLoader{ - token: &authmodels.IdsecToken{Token: "jwt", Username: "user@example.com", ExpiresIn: expiresIn}, - } - }, - setupCloudElig: func() *mockEligibilityLister { - return &mockEligibilityLister{listErr: errors.New("cloud eligibility unavailable")} - }, - setupElig: func() *mockGroupsEligibilityLister { - return &mockGroupsEligibilityLister{response: newEligibleGroups()} - }, - setupElevator: func() *mockGroupsElevator { - return &mockGroupsElevator{ - response: &scamodels.GroupsElevateResponse{ - DirectoryID: "dir1", - CSP: scamodels.CSPAzure, - Results: []scamodels.GroupsElevateTargetResult{ - {GroupID: "grp1", SessionID: "sess1"}, - }, - }, - } - }, - setupSelector: func() *mockGroupSelector { return &mockGroupSelector{} }, - wantContain: []string{"Elevated to group Engineering\n"}, - wantErr: false, - }, - { - name: "direct mode with --group flag", - args: []string{"--group", "Engineering"}, - setupAuth: func() *mockAuthLoader { - return &mockAuthLoader{ - token: &authmodels.IdsecToken{Token: "jwt", Username: "user@example.com", ExpiresIn: expiresIn}, - } - }, - setupCloudElig: func() *mockEligibilityLister { - return &mockEligibilityLister{response: cloudEligWithDir} - }, - setupElig: func() *mockGroupsEligibilityLister { - return &mockGroupsEligibilityLister{response: newEligibleGroups()} - }, - setupElevator: func() *mockGroupsElevator { - return &mockGroupsElevator{ - elevateFunc: func(ctx context.Context, req *scamodels.GroupsElevateRequest) (*scamodels.GroupsElevateResponse, error) { - if req.Targets[0].GroupID != "grp1" { - t.Errorf("expected group ID grp1, got %s", req.Targets[0].GroupID) - } - if req.DirectoryID != "dir1" { - t.Errorf("expected directory ID dir1, got %s", req.DirectoryID) - } - return &scamodels.GroupsElevateResponse{ - DirectoryID: "dir1", - CSP: scamodels.CSPAzure, - Results: []scamodels.GroupsElevateTargetResult{ - {GroupID: "grp1", SessionID: "sess1"}, - }, - }, nil - }, - } - }, - setupSelector: func() *mockGroupSelector { return &mockGroupSelector{} }, - wantContain: []string{"Elevated to group Engineering in Contoso", "Session ID: sess1"}, - wantErr: false, - }, - { - name: "direct mode - group not found", - args: []string{"--group", "NonExistent"}, - setupAuth: func() *mockAuthLoader { - return &mockAuthLoader{ - token: &authmodels.IdsecToken{Token: "jwt", Username: "user@example.com", ExpiresIn: expiresIn}, - } - }, - setupCloudElig: func() *mockEligibilityLister { - return &mockEligibilityLister{response: emptyCloudElig} - }, - setupElig: func() *mockGroupsEligibilityLister { - return &mockGroupsEligibilityLister{response: newEligibleGroups()} - }, - setupElevator: func() *mockGroupsElevator { return &mockGroupsElevator{} }, - setupSelector: func() *mockGroupSelector { return &mockGroupSelector{} }, - wantContain: []string{"group \"NonExistent\" not found"}, - wantErr: true, - }, - { - name: "direct mode - case insensitive match", - args: []string{"--group", "engineering"}, - setupAuth: func() *mockAuthLoader { - return &mockAuthLoader{ - token: &authmodels.IdsecToken{Token: "jwt", Username: "user@example.com", ExpiresIn: expiresIn}, - } - }, - setupCloudElig: func() *mockEligibilityLister { - return &mockEligibilityLister{response: emptyCloudElig} - }, - setupElig: func() *mockGroupsEligibilityLister { - return &mockGroupsEligibilityLister{response: newEligibleGroups()} - }, - setupElevator: func() *mockGroupsElevator { - return &mockGroupsElevator{ - response: &scamodels.GroupsElevateResponse{ - DirectoryID: "dir1", - CSP: scamodels.CSPAzure, - Results: []scamodels.GroupsElevateTargetResult{ - {GroupID: "grp1", SessionID: "sess1"}, - }, - }, - } - }, - setupSelector: func() *mockGroupSelector { return &mockGroupSelector{} }, - wantContain: []string{"Elevated to group Engineering"}, - wantErr: false, - }, - { - name: "elevation error in result", - args: []string{"--group", "Engineering"}, - setupAuth: func() *mockAuthLoader { - return &mockAuthLoader{ - token: &authmodels.IdsecToken{Token: "jwt", Username: "user@example.com", ExpiresIn: expiresIn}, - } - }, - setupCloudElig: func() *mockEligibilityLister { - return &mockEligibilityLister{response: emptyCloudElig} - }, - setupElig: func() *mockGroupsEligibilityLister { - return &mockGroupsEligibilityLister{response: newEligibleGroups()} - }, - setupElevator: func() *mockGroupsElevator { - return &mockGroupsElevator{ - response: &scamodels.GroupsElevateResponse{ - DirectoryID: "dir1", - CSP: scamodels.CSPAzure, - Results: []scamodels.GroupsElevateTargetResult{ - { - GroupID: "grp1", - SessionID: "", - ErrorInfo: &scamodels.ErrorInfo{ - Code: "ERR_INELIGIBLE", - Message: "Not eligible", - Description: "User not eligible", - }, - }, - }, - }, - } - }, - setupSelector: func() *mockGroupSelector { return &mockGroupSelector{} }, - wantContain: []string{"elevation failed", "ERR_INELIGIBLE", "Not eligible"}, - wantErr: true, - }, - { - name: "eligibility API error", - args: []string{}, - setupAuth: func() *mockAuthLoader { - return &mockAuthLoader{ - token: &authmodels.IdsecToken{Token: "jwt", Username: "user@example.com", ExpiresIn: expiresIn}, - } - }, - setupCloudElig: func() *mockEligibilityLister { - return &mockEligibilityLister{response: emptyCloudElig} - }, - setupElig: func() *mockGroupsEligibilityLister { - return &mockGroupsEligibilityLister{listErr: errors.New("service unavailable")} - }, - setupElevator: func() *mockGroupsElevator { return &mockGroupsElevator{} }, - setupSelector: func() *mockGroupSelector { return &mockGroupSelector{} }, - wantContain: []string{"failed to fetch eligible groups"}, - wantErr: true, - }, - { - name: "elevation API error", - args: []string{"--group", "Engineering"}, - setupAuth: func() *mockAuthLoader { - return &mockAuthLoader{ - token: &authmodels.IdsecToken{Token: "jwt", Username: "user@example.com", ExpiresIn: expiresIn}, - } - }, - setupCloudElig: func() *mockEligibilityLister { - return &mockEligibilityLister{response: emptyCloudElig} - }, - setupElig: func() *mockGroupsEligibilityLister { - return &mockGroupsEligibilityLister{response: newEligibleGroups()} - }, - setupElevator: func() *mockGroupsElevator { - return &mockGroupsElevator{elevateErr: errors.New("API error: forbidden")} - }, - setupSelector: func() *mockGroupSelector { return &mockGroupSelector{} }, - wantContain: []string{"elevation request failed"}, - wantErr: true, - }, - { - name: "interactive mode - selection error", - args: []string{}, - setupAuth: func() *mockAuthLoader { - return &mockAuthLoader{ - token: &authmodels.IdsecToken{Token: "jwt", Username: "user@example.com", ExpiresIn: expiresIn}, - } - }, - setupCloudElig: func() *mockEligibilityLister { - return &mockEligibilityLister{response: emptyCloudElig} - }, - setupElig: func() *mockGroupsEligibilityLister { - return &mockGroupsEligibilityLister{response: newEligibleGroups()} - }, - setupElevator: func() *mockGroupsElevator { return &mockGroupsElevator{} }, - setupSelector: func() *mockGroupSelector { - return &mockGroupSelector{selectErr: errors.New("prompt interrupted")} - }, - wantContain: []string{"group selection failed"}, - wantErr: true, - }, - { - name: "elevation returns no results", - args: []string{"--group", "Engineering"}, - setupAuth: func() *mockAuthLoader { - return &mockAuthLoader{ - token: &authmodels.IdsecToken{Token: "jwt", Username: "user@example.com", ExpiresIn: expiresIn}, - } - }, - setupCloudElig: func() *mockEligibilityLister { - return &mockEligibilityLister{response: emptyCloudElig} - }, - setupElig: func() *mockGroupsEligibilityLister { - return &mockGroupsEligibilityLister{response: newEligibleGroups()} - }, - setupElevator: func() *mockGroupsElevator { - return &mockGroupsElevator{ - response: &scamodels.GroupsElevateResponse{ - DirectoryID: "dir1", - CSP: scamodels.CSPAzure, - Results: []scamodels.GroupsElevateTargetResult{}, - }, - } - }, - setupSelector: func() *mockGroupSelector { return &mockGroupSelector{} }, - wantContain: []string{"no results returned"}, - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - auth := tt.setupAuth() - cloudElig := tt.setupCloudElig() - elig := tt.setupElig() - elevator := tt.setupElevator() - selector := tt.setupSelector() - - cmd := NewGroupsCommandWithDeps(nil, auth, cloudElig, elig, elevator, selector, nil) - output, err := executeCommand(cmd, tt.args...) - - if tt.wantErr && err == nil { - t.Errorf("expected error but got none") - } - if !tt.wantErr && err != nil { - t.Errorf("unexpected error: %v", err) - } - - for _, want := range tt.wantContain { - if !strings.Contains(output, want) { - t.Errorf("output missing %q\ngot:\n%s", want, output) - } - } - }) - } -} - -func TestGroupsCommandFavoriteMode(t *testing.T) { - now := time.Now() - expiresIn := commonmodels.IdsecRFC3339Time(now.Add(1 * time.Hour)) - - tests := []struct { - name string - args []string - cfg *config.Config - setupAuth func() *mockAuthLoader - setupCloudElig func() *mockEligibilityLister - setupElig func() *mockGroupsEligibilityLister - setupElevator func() *mockGroupsElevator - setupSelector func() *mockGroupSelector - wantContain []string - wantErr bool - }{ - { - name: "favorite mode - success", - args: []string{"--favorite", "my-grp"}, - cfg: func() *config.Config { - cfg := config.DefaultConfig() - _ = config.AddFavorite(cfg, "my-grp", config.Favorite{ - Type: config.FavoriteTypeGroups, - Provider: "azure", - Group: "Engineering", - DirectoryID: "dir1", - }) - return cfg - }(), - setupAuth: func() *mockAuthLoader { - return &mockAuthLoader{ - token: &authmodels.IdsecToken{Token: "jwt", Username: "user@example.com", ExpiresIn: expiresIn}, - } - }, - setupCloudElig: func() *mockEligibilityLister { - return &mockEligibilityLister{response: &scamodels.EligibilityResponse{Response: []scamodels.EligibleTarget{}}} - }, - setupElig: func() *mockGroupsEligibilityLister { - return &mockGroupsEligibilityLister{ - response: &scamodels.GroupsEligibilityResponse{ - Response: []scamodels.GroupsEligibleTarget{ - {DirectoryID: "dir1", GroupID: "grp1", GroupName: "Engineering"}, - }, - Total: 1, - }, - } - }, - setupElevator: func() *mockGroupsElevator { - return &mockGroupsElevator{ - response: &scamodels.GroupsElevateResponse{ - DirectoryID: "dir1", - CSP: scamodels.CSPAzure, - Results: []scamodels.GroupsElevateTargetResult{ - {GroupID: "grp1", SessionID: "sess1"}, - }, - }, - } - }, - setupSelector: func() *mockGroupSelector { return &mockGroupSelector{} }, - wantContain: []string{"Elevated to group Engineering", "Session ID: sess1"}, - wantErr: false, - }, - { - name: "favorite not found", - args: []string{"--favorite", "nonexistent"}, - cfg: config.DefaultConfig(), - setupAuth: func() *mockAuthLoader { - return &mockAuthLoader{ - token: &authmodels.IdsecToken{Token: "jwt", Username: "user@example.com", ExpiresIn: expiresIn}, - } - }, - setupCloudElig: func() *mockEligibilityLister { return &mockEligibilityLister{} }, - setupElig: func() *mockGroupsEligibilityLister { return &mockGroupsEligibilityLister{} }, - setupElevator: func() *mockGroupsElevator { return &mockGroupsElevator{} }, - setupSelector: func() *mockGroupSelector { return &mockGroupSelector{} }, - wantContain: []string{"not found"}, - wantErr: true, - }, - { - name: "favorite mode - directory ID filters correct group", - args: []string{"--favorite", "my-grp"}, - cfg: func() *config.Config { - cfg := config.DefaultConfig() - _ = config.AddFavorite(cfg, "my-grp", config.Favorite{ - Type: config.FavoriteTypeGroups, - Provider: "azure", - Group: "Engineering", - DirectoryID: "dir1", - }) - return cfg - }(), - setupAuth: func() *mockAuthLoader { - return &mockAuthLoader{ - token: &authmodels.IdsecToken{Token: "jwt", Username: "user@example.com", ExpiresIn: expiresIn}, - } - }, - setupCloudElig: func() *mockEligibilityLister { - return &mockEligibilityLister{response: &scamodels.EligibilityResponse{Response: []scamodels.EligibleTarget{}}} - }, - setupElig: func() *mockGroupsEligibilityLister { - return &mockGroupsEligibilityLister{ - response: &scamodels.GroupsEligibilityResponse{ - Response: []scamodels.GroupsEligibleTarget{ - {DirectoryID: "dir2", GroupID: "grp-wrong", GroupName: "Engineering"}, - {DirectoryID: "dir1", GroupID: "grp-right", GroupName: "Engineering"}, - }, - Total: 2, - }, - } - }, - setupElevator: func() *mockGroupsElevator { - return &mockGroupsElevator{ - elevateFunc: func(ctx context.Context, req *scamodels.GroupsElevateRequest) (*scamodels.GroupsElevateResponse, error) { - if req.Targets[0].GroupID != "grp-right" { - t.Errorf("expected group ID grp-right, got %s", req.Targets[0].GroupID) - } - if req.DirectoryID != "dir1" { - t.Errorf("expected directory ID dir1, got %s", req.DirectoryID) - } - return &scamodels.GroupsElevateResponse{ - DirectoryID: "dir1", - CSP: scamodels.CSPAzure, - Results: []scamodels.GroupsElevateTargetResult{ - {GroupID: "grp-right", SessionID: "sess1"}, - }, - }, nil - }, - } - }, - setupSelector: func() *mockGroupSelector { return &mockGroupSelector{} }, - wantContain: []string{"Elevated to group Engineering", "Session ID: sess1"}, - wantErr: false, - }, - { - name: "favorite mode - directory ID mismatch returns error", - args: []string{"--favorite", "my-grp"}, - cfg: func() *config.Config { - cfg := config.DefaultConfig() - _ = config.AddFavorite(cfg, "my-grp", config.Favorite{ - Type: config.FavoriteTypeGroups, - Provider: "azure", - Group: "Engineering", - DirectoryID: "dir-nonexistent", - }) - return cfg - }(), - setupAuth: func() *mockAuthLoader { - return &mockAuthLoader{ - token: &authmodels.IdsecToken{Token: "jwt", Username: "user@example.com", ExpiresIn: expiresIn}, - } - }, - setupCloudElig: func() *mockEligibilityLister { - return &mockEligibilityLister{response: &scamodels.EligibilityResponse{Response: []scamodels.EligibleTarget{}}} - }, - setupElig: func() *mockGroupsEligibilityLister { - return &mockGroupsEligibilityLister{ - response: &scamodels.GroupsEligibilityResponse{ - Response: []scamodels.GroupsEligibleTarget{ - {DirectoryID: "dir1", GroupID: "grp1", GroupName: "Engineering"}, - }, - Total: 1, - }, - } - }, - setupElevator: func() *mockGroupsElevator { return &mockGroupsElevator{} }, - setupSelector: func() *mockGroupSelector { return &mockGroupSelector{} }, - wantContain: []string{"not found in directory"}, - wantErr: true, - }, - { - name: "cloud favorite is rejected", - args: []string{"--favorite", "cloud-fav"}, - cfg: func() *config.Config { - cfg := config.DefaultConfig() - _ = config.AddFavorite(cfg, "cloud-fav", config.Favorite{ - Provider: "azure", - Target: "sub-1", - Role: "Contributor", - }) - return cfg - }(), - setupAuth: func() *mockAuthLoader { - return &mockAuthLoader{ - token: &authmodels.IdsecToken{Token: "jwt", Username: "user@example.com", ExpiresIn: expiresIn}, - } - }, - setupCloudElig: func() *mockEligibilityLister { return &mockEligibilityLister{} }, - setupElig: func() *mockGroupsEligibilityLister { return &mockGroupsEligibilityLister{} }, - setupElevator: func() *mockGroupsElevator { return &mockGroupsElevator{} }, - setupSelector: func() *mockGroupSelector { return &mockGroupSelector{} }, - wantContain: []string{"cloud favorite", "grant --favorite cloud-fav"}, - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - auth := tt.setupAuth() - cloudElig := tt.setupCloudElig() - elig := tt.setupElig() - elevator := tt.setupElevator() - selector := tt.setupSelector() - - cmd := NewGroupsCommandWithDeps(nil, auth, cloudElig, elig, elevator, selector, tt.cfg) - output, err := executeCommand(cmd, tt.args...) - - if tt.wantErr && err == nil { - t.Errorf("expected error but got none, output:\n%s", output) - } - if !tt.wantErr && err != nil { - t.Errorf("unexpected error: %v", err) - } - - for _, want := range tt.wantContain { - if !strings.Contains(output, want) { - t.Errorf("output missing %q\ngot:\n%s", want, output) - } - } - }) - } -} - -func TestGroupsCommand_CachedEligibility(t *testing.T) { - now := time.Now() - expiresIn := commonmodels.IdsecRFC3339Time(now.Add(1 * time.Hour)) - - innerCloud := newCountingEligibilityLister(&mockEligibilityLister{ - response: &scamodels.EligibilityResponse{ - Response: []scamodels.EligibleTarget{ - { - OrganizationID: "dir1", - WorkspaceID: "dir1", - WorkspaceName: "Contoso", - WorkspaceType: scamodels.WorkspaceTypeDirectory, - }, - }, - }, - }) - innerGroups := newCountingGroupsEligibilityLister(&mockGroupsEligibilityLister{ - response: &scamodels.GroupsEligibilityResponse{ - Response: []scamodels.GroupsEligibleTarget{ - {DirectoryID: "dir1", GroupID: "grp1", GroupName: "Engineering"}, - }, - Total: 1, - }, - }) - - store := cache.NewStore(t.TempDir(), 4*time.Hour) - cachedLister := cache.NewCachedEligibilityLister(innerCloud, innerGroups, store, false, nil) - - auth := &mockAuthLoader{ - token: &authmodels.IdsecToken{Token: "jwt", Username: "user@example.com", ExpiresIn: expiresIn}, - } - elevator := &mockGroupsElevator{ - response: &scamodels.GroupsElevateResponse{ - DirectoryID: "dir1", - CSP: scamodels.CSPAzure, - Results: []scamodels.GroupsElevateTargetResult{ - {GroupID: "grp1", SessionID: "sess1"}, - }, - }, - } - - cmd := NewGroupsCommandWithDeps(nil, auth, cachedLister, cachedLister, elevator, &mockGroupSelector{}, nil) - output, err := executeCommand(cmd, "--group", "Engineering") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if !strings.Contains(output, "Elevated to group Engineering in Contoso") { - t.Errorf("output missing expected text, got:\n%s", output) - } - - // Cloud inner called once for Azure (by buildDirectoryNameMap) - if got := innerCloud.CallCount(scamodels.CSPAzure); got != 1 { - t.Errorf("cloud inner Azure called %d times, want 1", got) - } - // Groups inner called once for Azure (by ListGroupsEligibility) - if got := innerGroups.CallCount(scamodels.CSPAzure); got != 1 { - t.Errorf("groups inner Azure called %d times, want 1", got) - } -} - -func TestGroupsCommandUsage(t *testing.T) { - cmd := NewGroupsCommand() - - if cmd.Use != "groups" { - t.Errorf("expected Use='groups', got %q", cmd.Use) - } - if cmd.Short == "" { - t.Error("expected non-empty Short description") - } - - groupFlag := cmd.Flags().Lookup("group") - if groupFlag == nil { - t.Fatal("expected --group flag") - } - if groupFlag.Shorthand != "g" { - t.Errorf("expected -g shorthand, got %q", groupFlag.Shorthand) - } - - favoriteFlag := cmd.Flags().Lookup("favorite") - if favoriteFlag == nil { - t.Fatal("expected --favorite flag") - } - if favoriteFlag.Shorthand != "f" { - t.Errorf("expected -f shorthand, got %q", favoriteFlag.Shorthand) - } -} diff --git a/cmd/helpers.go b/cmd/helpers.go index 857322f..085309d 100644 --- a/cmd/helpers.go +++ b/cmd/helpers.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "io" + "strings" "sync" scamodels "github.com/aaearon/grant-cli/internal/sca/models" @@ -188,3 +189,17 @@ func buildWorkspaceNameMap(ctx context.Context, eligLister eligibilityLister, se return nameMap } + +// findMatchingGroup finds a group by name (case-insensitive). +// If directoryID is non-empty, only matches groups in that directory. +func findMatchingGroup(groups []scamodels.GroupsEligibleTarget, name string, directoryID string) *scamodels.GroupsEligibleTarget { + for i := range groups { + if strings.EqualFold(groups[i].GroupName, name) { + if directoryID != "" && groups[i].DirectoryID != directoryID { + continue + } + return &groups[i] + } + } + return nil +} diff --git a/cmd/interfaces.go b/cmd/interfaces.go index d49f51a..8b9ecd1 100644 --- a/cmd/interfaces.go +++ b/cmd/interfaces.go @@ -78,7 +78,7 @@ type groupsElevator interface { ElevateGroups(ctx context.Context, req *models.GroupsElevateRequest) (*models.GroupsElevateResponse, error) } -// groupSelector interface for interactive group selection -type groupSelector interface { - SelectGroup(groups []models.GroupsEligibleTarget) (*models.GroupsEligibleTarget, error) +// unifiedSelector interface for interactive selection of cloud targets or groups +type unifiedSelector interface { + SelectItem(items []selectionItem) (*selectionItem, error) } diff --git a/cmd/root.go b/cmd/root.go index 80e2893..5305d87 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -3,12 +3,14 @@ package cmd import ( "context" "fmt" + "io" "os" "slices" "strings" "sync" "time" + survey "github.com/Iilun/survey/v2" "github.com/aaearon/grant-cli/internal/cache" "github.com/aaearon/grant-cli/internal/config" "github.com/aaearon/grant-cli/internal/sca" @@ -43,6 +45,8 @@ type elevateFlags struct { role string favorite string refresh bool + groups bool + group string } // newRootCommand creates the root cobra command with the given RunE function. @@ -53,24 +57,32 @@ func newRootCommand(runFn func(*cobra.Command, []string) error) *cobra.Command { Short: "Request temporary elevated cloud permissions", Long: `Grant temporary elevated cloud permissions via CyberArk Secure Cloud Access (SCA). -Running grant with no subcommand requests access elevation. +Running grant with no subcommand requests access elevation. The interactive +selector shows both cloud roles and Entra ID groups in a unified list. -Three execution modes: -1. Interactive mode (no flags): Select target interactively -2. Direct mode (--target and --role): Directly specify target and role -3. Favorite mode (--favorite): Use a saved favorite +Execution modes: +1. Interactive mode (no flags): Select target or group interactively +2. Direct cloud mode (--target and --role): Directly specify target and role +3. Direct group mode (--group): Directly specify group name +4. Favorite mode (--favorite): Use a saved favorite (cloud or group) Examples: - # Interactive selection + # Interactive selection (cloud roles + groups) grant - # Direct selection + # Direct cloud selection grant --target "Prod-EastUS" --role "Contributor" + # Direct group membership elevation + grant --group "Cloud Admins" + + # Show only groups in interactive selector + grant --groups + # Use a favorite grant --favorite prod-contrib - # Specify provider explicitly + # Specify provider explicitly (cloud targets only) grant --provider azure grant --provider aws @@ -96,9 +108,16 @@ Examples: cmd.Flags().StringP("role", "r", "", "Role name") cmd.Flags().StringP("favorite", "f", "", "Use a saved favorite (see 'grant favorites list')") cmd.Flags().Bool("refresh", false, "Bypass eligibility cache and fetch fresh data") + cmd.Flags().Bool("groups", false, "Show only Entra ID groups in interactive selector") + cmd.Flags().StringP("group", "g", "", "Group name for direct group membership elevation") cmd.MarkFlagsMutuallyExclusive("favorite", "target") cmd.MarkFlagsMutuallyExclusive("favorite", "role") + cmd.MarkFlagsMutuallyExclusive("groups", "provider") + cmd.MarkFlagsMutuallyExclusive("groups", "target") + cmd.MarkFlagsMutuallyExclusive("groups", "role") + cmd.MarkFlagsMutuallyExclusive("group", "target") + cmd.MarkFlagsMutuallyExclusive("group", "role") return cmd } @@ -136,6 +155,8 @@ func parseElevateFlags(cmd *cobra.Command) *elevateFlags { flags.role, _ = cmd.Flags().GetString("role") flags.favorite, _ = cmd.Flags().GetString("favorite") flags.refresh, _ = cmd.Flags().GetBool("refresh") + flags.groups, _ = cmd.Flags().GetBool("groups") + flags.group, _ = cmd.Flags().GetString("group") return flags } @@ -153,9 +174,9 @@ func runElevateProduction(cmd *cobra.Command, args []string) error { return err } - cachedLister := buildCachedLister(cfg, flags.refresh, scaService, nil) + cachedLister := buildCachedLister(cfg, flags.refresh, scaService, scaService) - return runElevateWithDeps(cmd, flags, profile, ispAuth, cachedLister, scaService, &uiSelector{}, cfg) + return runElevateWithDeps(cmd, flags, profile, ispAuth, cachedLister, scaService, &uiUnifiedSelector{}, cachedLister, scaService, cfg) } // buildCachedLister creates a CachedEligibilityLister wrapping the given services. @@ -178,12 +199,14 @@ func NewRootCommandWithDeps( authLoader authLoader, eligibilityLister eligibilityLister, elevateService elevateService, - selector targetSelector, + selector unifiedSelector, + groupsEligLister groupsEligibilityLister, + groupsElevator groupsElevator, cfg *config.Config, ) *cobra.Command { return newRootCommand(func(cmd *cobra.Command, args []string) error { flags := parseElevateFlags(cmd) - return runElevateWithDeps(cmd, flags, profile, authLoader, eligibilityLister, elevateService, selector, cfg) + return runElevateWithDeps(cmd, flags, profile, authLoader, eligibilityLister, elevateService, selector, groupsEligLister, groupsElevator, cfg) }) } @@ -415,6 +438,278 @@ func resolveAndElevate( return &elevationResult{target: selectedTarget, result: &result}, nil } +// fetchGroupsEligibility fetches groups eligibility and enriches with directory names. +func fetchGroupsEligibility(ctx context.Context, groupsEligLister groupsEligibilityLister, cloudEligLister eligibilityLister, errWriter io.Writer) ([]models.GroupsEligibleTarget, error) { + eligResp, err := groupsEligLister.ListGroupsEligibility(ctx, models.CSPAzure) + if err != nil { + return nil, fmt.Errorf("failed to fetch eligible groups: %w", err) + } + if len(eligResp.Response) == 0 { + return nil, fmt.Errorf("no eligible groups found, check your SCA policies") + } + + // Resolve directory names from cloud eligibility (best-effort) + dirNameMap := buildDirectoryNameMap(ctx, cloudEligLister, errWriter) + for i := range eligResp.Response { + if name, ok := dirNameMap[eligResp.Response[i].DirectoryID]; ok { + eligResp.Response[i].DirectoryName = name + } + } + + return eligResp.Response, nil +} + +// resolveAndElevateUnified handles all elevation modes: cloud, group, and unified. +// Returns (*elevationResult, nil, nil) for cloud or (nil, *groupElevationResult, nil) for group. +func resolveAndElevateUnified( + cmd *cobra.Command, + flags *elevateFlags, + profile *sdkmodels.IdsecProfile, + authLoader authLoader, + eligibilityLister eligibilityLister, + elevateService elevateService, + selector unifiedSelector, + groupsEligLister groupsEligibilityLister, + groupsElevator groupsElevator, + cfg *config.Config, +) (*elevationResult, *groupElevationResult, error) { + ctx, cancel := context.WithTimeout(context.Background(), apiTimeout) + defer cancel() + + // Check authentication state + _, err := authLoader.LoadAuthentication(profile, true) + if err != nil { + return nil, nil, fmt.Errorf("not authenticated, run 'grant login' first: %w", err) + } + + // Determine execution mode + var targetName, roleName string + var isFavoriteMode bool + var provider string + var favDirectoryID string + var isGroupFavorite bool + + if flags.favorite != "" { + isFavoriteMode = true + fav, err := config.GetFavorite(cfg, flags.favorite) + if err != nil { + return nil, nil, fmt.Errorf("favorite %q not found, run 'grant favorites list'", flags.favorite) + } + + if fav.ResolvedType() == config.FavoriteTypeGroups { + // Group favorite — set flags to use group path + isGroupFavorite = true + flags.group = fav.Group + favDirectoryID = fav.DirectoryID + } else { + // Cloud favorite + if flags.provider != "" && !strings.EqualFold(flags.provider, fav.Provider) { + return nil, nil, fmt.Errorf("provider %q does not match favorite provider %q", flags.provider, fav.Provider) + } + provider = fav.Provider + targetName = fav.Target + roleName = fav.Role + } + } else { + targetName = flags.target + roleName = flags.role + provider = flags.provider + + if (targetName != "" && roleName == "") || (targetName == "" && roleName != "") { + return nil, nil, fmt.Errorf("both --target and --role must be provided") + } + } + + // Group-only path (--group flag or group favorite) + if flags.group != "" { + groups, err := fetchGroupsEligibility(ctx, groupsEligLister, eligibilityLister, cmd.ErrOrStderr()) + if err != nil { + return nil, nil, err + } + + selectedGroup := findMatchingGroup(groups, flags.group, favDirectoryID) + if selectedGroup == nil { + if favDirectoryID != "" { + return nil, nil, fmt.Errorf("group %q not found in directory %q, run 'grant' to see available options", flags.group, favDirectoryID) + } + return nil, nil, fmt.Errorf("group %q not found, run 'grant' to see available options", flags.group) + } + + return elevateGroup(ctx, selectedGroup, groupsElevator) + } + + // Groups-filter path (--groups flag, no --group) + if flags.groups { + groups, err := fetchGroupsEligibility(ctx, groupsEligLister, eligibilityLister, cmd.ErrOrStderr()) + if err != nil { + return nil, nil, err + } + + var items []selectionItem + for i := range groups { + items = append(items, selectionItem{kind: selectionGroup, group: &groups[i]}) + } + + selected, err := selector.SelectItem(items) + if err != nil { + return nil, nil, fmt.Errorf("selection failed: %w", err) + } + + return elevateGroup(ctx, selected.group, groupsElevator) + } + + // Cloud-only path (--provider specified, or direct/favorite cloud mode) + if provider != "" || isFavoriteMode || (targetName != "" && roleName != "") { + allTargets, err := fetchEligibility(ctx, eligibilityLister, provider) + if err != nil { + return nil, nil, err + } + + var selectedTarget *models.EligibleTarget + if isFavoriteMode && !isGroupFavorite || (targetName != "" && roleName != "") { + selectedTarget = findMatchingTarget(allTargets, targetName, roleName) + if selectedTarget == nil { + return nil, nil, fmt.Errorf("target %q or role %q not found, run 'grant' to see available options", targetName, roleName) + } + } else { + // Interactive cloud-only + var items []selectionItem + for i := range allTargets { + items = append(items, selectionItem{kind: selectionCloud, cloud: &allTargets[i]}) + } + + selected, err := selector.SelectItem(items) + if err != nil { + return nil, nil, fmt.Errorf("selection failed: %w", err) + } + selectedTarget = selected.cloud + } + + resolveTargetCSP(selectedTarget, allTargets, provider) + return elevateCloud(ctx, selectedTarget, elevateService) + } + + // Unified path (no filter flags) — fetch both cloud and groups in parallel + type cloudResult struct { + targets []models.EligibleTarget + err error + } + type groupsResult struct { + groups []models.GroupsEligibleTarget + err error + } + + cloudCh := make(chan cloudResult, 1) + groupsCh := make(chan groupsResult, 1) + + go func() { + targets, err := fetchEligibility(ctx, eligibilityLister, "") + cloudCh <- cloudResult{targets: targets, err: err} + }() + + go func() { + groups, err := fetchGroupsEligibility(ctx, groupsEligLister, eligibilityLister, cmd.ErrOrStderr()) + groupsCh <- groupsResult{groups: groups, err: err} + }() + + cr := <-cloudCh + gr := <-groupsCh + + var items []selectionItem + if cr.err == nil { + for i := range cr.targets { + items = append(items, selectionItem{kind: selectionCloud, cloud: &cr.targets[i]}) + } + } + if gr.err == nil { + for i := range gr.groups { + items = append(items, selectionItem{kind: selectionGroup, group: &gr.groups[i]}) + } + } + + if len(items) == 0 { + return nil, nil, fmt.Errorf("no eligible targets or groups found, check your SCA policies") + } + + selected, err := selector.SelectItem(items) + if err != nil { + return nil, nil, fmt.Errorf("selection failed: %w", err) + } + + switch selected.kind { + case selectionCloud: + resolveTargetCSP(selected.cloud, cr.targets, "") + return elevateCloud(ctx, selected.cloud, elevateService) + case selectionGroup: + return elevateGroup(ctx, selected.group, groupsElevator) + default: + return nil, nil, fmt.Errorf("unexpected selection kind") + } +} + +// elevateCloud performs cloud role elevation for a selected target. +func elevateCloud(ctx context.Context, target *models.EligibleTarget, elevateService elevateService) (*elevationResult, *groupElevationResult, error) { + req := &models.ElevateRequest{ + CSP: target.CSP, + OrganizationID: target.OrganizationID, + Targets: []models.ElevateTarget{ + { + WorkspaceID: target.WorkspaceID, + RoleID: target.RoleInfo.ID, + }, + }, + } + + elevateResp, err := elevateService.Elevate(ctx, req) + if err != nil { + return nil, nil, fmt.Errorf("elevation request failed: %w", err) + } + + if len(elevateResp.Response.Results) == 0 { + return nil, nil, fmt.Errorf("elevation failed: no results returned") + } + + result := elevateResp.Response.Results[0] + if result.ErrorInfo != nil { + return nil, nil, fmt.Errorf("elevation failed: %s - %s\n%s", + result.ErrorInfo.Code, + result.ErrorInfo.Message, + result.ErrorInfo.Description) + } + + return &elevationResult{target: target, result: &result}, nil, nil +} + +// elevateGroup performs Entra ID group membership elevation. +func elevateGroup(ctx context.Context, group *models.GroupsEligibleTarget, elevator groupsElevator) (*elevationResult, *groupElevationResult, error) { + req := &models.GroupsElevateRequest{ + DirectoryID: group.DirectoryID, + CSP: models.CSPAzure, + Targets: []models.GroupsElevateTarget{ + {GroupID: group.GroupID}, + }, + } + + elevateResp, err := elevator.ElevateGroups(ctx, req) + if err != nil { + return nil, nil, fmt.Errorf("elevation request failed: %w", err) + } + + if len(elevateResp.Results) == 0 { + return nil, nil, fmt.Errorf("elevation failed: no results returned") + } + + result := elevateResp.Results[0] + if result.ErrorInfo != nil { + return nil, nil, fmt.Errorf("elevation failed: %s - %s\n%s", + result.ErrorInfo.Code, + result.ErrorInfo.Message, + result.ErrorInfo.Description) + } + + return nil, &groupElevationResult{group: group, result: &result}, nil +} + func runElevateWithDeps( cmd *cobra.Command, flags *elevateFlags, @@ -422,15 +717,32 @@ func runElevateWithDeps( authLoader authLoader, eligibilityLister eligibilityLister, elevateService elevateService, - selector targetSelector, + selector unifiedSelector, + groupsEligLister groupsEligibilityLister, + groupsElevator groupsElevator, cfg *config.Config, ) error { - res, err := resolveAndElevate(flags, profile, authLoader, eligibilityLister, elevateService, selector, cfg) + cloudRes, groupRes, err := resolveAndElevateUnified( + cmd, flags, profile, authLoader, eligibilityLister, elevateService, + selector, groupsEligLister, groupsElevator, cfg, + ) if err != nil { return err } - // Display success message + if groupRes != nil { + // Display group elevation result + dirContext := "" + if groupRes.group.DirectoryName != "" { + dirContext = fmt.Sprintf(" in %s", groupRes.group.DirectoryName) + } + fmt.Fprintf(cmd.OutOrStdout(), "Elevated to group %s%s\n", groupRes.group.GroupName, dirContext) + fmt.Fprintf(cmd.OutOrStdout(), " Session ID: %s\n", groupRes.result.SessionID) + return nil + } + + // Display cloud elevation result + res := cloudRes fmt.Fprintf(cmd.OutOrStdout(), "Elevated to %s on %s\n", res.target.RoleInfo.Name, res.target.WorkspaceName) @@ -469,3 +781,27 @@ type uiSelector struct{} func (s *uiSelector) SelectTarget(targets []models.EligibleTarget) (*models.EligibleTarget, error) { return ui.SelectTarget(targets) } + +// uiUnifiedSelector implements unifiedSelector using survey.Select +type uiUnifiedSelector struct{} + +func (s *uiUnifiedSelector) SelectItem(items []selectionItem) (*selectionItem, error) { + if len(items) == 0 { + return nil, fmt.Errorf("no eligible targets or groups available") + } + + options, sorted := buildUnifiedOptions(items) + + var selected string + prompt := &survey.Select{ + Message: "Select a target:", + Options: options, + Filter: nil, + } + + if err := survey.AskOne(prompt, &selected, survey.WithStdio(os.Stdin, os.Stderr, os.Stderr)); err != nil { + return nil, fmt.Errorf("selection failed: %w", err) + } + + return findItemByDisplay(sorted, selected) +} diff --git a/cmd/root_elevate_test.go b/cmd/root_elevate_test.go index d100261..2a9457b 100644 --- a/cmd/root_elevate_test.go +++ b/cmd/root_elevate_test.go @@ -11,19 +11,20 @@ import ( "github.com/aaearon/grant-cli/internal/sca/models" authmodels "github.com/cyberark/idsec-sdk-golang/pkg/models/auth" commonmodels "github.com/cyberark/idsec-sdk-golang/pkg/models/common" + "github.com/spf13/cobra" ) func TestRootElevate_InteractiveMode(t *testing.T) { tests := []struct { name string - setupMocks func() (*mockAuthLoader, *mockEligibilityLister, *mockElevateService, *mockTargetSelector, *config.Config) + setupMocks func() (*mockAuthLoader, *mockEligibilityLister, *mockElevateService, *mockUnifiedSelector, *config.Config) args []string wantContain []string wantErr bool }{ { name: "interactive mode success", - setupMocks: func() (*mockAuthLoader, *mockEligibilityLister, *mockElevateService, *mockTargetSelector, *config.Config) { + setupMocks: func() (*mockAuthLoader, *mockEligibilityLister, *mockElevateService, *mockUnifiedSelector, *config.Config) { authLoader := &mockAuthLoader{ token: &authmodels.IdsecToken{ Token: "test-jwt", @@ -66,8 +67,8 @@ func TestRootElevate_InteractiveMode(t *testing.T) { }, } - selector := &mockTargetSelector{ - target: &models.EligibleTarget{ + selector := &mockUnifiedSelector{ + item: &selectionItem{kind: selectionCloud, cloud: &models.EligibleTarget{ OrganizationID: "org-123", WorkspaceID: "sub-456", WorkspaceName: "Prod-EastUS", @@ -76,7 +77,7 @@ func TestRootElevate_InteractiveMode(t *testing.T) { ID: "role-789", Name: "Contributor", }, - }, + }}, } cfg := config.DefaultConfig() @@ -93,7 +94,7 @@ func TestRootElevate_InteractiveMode(t *testing.T) { }, { name: "AWS elevation success with credentials", - setupMocks: func() (*mockAuthLoader, *mockEligibilityLister, *mockElevateService, *mockTargetSelector, *config.Config) { + setupMocks: func() (*mockAuthLoader, *mockEligibilityLister, *mockElevateService, *mockUnifiedSelector, *config.Config) { authLoader := &mockAuthLoader{ token: &authmodels.IdsecToken{ Token: "test-jwt", @@ -138,8 +139,8 @@ func TestRootElevate_InteractiveMode(t *testing.T) { }, } - selector := &mockTargetSelector{ - target: &models.EligibleTarget{ + selector := &mockUnifiedSelector{ + item: &selectionItem{kind: selectionCloud, cloud: &models.EligibleTarget{ OrganizationID: "o-abc123", WorkspaceID: "123456789012", WorkspaceName: "AWS Management", @@ -148,7 +149,7 @@ func TestRootElevate_InteractiveMode(t *testing.T) { ID: "arn:aws:iam::123456789012:role/AdminAccess", Name: "AdminAccess", }, - }, + }}, } cfg := config.DefaultConfig() @@ -167,7 +168,7 @@ func TestRootElevate_InteractiveMode(t *testing.T) { }, { name: "multi-CSP interactive mode - mixed providers", - setupMocks: func() (*mockAuthLoader, *mockEligibilityLister, *mockElevateService, *mockTargetSelector, *config.Config) { + setupMocks: func() (*mockAuthLoader, *mockEligibilityLister, *mockElevateService, *mockUnifiedSelector, *config.Config) { authLoader := &mockAuthLoader{ token: &authmodels.IdsecToken{ Token: "test-jwt", @@ -223,8 +224,8 @@ func TestRootElevate_InteractiveMode(t *testing.T) { } // User selects the AWS target - selector := &mockTargetSelector{ - target: &awsTarget, + selector := &mockUnifiedSelector{ + item: &selectionItem{kind: selectionCloud, cloud: &awsTarget}, } cfg := config.DefaultConfig() @@ -241,7 +242,7 @@ func TestRootElevate_InteractiveMode(t *testing.T) { }, { name: "multi-CSP concurrent fetch - parallel execution", - setupMocks: func() (*mockAuthLoader, *mockEligibilityLister, *mockElevateService, *mockTargetSelector, *config.Config) { + setupMocks: func() (*mockAuthLoader, *mockEligibilityLister, *mockElevateService, *mockUnifiedSelector, *config.Config) { authLoader := &mockAuthLoader{ token: &authmodels.IdsecToken{ Token: "test-jwt", @@ -297,8 +298,8 @@ func TestRootElevate_InteractiveMode(t *testing.T) { }, } - selector := &mockTargetSelector{ - target: &awsTarget, + selector := &mockUnifiedSelector{ + item: &selectionItem{kind: selectionCloud, cloud: &awsTarget}, } cfg := config.DefaultConfig() @@ -314,7 +315,7 @@ func TestRootElevate_InteractiveMode(t *testing.T) { }, { name: "no eligible targets found across all providers", - setupMocks: func() (*mockAuthLoader, *mockEligibilityLister, *mockElevateService, *mockTargetSelector, *config.Config) { + setupMocks: func() (*mockAuthLoader, *mockEligibilityLister, *mockElevateService, *mockUnifiedSelector, *config.Config) { authLoader := &mockAuthLoader{ token: &authmodels.IdsecToken{Token: "test-jwt"}, } @@ -332,7 +333,7 @@ func TestRootElevate_InteractiveMode(t *testing.T) { }, args: []string{}, wantContain: []string{ - "no eligible targets found", + "no eligible targets or groups found", }, wantErr: true, }, @@ -342,7 +343,7 @@ func TestRootElevate_InteractiveMode(t *testing.T) { t.Run(tt.name, func(t *testing.T) { authLoader, eligibilityLister, elevateService, selector, cfg := tt.setupMocks() - cmd := NewRootCommandWithDeps(nil, authLoader, eligibilityLister, elevateService, selector, cfg) + cmd := NewRootCommandWithDeps(nil, authLoader, eligibilityLister, elevateService, selector, &mockGroupsEligibilityLister{response: &models.GroupsEligibilityResponse{}}, nil, cfg) output, err := executeCommand(cmd, tt.args...) @@ -568,7 +569,7 @@ func TestRootElevate_DirectMode(t *testing.T) { t.Run(tt.name, func(t *testing.T) { authLoader, eligibilityLister, elevateService, cfg := tt.setupMocks() - cmd := NewRootCommandWithDeps(nil, authLoader, eligibilityLister, elevateService, nil, cfg) + cmd := NewRootCommandWithDeps(nil, authLoader, eligibilityLister, elevateService, nil, nil, nil, cfg) output, err := executeCommand(cmd, tt.args...) @@ -591,14 +592,14 @@ func TestRootElevate_DirectMode(t *testing.T) { func TestRootElevate_FavoriteMode(t *testing.T) { tests := []struct { name string - setupMocks func() (*mockAuthLoader, *mockEligibilityLister, *mockElevateService, *config.Config) + setupMocks func() (*mockAuthLoader, *mockEligibilityLister, *mockElevateService, *mockGroupsEligibilityLister, *mockGroupsElevator, *config.Config) args []string wantContain []string wantErr bool }{ { name: "favorite mode success", - setupMocks: func() (*mockAuthLoader, *mockEligibilityLister, *mockElevateService, *config.Config) { + setupMocks: func() (*mockAuthLoader, *mockEligibilityLister, *mockElevateService, *mockGroupsEligibilityLister, *mockGroupsElevator, *config.Config) { authLoader := &mockAuthLoader{ token: &authmodels.IdsecToken{Token: "test-jwt"}, } @@ -646,7 +647,7 @@ func TestRootElevate_FavoriteMode(t *testing.T) { }, } - return authLoader, eligibilityLister, elevateService, cfg + return authLoader, eligibilityLister, elevateService, nil, nil, cfg }, args: []string{"--favorite", "prod-contrib"}, wantContain: []string{ @@ -657,14 +658,14 @@ func TestRootElevate_FavoriteMode(t *testing.T) { }, { name: "favorite not found", - setupMocks: func() (*mockAuthLoader, *mockEligibilityLister, *mockElevateService, *config.Config) { + setupMocks: func() (*mockAuthLoader, *mockEligibilityLister, *mockElevateService, *mockGroupsEligibilityLister, *mockGroupsElevator, *config.Config) { authLoader := &mockAuthLoader{ token: &authmodels.IdsecToken{Token: "test-jwt"}, } cfg := config.DefaultConfig() - return authLoader, nil, nil, cfg + return authLoader, nil, nil, nil, nil, cfg }, args: []string{"--favorite", "nonexistent"}, wantContain: []string{ @@ -674,7 +675,7 @@ func TestRootElevate_FavoriteMode(t *testing.T) { }, { name: "provider mismatch with favorite", - setupMocks: func() (*mockAuthLoader, *mockEligibilityLister, *mockElevateService, *config.Config) { + setupMocks: func() (*mockAuthLoader, *mockEligibilityLister, *mockElevateService, *mockGroupsEligibilityLister, *mockGroupsElevator, *config.Config) { authLoader := &mockAuthLoader{ token: &authmodels.IdsecToken{Token: "test-jwt"}, } @@ -688,7 +689,7 @@ func TestRootElevate_FavoriteMode(t *testing.T) { }, } - return authLoader, nil, nil, cfg + return authLoader, nil, nil, nil, nil, cfg }, args: []string{"--favorite", "prod-contrib", "--provider", "aws"}, wantContain: []string{ @@ -697,12 +698,38 @@ func TestRootElevate_FavoriteMode(t *testing.T) { wantErr: true, }, { - name: "group favorite redirects to grant groups", - setupMocks: func() (*mockAuthLoader, *mockEligibilityLister, *mockElevateService, *config.Config) { + name: "group favorite elevates group directly", + setupMocks: func() (*mockAuthLoader, *mockEligibilityLister, *mockElevateService, *mockGroupsEligibilityLister, *mockGroupsElevator, *config.Config) { authLoader := &mockAuthLoader{ token: &authmodels.IdsecToken{Token: "test-jwt"}, } + groupsEligLister := &mockGroupsEligibilityLister{ + response: &models.GroupsEligibilityResponse{ + Response: []models.GroupsEligibleTarget{ + { + DirectoryID: "dir-uuid", + GroupID: "grp-123", + GroupName: "Engineering", + }, + }, + Total: 1, + }, + } + + groupsElevator := &mockGroupsElevator{ + response: &models.GroupsElevateResponse{ + DirectoryID: "dir-uuid", + CSP: models.CSPAzure, + Results: []models.GroupsElevateTargetResult{ + { + GroupID: "grp-123", + SessionID: "session-grp-fav", + }, + }, + }, + } + cfg := config.DefaultConfig() _ = config.AddFavorite(cfg, "my-grp", config.Favorite{ Type: config.FavoriteTypeGroups, @@ -711,22 +738,22 @@ func TestRootElevate_FavoriteMode(t *testing.T) { DirectoryID: "dir-uuid", }) - return authLoader, nil, nil, cfg + return authLoader, &mockEligibilityLister{response: &models.EligibilityResponse{}}, nil, groupsEligLister, groupsElevator, cfg }, args: []string{"--favorite", "my-grp"}, wantContain: []string{ - "group favorite", - "grant groups --favorite my-grp", + "Elevated to group Engineering", + "Session ID: session-grp-fav", }, - wantErr: true, + wantErr: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - authLoader, eligibilityLister, elevateService, cfg := tt.setupMocks() + authLoader, eligibilityLister, elevateService, groupsEligLister, groupsElevator, cfg := tt.setupMocks() - cmd := NewRootCommandWithDeps(nil, authLoader, eligibilityLister, elevateService, nil, cfg) + cmd := NewRootCommandWithDeps(nil, authLoader, eligibilityLister, elevateService, nil, groupsEligLister, groupsElevator, cfg) output, err := executeCommand(cmd, tt.args...) @@ -818,7 +845,7 @@ func TestRootElevate_ProviderValidation(t *testing.T) { t.Run(tt.name, func(t *testing.T) { authLoader, eligibilityLister, cfg := tt.setupMocks() - cmd := NewRootCommandWithDeps(nil, authLoader, eligibilityLister, nil, nil, cfg) + cmd := NewRootCommandWithDeps(nil, authLoader, eligibilityLister, nil, nil, nil, nil, cfg) output, err := executeCommand(cmd, tt.args...) @@ -869,7 +896,7 @@ func TestRootElevate_AuthenticationErrors(t *testing.T) { t.Run(tt.name, func(t *testing.T) { authLoader, cfg := tt.setupMocks() - cmd := NewRootCommandWithDeps(nil, authLoader, nil, nil, nil, cfg) + cmd := NewRootCommandWithDeps(nil, authLoader, nil, nil, nil, nil, nil, cfg) output, err := executeCommand(cmd, tt.args...) @@ -983,7 +1010,7 @@ func TestRootElevate_ElevationErrors(t *testing.T) { t.Run(tt.name, func(t *testing.T) { authLoader, eligibilityLister, elevateService, cfg := tt.setupMocks() - cmd := NewRootCommandWithDeps(nil, authLoader, eligibilityLister, elevateService, nil, cfg) + cmd := NewRootCommandWithDeps(nil, authLoader, eligibilityLister, elevateService, nil, nil, nil, cfg) output, err := executeCommand(cmd, tt.args...) @@ -1005,7 +1032,7 @@ func TestRootElevate_ElevationErrors(t *testing.T) { func TestRootElevate_UsageAndFlags(t *testing.T) { cfg := config.DefaultConfig() - cmd := NewRootCommandWithDeps(nil, &mockAuthLoader{}, nil, nil, nil, cfg) + cmd := NewRootCommandWithDeps(nil, &mockAuthLoader{}, nil, nil, nil, nil, nil, cfg) // Verify command metadata if cmd.Use != "grant" { @@ -1115,3 +1142,454 @@ func TestFetchEligibility_ConcurrentExecution(t *testing.T) { t.Errorf("expected both CSPs in results, got %v", cspSeen) } } + +func TestRootElevate_GroupsInteractiveMode(t *testing.T) { + now := time.Now() + expiresIn := commonmodels.IdsecRFC3339Time(now.Add(1 * time.Hour)) + + authLoader := &mockAuthLoader{ + token: &authmodels.IdsecToken{Token: "jwt", Username: "user@example.com", ExpiresIn: expiresIn}, + } + cloudElig := &mockEligibilityLister{ + response: &models.EligibilityResponse{ + Response: []models.EligibleTarget{ + { + OrganizationID: "dir1", + WorkspaceID: "dir1", + WorkspaceName: "Contoso", + WorkspaceType: models.WorkspaceTypeDirectory, + }, + }, + }, + } + groupsElig := &mockGroupsEligibilityLister{ + response: &models.GroupsEligibilityResponse{ + Response: []models.GroupsEligibleTarget{ + {DirectoryID: "dir1", GroupID: "grp1", GroupName: "Engineering"}, + {DirectoryID: "dir1", GroupID: "grp2", GroupName: "DevOps"}, + }, + Total: 2, + }, + } + groupsElev := &mockGroupsElevator{ + response: &models.GroupsElevateResponse{ + DirectoryID: "dir1", + CSP: models.CSPAzure, + Results: []models.GroupsElevateTargetResult{ + {GroupID: "grp1", SessionID: "sess-grp-1"}, + }, + }, + } + + // User selects a group from the unified list + selector := &mockUnifiedSelector{ + selectFunc: func(items []selectionItem) (*selectionItem, error) { + // Verify both cloud and group items are present + hasCloud := false + hasGroup := false + for _, item := range items { + if item.kind == selectionCloud { + hasCloud = true + } + if item.kind == selectionGroup { + hasGroup = true + } + } + if !hasCloud { + t.Error("expected cloud items in unified selector") + } + if !hasGroup { + t.Error("expected group items in unified selector") + } + // Select the Engineering group + for i := range items { + if items[i].kind == selectionGroup && items[i].group.GroupName == "Engineering" { + return &items[i], nil + } + } + t.Fatal("Engineering group not found in items") + return nil, nil + }, + } + + cmd := NewRootCommandWithDeps(nil, authLoader, cloudElig, nil, selector, groupsElig, groupsElev, config.DefaultConfig()) + output, err := executeCommand(cmd) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(output, "Elevated to group Engineering in Contoso") { + t.Errorf("output missing expected text, got:\n%s", output) + } + if !strings.Contains(output, "Session ID: sess-grp-1") { + t.Errorf("output missing session ID, got:\n%s", output) + } +} + +func TestRootElevate_GroupsDirectMode(t *testing.T) { + now := time.Now() + expiresIn := commonmodels.IdsecRFC3339Time(now.Add(1 * time.Hour)) + + tests := []struct { + name string + args []string + groupsElig *mockGroupsEligibilityLister + groupsElev *mockGroupsElevator + cloudElig *mockEligibilityLister + wantContain []string + wantErr bool + }{ + { + name: "direct group elevation success", + args: []string{"--group", "Engineering"}, + cloudElig: &mockEligibilityLister{ + response: &models.EligibilityResponse{Response: []models.EligibleTarget{}}, + }, + groupsElig: &mockGroupsEligibilityLister{ + response: &models.GroupsEligibilityResponse{ + Response: []models.GroupsEligibleTarget{ + {DirectoryID: "dir1", GroupID: "grp1", GroupName: "Engineering"}, + }, + Total: 1, + }, + }, + groupsElev: &mockGroupsElevator{ + response: &models.GroupsElevateResponse{ + DirectoryID: "dir1", + CSP: models.CSPAzure, + Results: []models.GroupsElevateTargetResult{ + {GroupID: "grp1", SessionID: "sess1"}, + }, + }, + }, + wantContain: []string{"Elevated to group Engineering", "Session ID: sess1"}, + wantErr: false, + }, + { + name: "direct group not found", + args: []string{"--group", "NonExistent"}, + cloudElig: &mockEligibilityLister{ + response: &models.EligibilityResponse{Response: []models.EligibleTarget{}}, + }, + groupsElig: &mockGroupsEligibilityLister{ + response: &models.GroupsEligibilityResponse{ + Response: []models.GroupsEligibleTarget{ + {DirectoryID: "dir1", GroupID: "grp1", GroupName: "Engineering"}, + }, + Total: 1, + }, + }, + groupsElev: &mockGroupsElevator{}, + wantContain: []string{`group "NonExistent" not found`}, + wantErr: true, + }, + { + name: "group elevation API error", + args: []string{"--group", "Engineering"}, + cloudElig: &mockEligibilityLister{ + response: &models.EligibilityResponse{Response: []models.EligibleTarget{}}, + }, + groupsElig: &mockGroupsEligibilityLister{ + response: &models.GroupsEligibilityResponse{ + Response: []models.GroupsEligibleTarget{ + {DirectoryID: "dir1", GroupID: "grp1", GroupName: "Engineering"}, + }, + Total: 1, + }, + }, + groupsElev: &mockGroupsElevator{elevateErr: errors.New("API error: forbidden")}, + wantContain: []string{"elevation request failed"}, + wantErr: true, + }, + { + name: "groups eligibility API error", + args: []string{"--group", "Engineering"}, + cloudElig: &mockEligibilityLister{ + response: &models.EligibilityResponse{Response: []models.EligibleTarget{}}, + }, + groupsElig: &mockGroupsEligibilityLister{listErr: errors.New("service unavailable")}, + groupsElev: &mockGroupsElevator{}, + wantContain: []string{"failed to fetch eligible groups"}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + authLoader := &mockAuthLoader{ + token: &authmodels.IdsecToken{Token: "jwt", Username: "user@example.com", ExpiresIn: expiresIn}, + } + + cmd := NewRootCommandWithDeps(nil, authLoader, tt.cloudElig, nil, nil, tt.groupsElig, tt.groupsElev, config.DefaultConfig()) + output, err := executeCommand(cmd, tt.args...) + + if tt.wantErr && err == nil { + t.Errorf("expected error but got none, output:\n%s", output) + } + if !tt.wantErr && err != nil { + t.Errorf("unexpected error: %v", err) + } + for _, want := range tt.wantContain { + if !strings.Contains(output, want) { + t.Errorf("output missing %q\ngot:\n%s", want, output) + } + } + }) + } +} + +func TestRootElevate_GroupsFlag(t *testing.T) { + now := time.Now() + expiresIn := commonmodels.IdsecRFC3339Time(now.Add(1 * time.Hour)) + + authLoader := &mockAuthLoader{ + token: &authmodels.IdsecToken{Token: "jwt", Username: "user@example.com", ExpiresIn: expiresIn}, + } + cloudElig := &mockEligibilityLister{ + response: &models.EligibilityResponse{Response: []models.EligibleTarget{}}, + } + groupsElig := &mockGroupsEligibilityLister{ + response: &models.GroupsEligibilityResponse{ + Response: []models.GroupsEligibleTarget{ + {DirectoryID: "dir1", GroupID: "grp1", GroupName: "Engineering"}, + {DirectoryID: "dir1", GroupID: "grp2", GroupName: "DevOps"}, + }, + Total: 2, + }, + } + groupsElev := &mockGroupsElevator{ + response: &models.GroupsElevateResponse{ + DirectoryID: "dir1", + CSP: models.CSPAzure, + Results: []models.GroupsElevateTargetResult{ + {GroupID: "grp2", SessionID: "sess-devops"}, + }, + }, + } + + selector := &mockUnifiedSelector{ + selectFunc: func(items []selectionItem) (*selectionItem, error) { + // Verify only group items are present (no cloud) + for _, item := range items { + if item.kind == selectionCloud { + t.Error("expected no cloud items when --groups flag is set") + } + } + if len(items) != 2 { + t.Errorf("expected 2 group items, got %d", len(items)) + } + // Select DevOps + for i := range items { + if items[i].kind == selectionGroup && items[i].group.GroupName == "DevOps" { + return &items[i], nil + } + } + t.Fatal("DevOps group not found") + return nil, nil + }, + } + + cmd := NewRootCommandWithDeps(nil, authLoader, cloudElig, nil, selector, groupsElig, groupsElev, config.DefaultConfig()) + output, err := executeCommand(cmd, "--groups") + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(output, "Elevated to group DevOps") { + t.Errorf("output missing expected text, got:\n%s", output) + } + if !strings.Contains(output, "Session ID: sess-devops") { + t.Errorf("output missing session ID, got:\n%s", output) + } +} + +func TestRootElevate_GroupFavoriteDirectoryID(t *testing.T) { + now := time.Now() + expiresIn := commonmodels.IdsecRFC3339Time(now.Add(1 * time.Hour)) + + authLoader := &mockAuthLoader{ + token: &authmodels.IdsecToken{Token: "jwt", Username: "user@example.com", ExpiresIn: expiresIn}, + } + cloudElig := &mockEligibilityLister{ + response: &models.EligibilityResponse{Response: []models.EligibleTarget{}}, + } + groupsElig := &mockGroupsEligibilityLister{ + response: &models.GroupsEligibilityResponse{ + Response: []models.GroupsEligibleTarget{ + {DirectoryID: "dir2", GroupID: "grp-wrong", GroupName: "Engineering"}, + {DirectoryID: "dir1", GroupID: "grp-right", GroupName: "Engineering"}, + }, + Total: 2, + }, + } + + cfg := config.DefaultConfig() + _ = config.AddFavorite(cfg, "my-grp", config.Favorite{ + Type: config.FavoriteTypeGroups, + Provider: "azure", + Group: "Engineering", + DirectoryID: "dir1", + }) + + groupsElev := &mockGroupsElevator{ + elevateFunc: func(ctx context.Context, req *models.GroupsElevateRequest) (*models.GroupsElevateResponse, error) { + if req.Targets[0].GroupID != "grp-right" { + t.Errorf("expected group ID grp-right, got %s", req.Targets[0].GroupID) + } + if req.DirectoryID != "dir1" { + t.Errorf("expected directory ID dir1, got %s", req.DirectoryID) + } + return &models.GroupsElevateResponse{ + DirectoryID: "dir1", + CSP: models.CSPAzure, + Results: []models.GroupsElevateTargetResult{ + {GroupID: "grp-right", SessionID: "sess1"}, + }, + }, nil + }, + } + + cmd := NewRootCommandWithDeps(nil, authLoader, cloudElig, nil, nil, groupsElig, groupsElev, cfg) + output, err := executeCommand(cmd, "--favorite", "my-grp") + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(output, "Elevated to group Engineering") { + t.Errorf("output missing expected text, got:\n%s", output) + } + if !strings.Contains(output, "Session ID: sess1") { + t.Errorf("output missing session ID, got:\n%s", output) + } +} + +func TestRootElevate_MutualExclusivity(t *testing.T) { + tests := []struct { + name string + args []string + wantErr bool + }{ + { + name: "groups and provider are mutually exclusive", + args: []string{"--groups", "--provider", "azure"}, + wantErr: true, + }, + { + name: "group and target are mutually exclusive", + args: []string{"--group", "Eng", "--target", "Sub1"}, + wantErr: true, + }, + { + name: "group and role are mutually exclusive", + args: []string{"--group", "Eng", "--role", "Reader"}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := newRootCommand(func(cmd *cobra.Command, args []string) error { + return nil + }) + cmd.SetArgs(tt.args) + err := cmd.Execute() + if tt.wantErr && err == nil { + t.Error("expected error but got none") + } + }) + } +} + +func TestRootElevate_UnifiedInteractiveShowsBoth(t *testing.T) { + now := time.Now() + expiresIn := commonmodels.IdsecRFC3339Time(now.Add(1 * time.Hour)) + + authLoader := &mockAuthLoader{ + token: &authmodels.IdsecToken{Token: "jwt", Username: "user@example.com", ExpiresIn: expiresIn}, + } + + // Cloud eligibility with a subscription target + cloudElig := &mockEligibilityLister{ + listFunc: func(ctx context.Context, csp models.CSP) (*models.EligibilityResponse, error) { + if csp == models.CSPAzure { + return &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, + }, nil + } + return &models.EligibilityResponse{}, nil + }, + } + + groupsElig := &mockGroupsEligibilityLister{ + response: &models.GroupsEligibilityResponse{ + Response: []models.GroupsEligibleTarget{ + {DirectoryID: "dir1", GroupID: "grp1", GroupName: "Engineering"}, + }, + Total: 1, + }, + } + + elevateService := &mockElevateService{ + response: &models.ElevateResponse{ + Response: models.ElevateAccessResult{ + CSP: models.CSPAzure, + OrganizationID: "org-1", + Results: []models.ElevateTargetResult{ + {WorkspaceID: "sub-1", RoleID: "role-1", SessionID: "sess-cloud"}, + }, + }, + }, + } + + var receivedItems []selectionItem + selector := &mockUnifiedSelector{ + selectFunc: func(items []selectionItem) (*selectionItem, error) { + receivedItems = items + // Select the cloud target + for i := range items { + if items[i].kind == selectionCloud { + return &items[i], nil + } + } + return nil, errors.New("no cloud item found") + }, + } + + cmd := NewRootCommandWithDeps(nil, authLoader, cloudElig, elevateService, selector, groupsElig, nil, config.DefaultConfig()) + output, err := executeCommand(cmd) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Verify both cloud and group items were passed to selector + hasCloud := false + hasGroup := false + for _, item := range receivedItems { + if item.kind == selectionCloud { + hasCloud = true + } + if item.kind == selectionGroup { + hasGroup = true + } + } + if !hasCloud { + t.Error("expected cloud items in unified selector") + } + if !hasGroup { + t.Error("expected group items in unified selector") + } + + if !strings.Contains(output, "Elevated to Contributor on Prod-EastUS") { + t.Errorf("output missing expected text, got:\n%s", output) + } +} diff --git a/cmd/root_test.go b/cmd/root_test.go index 1c766f7..bff80e1 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -24,7 +24,7 @@ func TestNewRootCommand_SilenceFlags(t *testing.T) { func TestNewRootCommand_FlagsRegistered(t *testing.T) { cmd := newRootCommand(nil) - flags := []string{"verbose", "provider", "target", "role", "favorite", "refresh"} + flags := []string{"verbose", "provider", "target", "role", "favorite", "refresh", "groups", "group"} for _, flag := range flags { if cmd.Flags().Lookup(flag) == nil && cmd.PersistentFlags().Lookup(flag) == nil { t.Errorf("expected --%s flag to be registered", flag) diff --git a/cmd/selection.go b/cmd/selection.go new file mode 100644 index 0000000..1ec569f --- /dev/null +++ b/cmd/selection.go @@ -0,0 +1,82 @@ +package cmd + +import ( + "fmt" + "sort" + + scamodels "github.com/aaearon/grant-cli/internal/sca/models" + "github.com/aaearon/grant-cli/internal/ui" +) + +type selectionKind int + +const ( + selectionCloud selectionKind = iota + selectionGroup +) + +// selectionItem is a tagged union representing either a cloud target or a group target. +type selectionItem struct { + kind selectionKind + cloud *scamodels.EligibleTarget + group *scamodels.GroupsEligibleTarget +} + +// groupElevationResult holds the outcome of a successful group elevation request. +type groupElevationResult struct { + group *scamodels.GroupsEligibleTarget + result *scamodels.GroupsElevateTargetResult +} + +// formatSelectionItem formats a selectionItem into a display string. +// Group items always show an (azure) suffix since Entra ID groups are Azure-only. +func formatSelectionItem(item selectionItem) string { + switch item.kind { + case selectionCloud: + return ui.FormatTargetOption(*item.cloud) + case selectionGroup: + return fmt.Sprintf("%s (azure)", ui.FormatGroupOption(*item.group)) + default: + return "" + } +} + +// buildUnifiedOptions builds sorted display strings and a matching sorted items slice. +func buildUnifiedOptions(items []selectionItem) ([]string, []selectionItem) { + if len(items) == 0 { + return []string{}, nil + } + + // Build display strings for sorting + type indexed struct { + display string + item selectionItem + } + pairs := make([]indexed, len(items)) + for i, item := range items { + pairs[i] = indexed{display: formatSelectionItem(item), item: item} + } + + sort.Slice(pairs, func(i, j int) bool { + return pairs[i].display < pairs[j].display + }) + + options := make([]string, len(pairs)) + sorted := make([]selectionItem, len(pairs)) + for i, p := range pairs { + options[i] = p.display + sorted[i] = p.item + } + + return options, sorted +} + +// findItemByDisplay finds a selectionItem by its formatted display string. +func findItemByDisplay(items []selectionItem, display string) (*selectionItem, error) { + for i := range items { + if formatSelectionItem(items[i]) == display { + return &items[i], nil + } + } + return nil, fmt.Errorf("item not found: %s", display) +} diff --git a/cmd/selection_test.go b/cmd/selection_test.go new file mode 100644 index 0000000..554c79b --- /dev/null +++ b/cmd/selection_test.go @@ -0,0 +1,203 @@ +package cmd + +import ( + "testing" + + scamodels "github.com/aaearon/grant-cli/internal/sca/models" +) + +func TestFormatSelectionItem(t *testing.T) { + tests := []struct { + name string + item selectionItem + want string + }{ + { + name: "cloud item delegates to FormatTargetOption", + item: selectionItem{ + kind: selectionCloud, + cloud: &scamodels.EligibleTarget{ + WorkspaceName: "Prod-EastUS", + WorkspaceType: scamodels.WorkspaceTypeSubscription, + RoleInfo: scamodels.RoleInfo{Name: "Contributor"}, + }, + }, + want: "Subscription: Prod-EastUS / Role: Contributor", + }, + { + name: "cloud item with CSP tag", + item: selectionItem{ + kind: selectionCloud, + cloud: &scamodels.EligibleTarget{ + WorkspaceName: "AWS Sandbox", + WorkspaceType: scamodels.WorkspaceTypeAccount, + RoleInfo: scamodels.RoleInfo{Name: "ReadOnly"}, + CSP: scamodels.CSPAWS, + }, + }, + want: "Account: AWS Sandbox / Role: ReadOnly (aws)", + }, + { + name: "group item with directory name shows azure suffix", + item: selectionItem{ + kind: selectionGroup, + group: &scamodels.GroupsEligibleTarget{ + DirectoryName: "Contoso", + GroupName: "Engineering", + }, + }, + want: "Directory: Contoso / Group: Engineering (azure)", + }, + { + name: "group item without directory name shows azure suffix", + item: selectionItem{ + kind: selectionGroup, + group: &scamodels.GroupsEligibleTarget{ + GroupName: "DevOps", + }, + }, + want: "Group: DevOps (azure)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := formatSelectionItem(tt.item) + if got != tt.want { + t.Errorf("formatSelectionItem() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestBuildUnifiedOptions(t *testing.T) { + tests := []struct { + name string + items []selectionItem + wantLen int + wantFirst string + }{ + { + name: "empty list returns empty", + items: []selectionItem{}, + wantLen: 0, + }, + { + name: "mixed items sorted alphabetically", + items: []selectionItem{ + { + kind: selectionCloud, + cloud: &scamodels.EligibleTarget{ + WorkspaceName: "Prod-EastUS", + WorkspaceType: scamodels.WorkspaceTypeSubscription, + RoleInfo: scamodels.RoleInfo{Name: "Contributor"}, + }, + }, + { + kind: selectionGroup, + group: &scamodels.GroupsEligibleTarget{ + DirectoryName: "Contoso", + GroupName: "Engineering", + }, + }, + }, + wantLen: 2, + wantFirst: "Directory: Contoso / Group: Engineering (azure)", // D < S + }, + { + name: "cloud items only", + items: []selectionItem{ + { + kind: selectionCloud, + cloud: &scamodels.EligibleTarget{ + WorkspaceName: "Z-Sub", + WorkspaceType: scamodels.WorkspaceTypeSubscription, + RoleInfo: scamodels.RoleInfo{Name: "Reader"}, + }, + }, + { + kind: selectionCloud, + cloud: &scamodels.EligibleTarget{ + WorkspaceName: "A-Sub", + WorkspaceType: scamodels.WorkspaceTypeSubscription, + RoleInfo: scamodels.RoleInfo{Name: "Contributor"}, + }, + }, + }, + wantLen: 2, + wantFirst: "Subscription: A-Sub / Role: Contributor", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + options, sorted := buildUnifiedOptions(tt.items) + if len(options) != tt.wantLen { + t.Errorf("buildUnifiedOptions() returned %d options, want %d", len(options), tt.wantLen) + } + if len(sorted) != tt.wantLen { + t.Errorf("buildUnifiedOptions() returned %d sorted items, want %d", len(sorted), tt.wantLen) + } + if tt.wantFirst != "" && len(options) > 0 && options[0] != tt.wantFirst { + t.Errorf("first option = %q, want %q", options[0], tt.wantFirst) + } + }) + } +} + +func TestFindItemByDisplay(t *testing.T) { + cloudTarget := &scamodels.EligibleTarget{ + WorkspaceName: "Prod-EastUS", + WorkspaceType: scamodels.WorkspaceTypeSubscription, + RoleInfo: scamodels.RoleInfo{Name: "Contributor"}, + } + groupTarget := &scamodels.GroupsEligibleTarget{ + DirectoryName: "Contoso", + GroupName: "Engineering", + } + + items := []selectionItem{ + {kind: selectionCloud, cloud: cloudTarget}, + {kind: selectionGroup, group: groupTarget}, + } + + tests := []struct { + name string + display string + wantErr bool + }{ + { + name: "finds cloud by display", + display: "Subscription: Prod-EastUS / Role: Contributor", + wantErr: false, + }, + { + name: "finds group by display", + display: "Directory: Contoso / Group: Engineering (azure)", + wantErr: false, + }, + { + name: "returns error on mismatch", + display: "NonExistent Display String", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + item, err := findItemByDisplay(items, tt.display) + if tt.wantErr { + if err == nil { + t.Error("expected error but got none") + } + return + } + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if item == nil { + t.Fatal("expected non-nil item") + } + }) + } +} diff --git a/cmd/test_mocks.go b/cmd/test_mocks.go index 56e1c48..f44710c 100644 --- a/cmd/test_mocks.go +++ b/cmd/test_mocks.go @@ -208,18 +208,18 @@ func (m *mockGroupsElevator) ElevateGroups(ctx context.Context, req *models.Grou return m.response, m.elevateErr } -// mockGroupSelector implements groupSelector for testing -type mockGroupSelector struct { - selectFunc func(groups []models.GroupsEligibleTarget) (*models.GroupsEligibleTarget, error) - group *models.GroupsEligibleTarget +// mockUnifiedSelector implements unifiedSelector for testing +type mockUnifiedSelector struct { + selectFunc func(items []selectionItem) (*selectionItem, error) + item *selectionItem selectErr error } -func (m *mockGroupSelector) SelectGroup(groups []models.GroupsEligibleTarget) (*models.GroupsEligibleTarget, error) { +func (m *mockUnifiedSelector) SelectItem(items []selectionItem) (*selectionItem, error) { if m.selectFunc != nil { - return m.selectFunc(groups) + return m.selectFunc(items) } - return m.group, m.selectErr + return m.item, m.selectErr } // countingEligibilityLister wraps an eligibilityLister and counts calls per CSP. @@ -247,26 +247,3 @@ func (c *countingEligibilityLister) CallCount(csp models.CSP) int { return c.counts[csp] } -// countingGroupsEligibilityLister wraps a groupsEligibilityLister and counts calls per CSP. -type countingGroupsEligibilityLister struct { - inner groupsEligibilityLister - mu sync.Mutex - counts map[models.CSP]int -} - -func newCountingGroupsEligibilityLister(inner groupsEligibilityLister) *countingGroupsEligibilityLister { - return &countingGroupsEligibilityLister{inner: inner, counts: make(map[models.CSP]int)} -} - -func (c *countingGroupsEligibilityLister) ListGroupsEligibility(ctx context.Context, csp models.CSP) (*models.GroupsEligibilityResponse, error) { - c.mu.Lock() - c.counts[csp]++ - c.mu.Unlock() - return c.inner.ListGroupsEligibility(ctx, csp) -} - -func (c *countingGroupsEligibilityLister) CallCount(csp models.CSP) int { - c.mu.Lock() - defer c.mu.Unlock() - return c.counts[csp] -}