Skip to content

Latest commit

 

History

History
338 lines (280 loc) · 13.3 KB

File metadata and controls

338 lines (280 loc) · 13.3 KB

grant

Project

  • 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)

SDK Import Conventions

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"
)

SDK Types Cheat-Sheet

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

Service Pattern

Custom SCAAccessService follows SDK conventions:

  • Embed services.IdsecService + *services.IdsecBaseService
  • Create client via isp.FromISPAuth(ispAuth, "sca", ".", "", refreshCallback)
  • Set X-API-Version: 2.0 header on all requests
  • httpClient interface for DI/testing

SCA Access API

  • Base URL: https://{subdomain}.sca.{platform_domain}/api
  • Endpoints:
    • GET /api/access/{CSP}/eligibility — list eligible targets
    • POST /api/access/elevate — request JIT elevation (AWS responses include accessCredentials JSON string)
    • GET /api/access/sessions — list active sessions
    • POST /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 in response key, same as cloud elevation)
  • Headers: Authorization: Bearer {jwt}, X-API-Version: 2.0, Content-Type: application/json

Testing

  • TDD: write _test.go before .go for every package
  • Table-driven tests
  • httptest.NewServer for service mocks
  • httpClient interface for DI
  • Test files co-located as _test.go

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); supports --refresh
  • grant list — list eligible targets and groups without triggering elevation; supports --provider, --groups, --refresh, --output json; used by LLMs to discover available targets programmatically
  • grant revoke — revoke sessions: direct (grant revoke <id>), --all, or interactive multi-select; --yes skips confirmation
  • grant update — self-update binary via GitHub Releases (rhysd/go-github-selfupdate); guards against dev builds
  • --groups flag on root command shows only Entra ID groups in the interactive selector
  • --group / -g flag 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/groups and /elevate/groups API endpoints
  • 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

TTY Detection

  • internal/ui/tty.goIsTerminalFunc (overridable), IsInteractive(), ErrNotInteractive
  • All interactive prompts (SelectTarget, SelectSessions, ConfirmRevocation, SelectGroup, uiUnifiedSelector.SelectItem, surveyNamePrompter.PromptName) fail fast with ErrNotInteractive when stdin is not a TTY
  • Error messages suggest the appropriate non-interactive flag (e.g., --target/--role, --all, --yes, --group, --favorite)
  • go-isatty v0.0.20 is a direct dependency (promoted from indirect via survey)

JSON Output

  • --output / -o persistent flag on root command: text (default) or json
  • Validated in PersistentPreRunE; JSON mode forces IsTerminalFunc to return false (non-interactive)
  • cmd/output.gooutputFormat var, 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.Favorite has both yaml:"..." and json:"..." struct tags

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.goCachedEligibilityLister decorator implementing eligibilityLister + groupsEligibilityLister
  • internal/cache/session_tracker.goRecordSession, SessionTimestamps, CleanupSessions for tracking elevation timestamps in session_timestamps.json (25h TTL, auto-cleanup of inactive sessions)
  • buildCachedLister() in cmd/root.go — shared factory used by all commands (root, env, status, revoke, favorites add)
  • Commands without --refresh (status, revoke, favorites add) always pass refresh: false — they use eligibility for display only
  • Cache failures (read/write) silently fall through to the live API
  • cmd/session_tracking.gorecordSessionTimestamp var (injectable for tests), called after elevation in root and env commands

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)
  • cmdLogger interface in cmd/verbose.goInfo(msg string, v ...interface{}), satisfied by *common.IdsecLogger
  • log package-level var in cmd/verbose.go — all commands use log.Info(...) for verbose output; tests swap with spyLogger
  • loggingClient in internal/sca/logging_client.go decorates httpClient, logging method/route/status/duration at INFO, response headers at DEBUG with Authorization redaction
  • NewSCAAccessService() wraps ISP client with loggingClient using common.GetLogger("grant", -1) (dynamic level from env)
  • NewSCAAccessServiceWithClient() (test constructor) does not wrap — tests don't need logging
  • Execute() prints "Hint: re-run with --verbose for more details" on error when verbose is off
  • Users can set IDSEC_LOG_LEVEL=DEBUG env var for deeper SDK output

Config

  • App config: ~/.grant/config.yaml
  • SDK profile: ~/.idsec_profiles/grant

Authentication

  • Use the /grant-login skill when you need to authenticate to the grant CLI (e.g., before manual testing)
  • Skill definition: .claude/skills/grant-login/SKILL.md
  • Requires .env at project root with GRANT_PASSWORD and TOTP_SECRET

Lint

  • 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-parameter and revive/exported disabled (Cobra signatures, established API names)
  • Use errors.New for static error strings (perfsprint enforced); fmt.Errorf only with % verbs
  • Use t.Context() instead of context.Background() in tests (usetesting enforced)

Build

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
  • -trimpath used in both Makefile and .goreleaser.yaml for reproducible builds
  • .goreleaser.yaml uses CommitDate (not build date) and mod_timestamp for reproducibility

Release Process

  1. Move [Unreleased] entries in CHANGELOG.md to a new [X.Y.Z] - YYYY-MM-DD section (leave [Unreleased] header empty)
  2. Commit: docs: prepare CHANGELOG for vX.Y.Z release
  3. Tag: git tag vX.Y.Z
  4. Push commit and tag: git push origin main && git push origin vX.Y.Z
  5. The release.yml GitHub Actions workflow triggers on v* tags and runs GoReleaser to build binaries and create the GitHub Release

Git

  • Feature branches, conventional commits
  • Branch naming: feat/, fix/, docs/

Implementation Patterns

Command Structure

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())
}

Dependency Injection

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
    }
}

Testing Patterns

Table-Driven Tests

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
        })
    }
}

Mock Implementations

// 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
}

Integration Tests

//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)
}

Error Handling

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 RunE

Output Handling

Use 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")
}

Flag Patterns

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")
}

Config Loading

// Load config with GRANT_CONFIG override
cfg, err := config.Load()
if err != nil {
    // Default config if not found
    cfg = config.DefaultConfig()
}

Service Initialization

// 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)