diff --git a/CHANGELOG.md b/CHANGELOG.md index dc7c871..81b7584 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 0c827a0..19fc7af 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/README.md b/README.md index ba68be8..ac74b16 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 # direct by ID +grant revoke --all # revoke all ``` ## Installation diff --git a/cmd/favorites.go b/cmd/favorites.go index 495461f..c8d8dd1 100644 --- a/cmd/favorites.go +++ b/cmd/favorites.go @@ -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. diff --git a/cmd/favorites_test.go b/cmd/favorites_test.go index d49ded0..2d07b33 100644 --- a/cmd/favorites_test.go +++ b/cmd/favorites_test.go @@ -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" ) @@ -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 diff --git a/cmd/groups.go b/cmd/groups.go index 8114b83..c4dc023 100644 --- a/cmd/groups.go +++ b/cmd/groups.go @@ -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) }) } diff --git a/cmd/groups_test.go b/cmd/groups_test.go index 17dd81d..757ebf2 100644 --- a/cmd/groups_test.go +++ b/cmd/groups_test.go @@ -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" @@ -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: "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() diff --git a/cmd/revoke.go b/cmd/revoke.go index 407f304..380f96e 100644 --- a/cmd/revoke.go +++ b/cmd/revoke.go @@ -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" @@ -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) }) } diff --git a/cmd/revoke_test.go b/cmd/revoke_test.go index 2f5ff02..3fa8999 100644 --- a/cmd/revoke_test.go +++ b/cmd/revoke_test.go @@ -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" @@ -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: "user@example.com", 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() diff --git a/cmd/status.go b/cmd/status.go index 6aa2305..04995a6 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -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" @@ -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) }) } diff --git a/cmd/status_test.go b/cmd/status_test.go index 8d6db13..617b8a5 100644 --- a/cmd/status_test.go +++ b/cmd/status_test.go @@ -7,6 +7,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" @@ -657,6 +658,79 @@ func TestStatusCommandIntegration(t *testing.T) { } +func TestStatusCommand_CachedEligibility(t *testing.T) { + now := time.Now() + expiresIn := commonmodels.IdsecRFC3339Time(now.Add(1 * time.Hour)) + + // Inner mock returns Azure targets including DIRECTORY type for directory name resolution + innerElig := newCountingEligibilityLister(&mockEligibilityLister{ + listFunc: func(ctx context.Context, csp scamodels.CSP) (*scamodels.EligibilityResponse, error) { + if csp == scamodels.CSPAzure { + return &scamodels.EligibilityResponse{ + Response: []scamodels.EligibleTarget{ + { + OrganizationID: "org-1", + WorkspaceID: "/subscriptions/sub-1", + WorkspaceName: "Dev Sub", + }, + { + OrganizationID: "dir-1", + WorkspaceID: "dir-1", + WorkspaceName: "Contoso", + WorkspaceType: scamodels.WorkspaceTypeDirectory, + }, + }, + }, nil + } + return &scamodels.EligibilityResponse{Response: []scamodels.EligibleTarget{}}, nil + }, + }) + + // Wrap in CachedEligibilityLister with a real temp dir store + store := cache.NewStore(t.TempDir(), 4*time.Hour) + cachedLister := cache.NewCachedEligibilityLister(innerElig, nil, store, false, nil) + + auth := &mockAuthLoader{ + token: &authmodels.IdsecToken{Token: "jwt", Username: "user@example.com", ExpiresIn: expiresIn}, + } + sessions := &mockSessionLister{ + sessions: &scamodels.SessionsResponse{ + Response: []scamodels.SessionInfo{ + { + SessionID: "session-1", + CSP: scamodels.CSPAzure, + WorkspaceID: "/subscriptions/sub-1", + RoleID: "Contributor", + SessionDuration: 3600, + }, + }, + Total: 1, + }, + } + + cmd := NewStatusCommandWithDeps(auth, sessions, cachedLister) + output, err := executeCommand(cmd) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !strings.Contains(output, "Dev Sub") { + t.Errorf("output missing workspace name, got:\n%s", output) + } + + // Key assertion: Azure inner was called only once. + // fetchStatusData calls Azure once, then buildDirectoryNameMap calls Azure again, + // but the second call is a cache hit. + if got := innerElig.CallCount(scamodels.CSPAzure); got != 1 { + t.Errorf("Azure inner called %d times, want 1 (cache should deduplicate)", got) + } + + // AWS was called once by fetchStatusData + if got := innerElig.CallCount(scamodels.CSPAWS); got != 1 { + t.Errorf("AWS inner called %d times, want 1", got) + } +} + func TestStatusCommandUsage(t *testing.T) { cmd := NewStatusCommand() diff --git a/cmd/test_mocks.go b/cmd/test_mocks.go index 5738028..56e1c48 100644 --- a/cmd/test_mocks.go +++ b/cmd/test_mocks.go @@ -3,6 +3,7 @@ package cmd import ( "context" "errors" + "sync" "github.com/aaearon/grant-cli/internal/sca/models" sdkmodels "github.com/cyberark/idsec-sdk-golang/pkg/models" @@ -220,3 +221,52 @@ func (m *mockGroupSelector) SelectGroup(groups []models.GroupsEligibleTarget) (* } return m.group, m.selectErr } + +// countingEligibilityLister wraps an eligibilityLister and counts calls per CSP. +// Thread-safe for concurrent access from goroutines in fetchStatusData etc. +type countingEligibilityLister struct { + inner eligibilityLister + mu sync.Mutex + counts map[models.CSP]int +} + +func newCountingEligibilityLister(inner eligibilityLister) *countingEligibilityLister { + return &countingEligibilityLister{inner: inner, counts: make(map[models.CSP]int)} +} + +func (c *countingEligibilityLister) ListEligibility(ctx context.Context, csp models.CSP) (*models.EligibilityResponse, error) { + c.mu.Lock() + c.counts[csp]++ + c.mu.Unlock() + return c.inner.ListEligibility(ctx, csp) +} + +func (c *countingEligibilityLister) CallCount(csp models.CSP) int { + c.mu.Lock() + defer c.mu.Unlock() + 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] +}