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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ 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 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 Down
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,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 root and env commands
- `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
- Cache failures (read/write) silently fall through to the live API

## Verbose / Logging
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ 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`
- Session revocation via `grant revoke`
- Session status monitoring
- Local eligibility cache with configurable TTL
- Secure token storage in system keyring

## Usage
Expand All @@ -39,8 +42,17 @@ eval $(grant env --provider aws)
# Use a saved favorite
grant --favorite prod-contrib

# Elevate Entra ID group membership
grant groups
grant groups --group "Cloud Admins"

# Check active sessions
grant status

# Revoke sessions
grant revoke # interactive multi-select
grant revoke <session-id> # direct by ID
grant revoke --all # revoke all
```

## Installation
Expand Down
4 changes: 3 additions & 1 deletion cmd/favorites.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,9 @@ func runFavoritesAddProduction(cmd *cobra.Command, args []string) error {
return err
}

return runFavoritesAddWithDeps(cmd, args, scaService, &uiSelector{}, &surveyNamePrompter{}, cfg, scaService, &uiGroupSelector{})
cachedLister := buildCachedLister(cfg, false, scaService, scaService)

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

// runFavoritesAddWithDeps contains the core logic for favorites add.
Expand Down
55 changes: 55 additions & 0 deletions cmd/favorites_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import (
"path/filepath"
"strings"
"testing"
"time"

"github.com/aaearon/grant-cli/internal/cache"
"github.com/aaearon/grant-cli/internal/config"
"github.com/aaearon/grant-cli/internal/sca/models"
)
Expand Down Expand Up @@ -828,6 +830,59 @@ func TestFavoritesAddGroupPersistence(t *testing.T) {
}
}

func TestFavoritesAdd_CachedEligibility(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.yaml")
t.Setenv("GRANT_CONFIG", configPath)
cfg := config.DefaultConfig()
_ = config.Save(cfg, configPath)

azureTargets := []models.EligibleTarget{
{
OrganizationID: "org-123",
WorkspaceID: "sub-456",
WorkspaceName: "Prod-EastUS",
WorkspaceType: models.WorkspaceTypeSubscription,
RoleInfo: models.RoleInfo{ID: "role-789", Name: "Contributor"},
},
}

innerCloud := newCountingEligibilityLister(&mockEligibilityLister{
listFunc: func(_ context.Context, csp models.CSP) (*models.EligibilityResponse, error) {
if csp == models.CSPAzure {
return &models.EligibilityResponse{Response: azureTargets, Total: 1}, nil
}
return &models.EligibilityResponse{}, nil
},
})

store := cache.NewStore(filepath.Join(tmpDir, "cache"), 4*time.Hour)
cachedLister := cache.NewCachedEligibilityLister(innerCloud, nil, store, false, nil)

selector := &mockTargetSelector{target: &azureTargets[0]}

rootCmd := newTestRootCommand()
favCmd := NewFavoritesCommandWithAllDeps(cachedLister, selector, nil, nil, nil)
rootCmd.AddCommand(favCmd)

output, err := executeCommand(rootCmd, "favorites", "add", "myfav")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

if !strings.Contains(output, "Added favorite") {
t.Errorf("output missing expected text, got:\n%s", output)
}

// fetchEligibility calls all CSPs when no provider specified
if got := innerCloud.CallCount(models.CSPAzure); got != 1 {
t.Errorf("Azure inner called %d times, want 1", got)
}
if got := innerCloud.CallCount(models.CSPAWS); got != 1 {
t.Errorf("AWS inner called %d times, want 1", got)
}
}

func TestFavoritesListWithGroupFavorites(t *testing.T) {
tests := []struct {
name string
Expand Down
9 changes: 8 additions & 1 deletion cmd/groups.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,14 @@ func NewGroupsCommand() *cobra.Command {
return err
}

return runGroups(cmd, ispAuth, svc, svc, svc, &uiGroupSelector{}, profile, nil)
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)
})
}

Expand Down
62 changes: 62 additions & 0 deletions cmd/groups_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"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"
Expand Down Expand Up @@ -615,6 +616,67 @@ func TestGroupsCommandFavoriteMode(t *testing.T) {
}
}

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: "[email protected]", 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()

Expand Down
10 changes: 9 additions & 1 deletion cmd/revoke.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"fmt"

"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"
Expand Down Expand Up @@ -57,7 +58,14 @@ func NewRevokeCommand() *cobra.Command {
return err
}

return runRevoke(cmd, args, ispAuth, svc, svc, svc, &uiSessionSelector{}, &uiConfirmPrompter{}, profile)
cfg, _, err := config.LoadDefaultWithPath()
if err != nil {
return err
}

cachedLister := buildCachedLister(cfg, false, svc, nil)

return runRevoke(cmd, args, ispAuth, svc, cachedLister, svc, &uiSessionSelector{}, &uiConfirmPrompter{}, profile)
})
}

Expand Down
57 changes: 57 additions & 0 deletions cmd/revoke_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"testing"
"time"

"github.com/aaearon/grant-cli/internal/cache"
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"
Expand Down Expand Up @@ -623,6 +624,62 @@ func TestRevokeCommand(t *testing.T) {
}
}

func TestRevokeCommand_CachedEligibility(t *testing.T) {
now := time.Now()
expiresIn := commonmodels.IdsecRFC3339Time(now.Add(1 * time.Hour))

innerElig := newCountingEligibilityLister(&mockEligibilityLister{
response: &scamodels.EligibilityResponse{
Response: []scamodels.EligibleTarget{
{WorkspaceID: "/subscriptions/sub-1", WorkspaceName: "Dev Sub"},
},
},
})

store := cache.NewStore(t.TempDir(), 4*time.Hour)
cachedLister := cache.NewCachedEligibilityLister(innerElig, nil, store, false, nil)

auth := &mockAuthLoader{
token: &authmodels.IdsecToken{Token: "jwt", Username: "[email protected]", ExpiresIn: expiresIn},
}
sessions := &mockSessionLister{
sessions: &scamodels.SessionsResponse{
Response: []scamodels.SessionInfo{
{SessionID: "s1", CSP: scamodels.CSPAzure, WorkspaceID: "/subscriptions/sub-1", RoleID: "Contributor", SessionDuration: 3600},
},
Total: 1,
},
}
revoker := &mockSessionRevoker{
response: &scamodels.RevokeResponse{
Response: []scamodels.RevocationResult{
{SessionID: "s1", RevocationStatus: scamodels.RevocationSuccessful},
},
},
}
selector := &mockSessionSelector{
sessions: []scamodels.SessionInfo{
{SessionID: "s1", CSP: scamodels.CSPAzure, WorkspaceID: "/subscriptions/sub-1", RoleID: "Contributor", SessionDuration: 3600},
},
}
confirmer := &mockConfirmPrompter{confirmed: true}

cmd := NewRevokeCommandWithDeps(auth, sessions, cachedLister, revoker, selector, confirmer)
output, err := executeCommand(cmd)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

if !strings.Contains(output, "SUCCESSFULLY_REVOKED") {
t.Errorf("expected successful revocation, got:\n%s", output)
}

// buildWorkspaceNameMap calls ListEligibility once per unique CSP in sessions
if got := innerElig.CallCount(scamodels.CSPAzure); got != 1 {
t.Errorf("Azure inner called %d times, want 1", got)
}
}

func TestRevokeCommandUsage(t *testing.T) {
cmd := NewRevokeCommand()

Expand Down
10 changes: 9 additions & 1 deletion cmd/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"sort"
"strings"

"github.com/aaearon/grant-cli/internal/config"
scamodels "github.com/aaearon/grant-cli/internal/sca/models"
"github.com/aaearon/grant-cli/internal/ui"
"github.com/cyberark/idsec-sdk-golang/pkg/models"
Expand Down Expand Up @@ -34,7 +35,14 @@ func NewStatusCommand() *cobra.Command {
return err
}

return runStatus(cmd, ispAuth, svc, svc, profile)
cfg, _, err := config.LoadDefaultWithPath()
if err != nil {
return err
}

cachedLister := buildCachedLister(cfg, false, svc, nil)

return runStatus(cmd, ispAuth, svc, cachedLister, profile)
})
}

Expand Down
Loading