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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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
Expand Down
12 changes: 11 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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
- `--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`)
Expand Down
8 changes: 6 additions & 2 deletions cmd/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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")
Expand All @@ -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)
})
}

Expand Down
7 changes: 7 additions & 0 deletions cmd/env_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
27 changes: 25 additions & 2 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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.
Expand Down Expand Up @@ -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 {
Expand All @@ -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")
Expand Down Expand Up @@ -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
}

Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion cmd/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
81 changes: 81 additions & 0 deletions internal/cache/cache.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading