Skip to content
Merged
10 changes: 9 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ All notable changes to this project will be documented in this file.

### Added

- `grant revoke` command for session revocation with three modes: direct (by session ID), `--all`, and interactive (multi-select)
- `grant groups` command for Entra ID group membership elevation with interactive, direct (`--group`), and favorite (`--favorite`) modes
- `grant --favorite <name>` now detects group-type favorites and redirects users to `grant groups --favorite <name>`
- `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
- Session ID displayed in `grant status` output for easy reference with `grant revoke`
Expand All @@ -21,6 +23,12 @@ 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
- `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

## [0.2.1] - 2026-02-18

Expand Down
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ Custom `SCAAccessService` follows SDK conventions:
- `POST /api/access/elevate` — request JIT elevation (AWS responses include `accessCredentials` JSON string)
- `GET /api/access/sessions` — list active sessions
- `POST /api/access/sessions/revoke` — revoke sessions by ID (request: `sessionIds[]`, response: `SessionRevocationInfo[]`)
- `GET /api/access/{CSP}/eligibility/groups` — list eligible Entra ID groups (response: `groupId`/`groupName`/`directoryId`)
- `POST /api/access/elevate/groups` — request group membership elevation (response wrapped in `response` key, same as cloud elevation)
- **Headers:** `Authorization: Bearer {jwt}`, `X-API-Version: 2.0`, `Content-Type: application/json`

