diff --git a/CHANGELOG.md b/CHANGELOG.md index e2b8b44..72aa571 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 7386620..931c7ff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 @@ -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 `), `--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 diff --git a/cmd/commands.go b/cmd/commands.go index 576c446..4b77285 100644 --- a/cmd/commands.go +++ b/cmd/commands.go @@ -9,5 +9,6 @@ func init() { NewVersionCommand(), NewFavoritesCommand(), NewEnvCommand(), + NewRevokeCommand(), ) } diff --git a/cmd/helpers.go b/cmd/helpers.go new file mode 100644 index 0000000..7299ca5 --- /dev/null +++ b/cmd/helpers.go @@ -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 +} diff --git a/cmd/interfaces.go b/cmd/interfaces.go index 90f2930..b3efe86 100644 --- a/cmd/interfaces.go +++ b/cmd/interfaces.go @@ -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 diff --git a/cmd/revoke.go b/cmd/revoke.go new file mode 100644 index 0000000..407f304 --- /dev/null +++ b/cmd/revoke.go @@ -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 [...] +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 +} diff --git a/cmd/revoke_test.go b/cmd/revoke_test.go new file mode 100644 index 0000000..a2ea144 --- /dev/null +++ b/cmd/revoke_test.go @@ -0,0 +1,489 @@ +// NOTE: Do not use t.Parallel() in cmd/ tests due to package-level state +// (verbose, passedArgValidation) that is mutated during test execution. +package cmd + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + scamodels "github.com/aaearon/grant-cli/internal/sca/models" + authmodels "github.com/cyberark/idsec-sdk-golang/pkg/models/auth" + commonmodels "github.com/cyberark/idsec-sdk-golang/pkg/models/common" +) + +func TestRevokeCommand(t *testing.T) { + now := time.Now() + expiresIn := commonmodels.IdsecRFC3339Time(now.Add(1 * time.Hour)) + + activeSessions := &scamodels.SessionsResponse{ + Response: []scamodels.SessionInfo{ + { + SessionID: "session-1", + UserID: "user@example.com", + CSP: scamodels.CSPAzure, + WorkspaceID: "/subscriptions/sub-1", + RoleID: "Contributor", + SessionDuration: 3600, + }, + { + SessionID: "session-2", + UserID: "user@example.com", + CSP: scamodels.CSPAzure, + WorkspaceID: "/subscriptions/sub-2", + RoleID: "Reader", + SessionDuration: 1800, + }, + }, + Total: 2, + } + + tests := []struct { + name string + args []string + setupAuth func() *mockAuthLoader + setupLister func() *mockSessionLister + setupElig func() *mockEligibilityLister + setupRevoker func() *mockSessionRevoker + setupSelector func() *mockSessionSelector + setupConfirm func() *mockConfirmPrompter + wantContain []string + wantNotContain []string + wantErr bool + }{ + { + name: "not authenticated", + args: []string{"--all", "--yes"}, + setupAuth: func() *mockAuthLoader { + return &mockAuthLoader{loadErr: errNotAuthenticated} + }, + setupLister: func() *mockSessionLister { return &mockSessionLister{} }, + setupElig: func() *mockEligibilityLister { return &mockEligibilityLister{} }, + setupRevoker: func() *mockSessionRevoker { return &mockSessionRevoker{} }, + setupSelector: func() *mockSessionSelector { return &mockSessionSelector{} }, + setupConfirm: func() *mockConfirmPrompter { return &mockConfirmPrompter{} }, + wantContain: []string{"not authenticated"}, + wantErr: true, + }, + { + name: "no active sessions", + args: []string{"--all", "--yes"}, + setupAuth: func() *mockAuthLoader { + return &mockAuthLoader{ + token: &authmodels.IdsecToken{Token: "jwt", Username: "user@example.com", ExpiresIn: expiresIn}, + } + }, + setupLister: func() *mockSessionLister { + return &mockSessionLister{sessions: &scamodels.SessionsResponse{Response: []scamodels.SessionInfo{}, Total: 0}} + }, + setupElig: func() *mockEligibilityLister { return &mockEligibilityLister{} }, + setupRevoker: func() *mockSessionRevoker { return &mockSessionRevoker{} }, + setupSelector: func() *mockSessionSelector { return &mockSessionSelector{} }, + setupConfirm: func() *mockConfirmPrompter { return &mockConfirmPrompter{} }, + wantContain: []string{"No active sessions to revoke"}, + wantErr: false, + }, + { + name: "direct mode - single session ID", + args: []string{"session-1"}, + setupAuth: func() *mockAuthLoader { + return &mockAuthLoader{ + token: &authmodels.IdsecToken{Token: "jwt", Username: "user@example.com", ExpiresIn: expiresIn}, + } + }, + setupLister: func() *mockSessionLister { return &mockSessionLister{} }, + setupElig: func() *mockEligibilityLister { return &mockEligibilityLister{} }, + setupRevoker: func() *mockSessionRevoker { + return &mockSessionRevoker{ + response: &scamodels.RevokeResponse{ + Response: []scamodels.RevocationResult{ + {SessionID: "session-1", RevocationStatus: scamodels.RevocationSuccessful}, + }, + }, + } + }, + setupSelector: func() *mockSessionSelector { return &mockSessionSelector{} }, + setupConfirm: func() *mockConfirmPrompter { return &mockConfirmPrompter{} }, + wantContain: []string{"session-1", "SUCCESSFULLY_REVOKED"}, + wantErr: false, + }, + { + name: "direct mode - multiple session IDs", + args: []string{"session-1", "session-2"}, + setupAuth: func() *mockAuthLoader { + return &mockAuthLoader{ + token: &authmodels.IdsecToken{Token: "jwt", Username: "user@example.com", ExpiresIn: expiresIn}, + } + }, + setupLister: func() *mockSessionLister { return &mockSessionLister{} }, + setupElig: func() *mockEligibilityLister { return &mockEligibilityLister{} }, + setupRevoker: func() *mockSessionRevoker { + return &mockSessionRevoker{ + response: &scamodels.RevokeResponse{ + Response: []scamodels.RevocationResult{ + {SessionID: "session-1", RevocationStatus: scamodels.RevocationSuccessful}, + {SessionID: "session-2", RevocationStatus: scamodels.RevocationInProgress}, + }, + }, + } + }, + setupSelector: func() *mockSessionSelector { return &mockSessionSelector{} }, + setupConfirm: func() *mockConfirmPrompter { return &mockConfirmPrompter{} }, + wantContain: []string{"session-1", "SUCCESSFULLY_REVOKED", "session-2", "REVOCATION_IN_PROGRESS"}, + wantErr: false, + }, + { + name: "all mode - revokes all sessions", + args: []string{"--all", "--yes"}, + setupAuth: func() *mockAuthLoader { + return &mockAuthLoader{ + token: &authmodels.IdsecToken{Token: "jwt", Username: "user@example.com", ExpiresIn: expiresIn}, + } + }, + setupLister: func() *mockSessionLister { + return &mockSessionLister{sessions: activeSessions} + }, + setupElig: func() *mockEligibilityLister { return &mockEligibilityLister{} }, + setupRevoker: func() *mockSessionRevoker { + return &mockSessionRevoker{ + revokeFunc: func(ctx context.Context, req *scamodels.RevokeRequest) (*scamodels.RevokeResponse, error) { + if len(req.SessionIDs) != 2 { + t.Errorf("expected 2 session IDs, got %d", len(req.SessionIDs)) + } + return &scamodels.RevokeResponse{ + Response: []scamodels.RevocationResult{ + {SessionID: "session-1", RevocationStatus: scamodels.RevocationSuccessful}, + {SessionID: "session-2", RevocationStatus: scamodels.RevocationSuccessful}, + }, + }, nil + }, + } + }, + setupSelector: func() *mockSessionSelector { return &mockSessionSelector{} }, + setupConfirm: func() *mockConfirmPrompter { return &mockConfirmPrompter{} }, + wantContain: []string{"session-1", "session-2", "SUCCESSFULLY_REVOKED"}, + wantErr: false, + }, + { + name: "all mode with provider filter", + args: []string{"--all", "--yes", "--provider", "azure"}, + setupAuth: func() *mockAuthLoader { + return &mockAuthLoader{ + token: &authmodels.IdsecToken{Token: "jwt", Username: "user@example.com", ExpiresIn: expiresIn}, + } + }, + setupLister: func() *mockSessionLister { + return &mockSessionLister{ + listFunc: func(ctx context.Context, csp *scamodels.CSP) (*scamodels.SessionsResponse, error) { + if csp == nil || *csp != scamodels.CSPAzure { + t.Errorf("expected Azure filter, got %v", csp) + } + return activeSessions, nil + }, + } + }, + setupElig: func() *mockEligibilityLister { return &mockEligibilityLister{} }, + setupRevoker: func() *mockSessionRevoker { + return &mockSessionRevoker{ + response: &scamodels.RevokeResponse{ + Response: []scamodels.RevocationResult{ + {SessionID: "session-1", RevocationStatus: scamodels.RevocationSuccessful}, + {SessionID: "session-2", RevocationStatus: scamodels.RevocationSuccessful}, + }, + }, + } + }, + setupSelector: func() *mockSessionSelector { return &mockSessionSelector{} }, + setupConfirm: func() *mockConfirmPrompter { return &mockConfirmPrompter{} }, + wantContain: []string{"SUCCESSFULLY_REVOKED"}, + wantErr: false, + }, + { + name: "all mode - confirmation required and confirmed", + args: []string{"--all"}, + setupAuth: func() *mockAuthLoader { + return &mockAuthLoader{ + token: &authmodels.IdsecToken{Token: "jwt", Username: "user@example.com", ExpiresIn: expiresIn}, + } + }, + setupLister: func() *mockSessionLister { + return &mockSessionLister{sessions: activeSessions} + }, + setupElig: func() *mockEligibilityLister { return &mockEligibilityLister{} }, + setupRevoker: func() *mockSessionRevoker { + return &mockSessionRevoker{ + response: &scamodels.RevokeResponse{ + Response: []scamodels.RevocationResult{ + {SessionID: "session-1", RevocationStatus: scamodels.RevocationSuccessful}, + {SessionID: "session-2", RevocationStatus: scamodels.RevocationSuccessful}, + }, + }, + } + }, + setupSelector: func() *mockSessionSelector { return &mockSessionSelector{} }, + setupConfirm: func() *mockConfirmPrompter { return &mockConfirmPrompter{confirmed: true} }, + wantContain: []string{"SUCCESSFULLY_REVOKED"}, + wantErr: false, + }, + { + name: "all mode - confirmation cancelled", + args: []string{"--all"}, + setupAuth: func() *mockAuthLoader { + return &mockAuthLoader{ + token: &authmodels.IdsecToken{Token: "jwt", Username: "user@example.com", ExpiresIn: expiresIn}, + } + }, + setupLister: func() *mockSessionLister { + return &mockSessionLister{sessions: activeSessions} + }, + setupElig: func() *mockEligibilityLister { return &mockEligibilityLister{} }, + setupRevoker: func() *mockSessionRevoker { return &mockSessionRevoker{} }, + setupSelector: func() *mockSessionSelector { return &mockSessionSelector{} }, + setupConfirm: func() *mockConfirmPrompter { return &mockConfirmPrompter{confirmed: false} }, + wantContain: []string{"Revocation cancelled"}, + wantErr: false, + }, + { + name: "all mode with args - mutual exclusivity error", + args: []string{"--all", "session-1"}, + setupAuth: func() *mockAuthLoader { + return &mockAuthLoader{ + token: &authmodels.IdsecToken{Token: "jwt", Username: "user@example.com", ExpiresIn: expiresIn}, + } + }, + setupLister: func() *mockSessionLister { return &mockSessionLister{} }, + setupElig: func() *mockEligibilityLister { return &mockEligibilityLister{} }, + setupRevoker: func() *mockSessionRevoker { return &mockSessionRevoker{} }, + setupSelector: func() *mockSessionSelector { return &mockSessionSelector{} }, + setupConfirm: func() *mockConfirmPrompter { return &mockConfirmPrompter{} }, + wantContain: []string{"--all", "cannot be used with session ID arguments"}, + wantErr: true, + }, + { + name: "interactive mode - sessions selected", + args: []string{}, + setupAuth: func() *mockAuthLoader { + return &mockAuthLoader{ + token: &authmodels.IdsecToken{Token: "jwt", Username: "user@example.com", ExpiresIn: expiresIn}, + } + }, + setupLister: func() *mockSessionLister { + return &mockSessionLister{sessions: activeSessions} + }, + setupElig: func() *mockEligibilityLister { return &mockEligibilityLister{} }, + setupRevoker: func() *mockSessionRevoker { + return &mockSessionRevoker{ + response: &scamodels.RevokeResponse{ + Response: []scamodels.RevocationResult{ + {SessionID: "session-1", RevocationStatus: scamodels.RevocationSuccessful}, + }, + }, + } + }, + setupSelector: func() *mockSessionSelector { + return &mockSessionSelector{ + sessions: []scamodels.SessionInfo{ + {SessionID: "session-1", CSP: scamodels.CSPAzure, WorkspaceID: "/subscriptions/sub-1", RoleID: "Contributor", SessionDuration: 3600}, + }, + } + }, + setupConfirm: func() *mockConfirmPrompter { return &mockConfirmPrompter{confirmed: true} }, + wantContain: []string{"session-1", "SUCCESSFULLY_REVOKED"}, + wantErr: false, + }, + { + name: "API error propagated", + args: []string{"session-1"}, + setupAuth: func() *mockAuthLoader { + return &mockAuthLoader{ + token: &authmodels.IdsecToken{Token: "jwt", Username: "user@example.com", ExpiresIn: expiresIn}, + } + }, + setupLister: func() *mockSessionLister { return &mockSessionLister{} }, + setupElig: func() *mockEligibilityLister { return &mockEligibilityLister{} }, + setupRevoker: func() *mockSessionRevoker { + return &mockSessionRevoker{revokeErr: errors.New("API error: forbidden")} + }, + setupSelector: func() *mockSessionSelector { return &mockSessionSelector{} }, + setupConfirm: func() *mockConfirmPrompter { return &mockConfirmPrompter{} }, + wantContain: []string{"API error: forbidden"}, + wantErr: true, + }, + { + name: "direct mode with --provider - mutual exclusivity error", + args: []string{"--provider", "azure", "session-1"}, + setupAuth: func() *mockAuthLoader { + return &mockAuthLoader{ + token: &authmodels.IdsecToken{Token: "jwt", Username: "user@example.com", ExpiresIn: expiresIn}, + } + }, + setupLister: func() *mockSessionLister { return &mockSessionLister{} }, + setupElig: func() *mockEligibilityLister { return &mockEligibilityLister{} }, + setupRevoker: func() *mockSessionRevoker { return &mockSessionRevoker{} }, + setupSelector: func() *mockSessionSelector { return &mockSessionSelector{} }, + setupConfirm: func() *mockConfirmPrompter { return &mockConfirmPrompter{} }, + wantContain: []string{"--provider cannot be used with session ID arguments"}, + wantErr: true, + }, + { + name: "all mode - list sessions error", + args: []string{"--all", "--yes"}, + setupAuth: func() *mockAuthLoader { + return &mockAuthLoader{ + token: &authmodels.IdsecToken{Token: "jwt", Username: "user@example.com", ExpiresIn: expiresIn}, + } + }, + setupLister: func() *mockSessionLister { + return &mockSessionLister{listErr: errors.New("service unavailable")} + }, + setupElig: func() *mockEligibilityLister { return &mockEligibilityLister{} }, + setupRevoker: func() *mockSessionRevoker { return &mockSessionRevoker{} }, + setupSelector: func() *mockSessionSelector { return &mockSessionSelector{} }, + setupConfirm: func() *mockConfirmPrompter { return &mockConfirmPrompter{} }, + wantContain: []string{"failed to list sessions"}, + wantErr: true, + }, + { + name: "interactive mode - selection error", + args: []string{}, + setupAuth: func() *mockAuthLoader { + return &mockAuthLoader{ + token: &authmodels.IdsecToken{Token: "jwt", Username: "user@example.com", ExpiresIn: expiresIn}, + } + }, + setupLister: func() *mockSessionLister { + return &mockSessionLister{sessions: activeSessions} + }, + setupElig: func() *mockEligibilityLister { return &mockEligibilityLister{} }, + setupRevoker: func() *mockSessionRevoker { return &mockSessionRevoker{} }, + setupSelector: func() *mockSessionSelector { + return &mockSessionSelector{selectErr: errors.New("prompt interrupted")} + }, + setupConfirm: func() *mockConfirmPrompter { return &mockConfirmPrompter{} }, + wantContain: []string{"session selection failed"}, + wantErr: true, + }, + { + name: "interactive mode with --yes skips confirmation", + args: []string{"--yes"}, + setupAuth: func() *mockAuthLoader { + return &mockAuthLoader{ + token: &authmodels.IdsecToken{Token: "jwt", Username: "user@example.com", ExpiresIn: expiresIn}, + } + }, + setupLister: func() *mockSessionLister { + return &mockSessionLister{sessions: activeSessions} + }, + setupElig: func() *mockEligibilityLister { return &mockEligibilityLister{} }, + setupRevoker: func() *mockSessionRevoker { + return &mockSessionRevoker{ + response: &scamodels.RevokeResponse{ + Response: []scamodels.RevocationResult{ + {SessionID: "session-1", RevocationStatus: scamodels.RevocationSuccessful}, + }, + }, + } + }, + setupSelector: func() *mockSessionSelector { + return &mockSessionSelector{ + sessions: []scamodels.SessionInfo{ + {SessionID: "session-1", CSP: scamodels.CSPAzure, WorkspaceID: "/subscriptions/sub-1", RoleID: "Contributor", SessionDuration: 3600}, + }, + } + }, + setupConfirm: func() *mockConfirmPrompter { + return &mockConfirmPrompter{ + confirmFunc: func(count int) (bool, error) { + t.Error("ConfirmRevocation should not be called with --yes flag") + return false, nil + }, + } + }, + wantContain: []string{"session-1", "SUCCESSFULLY_REVOKED"}, + wantErr: false, + }, + { + name: "invalid provider", + args: []string{"--all", "--provider", "invalid"}, + setupAuth: func() *mockAuthLoader { + return &mockAuthLoader{ + token: &authmodels.IdsecToken{Token: "jwt", Username: "user@example.com", ExpiresIn: expiresIn}, + } + }, + setupLister: func() *mockSessionLister { return &mockSessionLister{} }, + setupElig: func() *mockEligibilityLister { return &mockEligibilityLister{} }, + setupRevoker: func() *mockSessionRevoker { return &mockSessionRevoker{} }, + setupSelector: func() *mockSessionSelector { return &mockSessionSelector{} }, + setupConfirm: func() *mockConfirmPrompter { return &mockConfirmPrompter{} }, + wantContain: []string{"invalid provider"}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + auth := tt.setupAuth() + lister := tt.setupLister() + elig := tt.setupElig() + revoker := tt.setupRevoker() + selector := tt.setupSelector() + confirmer := tt.setupConfirm() + + cmd := NewRevokeCommandWithDeps(auth, lister, elig, revoker, selector, confirmer) + output, err := executeCommand(cmd, tt.args...) + + if tt.wantErr && err == nil { + t.Errorf("expected error but got none") + } + if !tt.wantErr && err != nil { + t.Errorf("unexpected error: %v", err) + } + + for _, want := range tt.wantContain { + if !strings.Contains(output, want) { + t.Errorf("output missing %q\ngot:\n%s", want, output) + } + } + for _, notWant := range tt.wantNotContain { + if strings.Contains(output, notWant) { + t.Errorf("output should not contain %q\ngot:\n%s", notWant, output) + } + } + }) + } +} + +func TestRevokeCommandUsage(t *testing.T) { + cmd := NewRevokeCommand() + + if cmd.Use != "revoke [session-id...]" { + t.Errorf("expected Use='revoke [session-id...]', got %q", cmd.Use) + } + if cmd.Short == "" { + t.Error("expected non-empty Short description") + } + + // Verify flags + allFlag := cmd.Flags().Lookup("all") + if allFlag == nil { + t.Fatal("expected --all flag") + } + if allFlag.Shorthand != "a" { + t.Errorf("expected -a shorthand, got %q", allFlag.Shorthand) + } + + yesFlag := cmd.Flags().Lookup("yes") + if yesFlag == nil { + t.Fatal("expected --yes flag") + } + if yesFlag.Shorthand != "y" { + t.Errorf("expected -y shorthand, got %q", yesFlag.Shorthand) + } + + providerFlag := cmd.Flags().Lookup("provider") + if providerFlag == nil { + t.Fatal("expected --provider flag") + } +} diff --git a/cmd/status.go b/cmd/status.go index a2e4136..d1daed6 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -3,11 +3,11 @@ package cmd import ( "context" "fmt" - "io" "sort" "strings" scamodels "github.com/aaearon/grant-cli/internal/sca/models" + "github.com/aaearon/grant-cli/internal/ui" "github.com/cyberark/idsec-sdk-golang/pkg/models" "github.com/spf13/cobra" ) @@ -96,7 +96,7 @@ func runStatus(cmd *cobra.Command, authLoader authLoader, sessionLister sessionL for _, p := range sortedProviders(sessionsByProvider) { fmt.Fprintf(cmd.OutOrStdout(), "%s sessions:\n", formatProviderName(p)) for _, session := range sessionsByProvider[p] { - fmt.Fprintf(cmd.OutOrStdout(), " %s\n", formatSession(session, nameMap)) + fmt.Fprintf(cmd.OutOrStdout(), " %s\n", ui.FormatSessionOption(session, nameMap)) } } @@ -147,57 +147,3 @@ func formatProviderName(provider string) string { } } -// 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 -} - -// formatSession formats a session for display. -// The live API's role_id field contains the role display name (e.g., "User Access Administrator"). -// workspace_id contains the ARM resource path. If a friendly name is available from -// the eligibility API, it is shown as "name (path)"; otherwise the raw path is shown. -func formatSession(session scamodels.SessionInfo, nameMap map[string]string) string { - durationMin := session.SessionDuration / 60 - var durationStr string - if durationMin >= 60 { - durationStr = fmt.Sprintf("%dh %dm", durationMin/60, durationMin%60) - } else { - durationStr = fmt.Sprintf("%dm", durationMin) - } - - workspace := session.WorkspaceID - if name, ok := nameMap[session.WorkspaceID]; ok { - workspace = fmt.Sprintf("%s (%s)", name, session.WorkspaceID) - } - - return fmt.Sprintf("%s on %s - duration: %s", session.RoleID, workspace, durationStr) -} diff --git a/cmd/status_test.go b/cmd/status_test.go index 7683e3d..5a89487 100644 --- a/cmd/status_test.go +++ b/cmd/status_test.go @@ -136,8 +136,10 @@ func TestStatusCommand(t *testing.T) { "Azure sessions:", "Contributor on Tenant Root Group (providers/Microsoft.Management/managementGroups/29cb7961", "duration: 1h 12m", + "session: session-1", "Owner on My Subscription (/subscriptions/sub-2)", "duration: 25m", + "session: session-2", }, wantErr: false, }, @@ -190,6 +192,7 @@ func TestStatusCommand(t *testing.T) { "Authenticated as: tim@iosharp.com", "Azure sessions:", "Reader on Test Subscription (/subscriptions/sub-1)", + "session: session-azure", }, wantErr: false, }, @@ -244,8 +247,10 @@ func TestStatusCommand(t *testing.T) { "Authenticated as: user@example.com", "Azure sessions:", "Contributor on Dev Subscription (/subscriptions/sub-1)", + "session: session-azure", "AWS sessions:", "Administrator on arn:aws:iam::123456789012:role/Admin", + "session: session-aws", }, wantErr: false, }, @@ -399,6 +404,7 @@ func TestStatusCommand(t *testing.T) { "Azure sessions:", "User Access Administrator on Tenant Root Group (providers/Microsoft.Management/managementGroups/29cb7961", "duration: 1h 0m", + "session: 0e796e75-6027-48bd-bf1e-80e3b1024de4", }, wantErr: false, }, @@ -440,6 +446,7 @@ func TestStatusCommand(t *testing.T) { wantContain: []string{ "User Access Administrator on providers/Microsoft.Management/managementGroups/29cb7961", "duration: 1h 0m", + "session: session-1", }, wantErr: false, }, diff --git a/cmd/test_mocks.go b/cmd/test_mocks.go index 52f7688..d17b406 100644 --- a/cmd/test_mocks.go +++ b/cmd/test_mocks.go @@ -83,6 +83,48 @@ func (m *mockTargetSelector) SelectTarget(targets []models.EligibleTarget) (*mod return m.target, m.selectErr } +// mockSessionRevoker implements the sessionRevoker interface for testing +type mockSessionRevoker struct { + revokeFunc func(ctx context.Context, req *models.RevokeRequest) (*models.RevokeResponse, error) + response *models.RevokeResponse + revokeErr error +} + +func (m *mockSessionRevoker) RevokeSessions(ctx context.Context, req *models.RevokeRequest) (*models.RevokeResponse, error) { + if m.revokeFunc != nil { + return m.revokeFunc(ctx, req) + } + return m.response, m.revokeErr +} + +// mockSessionSelector implements the sessionSelector interface for testing +type mockSessionSelector struct { + selectFunc func(sessions []models.SessionInfo, nameMap map[string]string) ([]models.SessionInfo, error) + sessions []models.SessionInfo + selectErr error +} + +func (m *mockSessionSelector) SelectSessions(sessions []models.SessionInfo, nameMap map[string]string) ([]models.SessionInfo, error) { + if m.selectFunc != nil { + return m.selectFunc(sessions, nameMap) + } + return m.sessions, m.selectErr +} + +// mockConfirmPrompter implements the confirmPrompter interface for testing +type mockConfirmPrompter struct { + confirmFunc func(count int) (bool, error) + confirmed bool + confirmErr error +} + +func (m *mockConfirmPrompter) ConfirmRevocation(count int) (bool, error) { + if m.confirmFunc != nil { + return m.confirmFunc(count) + } + return m.confirmed, m.confirmErr +} + // mockAuthenticator implements the authenticator interface for testing type mockAuthenticator struct { authenticateFunc func(profile *sdkmodels.IdsecProfile, authProfile *authmodels.IdsecAuthProfile, secret *authmodels.IdsecSecret, force bool, refreshAuth bool) (*authmodels.IdsecToken, error) diff --git a/internal/sca/models/revoke.go b/internal/sca/models/revoke.go new file mode 100644 index 0000000..8cdbdf2 --- /dev/null +++ b/internal/sca/models/revoke.go @@ -0,0 +1,50 @@ +package models + +import "encoding/json" + +const ( + RevocationSuccessful = "SUCCESSFULLY_REVOKED" + RevocationInProgress = "REVOCATION_IN_PROGRESS" +) + +// RevokeRequest is the request body for POST /api/access/sessions/revoke. +type RevokeRequest struct { + SessionIDs []string `json:"sessionIds"` +} + +// RevocationResult represents the outcome of revoking a single session. +type RevocationResult struct { + SessionID string `json:"sessionId"` + RevocationStatus string `json:"revocationStatus"` +} + +// UnmarshalJSON implements custom unmarshaling to handle both camelCase (spec) +// and snake_case (live API) field names. +func (r *RevocationResult) UnmarshalJSON(data []byte) error { + type Alias RevocationResult + aux := &struct { + *Alias + SnakeSessionID string `json:"session_id"` + SnakeRevocationStatus string `json:"revocation_status"` + }{ + Alias: (*Alias)(r), + } + + if err := json.Unmarshal(data, aux); err != nil { + return err + } + + if r.SessionID == "" && aux.SnakeSessionID != "" { + r.SessionID = aux.SnakeSessionID + } + if r.RevocationStatus == "" && aux.SnakeRevocationStatus != "" { + r.RevocationStatus = aux.SnakeRevocationStatus + } + + return nil +} + +// RevokeResponse is the response from POST /api/access/sessions/revoke. +type RevokeResponse struct { + Response []RevocationResult `json:"response"` +} diff --git a/internal/sca/models/revoke_test.go b/internal/sca/models/revoke_test.go new file mode 100644 index 0000000..9b68f04 --- /dev/null +++ b/internal/sca/models/revoke_test.go @@ -0,0 +1,146 @@ +package models + +import ( + "encoding/json" + "testing" +) + +func TestRevokeRequest_JSONMarshal(t *testing.T) { + t.Parallel() + req := RevokeRequest{ + SessionIDs: []string{"session-1", "session-2"}, + } + + data, err := json.Marshal(req) + if err != nil { + t.Fatalf("unexpected marshal error: %v", err) + } + + var raw map[string]interface{} + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("unexpected unmarshal error: %v", err) + } + + ids, ok := raw["sessionIds"] + if !ok { + t.Fatal("expected 'sessionIds' key in JSON output") + } + + arr, ok := ids.([]interface{}) + if !ok { + t.Fatalf("expected sessionIds to be array, got %T", ids) + } + if len(arr) != 2 { + t.Errorf("expected 2 session IDs, got %d", len(arr)) + } +} + +func TestRevokeResponse_Success(t *testing.T) { + t.Parallel() + jsonInput := `{ + "response": [ + { + "sessionId": "session-1", + "revocationStatus": "SUCCESSFULLY_REVOKED" + } + ] + }` + + var resp RevokeResponse + if err := json.Unmarshal([]byte(jsonInput), &resp); err != nil { + t.Fatalf("unexpected unmarshal error: %v", err) + } + + if len(resp.Response) != 1 { + t.Fatalf("Response length = %d, want 1", len(resp.Response)) + } + + r := resp.Response[0] + if r.SessionID != "session-1" { + t.Errorf("SessionID = %q, want %q", r.SessionID, "session-1") + } + if r.RevocationStatus != RevocationSuccessful { + t.Errorf("RevocationStatus = %q, want %q", r.RevocationStatus, RevocationSuccessful) + } +} + +func TestRevokeResponse_InProgress(t *testing.T) { + t.Parallel() + jsonInput := `{ + "response": [ + { + "sessionId": "session-1", + "revocationStatus": "REVOCATION_IN_PROGRESS" + } + ] + }` + + var resp RevokeResponse + if err := json.Unmarshal([]byte(jsonInput), &resp); err != nil { + t.Fatalf("unexpected unmarshal error: %v", err) + } + + if resp.Response[0].RevocationStatus != RevocationInProgress { + t.Errorf("RevocationStatus = %q, want %q", resp.Response[0].RevocationStatus, RevocationInProgress) + } +} + +func TestRevokeResponse_Mixed(t *testing.T) { + t.Parallel() + jsonInput := `{ + "response": [ + { + "sessionId": "session-1", + "revocationStatus": "SUCCESSFULLY_REVOKED" + }, + { + "sessionId": "session-2", + "revocationStatus": "REVOCATION_IN_PROGRESS" + } + ] + }` + + var resp RevokeResponse + if err := json.Unmarshal([]byte(jsonInput), &resp); err != nil { + t.Fatalf("unexpected unmarshal error: %v", err) + } + + if len(resp.Response) != 2 { + t.Fatalf("Response length = %d, want 2", len(resp.Response)) + } + if resp.Response[0].RevocationStatus != RevocationSuccessful { + t.Errorf("Response[0].RevocationStatus = %q, want %q", resp.Response[0].RevocationStatus, RevocationSuccessful) + } + if resp.Response[1].RevocationStatus != RevocationInProgress { + t.Errorf("Response[1].RevocationStatus = %q, want %q", resp.Response[1].RevocationStatus, RevocationInProgress) + } +} + +func TestRevokeResponse_SnakeCase(t *testing.T) { + t.Parallel() + jsonInput := `{ + "response": [ + { + "session_id": "session-1", + "revocation_status": "SUCCESSFULLY_REVOKED" + } + ] + }` + + var resp RevokeResponse + if err := json.Unmarshal([]byte(jsonInput), &resp); err != nil { + t.Fatalf("unexpected unmarshal error: %v", err) + } + + if len(resp.Response) != 1 { + t.Fatalf("Response length = %d, want 1", len(resp.Response)) + } + + r := resp.Response[0] + if r.SessionID != "session-1" { + t.Errorf("SessionID = %q, want %q", r.SessionID, "session-1") + } + if r.RevocationStatus != RevocationSuccessful { + t.Errorf("RevocationStatus = %q, want %q", r.RevocationStatus, RevocationSuccessful) + } +} diff --git a/internal/sca/service.go b/internal/sca/service.go index 419991a..5e8cafa 100644 --- a/internal/sca/service.go +++ b/internal/sca/service.go @@ -150,6 +150,34 @@ func (s *SCAAccessService) Elevate(ctx context.Context, req *models.ElevateReque return &result, nil } +// RevokeSessions revokes one or more active sessions by their IDs. +// POST /api/access/sessions/revoke +func (s *SCAAccessService) RevokeSessions(ctx context.Context, req *models.RevokeRequest) (*models.RevokeResponse, error) { + if req == nil { + return nil, fmt.Errorf("revoke request cannot be nil") + } + if len(req.SessionIDs) == 0 { + return nil, fmt.Errorf("revoke request must contain at least one session ID") + } + + resp, err := s.httpClient.Post(ctx, "/api/access/sessions/revoke", req) + if err != nil { + return nil, fmt.Errorf("failed to revoke sessions: %w", err) + } + defer resp.Body.Close() + + if err := checkResponse(resp, "revoke sessions request"); err != nil { + return nil, err + } + + var result models.RevokeResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode revoke response: %w", err) + } + + return &result, nil +} + // ListSessions retrieves active elevated sessions, optionally filtered by CSP. // GET /api/access/sessions func (s *SCAAccessService) ListSessions(ctx context.Context, csp *models.CSP) (*models.SessionsResponse, error) { diff --git a/internal/sca/service_test.go b/internal/sca/service_test.go index cd54a34..8bf2197 100644 --- a/internal/sca/service_test.go +++ b/internal/sca/service_test.go @@ -318,6 +318,124 @@ func TestElevate_EmptyTargets(t *testing.T) { } } +func TestRevokeSessions_Success(t *testing.T) { + resp := models.RevokeResponse{ + Response: []models.RevocationResult{ + { + SessionID: "session-1", + RevocationStatus: models.RevocationSuccessful, + }, + }, + } + + body, _ := json.Marshal(resp) + mock := &mockHTTPClient{ + postResponse: &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(string(body))), + }, + } + + svc := &SCAAccessService{httpClient: mock} + result, err := svc.RevokeSessions(context.Background(), &models.RevokeRequest{ + SessionIDs: []string{"session-1"}, + }) + + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if result == nil { + t.Fatal("expected non-nil result") + } + if len(result.Response) != 1 { + t.Errorf("expected 1 result, got %d", len(result.Response)) + } + if result.Response[0].SessionID != "session-1" { + t.Errorf("expected session ID session-1, got %s", result.Response[0].SessionID) + } + if result.Response[0].RevocationStatus != models.RevocationSuccessful { + t.Errorf("expected status %s, got %s", models.RevocationSuccessful, result.Response[0].RevocationStatus) + } +} + +func TestRevokeSessions_Multiple(t *testing.T) { + resp := models.RevokeResponse{ + Response: []models.RevocationResult{ + {SessionID: "session-1", RevocationStatus: models.RevocationSuccessful}, + {SessionID: "session-2", RevocationStatus: models.RevocationInProgress}, + }, + } + + body, _ := json.Marshal(resp) + mock := &mockHTTPClient{ + postResponse: &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(string(body))), + }, + } + + svc := &SCAAccessService{httpClient: mock} + result, err := svc.RevokeSessions(context.Background(), &models.RevokeRequest{ + SessionIDs: []string{"session-1", "session-2"}, + }) + + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(result.Response) != 2 { + t.Errorf("expected 2 results, got %d", len(result.Response)) + } +} + +func TestRevokeSessions_NilRequest(t *testing.T) { + mock := &mockHTTPClient{} + svc := &SCAAccessService{httpClient: mock} + + _, err := svc.RevokeSessions(context.Background(), nil) + if err == nil { + t.Fatal("expected error for nil request") + } + if !strings.Contains(err.Error(), "nil") { + t.Errorf("expected error to mention nil, got: %v", err) + } +} + +func TestRevokeSessions_EmptySessionIDs(t *testing.T) { + mock := &mockHTTPClient{} + svc := &SCAAccessService{httpClient: mock} + + _, err := svc.RevokeSessions(context.Background(), &models.RevokeRequest{ + SessionIDs: []string{}, + }) + if err == nil { + t.Fatal("expected error for empty session IDs") + } + if !strings.Contains(err.Error(), "session ID") { + t.Errorf("expected error about session IDs, got: %v", err) + } +} + +func TestRevokeSessions_HTTPError(t *testing.T) { + mock := &mockHTTPClient{ + postResponse: &http.Response{ + StatusCode: http.StatusForbidden, + Body: io.NopCloser(strings.NewReader(`{"error": "forbidden"}`)), + }, + } + + svc := &SCAAccessService{httpClient: mock} + _, err := svc.RevokeSessions(context.Background(), &models.RevokeRequest{ + SessionIDs: []string{"session-1"}, + }) + + if err == nil { + t.Fatal("expected error for 403 response") + } + if !strings.Contains(err.Error(), "403") { + t.Errorf("expected error to mention status code 403, got: %v", err) + } +} + func TestListSessions_Success(t *testing.T) { resp := models.SessionsResponse{ Response: []models.SessionInfo{ diff --git a/internal/ui/session_selector.go b/internal/ui/session_selector.go new file mode 100644 index 0000000..1c0da92 --- /dev/null +++ b/internal/ui/session_selector.go @@ -0,0 +1,99 @@ +package ui + +import ( + "fmt" + "os" + "sort" + + "github.com/Iilun/survey/v2" + "github.com/aaearon/grant-cli/internal/sca/models" +) + +// FormatSessionOption formats a session for display in the multi-select UI. +func FormatSessionOption(session models.SessionInfo, nameMap map[string]string) string { + durationMin := session.SessionDuration / 60 + var durationStr string + if durationMin >= 60 { + durationStr = fmt.Sprintf("%dh %dm", durationMin/60, durationMin%60) + } else { + durationStr = fmt.Sprintf("%dm", durationMin) + } + + workspace := session.WorkspaceID + if nameMap != nil { + if name, ok := nameMap[session.WorkspaceID]; ok { + workspace = fmt.Sprintf("%s (%s)", name, session.WorkspaceID) + } + } + + return fmt.Sprintf("%s on %s - duration: %s (session: %s)", session.RoleID, workspace, durationStr, session.SessionID) +} + +// BuildSessionOptions builds a sorted list of display options from sessions. +func BuildSessionOptions(sessions []models.SessionInfo, nameMap map[string]string) []string { + options := make([]string, len(sessions)) + for i, s := range sessions { + options[i] = FormatSessionOption(s, nameMap) + } + sort.Strings(options) + return options +} + +// FindSessionByDisplay finds a session by its formatted display string. +func FindSessionByDisplay(sessions []models.SessionInfo, nameMap map[string]string, display string) (*models.SessionInfo, error) { + for i := range sessions { + if FormatSessionOption(sessions[i], nameMap) == display { + return &sessions[i], nil + } + } + return nil, fmt.Errorf("session not found: %s", display) +} + +// SelectSessions presents a multi-select prompt for choosing sessions to revoke. +func SelectSessions(sessions []models.SessionInfo, nameMap map[string]string) ([]models.SessionInfo, error) { + if len(sessions) == 0 { + return nil, fmt.Errorf("no sessions available") + } + + options := BuildSessionOptions(sessions, nameMap) + + var selected []string + prompt := &survey.MultiSelect{ + Message: "Select sessions to revoke:", + Options: options, + } + + if err := survey.AskOne(prompt, &selected, survey.WithStdio(os.Stdin, os.Stderr, os.Stderr)); err != nil { + return nil, fmt.Errorf("session selection failed: %w", err) + } + + if len(selected) == 0 { + return nil, fmt.Errorf("no sessions selected") + } + + var result []models.SessionInfo + for _, display := range selected { + s, err := FindSessionByDisplay(sessions, nameMap, display) + if err != nil { + return nil, err + } + result = append(result, *s) + } + + return result, nil +} + +// ConfirmRevocation prompts the user to confirm session revocation. +func ConfirmRevocation(count int) (bool, error) { + var confirmed bool + prompt := &survey.Confirm{ + Message: fmt.Sprintf("Revoke %d session(s)?", count), + Default: false, + } + + if err := survey.AskOne(prompt, &confirmed, survey.WithStdio(os.Stdin, os.Stderr, os.Stderr)); err != nil { + return false, fmt.Errorf("confirmation failed: %w", err) + } + + return confirmed, nil +} diff --git a/internal/ui/session_selector_test.go b/internal/ui/session_selector_test.go new file mode 100644 index 0000000..6f7bb6c --- /dev/null +++ b/internal/ui/session_selector_test.go @@ -0,0 +1,131 @@ +package ui + +import ( + "testing" + + "github.com/aaearon/grant-cli/internal/sca/models" +) + +func TestFormatSessionOption(t *testing.T) { + t.Parallel() + tests := []struct { + name string + session models.SessionInfo + nameMap map[string]string + want string + }{ + { + name: "with workspace name", + session: models.SessionInfo{ + SessionID: "session-1", + CSP: models.CSPAzure, + WorkspaceID: "/subscriptions/sub-1", + RoleID: "Contributor", + SessionDuration: 3600, + }, + nameMap: map[string]string{"/subscriptions/sub-1": "My Subscription"}, + want: "Contributor on My Subscription (/subscriptions/sub-1) - duration: 1h 0m (session: session-1)", + }, + { + name: "without workspace name", + session: models.SessionInfo{ + SessionID: "session-2", + CSP: models.CSPAzure, + WorkspaceID: "/subscriptions/sub-2", + RoleID: "Reader", + SessionDuration: 2700, + }, + nameMap: map[string]string{}, + want: "Reader on /subscriptions/sub-2 - duration: 45m (session: session-2)", + }, + { + name: "nil name map", + session: models.SessionInfo{ + SessionID: "session-3", + CSP: models.CSPAWS, + WorkspaceID: "arn:aws:iam::123:role/Admin", + RoleID: "Admin", + SessionDuration: 1800, + }, + nameMap: nil, + want: "Admin on arn:aws:iam::123:role/Admin - duration: 30m (session: session-3)", + }, + { + name: "duration less than a minute rounds to 0m", + session: models.SessionInfo{ + SessionID: "session-4", + CSP: models.CSPAzure, + WorkspaceID: "/subscriptions/sub-1", + RoleID: "Owner", + SessionDuration: 30, + }, + nameMap: map[string]string{}, + want: "Owner on /subscriptions/sub-1 - duration: 0m (session: session-4)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := FormatSessionOption(tt.session, tt.nameMap) + if got != tt.want { + t.Errorf("FormatSessionOption() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestBuildSessionOptions(t *testing.T) { + t.Parallel() + sessions := []models.SessionInfo{ + {SessionID: "s2", RoleID: "Reader", WorkspaceID: "ws-b", SessionDuration: 1800}, + {SessionID: "s1", RoleID: "Admin", WorkspaceID: "ws-a", SessionDuration: 3600}, + } + nameMap := map[string]string{} + options := BuildSessionOptions(sessions, nameMap) + + if len(options) != 2 { + t.Fatalf("expected 2 options, got %d", len(options)) + } + // Should be sorted alphabetically + if options[0] >= options[1] { + t.Errorf("expected sorted options, got %q before %q", options[0], options[1]) + } +} + +func TestFindSessionByDisplay(t *testing.T) { + t.Parallel() + sessions := []models.SessionInfo{ + {SessionID: "s1", RoleID: "Admin", WorkspaceID: "ws-a", SessionDuration: 3600}, + {SessionID: "s2", RoleID: "Reader", WorkspaceID: "ws-b", SessionDuration: 1800}, + } + nameMap := map[string]string{} + + t.Run("found", func(t *testing.T) { + t.Parallel() + display := FormatSessionOption(sessions[0], nameMap) + found, err := FindSessionByDisplay(sessions, nameMap, display) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if found.SessionID != "s1" { + t.Errorf("SessionID = %q, want %q", found.SessionID, "s1") + } + }) + + t.Run("not found", func(t *testing.T) { + t.Parallel() + _, err := FindSessionByDisplay(sessions, nameMap, "nonexistent") + if err == nil { + t.Fatal("expected error for not found") + } + }) + + t.Run("empty list", func(t *testing.T) { + t.Parallel() + _, err := FindSessionByDisplay(nil, nameMap, "anything") + if err == nil { + t.Fatal("expected error for empty list") + } + }) +}