From e20b99845701e31ca31ffb91588cc4e30fb7648c Mon Sep 17 00:00:00 2001 From: Tim Schindler Date: Thu, 19 Feb 2026 15:42:59 +0100 Subject: [PATCH 1/2] feat: add local file-based eligibility cache with --refresh flag Cache eligibility API responses in ~/.grant/cache/ to skip the API roundtrip on subsequent runs. Default TTL is 4 hours, configurable via cache_ttl in ~/.grant/config.yaml. - internal/cache: generic Store with Get/Set/Invalidate and TTL expiry - internal/cache: CachedEligibilityLister decorator for both cloud and groups eligibility interfaces with verbose logging on hit/miss/refresh - --refresh flag on grant and grant env to bypass cache - cache_ttl config option with ParseCacheTTL helper - groups model types added as prerequisite for cache interface --- CHANGELOG.md | 3 + CLAUDE.md | 12 +- cmd/env.go | 8 +- cmd/env_test.go | 7 + cmd/root.go | 27 +- cmd/root_test.go | 2 +- internal/cache/cache.go | 81 +++++ internal/cache/cache_test.go | 172 ++++++++++ internal/cache/cached_eligibility.go | 132 +++++++ internal/cache/cached_eligibility_test.go | 397 ++++++++++++++++++++++ internal/config/config.go | 18 + internal/config/config_test.go | 89 +++++ 12 files changed, 942 insertions(+), 6 deletions(-) create mode 100644 internal/cache/cache.go create mode 100644 internal/cache/cache_test.go create mode 100644 internal/cache/cached_eligibility.go create mode 100644 internal/cache/cached_eligibility_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 6689e77..dc7c871 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ All notable changes to this project will be documented in this file. ### Added +- 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 ` - `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 diff --git a/CLAUDE.md b/CLAUDE.md index ea796fa..0c827a0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,12 +54,22 @@ Custom `SCAAccessService` follows SDK conventions: ## CLI - `spf13/cobra` for CLI framework - `Iilun/survey/v2` for interactive prompts -- `grant env` — performs elevation, outputs only `export` statements (no human text); usage: `eval $(grant env --provider aws)` +- `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`) - 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 +## Cache +- Eligibility responses cached in `~/.grant/cache/` as JSON files (e.g., `eligibility_azure.json`, `groups_eligibility_azure.json`) +- Default TTL: 4 hours, configurable via `cache_ttl` in `~/.grant/config.yaml` (Go duration syntax: `2h`, `30m`) +- `--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 +- Cache failures (read/write) silently fall through to the live API + ## Verbose / Logging - `--verbose` / `-v` global flag wired via `PersistentPreRunE` in `cmd/root.go` - Calls `config.EnableVerboseLogging("INFO")` (sets `IDSEC_LOG_LEVEL=INFO`) or `config.DisableVerboseLogging()` (sets `IDSEC_LOG_LEVEL=CRITICAL`) diff --git a/cmd/env.go b/cmd/env.go index 53a2e72..36e9864 100644 --- a/cmd/env.go +++ b/cmd/env.go @@ -21,7 +21,8 @@ suitable for eval. No human-readable messages are printed to stdout. Usage: eval $(grant env --provider aws --target "Account" --role "AdminAccess") - eval $(grant env --favorite my-aws-fav)`, + eval $(grant env --favorite my-aws-fav) + eval $(grant env --refresh --provider aws)`, SilenceErrors: true, SilenceUsage: true, RunE: runFn, @@ -31,6 +32,7 @@ Usage: cmd.Flags().StringP("target", "t", "", "Target name (account, subscription, etc.)") 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.MarkFlagsMutuallyExclusive("favorite", "target") cmd.MarkFlagsMutuallyExclusive("favorite", "role") @@ -53,7 +55,9 @@ func NewEnvCommand() *cobra.Command { return err } - return runEnvWithDeps(cmd, flags, profile, ispAuth, scaService, scaService, &uiSelector{}, cfg) + cachedLister := buildCachedLister(cfg, flags.refresh, scaService, nil) + + return runEnvWithDeps(cmd, flags, profile, ispAuth, cachedLister, scaService, &uiSelector{}, cfg) }) } diff --git a/cmd/env_test.go b/cmd/env_test.go index 2951d55..cb3296b 100644 --- a/cmd/env_test.go +++ b/cmd/env_test.go @@ -153,3 +153,10 @@ func TestEnvCommand_NotAuthenticated(t *testing.T) { t.Errorf("expected 'not authenticated' error, got: %v", err) } } + +func TestNewEnvCommand_RefreshFlagRegistered(t *testing.T) { + cmd := newEnvCommand(nil) + if cmd.Flags().Lookup("refresh") == nil { + t.Error("expected --refresh flag to be registered") + } +} diff --git a/cmd/root.go b/cmd/root.go index 5f47d2b..80e2893 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -9,11 +9,13 @@ import ( "sync" "time" + "github.com/aaearon/grant-cli/internal/cache" "github.com/aaearon/grant-cli/internal/config" "github.com/aaearon/grant-cli/internal/sca" "github.com/aaearon/grant-cli/internal/sca/models" "github.com/aaearon/grant-cli/internal/ui" "github.com/cyberark/idsec-sdk-golang/pkg/auth" + "github.com/cyberark/idsec-sdk-golang/pkg/common" sdkconfig "github.com/cyberark/idsec-sdk-golang/pkg/config" sdkmodels "github.com/cyberark/idsec-sdk-golang/pkg/models" authmodels "github.com/cyberark/idsec-sdk-golang/pkg/models/auth" @@ -40,6 +42,7 @@ type elevateFlags struct { target string role string favorite string + refresh bool } // newRootCommand creates the root cobra command with the given RunE function. @@ -69,7 +72,10 @@ Examples: # Specify provider explicitly grant --provider azure - grant --provider aws`, + grant --provider aws + + # Bypass eligibility cache and fetch fresh data + grant --refresh`, SilenceErrors: true, SilenceUsage: true, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { @@ -89,6 +95,7 @@ Examples: cmd.Flags().StringP("target", "t", "", "Target name (subscription, resource group, etc.)") 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.MarkFlagsMutuallyExclusive("favorite", "target") cmd.MarkFlagsMutuallyExclusive("favorite", "role") @@ -128,6 +135,7 @@ func parseElevateFlags(cmd *cobra.Command) *elevateFlags { flags.target, _ = cmd.Flags().GetString("target") flags.role, _ = cmd.Flags().GetString("role") flags.favorite, _ = cmd.Flags().GetString("favorite") + flags.refresh, _ = cmd.Flags().GetBool("refresh") return flags } @@ -145,7 +153,22 @@ func runElevateProduction(cmd *cobra.Command, args []string) error { return err } - return runElevateWithDeps(cmd, flags, profile, ispAuth, scaService, scaService, &uiSelector{}, cfg) + cachedLister := buildCachedLister(cfg, flags.refresh, scaService, nil) + + return runElevateWithDeps(cmd, flags, profile, ispAuth, cachedLister, scaService, &uiSelector{}, cfg) +} + +// buildCachedLister creates a CachedEligibilityLister wrapping the given services. +// If the cache directory cannot be resolved, it falls back to the unwrapped services. +func buildCachedLister(cfg *config.Config, refresh bool, cloudInner cache.EligibilityLister, groupsInner cache.GroupsEligibilityLister) *cache.CachedEligibilityLister { + log := common.GetLogger("grant", -1) + cacheDir, err := cache.CacheDir() + if err != nil { + return cache.NewCachedEligibilityLister(cloudInner, groupsInner, cache.NewStore("", 0), true, nil) + } + ttl := config.ParseCacheTTL(cfg) + store := cache.NewStore(cacheDir, ttl) + return cache.NewCachedEligibilityLister(cloudInner, groupsInner, store, refresh, log) } // NewRootCommandWithDeps creates a root command with injected dependencies for testing. diff --git a/cmd/root_test.go b/cmd/root_test.go index 7d142b0..1c766f7 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"} + flags := []string{"verbose", "provider", "target", "role", "favorite", "refresh"} 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/internal/cache/cache.go b/internal/cache/cache.go new file mode 100644 index 0000000..3d696d3 --- /dev/null +++ b/internal/cache/cache.go @@ -0,0 +1,81 @@ +package cache + +import ( + "encoding/json" + "os" + "path/filepath" + "time" + + "github.com/aaearon/grant-cli/internal/config" +) + +// entry is the on-disk envelope for cached data. +type entry[T any] struct { + CachedAt time.Time `json:"cached_at"` + Response T `json:"response"` +} + +// Store manages a directory of JSON cache files with TTL expiry. +type Store struct { + dir string + ttl time.Duration + now func() time.Time // injectable clock for testing +} + +// NewStore creates a Store with the given directory and TTL. +func NewStore(dir string, ttl time.Duration) *Store { + return &Store{dir: dir, ttl: ttl, now: time.Now} +} + +// Get reads a cached value for key into dst. Returns true on hit, false on miss/expiry/error. +func Get[T any](s *Store, key string, dst *T) bool { + data, err := os.ReadFile(filepath.Join(s.dir, key+".json")) + if err != nil { + return false + } + + var e entry[T] + if err := json.Unmarshal(data, &e); err != nil { + return false + } + + if s.now().Sub(e.CachedAt) > s.ttl { + return false + } + + *dst = e.Response + return true +} + +// Set writes a value to the cache under key. Creates the directory if needed. +func Set[T any](s *Store, key string, value T) error { + if err := os.MkdirAll(s.dir, 0700); err != nil { + return err + } + + e := entry[T]{ + CachedAt: s.now(), + Response: value, + } + + data, err := json.Marshal(e) + if err != nil { + return err + } + + return os.WriteFile(filepath.Join(s.dir, key+".json"), data, 0600) +} + +// Invalidate removes a cached entry by key. +func Invalidate(s *Store, key string) { + _ = os.Remove(filepath.Join(s.dir, key+".json")) +} + +// CacheDir returns the default cache directory path (~/.grant/cache/). +func CacheDir() (string, error) { + cfgDir, err := config.ConfigDir() + if err != nil { + return "", err + } + return filepath.Join(cfgDir, "cache"), nil +} diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go new file mode 100644 index 0000000..8345503 --- /dev/null +++ b/internal/cache/cache_test.go @@ -0,0 +1,172 @@ +package cache + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestGet_Miss(t *testing.T) { + t.Parallel() + s := NewStore(t.TempDir(), 4*time.Hour) + + var out []string + ok := Get(s, "nonexistent", &out) + if ok { + t.Fatal("expected miss for nonexistent key") + } +} + +func TestSetAndGet_RoundTrip(t *testing.T) { + t.Parallel() + s := NewStore(t.TempDir(), 4*time.Hour) + + in := []string{"alpha", "beta"} + if err := Set(s, "items", in); err != nil { + t.Fatalf("Set() error = %v", err) + } + + var out []string + ok := Get(s, "items", &out) + if !ok { + t.Fatal("expected cache hit") + } + if len(out) != 2 || out[0] != "alpha" || out[1] != "beta" { + t.Errorf("got %v, want [alpha beta]", out) + } +} + +func TestGet_Expired(t *testing.T) { + t.Parallel() + dir := t.TempDir() + ttl := 1 * time.Hour + + // Write with a clock in the past + past := time.Now().Add(-2 * time.Hour) + s := &Store{dir: dir, ttl: ttl, now: func() time.Time { return past }} + if err := Set(s, "old", "data"); err != nil { + t.Fatalf("Set() error = %v", err) + } + + // Read with real clock — entry should be expired + s2 := NewStore(dir, ttl) + var out string + ok := Get(s2, "old", &out) + if ok { + t.Fatal("expected miss for expired entry") + } +} + +func TestGet_CorruptJSON(t *testing.T) { + t.Parallel() + dir := t.TempDir() + s := NewStore(dir, 4*time.Hour) + + // Write garbage to the cache file + path := filepath.Join(dir, "corrupt.json") + if err := os.WriteFile(path, []byte("{not valid json"), 0600); err != nil { + t.Fatalf("failed to write corrupt file: %v", err) + } + + var out string + ok := Get(s, "corrupt", &out) + if ok { + t.Fatal("expected miss for corrupt JSON") + } +} + +func TestSet_CreatesDirectory(t *testing.T) { + t.Parallel() + dir := filepath.Join(t.TempDir(), "nested", "cache") + s := NewStore(dir, 4*time.Hour) + + if err := Set(s, "key", "value"); err != nil { + t.Fatalf("Set() error = %v", err) + } + + if _, err := os.Stat(filepath.Join(dir, "key.json")); os.IsNotExist(err) { + t.Fatal("expected cache file to be created") + } +} + +func TestSet_FilePermissions(t *testing.T) { + t.Parallel() + dir := t.TempDir() + s := NewStore(dir, 4*time.Hour) + + if err := Set(s, "perms", "data"); err != nil { + t.Fatalf("Set() error = %v", err) + } + + info, err := os.Stat(filepath.Join(dir, "perms.json")) + if err != nil { + t.Fatalf("Stat() error = %v", err) + } + perm := info.Mode().Perm() + if perm != 0600 { + t.Errorf("file permissions = %o, want 0600", perm) + } +} + +type testStruct struct { + Name string `json:"name"` + Count int `json:"count"` +} + +func TestRoundTrip_Struct(t *testing.T) { + t.Parallel() + s := NewStore(t.TempDir(), 4*time.Hour) + + in := testStruct{Name: "test", Count: 42} + if err := Set(s, "struct", in); err != nil { + t.Fatalf("Set() error = %v", err) + } + + var out testStruct + ok := Get(s, "struct", &out) + if !ok { + t.Fatal("expected cache hit") + } + if out.Name != "test" || out.Count != 42 { + t.Errorf("got %+v, want {Name:test Count:42}", out) + } +} + +func TestInvalidate(t *testing.T) { + t.Parallel() + s := NewStore(t.TempDir(), 4*time.Hour) + + if err := Set(s, "del-me", "data"); err != nil { + t.Fatalf("Set() error = %v", err) + } + + Invalidate(s, "del-me") + + var out string + ok := Get(s, "del-me", &out) + if ok { + t.Fatal("expected miss after invalidation") + } +} + +func TestInvalidate_NonExistent(t *testing.T) { + t.Parallel() + s := NewStore(t.TempDir(), 4*time.Hour) + + // Should not panic + Invalidate(s, "no-such-key") +} + +func TestCacheDir(t *testing.T) { + dir, err := CacheDir() + if err != nil { + t.Fatalf("CacheDir() error = %v", err) + } + if !filepath.IsAbs(dir) { + t.Errorf("expected absolute path, got %q", dir) + } + if filepath.Base(dir) != "cache" { + t.Errorf("expected dir to end with 'cache', got %q", dir) + } +} diff --git a/internal/cache/cached_eligibility.go b/internal/cache/cached_eligibility.go new file mode 100644 index 0000000..3aaf22d --- /dev/null +++ b/internal/cache/cached_eligibility.go @@ -0,0 +1,132 @@ +package cache + +import ( + "context" + "fmt" + "strings" + + "github.com/aaearon/grant-cli/internal/sca/models" +) + +// EligibilityLister mirrors cmd.eligibilityLister to avoid import cycles. +type EligibilityLister interface { + ListEligibility(ctx context.Context, csp models.CSP) (*models.EligibilityResponse, error) +} + +// GroupsEligibilityLister mirrors cmd.groupsEligibilityLister to avoid import cycles. +type GroupsEligibilityLister interface { + ListGroupsEligibility(ctx context.Context, csp models.CSP) (*models.GroupsEligibilityResponse, error) +} + +// Logger interface for verbose output — satisfied by *common.IdsecLogger. +type Logger interface { + Info(msg string, v ...interface{}) +} + +// nopLogger discards all log output. +type nopLogger struct{} + +func (nopLogger) Info(string, ...interface{}) {} + +// CachedEligibilityLister decorates eligibility listers with file-based caching. +// It implements both EligibilityLister and GroupsEligibilityLister. +type CachedEligibilityLister struct { + cloudInner EligibilityLister + groupsInner GroupsEligibilityLister + store *Store + refresh bool + log Logger +} + +// NewCachedEligibilityLister creates a new caching decorator. +// Either inner may be nil if that type of listing is not needed. +// When refresh is true, the cache read is bypassed but the API response is still cached. +// Logger is optional — pass nil for silent operation. +func NewCachedEligibilityLister( + cloudInner EligibilityLister, + groupsInner GroupsEligibilityLister, + store *Store, + refresh bool, + log Logger, +) *CachedEligibilityLister { + if log == nil { + log = nopLogger{} + } + return &CachedEligibilityLister{ + cloudInner: cloudInner, + groupsInner: groupsInner, + store: store, + refresh: refresh, + log: log, + } +} + +// ListEligibility checks the cache first, then falls through to the inner lister. +func (c *CachedEligibilityLister) ListEligibility(ctx context.Context, csp models.CSP) (*models.EligibilityResponse, error) { + key := eligibilityCacheKey(csp) + + if c.refresh { + c.log.Info("Cache refresh requested for %s eligibility, bypassing cache", csp) + } else { + var cached models.EligibilityResponse + if Get(c.store, key, &cached) { + c.log.Info("Cache hit for %s eligibility (%d targets)", csp, len(cached.Response)) + return &cached, nil + } + c.log.Info("Cache miss for %s eligibility, fetching from API", csp) + } + + resp, err := c.cloudInner.ListEligibility(ctx, csp) + if err != nil { + return nil, err + } + + if err := Set(c.store, key, *resp); err != nil { + c.log.Info("Cache write failed for %s eligibility: %v", csp, err) + } else { + c.log.Info("Cached %s eligibility (%d targets)", csp, len(resp.Response)) + } + + return resp, nil +} + +// ListGroupsEligibility checks the cache first, then falls through to the inner lister. +func (c *CachedEligibilityLister) ListGroupsEligibility(ctx context.Context, csp models.CSP) (*models.GroupsEligibilityResponse, error) { + if c.groupsInner == nil { + return nil, fmt.Errorf("groups eligibility listing not available") + } + + key := groupsEligibilityCacheKey(csp) + + if c.refresh { + c.log.Info("Cache refresh requested for %s groups eligibility, bypassing cache", csp) + } else { + var cached models.GroupsEligibilityResponse + if Get(c.store, key, &cached) { + c.log.Info("Cache hit for %s groups eligibility (%d groups)", csp, len(cached.Response)) + return &cached, nil + } + c.log.Info("Cache miss for %s groups eligibility, fetching from API", csp) + } + + resp, err := c.groupsInner.ListGroupsEligibility(ctx, csp) + if err != nil { + return nil, err + } + + if err := Set(c.store, key, *resp); err != nil { + c.log.Info("Cache write failed for %s groups eligibility: %v", csp, err) + } else { + c.log.Info("Cached %s groups eligibility (%d groups)", csp, len(resp.Response)) + } + + return resp, nil +} + +func eligibilityCacheKey(csp models.CSP) string { + return "eligibility_" + strings.ToLower(string(csp)) +} + +func groupsEligibilityCacheKey(csp models.CSP) string { + return "groups_eligibility_" + strings.ToLower(string(csp)) +} diff --git a/internal/cache/cached_eligibility_test.go b/internal/cache/cached_eligibility_test.go new file mode 100644 index 0000000..dcaef02 --- /dev/null +++ b/internal/cache/cached_eligibility_test.go @@ -0,0 +1,397 @@ +package cache + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + "testing" + "time" + + "github.com/aaearon/grant-cli/internal/sca/models" +) + +// mockEligibilityLister implements eligibilityLister for testing. +type mockEligibilityLister struct { + calls int + response *models.EligibilityResponse + err error +} + +func (m *mockEligibilityLister) ListEligibility(ctx context.Context, csp models.CSP) (*models.EligibilityResponse, error) { + m.calls++ + return m.response, m.err +} + +// mockGroupsEligibilityLister implements groupsEligibilityLister for testing. +type mockGroupsEligibilityLister struct { + calls int + response *models.GroupsEligibilityResponse + err error +} + +func (m *mockGroupsEligibilityLister) ListGroupsEligibility(ctx context.Context, csp models.CSP) (*models.GroupsEligibilityResponse, error) { + m.calls++ + return m.response, m.err +} + +func TestCachedEligibilityLister_CacheHit(t *testing.T) { + t.Parallel() + store := NewStore(t.TempDir(), 4*time.Hour) + + inner := &mockEligibilityLister{ + response: &models.EligibilityResponse{ + Response: []models.EligibleTarget{ + {WorkspaceID: "ws-1", WorkspaceName: "Sub1", RoleInfo: models.RoleInfo{ID: "r1", Name: "Reader"}}, + }, + Total: 1, + }, + } + + cached := NewCachedEligibilityLister(inner, nil, store, false, nil) + ctx := context.Background() + + // First call — miss, calls inner + resp1, err := cached.ListEligibility(ctx, models.CSPAzure) + if err != nil { + t.Fatalf("first call error = %v", err) + } + if inner.calls != 1 { + t.Fatalf("expected 1 inner call, got %d", inner.calls) + } + if len(resp1.Response) != 1 { + t.Fatalf("expected 1 target, got %d", len(resp1.Response)) + } + + // Second call — hit, no additional inner call + resp2, err := cached.ListEligibility(ctx, models.CSPAzure) + if err != nil { + t.Fatalf("second call error = %v", err) + } + if inner.calls != 1 { + t.Fatalf("expected still 1 inner call, got %d", inner.calls) + } + if len(resp2.Response) != 1 || resp2.Response[0].WorkspaceID != "ws-1" { + t.Errorf("unexpected cached response: %+v", resp2) + } +} + +func TestCachedEligibilityLister_CacheMiss_Expired(t *testing.T) { + t.Parallel() + dir := t.TempDir() + ttl := 1 * time.Hour + + inner := &mockEligibilityLister{ + response: &models.EligibilityResponse{ + Response: []models.EligibleTarget{ + {WorkspaceID: "ws-1", WorkspaceName: "Sub1"}, + }, + Total: 1, + }, + } + + // Write cache entry in the past + pastStore := &Store{dir: dir, ttl: ttl, now: func() time.Time { return time.Now().Add(-2 * time.Hour) }} + cached := NewCachedEligibilityLister(inner, nil, pastStore, false, nil) + ctx := context.Background() + + _, _ = cached.ListEligibility(ctx, models.CSPAzure) + if inner.calls != 1 { + t.Fatalf("expected 1 inner call for initial set, got %d", inner.calls) + } + + // Now read with current time — should be expired + currentStore := NewStore(dir, ttl) + cached2 := NewCachedEligibilityLister(inner, nil, currentStore, false, nil) + _, err := cached2.ListEligibility(ctx, models.CSPAzure) + if err != nil { + t.Fatalf("error = %v", err) + } + if inner.calls != 2 { + t.Fatalf("expected 2 inner calls (expired cache), got %d", inner.calls) + } +} + +func TestCachedEligibilityLister_RefreshBypass(t *testing.T) { + t.Parallel() + store := NewStore(t.TempDir(), 4*time.Hour) + + inner := &mockEligibilityLister{ + response: &models.EligibilityResponse{ + Response: []models.EligibleTarget{ + {WorkspaceID: "ws-1"}, + }, + Total: 1, + }, + } + + // Pre-populate cache + cached := NewCachedEligibilityLister(inner, nil, store, false, nil) + ctx := context.Background() + _, _ = cached.ListEligibility(ctx, models.CSPAzure) + if inner.calls != 1 { + t.Fatalf("expected 1 call, got %d", inner.calls) + } + + // With refresh=true, should bypass cache + refreshed := NewCachedEligibilityLister(inner, nil, store, true, nil) + _, err := refreshed.ListEligibility(ctx, models.CSPAzure) + if err != nil { + t.Fatalf("error = %v", err) + } + if inner.calls != 2 { + t.Fatalf("expected 2 calls with refresh, got %d", inner.calls) + } +} + +func TestCachedEligibilityLister_APIError_NoCache(t *testing.T) { + t.Parallel() + store := NewStore(t.TempDir(), 4*time.Hour) + + apiErr := errors.New("api failure") + inner := &mockEligibilityLister{err: apiErr} + + cached := NewCachedEligibilityLister(inner, nil, store, false, nil) + ctx := context.Background() + + _, err := cached.ListEligibility(ctx, models.CSPAzure) + if !errors.Is(err, apiErr) { + t.Fatalf("expected api error, got %v", err) + } +} + +func TestCachedEligibilityLister_CorruptCache_Fallthrough(t *testing.T) { + t.Parallel() + dir := t.TempDir() + store := NewStore(dir, 4*time.Hour) + + inner := &mockEligibilityLister{ + response: &models.EligibilityResponse{ + Response: []models.EligibleTarget{{WorkspaceID: "ws-fresh"}}, + Total: 1, + }, + } + + // Write corrupt cache file + key := eligibilityCacheKey(models.CSPAzure) + if err := writeCorruptCacheFile(dir, key); err != nil { + t.Fatalf("failed to write corrupt cache: %v", err) + } + + cached := NewCachedEligibilityLister(inner, nil, store, false, nil) + ctx := context.Background() + + resp, err := cached.ListEligibility(ctx, models.CSPAzure) + if err != nil { + t.Fatalf("error = %v", err) + } + if inner.calls != 1 { + t.Fatalf("expected inner call on corrupt cache, got %d calls", inner.calls) + } + if resp.Response[0].WorkspaceID != "ws-fresh" { + t.Errorf("expected fresh data, got %+v", resp) + } +} + +func TestCachedGroupsEligibilityLister_CacheHit(t *testing.T) { + t.Parallel() + store := NewStore(t.TempDir(), 4*time.Hour) + + inner := &mockGroupsEligibilityLister{ + response: &models.GroupsEligibilityResponse{ + Response: []models.GroupsEligibleTarget{ + {GroupID: "g-1", GroupName: "Admins"}, + }, + Total: 1, + }, + } + + cached := NewCachedEligibilityLister(nil, inner, store, false, nil) + ctx := context.Background() + + // First call — miss + resp1, err := cached.ListGroupsEligibility(ctx, models.CSPAzure) + if err != nil { + t.Fatalf("first call error = %v", err) + } + if inner.calls != 1 { + t.Fatalf("expected 1 inner call, got %d", inner.calls) + } + if len(resp1.Response) != 1 { + t.Fatalf("expected 1 group, got %d", len(resp1.Response)) + } + + // Second call — hit + resp2, err := cached.ListGroupsEligibility(ctx, models.CSPAzure) + if err != nil { + t.Fatalf("second call error = %v", err) + } + if inner.calls != 1 { + t.Fatalf("expected still 1 inner call, got %d", inner.calls) + } + if resp2.Response[0].GroupID != "g-1" { + t.Errorf("unexpected cached response: %+v", resp2) + } +} + +func TestCachedGroupsEligibilityLister_RefreshBypass(t *testing.T) { + t.Parallel() + store := NewStore(t.TempDir(), 4*time.Hour) + + inner := &mockGroupsEligibilityLister{ + response: &models.GroupsEligibilityResponse{ + Response: []models.GroupsEligibleTarget{ + {GroupID: "g-1", GroupName: "Admins"}, + }, + Total: 1, + }, + } + + // Pre-populate cache + cached := NewCachedEligibilityLister(nil, inner, store, false, nil) + ctx := context.Background() + _, _ = cached.ListGroupsEligibility(ctx, models.CSPAzure) + + // With refresh=true, should bypass cache + refreshed := NewCachedEligibilityLister(nil, inner, store, true, nil) + _, err := refreshed.ListGroupsEligibility(ctx, models.CSPAzure) + if err != nil { + t.Fatalf("error = %v", err) + } + if inner.calls != 2 { + t.Fatalf("expected 2 calls with refresh, got %d", inner.calls) + } +} + +func TestCachedGroupsEligibilityLister_NilInner(t *testing.T) { + t.Parallel() + store := NewStore(t.TempDir(), 4*time.Hour) + + cached := NewCachedEligibilityLister(nil, nil, store, false, nil) + ctx := context.Background() + + _, err := cached.ListGroupsEligibility(ctx, models.CSPAzure) + if err == nil { + t.Fatal("expected error when groupsInner is nil") + } +} + +func TestCachedEligibilityLister_DifferentCSPs_SeparateKeys(t *testing.T) { + t.Parallel() + store := NewStore(t.TempDir(), 4*time.Hour) + + callCSPs := []models.CSP{} + inner := &mockEligibilityLister{ + response: &models.EligibilityResponse{ + Response: []models.EligibleTarget{{WorkspaceID: "ws-1"}}, + Total: 1, + }, + } + + cached := NewCachedEligibilityLister(inner, nil, store, false, nil) + ctx := context.Background() + + _, _ = cached.ListEligibility(ctx, models.CSPAzure) + callCSPs = append(callCSPs, models.CSPAzure) + + _, _ = cached.ListEligibility(ctx, models.CSPAWS) + callCSPs = append(callCSPs, models.CSPAWS) + + // Both CSPs should have called inner (separate cache keys) + if inner.calls != 2 { + t.Fatalf("expected 2 inner calls for different CSPs, got %d", inner.calls) + } + + // Now both should be cached + _, _ = cached.ListEligibility(ctx, models.CSPAzure) + _, _ = cached.ListEligibility(ctx, models.CSPAWS) + if inner.calls != 2 { + t.Fatalf("expected still 2 inner calls after cache hits, got %d", inner.calls) + } +} + +// recordingLogger captures Info calls for assertions. +type recordingLogger struct { + messages []string +} + +func (l *recordingLogger) Info(msg string, v ...interface{}) { + l.messages = append(l.messages, fmt.Sprintf(msg, v...)) +} + +func TestCachedEligibilityLister_LogsHitAndMiss(t *testing.T) { + t.Parallel() + store := NewStore(t.TempDir(), 4*time.Hour) + log := &recordingLogger{} + + inner := &mockEligibilityLister{ + response: &models.EligibilityResponse{ + Response: []models.EligibleTarget{{WorkspaceID: "ws-1"}}, + Total: 1, + }, + } + + cached := NewCachedEligibilityLister(inner, nil, store, false, log) + ctx := context.Background() + + // First call — miss + _, _ = cached.ListEligibility(ctx, models.CSPAzure) + if len(log.messages) < 1 { + t.Fatal("expected at least 1 log message on miss") + } + found := false + for _, m := range log.messages { + if strings.Contains(m, "Cache miss") && strings.Contains(m, "AZURE") { + found = true + } + } + if !found { + t.Errorf("expected 'Cache miss' log, got: %v", log.messages) + } + + // Second call — hit + log.messages = nil + _, _ = cached.ListEligibility(ctx, models.CSPAzure) + found = false + for _, m := range log.messages { + if strings.Contains(m, "Cache hit") && strings.Contains(m, "AZURE") { + found = true + } + } + if !found { + t.Errorf("expected 'Cache hit' log, got: %v", log.messages) + } +} + +func TestCachedEligibilityLister_LogsRefreshBypass(t *testing.T) { + t.Parallel() + store := NewStore(t.TempDir(), 4*time.Hour) + log := &recordingLogger{} + + inner := &mockEligibilityLister{ + response: &models.EligibilityResponse{ + Response: []models.EligibleTarget{{WorkspaceID: "ws-1"}}, + Total: 1, + }, + } + + cached := NewCachedEligibilityLister(inner, nil, store, true, log) + ctx := context.Background() + + _, _ = cached.ListEligibility(ctx, models.CSPAzure) + found := false + for _, m := range log.messages { + if strings.Contains(m, "refresh requested") { + found = true + } + } + if !found { + t.Errorf("expected 'refresh requested' log, got: %v", log.messages) + } +} + +// writeCorruptCacheFile writes invalid JSON to a cache file. +func writeCorruptCacheFile(dir, key string) error { + return os.WriteFile(dir+"/"+key+".json", []byte("{invalid json"), 0600) +} diff --git a/internal/config/config.go b/internal/config/config.go index dc62e28..b6846e4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "time" "gopkg.in/yaml.v3" ) @@ -14,6 +15,9 @@ const ( FavoriteTypeGroups = "groups" ) +// DefaultCacheTTL is the default eligibility cache TTL. +const DefaultCacheTTL = 4 * time.Hour + // Favorite represents a saved elevation target. type Favorite struct { Type string `yaml:"type,omitempty"` // "cloud" or "groups"; empty → "cloud" @@ -28,6 +32,7 @@ type Favorite struct { type Config struct { Profile string `yaml:"profile"` DefaultProvider string `yaml:"default_provider"` + CacheTTL string `yaml:"cache_ttl,omitempty"` Favorites map[string]Favorite `yaml:"favorites"` } @@ -101,6 +106,19 @@ func ConfigDir() (string, error) { return filepath.Join(home, ".grant"), nil } +// ParseCacheTTL returns the configured cache TTL duration. +// Falls back to DefaultCacheTTL if the config value is empty or unparseable. +func ParseCacheTTL(cfg *Config) time.Duration { + if cfg.CacheTTL == "" { + return DefaultCacheTTL + } + d, err := time.ParseDuration(cfg.CacheTTL) + if err != nil { + return DefaultCacheTTL + } + return d +} + // ConfigPath returns the config file path, respecting the GRANT_CONFIG env var. func ConfigPath() (string, error) { if p := os.Getenv("GRANT_CONFIG"); p != "" { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 8fcf3f5..52fd135 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -3,7 +3,9 @@ package config import ( "os" "path/filepath" + "strings" "testing" + "time" ) func TestLoadConfig_FileNotFound(t *testing.T) { @@ -418,3 +420,90 @@ favorites: t.Errorf("legacy-fav ResolvedType() = %q, want %q", legacy.ResolvedType(), FavoriteTypeCloud) } } + +func TestLoadConfig_WithCacheTTL(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + + content := []byte(`profile: my-profile +default_provider: azure +cache_ttl: 2h +`) + if err := os.WriteFile(path, content, 0644); err != nil { + t.Fatalf("failed to write test file: %v", err) + } + + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if cfg.CacheTTL != "2h" { + t.Errorf("cache_ttl = %q, want %q", cfg.CacheTTL, "2h") + } +} + +func TestLoadConfig_BackwardsCompat_NoCacheTTL(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + + content := []byte(`profile: old-profile +default_provider: azure +`) + if err := os.WriteFile(path, content, 0644); err != nil { + t.Fatalf("failed to write test file: %v", err) + } + + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if cfg.CacheTTL != "" { + t.Errorf("cache_ttl = %q, want empty for legacy config", cfg.CacheTTL) + } +} + +func TestSaveConfig_CacheTTL_OmitsEmpty(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + + cfg := DefaultConfig() + if err := Save(cfg, path); err != nil { + t.Fatalf("Save() error = %v", err) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + + if strings.Contains(string(data), "cache_ttl") { + t.Errorf("expected cache_ttl to be omitted when empty, got:\n%s", string(data)) + } +} + +func TestParseCacheTTL(t *testing.T) { + t.Parallel() + tests := []struct { + name string + value string + want time.Duration + }{ + {name: "empty uses default", value: "", want: DefaultCacheTTL}, + {name: "custom 2h", value: "2h", want: 2 * time.Hour}, + {name: "custom 30m", value: "30m", want: 30 * time.Minute}, + {name: "invalid falls back to default", value: "garbage", want: DefaultCacheTTL}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + cfg := &Config{CacheTTL: tt.value} + got := ParseCacheTTL(cfg) + if got != tt.want { + t.Errorf("ParseCacheTTL(%q) = %v, want %v", tt.value, got, tt.want) + } + }) + } +} From 01f233e36e8045a60e9d8819d0cf4292097bbba2 Mon Sep 17 00:00:00 2001 From: Tim Schindler Date: Thu, 19 Feb 2026 15:52:04 +0100 Subject: [PATCH 2/2] fix: remove unused variable to satisfy linter --- internal/cache/cached_eligibility_test.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/internal/cache/cached_eligibility_test.go b/internal/cache/cached_eligibility_test.go index dcaef02..13d7e47 100644 --- a/internal/cache/cached_eligibility_test.go +++ b/internal/cache/cached_eligibility_test.go @@ -281,7 +281,6 @@ func TestCachedEligibilityLister_DifferentCSPs_SeparateKeys(t *testing.T) { t.Parallel() store := NewStore(t.TempDir(), 4*time.Hour) - callCSPs := []models.CSP{} inner := &mockEligibilityLister{ response: &models.EligibilityResponse{ Response: []models.EligibleTarget{{WorkspaceID: "ws-1"}}, @@ -293,10 +292,7 @@ func TestCachedEligibilityLister_DifferentCSPs_SeparateKeys(t *testing.T) { ctx := context.Background() _, _ = cached.ListEligibility(ctx, models.CSPAzure) - callCSPs = append(callCSPs, models.CSPAzure) - _, _ = cached.ListEligibility(ctx, models.CSPAWS) - callCSPs = append(callCSPs, models.CSPAWS) // Both CSPs should have called inner (separate cache keys) if inner.calls != 2 {