## Testing
Expand All @@ -54,6 +56,7 @@ 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)`
- `grant revoke` — revoke sessions: direct (`grant revoke <id>`), `--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`)
- Multi-CSP: omitting `--provider` fetches eligibility from all supported CSPs and merges results
- `fetchEligibility()` and `resolveTargetCSP()` in `cmd/root.go` — shared by root, env, and favorites

Expand Down
1 change: 1 addition & 0 deletions cmd/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@ func init() {
NewFavoritesCommand(),
NewEnvCommand(),
NewRevokeCommand(),
NewGroupsCommand(),
)
}
138 changes: 109 additions & 29 deletions cmd/favorites.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (

survey "github.com/Iilun/survey/v2"
"github.com/aaearon/grant-cli/internal/config"
scamodels "github.com/aaearon/grant-cli/internal/sca/models"
"github.com/spf13/cobra"
)

Expand Down Expand Up @@ -35,6 +36,11 @@ Workflow:

// 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 {
cmd := &cobra.Command{
Use: "favorites",
Short: "Manage saved elevation favorites",
Expand All @@ -50,7 +56,7 @@ Workflow:
}

cmd.AddCommand(newFavoritesAddCommandWithRunner(func(c *cobra.Command, args []string) error {
return runFavoritesAddWithDeps(c, args, eligLister, sel, prompter, nil)
return runFavoritesAddWithDeps(c, args, eligLister, sel, prompter, nil, groupsElig, groupSel)
}))
cmd.AddCommand(newFavoritesListCommand())
cmd.AddCommand(newFavoritesRemoveCommand())
Expand Down Expand Up @@ -80,6 +86,8 @@ func newFavoritesAddCommandWithRunner(runFn func(*cobra.Command, []string) error
cmd.Flags().StringP("provider", "p", "", "Cloud provider: azure, aws (omit to show all)")
cmd.Flags().StringP("target", "t", "", "Target name (subscription, resource group, etc.)")
cmd.Flags().StringP("role", "r", "", "Role name")
cmd.Flags().String("type", "", "Favorite type: cloud, groups (default: cloud)")
cmd.Flags().StringP("group", "g", "", "Group name (for --type groups)")

return cmd
}
Expand Down Expand Up @@ -112,9 +120,17 @@ func runFavoritesAddProduction(cmd *cobra.Command, args []string) error {
return fmt.Errorf("both --target and --role must be provided")
}

if target != "" && role != "" {
// Non-interactive: no auth needed
return runFavoritesAddWithDeps(cmd, args, nil, nil, nil, nil)
favType, _ := cmd.Flags().GetString("type")
group, _ := cmd.Flags().GetString("group")

if favType == config.FavoriteTypeGroups {
if group != "" {
// Non-interactive groups mode: no auth needed
return runFavoritesAddWithDeps(cmd, args, nil, 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)
}

// Interactive path: load config early for fast-fail duplicate check
Expand All @@ -135,31 +151,51 @@ func runFavoritesAddProduction(cmd *cobra.Command, args []string) error {
return err
}

return runFavoritesAddWithDeps(cmd, args, scaService, &uiSelector{}, &surveyNamePrompter{}, cfg)
return runFavoritesAddWithDeps(cmd, args, scaService, &uiSelector{}, &surveyNamePrompter{}, cfg, scaService, &uiGroupSelector{})
}

// 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) error {
func runFavoritesAddWithDeps(cmd *cobra.Command, args []string, eligLister eligibilityLister, sel targetSelector, prompter namePrompter, preloadedCfg *config.Config, groupsElig groupsEligibilityLister, groupSel groupSelector) error {
// Read flags
provider, _ := cmd.Flags().GetString("provider")
target, _ := cmd.Flags().GetString("target")
role, _ := cmd.Flags().GetString("role")
favType, _ := cmd.Flags().GetString("type")
group, _ := cmd.Flags().GetString("group")

// Validate: target and role must both be provided or both omitted
if (target != "" && role == "") || (target == "" && role != "") {
return fmt.Errorf("both --target and --role must be provided")
// Validate type flag
if favType != "" && favType != config.FavoriteTypeCloud && favType != config.FavoriteTypeGroups {
return fmt.Errorf("invalid --type %q: must be one of: cloud, groups", favType)
}

// Determine name from arg (may be empty for interactive prompt-after-selection)
// Validate flag combinations
if favType == config.FavoriteTypeGroups {
if target != "" || role != "" {
return fmt.Errorf("--target and --role cannot be used with --type groups")
}
} else {
if group != "" {
return fmt.Errorf("--group requires --type groups")
}
if (target != "" && role == "") || (target == "" && role != "") {
return fmt.Errorf("both --target and --role must be provided")
}
}

// Determine name from arg
var name string
if len(args) > 0 {
name = args[0]
}

// Non-interactive mode requires name upfront
if target != "" && role != "" && name == "" {
isNonInteractive := (target != "" && role != "") || (favType == config.FavoriteTypeGroups && group != "")
if isNonInteractive && name == "" {
if favType == config.FavoriteTypeGroups {
return fmt.Errorf("name is required when using --group flag\n\nUsage:\n grant favorites add <name> --type groups --group <group>")
}
return fmt.Errorf("name is required when using --target and --role flags\n\nUsage:\n grant favorites add <name> --target <target> --role <role>")
}

Expand All @@ -184,18 +220,21 @@ func runFavoritesAddWithDeps(cmd *cobra.Command, args []string, eligLister eligi
}
}

var fav config.Favorite
// Groups flow
if favType == config.FavoriteTypeGroups {
return addGroupFavorite(cmd, name, group, cfg, cfgPath, groupsElig, groupSel, prompter)
}

// Cloud flow
var fav config.Favorite
if target != "" && role != "" {
// Non-interactive mode: use flags directly
fav.Target = target
fav.Role = role
fav.Provider = provider
if fav.Provider == "" {
fav.Provider = cfg.DefaultProvider
}
} else {
// Interactive mode: select from eligible targets (all CSPs when provider is empty)
ctx, cancel := context.WithTimeout(context.Background(), apiTimeout)
defer cancel()

Expand All @@ -204,13 +243,10 @@ func runFavoritesAddWithDeps(cmd *cobra.Command, args []string, eligLister eligi
return err
}

// Interactive selection
selectedTarget, err := sel.SelectTarget(allTargets)
if err != nil {
return fmt.Errorf("target selection failed: %w", err)
}

// Ensure CSP is set on selected target
resolveTargetCSP(selectedTarget, allTargets, provider)

if provider != "" {
Expand All @@ -221,34 +257,78 @@ func runFavoritesAddWithDeps(cmd *cobra.Command, args []string, eligLister eligi
fav.Target = selectedTarget.WorkspaceName
fav.Role = selectedTarget.RoleInfo.Name

// Prompt for name after selection if not provided
if name == "" {
name, err = prompter.PromptName()
if err != nil {
return fmt.Errorf("failed to read favorite name: %w", err)
}

// Check duplicate for prompted name
if _, err := config.GetFavorite(cfg, name); err == nil {
return fmt.Errorf("favorite %q already exists", name)
}
}
}

// Add favorite
if err := config.AddFavorite(cfg, name, fav); err != nil {
return fmt.Errorf("failed to add favorite: %w", err)
}

// Save config
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)
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 {
var fav config.Favorite
fav.Type = config.FavoriteTypeGroups
fav.Provider = "azure"

if group != "" {
// Non-interactive: group specified via flag
fav.Group = group
} else {
// Interactive: select from eligible groups
ctx, cancel := context.WithTimeout(context.Background(), apiTimeout)
defer cancel()

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")
}

selected, err := groupSel.SelectGroup(eligResp.Response)
if err != nil {
return fmt.Errorf("group selection failed: %w", err)
}

fav.Group = selected.GroupName
fav.DirectoryID = selected.DirectoryID

if name == "" {
name, err = prompter.PromptName()
if err != nil {
return fmt.Errorf("failed to read favorite name: %w", err)
}
if _, err := config.GetFavorite(cfg, name); err == nil {
return fmt.Errorf("favorite %q already exists", name)
}
}
}

if err := config.AddFavorite(cfg, name, fav); err != nil {
return fmt.Errorf("failed to add favorite: %w", err)
}
if err := config.Save(cfg, cfgPath); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
fmt.Fprintf(cmd.OutOrStdout(), "Added favorite %q: groups/%s\n", name, fav.Group)
return nil
}

func newFavoritesListCommand() *cobra.Command {
return &cobra.Command{
Use: "list",
Expand Down Expand Up @@ -293,11 +373,11 @@ func runFavoritesList(cmd *cobra.Command, args []string) error {
}

for _, entry := range favorites {
fmt.Fprintf(cmd.OutOrStdout(), "%s: %s/%s/%s\n",
entry.Name,
entry.Provider,
entry.Target,
entry.Role)
if entry.ResolvedType() == config.FavoriteTypeGroups {
fmt.Fprintf(cmd.OutOrStdout(), "%s: groups/%s\n", entry.Name, entry.Group)
} else {
fmt.Fprintf(cmd.OutOrStdout(), "%s: %s/%s/%s\n", entry.Name, entry.Provider, entry.Target, entry.Role)
}
}

return nil
Expand Down
Loading