- 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
- Base URL:
https://{subdomain}.uar.{platform_domain}/api - Package:
internal/workflows/—AccessRequestService(mirrors SCA service pattern with ISP client for "uar" service) - Models:
internal/workflows/models/—AccessRequest,RequestState,RequestResult,SubmitAccessRequest,CancelAccessRequest,FinalizeAccessRequest,RequestFormResponse - Endpoints:
GET /api/workflows/request-forms— get form structure for target category + request typeGET /api/workflows/requests— list access requests (offset/limit pagination, filter/sort/freeText)GET /api/workflows/requests/{requestId}— get single request detailsPOST /api/workflows/requests— submit new access requestPOST /api/workflows/requests/{requestId}/cancel— cancel an open requestPOST /api/workflows/requests/{requestId}/finalize— approve or reject a request
- Pagination: offset/limit (not nextToken);
ListRequestsfetches all pages automatically - DI interfaces:
accessRequestServiceincmd/interfaces.go - Target category:
CLOUD_CONSOLE(hardcoded for v1) - Headers:
Authorization: Bearer {jwt},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 request— manage access requests through approval workflow; subcommands:submit,list,get,cancel,approve,rejectgrant request submit— submit on-demand access request; workspace selector uses SCA eligibility (deduplicated to unique workspaces); after workspace selection, interactive role selector fetches roles via SCA on-demand endpoints (GET/api/cloud/resources/ondemandforazure_ad/aws, POST/api/cloud/cloud-roles/ondemandforazure_resource); shows summary + confirmation before submitting; flags:--target,--role-id,--role,--provider,--reason,--priority,--date,--timezone,--from,--to,--yes,--refresh- Interactive role selection supports
DIRECTORY,ACCOUNT(AWS),MANAGEMENT_GROUP,SUBSCRIPTION,RESOURCE_GROUP, andRESOURCEworkspaces (azure_resource scopes use naive 2-level ancestors; custom roles scoped to intermediate management groups may be missing — fall back to--role-id) - On-demand role cache:
~/.grant/cache/ondemand_roles_<platform>_<sha256(workspaceID)>.json(4h TTL);--refreshongrant request submitbypasses the cache
- Interactive role selection supports
grant request list— list access requests; flags:--state,--result,--priority,--role(CREATOR/APPROVER),--search,--sort,--descgrant request get [id]— get full request details; omitting<id>in a TTY opens a fuzzy-filterable picker of all your requestsgrant request cancel [id]— cancel an open request; optional--reason. Omitting<id>in a TTY opens a picker scoped to STARTING/RUNNING/PENDING requests you created (role=CREATOR)grant request approve [id]/grant request reject [id]— finalize a request; optional--reason. Omitting<id>in a TTY opens a picker scoped to PENDING requests assigned to you (role=APPROVER)- Request picker:
internal/ui/request_selector.gomirrors the role-selector Format/Build/Select quartet;resolveRequestIDFnincmd/request_picker.gois injectable for tests. Non-TTY invocation without<id>returnsErrNotInteractivewith a hint to rungrant request list grant 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.--output jsonis a pure serialisation flag; it does not affect interactivity — interactive prompts still run in a TTY and write to stderr while JSON goes to stdout cmd/output.go—outputFormatvar,isJSONOutput(),writeJSON(w, data)cmd/output_types.go— JSON structs:cloudElevationOutput,groupElevationJSON,sessionOutput,statusOutput,revocationOutput,favoriteOutput,awsCredentialOutput,accessRequestOutput,accessRequestListOutput- All commands support JSON: root elevation,
env,status,revoke,favorites list,request list,request get,request submit,request cancel,request approve,request reject 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)