- Language: Go 1.25+
- Module:
github.com/aaearon/grant-cli - Sole dependency:
github.com/cyberark/idsec-sdk-golang— zero new Go module deps (all libs reused from SDK dep tree)
import (
"github.com/cyberark/idsec-sdk-golang/pkg/auth"
"github.com/cyberark/idsec-sdk-golang/pkg/common"
"github.com/cyberark/idsec-sdk-golang/pkg/common/isp"
"github.com/cyberark/idsec-sdk-golang/pkg/models"
"github.com/cyberark/idsec-sdk-golang/pkg/services"
)| Type | Package | Purpose |
|---|---|---|
auth.IdsecAuth |
pkg/auth |
Auth interface |
auth.IdsecISPAuth |
pkg/auth |
ISP authenticator — NewIdsecISPAuth(isServiceUser bool) |
isp.IdsecISPServiceClient |
pkg/common/isp |
HTTP client with auth headers |
services.IdsecService |
pkg/services |
Service interface |
services.IdsecBaseService |
pkg/services |
Base service with auth resolution |
models.IdsecProfile |
pkg/models |
Profile storage |
Custom SCAAccessService follows SDK conventions:
- Embed
services.IdsecService+*services.IdsecBaseService - Create client via
isp.FromISPAuth(ispAuth, "sca", ".", "", refreshCallback) - Set
X-API-Version: 2.0header on all requests httpClientinterface for DI/testing
- Base URL:
https://{subdomain}.sca.{platform_domain}/api - Endpoints:
GET /api/access/{CSP}/eligibility— list eligible targetsPOST /api/access/elevate— request JIT elevation (AWS responses includeaccessCredentialsJSON string)GET /api/access/sessions— list active sessionsPOST /api/access/sessions/revoke— revoke sessions by ID (request:sessionIds[], response:SessionRevocationInfo[])GET /api/access/{CSP}/eligibility/groups— list eligible Entra ID groups (response:groupId/groupName/directoryId)POST /api/access/elevate/groups— request group membership elevation (response wrapped inresponsekey, same as cloud elevation)
- Headers:
Authorization: Bearer {jwt},X-API-Version: 2.0,Content-Type: application/json
- TDD: write
_test.gobefore.gofor every package - Table-driven tests
httptest.NewServerfor service mockshttpClientinterface for DI- Test files co-located as
_test.go
spf13/cobrafor CLI frameworkIilun/survey/v2for interactive promptsgrant env— performs elevation, outputs onlyexportstatements (no human text); usage:eval $(grant env --provider aws); supports--refreshgrant list— list eligible targets and groups without triggering elevation; supports--provider,--groups,--refresh,--output json; used by LLMs to discover available targets programmaticallygrant revoke— revoke sessions: direct (grant revoke <id>),--all, or interactive multi-select;--yesskips confirmationgrant update— self-update binary via GitHub Releases (rhysd/go-github-selfupdate); guards against dev builds--groupsflag on root command shows only Entra ID groups in the interactive selector--group/-gflag 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/groupsand/elevate/groupsAPI endpoints - Multi-CSP: omitting
--providerfetches eligibility from all supported CSPs and merges results --refreshbypasses eligibility cache ongrantandgrant envfetchEligibility()andresolveTargetCSP()incmd/root.go— shared by root, env, and favorites
internal/ui/tty.go—IsTerminalFunc(overridable),IsInteractive(),ErrNotInteractive- All interactive prompts (
SelectTarget,SelectSessions,ConfirmRevocation,SelectGroup,uiUnifiedSelector.SelectItem,surveyNamePrompter.PromptName) fail fast withErrNotInteractivewhen stdin is not a TTY - Error messages suggest the appropriate non-interactive flag (e.g.,
--target/--role,--all,--yes,--group,--favorite) go-isattyv0.0.20 is a direct dependency (promoted from indirect via survey)
--output/-opersistent flag on root command:text(default) orjson- Validated in
PersistentPreRunE; JSON mode forcesIsTerminalFuncto return false (non-interactive) cmd/output.go—outputFormatvar,isJSONOutput(),writeJSON(w, data)cmd/output_types.go— JSON structs:cloudElevationOutput,groupElevationJSON,sessionOutput,statusOutput,revocationOutput,favoriteOutput,awsCredentialOutput- All commands support JSON: root elevation,
env,status,revoke,favorites list config.Favoritehas bothyaml:"..."andjson:"..."struct tags
- 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_ttlin~/.grant/config.yaml(Go duration syntax:2h,30m) --refreshflag ongrantandgrant envbypasses cache reads but still writes fresh datainternal/cache/cache.go— genericStorewithGet[T]/Set[T], injectable clock for testinginternal/cache/cached_eligibility.go—CachedEligibilityListerdecorator implementingeligibilityLister+groupsEligibilityListerinternal/cache/session_tracker.go—RecordSession,SessionTimestamps,CleanupSessionsfor tracking elevation timestamps insession_timestamps.json(25h TTL, auto-cleanup of inactive sessions)buildCachedLister()incmd/root.go— shared factory used by all commands (root, env, status, revoke, favorites add)- Commands without
--refresh(status, revoke, favorites add) always passrefresh: false— they use eligibility for display only - Cache failures (read/write) silently fall through to the live API
cmd/session_tracking.go—recordSessionTimestampvar (injectable for tests), called after elevation in root and env commands
--verbose/-vglobal flag wired viaPersistentPreRunEincmd/root.go- Calls
config.EnableVerboseLogging("INFO")(setsIDSEC_LOG_LEVEL=INFO) orconfig.DisableVerboseLogging()(setsIDSEC_LOG_LEVEL=CRITICAL) cmdLoggerinterface incmd/verbose.go—Info(msg string, v ...interface{}), satisfied by*common.IdsecLoggerlogpackage-level var incmd/verbose.go— all commands uselog.Info(...)for verbose output; tests swap withspyLoggerloggingClientininternal/sca/logging_client.godecorateshttpClient, logging method/route/status/duration at INFO, response headers at DEBUG with Authorization redactionNewSCAAccessService()wraps ISP client withloggingClientusingcommon.GetLogger("grant", -1)(dynamic level from env)NewSCAAccessServiceWithClient()(test constructor) does not wrap — tests don't need loggingExecute()prints"Hint: re-run with --verbose for more details"on error when verbose is off- Users can set
IDSEC_LOG_LEVEL=DEBUGenv var for deeper SDK output
- App config:
~/.grant/config.yaml - SDK profile:
~/.idsec_profiles/grant
- Use the
/grant-loginskill when you need to authenticate to the grant CLI (e.g., before manual testing) - Skill definition:
.claude/skills/grant-login/SKILL.md - Requires
.envat project root withGRANT_PASSWORDandTOTP_SECRET
- Config:
.golangci.yml(golangci-lint v1 format) - 19 linters enabled: defaults (errcheck, gosimple, govet, ineffassign, staticcheck, unused) + bodyclose, errorlint, noctx, gosec (G101 excluded), errname, gocritic, misspell, revive, gocognit (threshold 40), perfsprint, unconvert, usetesting
- Test files excluded from gosec, gocognit, bodyclose
revive/unused-parameterandrevive/exporteddisabled (Cobra signatures, established API names)- Use
errors.Newfor static error strings (perfsprint enforced);fmt.Errorfonly with%verbs - Use
t.Context()instead ofcontext.Background()in tests (usetesting enforced)
make build # Build binary with -trimpath and ldflags
make test # Run unit tests
make test-integration # Run integration tests (builds binary)
make test-all # Run all tests
make lint # Run linter (golangci-lint)
make clean # Clean build artifacts-trimpathused in bothMakefileand.goreleaser.yamlfor reproducible builds.goreleaser.yamlusesCommitDate(not build date) andmod_timestampfor reproducibility
- Move
[Unreleased]entries inCHANGELOG.mdto a new[X.Y.Z] - YYYY-MM-DDsection (leave[Unreleased]header empty) - Commit:
docs: prepare CHANGELOG for vX.Y.Z release - Tag:
git tag vX.Y.Z - Push commit and tag:
git push origin main && git push origin vX.Y.Z - The
release.ymlGitHub Actions workflow triggers onv*tags and runs GoReleaser to build binaries and create the GitHub Release
- Feature branches, conventional commits
- Branch naming:
feat/,fix/,docs/
Commands follow Cobra best practices:
// Factory function for testability
func NewCommandName() *cobra.Command {
return &cobra.Command{
Use: "command-name",
Short: "Brief description",
Long: "Detailed description...",
RunE: func(cmd *cobra.Command, args []string) error {
return runCommandName(cmd, args)
},
}
}
// Separate run function for testability
func runCommandName(cmd *cobra.Command, args []string) error {
// Implementation
}
// Auto-register in init()
func init() {
rootCmd.AddCommand(NewCommandName())
}Commands use interfaces for testability:
// interfaces.go
type authProvider interface {
Authenticate(profile *models.IdsecProfile) (*models.IdsecToken, error)
}
type scaService interface {
ListEligibility(ctx context.Context, csp models.CSP) (*models.EligibilityResponse, error)
Elevate(ctx context.Context, req *models.ElevateRequest) (*models.ElevateResponse, error)
}
// Command runtime resolution
var (
getAuth = func() (authProvider, error) { /* ... */ }
getSCAService = func() (scaService, error) { /* ... */ }
)
// Test injection via package vars
func TestMyCommand(t *testing.T) {
originalGetAuth := getAuth
defer func() { getAuth = originalGetAuth }()
getAuth = func() (authProvider, error) {
return &mockAuth{}, nil
}
}func TestCommand(t *testing.T) {
tests := []struct {
name string
args []string
flags map[string]string
wantErr bool
wantOutput string
}{
{name: "success case", args: []string{}, wantErr: false},
{name: "error case", args: []string{}, wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Test implementation
})
}
}// test_mocks.go - shared mocks across tests
type mockAuthProvider struct {
authenticateFn func(*models.IdsecProfile) (*models.IdsecToken, error)
}
func (m *mockAuthProvider) Authenticate(p *models.IdsecProfile) (*models.IdsecToken, error) {
if m.authenticateFn != nil {
return m.authenticateFn(p)
}
return &models.IdsecToken{}, nil
}//go:build integration
// integration_test.go - tests compiled binary
func TestMain(m *testing.M) {
// Build binary before tests
cmd := exec.Command("go", "build", "-o", "../grant-test", "../.")
cmd.Run()
code := m.Run()
os.Remove("../grant-test")
os.Exit(code)
}Commands use consistent error patterns:
// Return errors, don't print
func runCommand(cmd *cobra.Command, args []string) error {
if err := validate(args); err != nil {
return fmt.Errorf("validation failed: %w", err)
}
result, err := doWork()
if err != nil {
return fmt.Errorf("operation failed: %w", err)
}
cmd.Println("Success:", result)
return nil
}
// Cobra automatically prints errors from RunEUse cmd.OutOrStdout() for testability:
func runCommand(cmd *cobra.Command, args []string) error {
// Use cmd methods for output
fmt.Fprintln(cmd.OutOrStdout(), "Output message")
fmt.Fprintf(cmd.ErrOrStderr(), "Error: %s\n", err)
// NOT: fmt.Println("message")
}cmd.Flags().StringP("flag", "f", "default", "description")
cmd.Flags().StringVarP(&variable, "flag", "f", "default", "description")
// Mark flags as required
cmd.MarkFlagRequired("required-flag")
// Mutually exclusive flags (handled in RunE)
if cmd.Flags().Changed("flag1") && cmd.Flags().Changed("flag2") {
return errors.New("--flag1 and --flag2 are mutually exclusive")
}// Load config with GRANT_CONFIG override
cfg, err := config.Load()
if err != nil {
// Default config if not found
cfg = config.DefaultConfig()
}// Create ISP auth
ispAuth := auth.NewIdsecISPAuth(true) // cacheAuthentication=true
// Load profile and authenticate
profile, err := models.LoadProfile(cfg.Profile)
token, err := ispAuth.Authenticate(profile)
// Create SCA service
svc, err := sca.NewSCAAccessService(ispAuth)