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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,19 @@ All notable changes to this project will be documented in this file.

## [Unreleased]

### Added

- `grant revoke` command for session revocation with three modes: direct (by session ID), `--all`, and interactive (multi-select)
- `--yes`/`-y` flag on `grant revoke` to skip confirmation for scripting
- `--provider`/`-p` flag on `grant revoke --all` and interactive mode to filter by cloud provider
- Session ID displayed in `grant status` output for easy reference with `grant revoke`

### Fixed

- `grant revoke` now rejects `--provider` in direct mode (session IDs are already explicit)
- `grant status` session formatting reuses shared `ui.FormatSessionOption` instead of duplicated logic
- `buildWorkspaceNameMap` moved to shared `cmd/helpers.go` to eliminate cross-command dependency

## [0.2.1] - 2026-02-18

### Fixed
Expand Down
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ Custom `SCAAccessService` follows SDK conventions:
- `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[]`)
- **Headers:** `Authorization: Bearer {jwt}`, `X-API-Version: 2.0`, `Content-Type: application/json`

## Testing
Expand All @@ -52,6 +53,7 @@ Custom `SCAAccessService` follows SDK conventions:
- `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 revoke` — revoke sessions: direct (`grant revoke <id>`), `--all`, or interactive multi-select; `--yes` skips confirmation
- Multi-CSP: omitting `--provider` fetches eligibility from all supported CSPs and merges results
- `fetchEligibility()` and `resolveTargetCSP()` in `cmd/root.go` — shared by root, env, and favorites

Expand Down
1 change: 1 addition & 0 deletions cmd/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,6 @@ func init() {
NewVersionCommand(),
NewFavoritesCommand(),
NewEnvCommand(),
NewRevokeCommand(),
)
}
43 changes: 43 additions & 0 deletions cmd/helpers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package cmd

import (
"context"
"fmt"
"io"

scamodels "github.com/aaearon/grant-cli/internal/sca/models"
)

// buildWorkspaceNameMap fetches eligibility for each unique CSP in sessions
// and builds a workspaceID -> workspaceName map. Errors are silently ignored
// (graceful degradation — the raw workspace ID is shown as fallback).
func buildWorkspaceNameMap(ctx context.Context, eligLister eligibilityLister, sessions []scamodels.SessionInfo, errWriter io.Writer) map[string]string {
nameMap := make(map[string]string)

// Collect unique CSPs
csps := make(map[scamodels.CSP]bool)
for _, s := range sessions {
csps[s.CSP] = true
}

// Fetch eligibility for each CSP
for csp := range csps {
if ctx.Err() != nil {
break
}
resp, err := eligLister.ListEligibility(ctx, csp)
if err != nil || resp == nil {
if verbose && err != nil {
fmt.Fprintf(errWriter, "Warning: failed to fetch names for %s: %v\n", csp, err)
}
continue
}
for _, target := range resp.Response {
if target.WorkspaceName != "" {
nameMap[target.WorkspaceID] = target.WorkspaceName
}
}
}

return nameMap
}
15 changes: 15 additions & 0 deletions cmd/interfaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,21 @@ type sessionLister interface {
ListSessions(ctx context.Context, csp *models.CSP) (*models.SessionsResponse, error)
}

// sessionRevoker interface for revoking sessions
type sessionRevoker interface {
RevokeSessions(ctx context.Context, req *models.RevokeRequest) (*models.RevokeResponse, error)
}

// sessionSelector interface for interactive session selection
type sessionSelector interface {
SelectSessions(sessions []models.SessionInfo, nameMap map[string]string) ([]models.SessionInfo, error)
}

// confirmPrompter interface for confirmation prompts
type confirmPrompter interface {
ConfirmRevocation(count int) (bool, error)
}

