Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 11 additions & 7 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,19 @@ 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 <name>` now detects group-type favorites and redirects users to `grant groups --favorite <name>`
- `--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 <name>` 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
- Session ID displayed in `grant status` output for easy reference with `grant revoke`

### 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

Expand All @@ -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
Expand Down
8 changes: 5 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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`)
- `--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
Expand All @@ -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
Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
1 change: 0 additions & 1 deletion cmd/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,5 @@ func init() {
NewFavoritesCommand(),
NewEnvCommand(),
NewRevokeCommand(),
NewGroupsCommand(),
)
}
88 changes: 61 additions & 27 deletions cmd/favorites.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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())
Expand Down Expand Up @@ -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
Expand All @@ -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")
Expand Down Expand Up @@ -224,19 +219,21 @@ 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
if fav.Provider == "" {
fav.Provider = cfg.DefaultProvider
}
} else {
// Interactive: unified selector showing cloud targets and groups
ctx, cancel := context.WithTimeout(context.Background(), apiTimeout)
defer cancel()

Expand All @@ -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()
Expand All @@ -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"
Expand All @@ -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()

Expand All @@ -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()
Expand Down
Loading