// keyringClearer interface for clearing keyring passwords
type keyringClearer interface {
ClearAllPasswords() error
Expand Down
199 changes: 199 additions & 0 deletions cmd/revoke.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
package cmd

import (
"context"
"fmt"

scamodels "github.com/aaearon/grant-cli/internal/sca/models"
"github.com/aaearon/grant-cli/internal/ui"
sdkmodels "github.com/cyberark/idsec-sdk-golang/pkg/models"
"github.com/spf13/cobra"
)

// uiSessionSelector wraps ui.SelectSessions to implement sessionSelector
type uiSessionSelector struct{}

func (s *uiSessionSelector) SelectSessions(sessions []scamodels.SessionInfo, nameMap map[string]string) ([]scamodels.SessionInfo, error) {
return ui.SelectSessions(sessions, nameMap)
}

// uiConfirmPrompter wraps ui.ConfirmRevocation to implement confirmPrompter
type uiConfirmPrompter struct{}

func (p *uiConfirmPrompter) ConfirmRevocation(count int) (bool, error) {
return ui.ConfirmRevocation(count)
}

// newRevokeCommand creates the revoke cobra command with the given RunE function.
func newRevokeCommand(runFn func(*cobra.Command, []string) error) *cobra.Command {
cmd := &cobra.Command{
Use: "revoke [session-id...]",
Short: "Revoke active elevated sessions",
Long: `Revoke one or more active elevated sessions.

Three execution modes:
1. Direct mode: grant revoke <session-id> [<session-id>...]
2. All mode: grant revoke --all [--provider azure]
3. Interactive mode: grant revoke (multi-select prompt)

Use 'grant status' to view session IDs.`,
SilenceErrors: true,
SilenceUsage: true,
RunE: runFn,
}

cmd.Flags().BoolP("all", "a", false, "revoke all active sessions")
cmd.Flags().BoolP("yes", "y", false, "skip confirmation prompt")
cmd.Flags().StringP("provider", "p", "", "filter sessions by provider (azure, aws)")

return cmd
}

// NewRevokeCommand creates the production revoke command.
func NewRevokeCommand() *cobra.Command {
return newRevokeCommand(func(cmd *cobra.Command, args []string) error {
ispAuth, svc, profile, err := bootstrapSCAService()
if err != nil {
return err
}

return runRevoke(cmd, args, ispAuth, svc, svc, svc, &uiSessionSelector{}, &uiConfirmPrompter{}, profile)
})
}

// NewRevokeCommandWithDeps creates a revoke command with injected dependencies for testing.
func NewRevokeCommandWithDeps(
auth authLoader,
lister sessionLister,
elig eligibilityLister,
revoker sessionRevoker,
selector sessionSelector,
confirmer confirmPrompter,
) *cobra.Command {
return newRevokeCommand(func(cmd *cobra.Command, args []string) error {
return runRevoke(cmd, args, auth, lister, elig, revoker, selector, confirmer, nil)
})
}

func runRevoke(
cmd *cobra.Command,
args []string,
auth authLoader,
lister sessionLister,
elig eligibilityLister,
revoker sessionRevoker,
selector sessionSelector,
confirmer confirmPrompter,
profile *sdkmodels.IdsecProfile,
) error {
allFlag, _ := cmd.Flags().GetBool("all")
yesFlag, _ := cmd.Flags().GetBool("yes")
provider, _ := cmd.Flags().GetString("provider")

// Validate mutual exclusivity
if allFlag && len(args) > 0 {
return fmt.Errorf("--all cannot be used with session ID arguments")
}
if len(args) > 0 && provider != "" {
return fmt.Errorf("--provider cannot be used with session ID arguments")
}

// Validate provider
var cspFilter *scamodels.CSP
if provider != "" {
csp, err := parseProvider(provider)
if err != nil {
return err
}
cspFilter = &csp
}

// Check authentication
_, err := auth.LoadAuthentication(profile, true)
if err != nil {
return fmt.Errorf("not authenticated, run 'grant login' first: %w", err)
}

// Determine session IDs to revoke
var sessionIDs []string

if len(args) > 0 {
// Direct mode: session IDs provided as arguments
sessionIDs = args
} else {
// All or interactive mode: need to list sessions first
ctx, cancel := context.WithTimeout(context.Background(), apiTimeout)
defer cancel()

sessions, err := lister.ListSessions(ctx, cspFilter)
if err != nil {
return fmt.Errorf("failed to list sessions: %w", err)
}

if len(sessions.Response) == 0 {
fmt.Fprintln(cmd.OutOrStdout(), "No active sessions to revoke.")
return nil
}

if allFlag {
// Collect all session IDs
for _, s := range sessions.Response {
sessionIDs = append(sessionIDs, s.SessionID)
}

// Confirm unless --yes
if !yesFlag {
confirmed, err := confirmer.ConfirmRevocation(len(sessionIDs))
if err != nil {
return fmt.Errorf("confirmation failed: %w", err)
}
if !confirmed {
fmt.Fprintln(cmd.OutOrStdout(), "Revocation cancelled.")
return nil
}
}
} else {
// Interactive mode
nameMap := buildWorkspaceNameMap(ctx, elig, sessions.Response, cmd.ErrOrStderr())

selected, err := selector.SelectSessions(sessions.Response, nameMap)
if err != nil {
return fmt.Errorf("session selection failed: %w", err)
}

for _, s := range selected {
sessionIDs = append(sessionIDs, s.SessionID)
}

// Confirm
if !yesFlag {
confirmed, err := confirmer.ConfirmRevocation(len(sessionIDs))
if err != nil {
return fmt.Errorf("confirmation failed: %w", err)
}
if !confirmed {
fmt.Fprintln(cmd.OutOrStdout(), "Revocation cancelled.")
return nil
}
}
}
}

// Call revoke API
ctx, cancel := context.WithTimeout(context.Background(), apiTimeout)
defer cancel()

result, err := revoker.RevokeSessions(ctx, &scamodels.RevokeRequest{
SessionIDs: sessionIDs,
})
if err != nil {
return err
}

// Display results
for _, r := range result.Response {
fmt.Fprintf(cmd.OutOrStdout(), " %s: %s\n", r.SessionID, r.RevocationStatus)
}

return nil
}
Loading