From 7b52856f4d5141865fe58b17f9932405ae3618b4 Mon Sep 17 00:00:00 2001 From: Tim Schindler Date: Thu, 13 Aug 2026 18:06:29 +0200 Subject: [PATCH 1/4] fix: validate provider before elevating in grant env grant env performed the elevation first and only then checked whether credentials came back, so 'grant env --provider azure' created a real SCA session and recorded a session timestamp before failing with 'no credentials returned'. The user was left with an active session they neither wanted nor were told about. resolveAndElevate now takes a preElevate hook that runs after target resolution and before the elevation request; env passes requireAWSTarget, which rejects non-AWS targets with an actionable message. The existing AccessCredentials == nil check stays as a fallback for AWS returning nothing. --- CHANGELOG.md | 4 ++ cmd/env.go | 36 ++++++++-- cmd/env_test.go | 171 ++++++++++++++++++++++++++++++++++++++---------- cmd/root.go | 16 ++++- 4 files changed, 186 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ccd5d5..65d0170 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Fixed + +- `grant env` no longer performs an elevation before validating the provider. Previously `grant env --provider azure` created a real SCA session and recorded a session timestamp before failing with "no credentials returned", leaving an unwanted active session behind. The provider is now validated before the elevation request is issued + ### Changed - Rebranded user-facing references from CyberArk to Idira (formerly CyberArk) in the README and CLI help/prompt text, following the Palo Alto Networks rebrand. No functional or API changes; SDK import paths (`github.com/cyberark/idsec-sdk-golang`), `*.cyberark.cloud` URLs, and environment variables are unchanged. diff --git a/cmd/env.go b/cmd/env.go index 3d77206..1c9b7dc 100644 --- a/cmd/env.go +++ b/cmd/env.go @@ -3,6 +3,7 @@ package cmd import ( "errors" "fmt" + "strings" "github.com/aaearon/grant-cli/internal/config" "github.com/aaearon/grant-cli/internal/sca/models" @@ -14,12 +15,15 @@ import ( func newEnvCommand(runFn func(*cobra.Command, []string) error) *cobra.Command { cmd := &cobra.Command{ Use: "env", - Short: "Output AWS credential export statements", - Long: `Perform elevation and output AWS credential export statements. + Short: "Output AWS credential export statements (AWS only)", + Long: `Perform an AWS elevation and output credential export statements. Runs the full elevation flow, then prints only shell export statements suitable for eval. No human-readable messages are printed to stdout. +Only AWS is supported: other providers return no credentials — their +elevation applies to your existing native CLI session, so use 'grant'. + Usage: eval $(grant env --provider aws --target "Account" --role "AdminAccess") eval $(grant env --favorite my-aws-fav) @@ -29,7 +33,7 @@ Usage: RunE: runFn, } - cmd.Flags().StringP("provider", "p", "", "Cloud provider: azure, aws (omit to show all)") + cmd.Flags().StringP("provider", "p", "", "Cloud provider (aws only)") cmd.Flags().StringP("target", "t", "", "Target name (account, subscription, etc.)") cmd.Flags().StringP("role", "r", "", "Role name") cmd.Flags().StringP("favorite", "f", "", "Use a saved favorite (see 'grant favorites list')") @@ -77,6 +81,29 @@ func NewEnvCommandWithDeps( }) } +// requireAWSTarget rejects non-AWS targets before an elevation is performed. +// An unresolved CSP is allowed through, so the post-elevation credential check +// remains the fallback. +func requireAWSTarget(target *models.EligibleTarget) error { + if target.CSP == "" || target.CSP == models.CSPAWS { + return nil + } + provider := strings.ToLower(string(target.CSP)) + return fmt.Errorf( + "grant env is only supported for AWS; %s elevations return no credentials — run 'grant --provider %s' instead and use your existing %s CLI session", + provider, provider, cliForCSP(target.CSP)) +} + +// cliForCSP names the native CLI whose session a non-AWS elevation applies to. +func cliForCSP(csp models.CSP) string { + switch csp { + case models.CSPAzure: + return "az" + default: + return "cloud provider" + } +} + func runEnvWithDeps( cmd *cobra.Command, flags *elevateFlags, @@ -87,7 +114,7 @@ func runEnvWithDeps( selector targetSelector, cfg *config.Config, ) error { - res, err := resolveAndElevate(flags, profile, authLoader, eligibilityLister, elevateService, selector, cfg) + res, err := resolveAndElevate(flags, profile, authLoader, eligibilityLister, elevateService, selector, cfg, requireAWSTarget) if err != nil { return err } @@ -95,6 +122,7 @@ func runEnvWithDeps( // Record session timestamp for remaining-time tracking (best-effort) recordSessionTimestamp(res.result.SessionID) + // Defense in depth: AWS itself returning no credentials. if res.result.AccessCredentials == nil { return errors.New("no credentials returned; grant env is only supported for AWS elevations") } diff --git a/cmd/env_test.go b/cmd/env_test.go index 114511f..487ea4c 100644 --- a/cmd/env_test.go +++ b/cmd/env_test.go @@ -1,7 +1,9 @@ package cmd import ( + "context" "encoding/json" + "errors" "strings" "testing" @@ -82,57 +84,156 @@ func TestEnvCommand_AWSSuccess(t *testing.T) { } } -func TestEnvCommand_AzureError(t *testing.T) { - authLoader := &mockAuthLoader{ - token: &authmodels.IdsecToken{Token: "test-jwt"}, +// TestEnvDoesNotElevateForNonAWSProvider pins Bug 4a: grant env must reject +// non-AWS providers BEFORE performing an elevation, so no live session is +// created and no session timestamp is recorded. +func TestEnvDoesNotElevateForNonAWSProvider(t *testing.T) { + tests := []struct { + name string + provider string + }{ + {name: "azure", provider: "azure"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + originalRecorder := recordSessionTimestamp + defer func() { recordSessionTimestamp = originalRecorder }() + + recordCount := 0 + recordSessionTimestamp = func(sessionID string) { recordCount++ } + + elevateCount := 0 + + authLoader := &mockAuthLoader{ + token: &authmodels.IdsecToken{Token: "test-jwt"}, + } + eligibilityLister := &mockEligibilityLister{ + response: &models.EligibilityResponse{ + Response: []models.EligibleTarget{ + { + OrganizationID: "org-1", + WorkspaceID: "ws-1", + WorkspaceName: "Workspace One", + RoleInfo: models.RoleInfo{ID: "role-1", Name: "Contributor"}, + }, + }, + Total: 1, + }, + } + elevateService := &mockElevateService{ + elevateFunc: func(ctx context.Context, req *models.ElevateRequest) (*models.ElevateResponse, error) { + elevateCount++ + t.Errorf("Elevate must not be called for provider %q", tt.provider) + return nil, errors.New("should not be called") + }, + } + selector := &mockTargetSelector{ + target: &models.EligibleTarget{ + OrganizationID: "org-1", + WorkspaceID: "ws-1", + WorkspaceName: "Workspace One", + RoleInfo: models.RoleInfo{ID: "role-1", Name: "Contributor"}, + }, + } + + cmd := NewEnvCommandWithDeps(nil, authLoader, eligibilityLister, elevateService, selector, config.DefaultConfig()) + _, err := executeCommand(cmd, "--provider", tt.provider) + if err == nil { + t.Fatalf("expected error for provider %q", tt.provider) + } + if !strings.Contains(strings.ToUpper(err.Error()), "AWS") { + t.Errorf("error should name AWS, got: %v", err) + } + if elevateCount != 0 { + t.Errorf("elevation call count = %d, want 0", elevateCount) + } + if recordCount != 0 { + t.Errorf("session timestamp record count = %d, want 0", recordCount) + } + }) } +} + +// TestEnvElevatesOnceForAWS is the companion to the pre-flight validation test. +func TestEnvElevatesOnceForAWS(t *testing.T) { + credsJSON := `{"aws_access_key":"ASIAXXX","aws_secret_access_key":"secret","aws_session_token":"tok"}` + + elevateCount := 0 + authLoader := &mockAuthLoader{token: &authmodels.IdsecToken{Token: "test-jwt"}} eligibilityLister := &mockEligibilityLister{ response: &models.EligibilityResponse{ - Response: []models.EligibleTarget{ - { - OrganizationID: "org-123", - WorkspaceID: "sub-456", - WorkspaceName: "Prod-EastUS", - WorkspaceType: models.WorkspaceTypeSubscription, - RoleInfo: models.RoleInfo{ID: "role-789", Name: "Contributor"}, - }, - }, - Total: 1, + Response: []models.EligibleTarget{{ + OrganizationID: "o-1", WorkspaceID: "acct-1", WorkspaceName: "AWS Mgmt", + WorkspaceType: models.WorkspaceTypeAccount, + RoleInfo: models.RoleInfo{ID: "role-1", Name: "Admin"}, + }}, Total: 1, }, } elevateService := &mockElevateService{ - response: &models.ElevateResponse{ - Response: models.ElevateAccessResult{ - CSP: models.CSPAzure, - OrganizationID: "org-123", - Results: []models.ElevateTargetResult{ - { - WorkspaceID: "sub-456", - RoleID: "role-789", - SessionID: "session-az", - }, - }, - }, + elevateFunc: func(ctx context.Context, req *models.ElevateRequest) (*models.ElevateResponse, error) { + elevateCount++ + return &models.ElevateResponse{Response: models.ElevateAccessResult{ + CSP: models.CSPAWS, OrganizationID: "o-1", + Results: []models.ElevateTargetResult{{ + WorkspaceID: "acct-1", RoleID: "role-1", SessionID: "sess-1", + AccessCredentials: &credsJSON, + }}, + }}, nil }, } selector := &mockTargetSelector{ target: &models.EligibleTarget{ - OrganizationID: "org-123", - WorkspaceID: "sub-456", - WorkspaceName: "Prod-EastUS", - WorkspaceType: models.WorkspaceTypeSubscription, - RoleInfo: models.RoleInfo{ID: "role-789", Name: "Contributor"}, + OrganizationID: "o-1", WorkspaceID: "acct-1", WorkspaceName: "AWS Mgmt", + WorkspaceType: models.WorkspaceTypeAccount, + RoleInfo: models.RoleInfo{ID: "role-1", Name: "Admin"}, }, } - cfg := config.DefaultConfig() - cmd := NewEnvCommandWithDeps(nil, authLoader, eligibilityLister, elevateService, selector, cfg) + cmd := NewEnvCommandWithDeps(nil, authLoader, eligibilityLister, elevateService, selector, config.DefaultConfig()) + if _, err := executeCommand(cmd, "--provider", "aws"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if elevateCount != 1 { + t.Errorf("elevation call count = %d, want 1", elevateCount) + } +} - _, err := executeCommand(cmd) - if err == nil { - t.Fatal("expected error for Azure elevation (no credentials)") +// TestEnvCommand_AWSNilCredentials is the defense-in-depth path: AWS itself +// returning no credentials must produce a clear error, not a panic. +func TestEnvCommand_AWSNilCredentials(t *testing.T) { + authLoader := &mockAuthLoader{token: &authmodels.IdsecToken{Token: "test-jwt"}} + eligibilityLister := &mockEligibilityLister{ + response: &models.EligibilityResponse{ + Response: []models.EligibleTarget{{ + OrganizationID: "o-1", WorkspaceID: "acct-1", WorkspaceName: "AWS Mgmt", + WorkspaceType: models.WorkspaceTypeAccount, + RoleInfo: models.RoleInfo{ID: "role-1", Name: "Admin"}, + }}, Total: 1, + }, + } + elevateService := &mockElevateService{ + response: &models.ElevateResponse{Response: models.ElevateAccessResult{ + CSP: models.CSPAWS, OrganizationID: "o-1", + Results: []models.ElevateTargetResult{{ + WorkspaceID: "acct-1", RoleID: "role-1", SessionID: "sess-1", + AccessCredentials: nil, + }}, + }}, + } + selector := &mockTargetSelector{ + target: &models.EligibleTarget{ + OrganizationID: "o-1", WorkspaceID: "acct-1", WorkspaceName: "AWS Mgmt", + WorkspaceType: models.WorkspaceTypeAccount, + RoleInfo: models.RoleInfo{ID: "role-1", Name: "Admin"}, + }, } + cmd := NewEnvCommandWithDeps(nil, authLoader, eligibilityLister, elevateService, selector, config.DefaultConfig()) + _, err := executeCommand(cmd, "--provider", "aws") + if err == nil { + t.Fatal("expected error when AWS returns no credentials") + } if !strings.Contains(err.Error(), "no credentials") { t.Errorf("expected 'no credentials' error, got: %v", err) } diff --git a/cmd/root.go b/cmd/root.go index 43cfaa0..6196b72 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -358,8 +358,12 @@ type elevationResult struct { } // resolveAndElevate performs the full elevation flow: auth check, flag resolution, -// eligibility fetch, target selection, and elevation request. It is shared by -// the root command and the env command. +// eligibility fetch, target selection, and elevation request. +// +// preElevate, when non-nil, is called with the resolved target immediately +// before the elevation request is issued. Returning an error aborts the flow +// without creating a session — used by 'grant env' to reject targets whose +// provider returns no credentials before burning a real elevation. func resolveAndElevate( flags *elevateFlags, profile *sdkmodels.IdsecProfile, @@ -368,6 +372,7 @@ func resolveAndElevate( elevateService elevateService, selector targetSelector, cfg *config.Config, + preElevate func(*models.EligibleTarget) error, ) (*elevationResult, error) { ctx, cancel := context.WithTimeout(context.Background(), apiTimeout) defer cancel() @@ -443,6 +448,13 @@ func resolveAndElevate( // Ensure CSP is set on selected target resolveTargetCSP(selectedTarget, allTargets, provider) + // Pre-flight validation — must run before any elevation is issued. + if preElevate != nil { + if err := preElevate(selectedTarget); err != nil { + return nil, err + } + } + // Build elevation request req := &models.ElevateRequest{ CSP: selectedTarget.CSP, From a3cb66e061b58aa224acb476ee0ffeb18078151f Mon Sep 17 00:00:00 2001 From: Tim Schindler Date: Thu, 13 Aug 2026 18:06:49 +0200 Subject: [PATCH 2/4] feat: GCP support for list, elevate, status and revoke Adds GCP as a third supported CSP: - models: CSPGCP and the PROJECT / FOLDER / GCP_ORGANIZATION workspace types from the GCPEligibleTarget schema (allOf CommonEligibleTarget, so the existing EligibleTarget shape and role/roleInfo fallback already parse it) - supportedCSPs gains GCP, which propagates through the multi-CSP fan-out, provider validation, grant list and grant status - status: parseProvider and formatProviderName accept GCP - selector: GCP workspace types render with readable prefixes - root: post-elevation guidance for GCP Out of scope, deliberately: the SCA API spec defines no GCP credential shape, so grant env stays AWS-only, and grant request submit rejects GCP explicitly rather than falling through the on-demand role branching. Not yet verified against a live GCP tenant; noted in README. --- CHANGELOG.md | 4 + CLAUDE.md | 7 +- README.md | 12 +- cmd/env.go | 6 +- cmd/env_test.go | 1 + cmd/favorites.go | 2 +- cmd/list.go | 2 +- cmd/request_submit.go | 9 +- cmd/request_test.go | 50 ++++++- cmd/revoke.go | 2 +- cmd/root.go | 11 +- cmd/root_elevate_test.go | 183 +++++++++++++++++++++++- cmd/status.go | 8 +- cmd/status_test.go | 36 +++++ internal/sca/models/eligibility.go | 6 + internal/sca/models/eligibility_test.go | 110 ++++++++++++++ internal/sca/service_test.go | 74 ++++++++++ internal/ui/selector.go | 6 + internal/ui/selector_test.go | 28 ++++ 19 files changed, 533 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 65d0170..4d73235 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Added + +- GCP support for `grant`, `grant list`, `grant status` and `grant revoke`: `--provider gcp`, GCP included in the multi-provider fan-out, and the `PROJECT`/`FOLDER`/`GCP_ORGANIZATION` workspace types rendered in the interactive selector. `grant env` stays AWS-only (the SCA API spec defines no GCP credential shape) and `grant request submit` explicitly rejects GCP. Not yet verified against a live GCP tenant + ### Fixed - `grant env` no longer performs an elevation before validating the provider. Previously `grant env --provider azure` created a real SCA session and recorded a session timestamp before failing with "no credentials returned", leaving an unwanted active session behind. The provider is now validated before the elevation request is issued diff --git a/CLAUDE.md b/CLAUDE.md index c0d02d8..dd82dd1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,7 +36,7 @@ Custom `SCAAccessService` follows SDK conventions: ## SCA Access API - **Base URL:** `https://{subdomain}.sca.{platform_domain}/api` - **Endpoints:** - - `GET /api/access/{CSP}/eligibility` — list eligible targets + - `GET /api/access/{CSP}/eligibility` — list eligible targets (`AZURE`, `AWS`, `GCP`) - `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[]`) @@ -70,7 +70,7 @@ Custom `SCAAccessService` follows SDK conventions: ## 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 env` — **AWS only**; performs elevation, outputs only `export` statements (no human text); usage: `eval $(grant env --provider aws)`; supports `--refresh`. Non-AWS targets are rejected by `requireAWSTarget` (passed as the `preElevate` hook to `resolveAndElevate`) *before* any elevation is issued, so no session is created; the `AccessCredentials == nil` check remains as a fallback - `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 `), `--all`, or interactive multi-select; `--yes` skips confirmation - `grant request` — manage access requests through approval workflow; subcommands: `submit`, `list`, `get`, `cancel`, `approve`, `reject` @@ -86,7 +86,8 @@ Custom `SCAAccessService` follows SDK conventions: - `--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 +- Multi-CSP: omitting `--provider` fetches eligibility from all supported CSPs (`supportedCSPs` in `cmd/root.go` — Azure, AWS, GCP) and merges results; a CSP that errors is skipped +- GCP: `list`/elevate/`status`/`revoke` only. Workspace types `PROJECT`/`FOLDER`/`GCP_ORGANIZATION`; no `accessCredentials` in the API spec, so `grant env` stays AWS-only and `grant request submit` rejects GCP. **Untested against a live GCP tenant** - `--refresh` bypasses eligibility cache on `grant` and `grant env` - `fetchEligibility()` and `resolveTargetCSP()` in `cmd/root.go` — shared by root, env, and favorites diff --git a/README.md b/README.md index 6b7f7d8..9852a84 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,16 @@ # grant -A CLI tool for elevating cloud permissions (Azure, AWS) via Idira (formerly CyberArk) Secure Cloud Access (SCA) — without leaving the terminal. +A CLI tool for elevating cloud permissions (Azure, AWS, GCP) via Idira (formerly CyberArk) Secure Cloud Access (SCA) — without leaving the terminal. ![grant demo](demo/demo.gif) ## Overview -`grant` enables terminal-based cloud permission elevation (Azure, AWS) through Idira SCA. It wraps the `idsec-sdk-golang` SDK for authentication and builds a custom SCA Access API client for JIT role elevation. +`grant` enables terminal-based cloud permission elevation (Azure, AWS, GCP) through Idira SCA. It wraps the `idsec-sdk-golang` SDK for authentication and builds a custom SCA Access API client for JIT role elevation. - **Azure:** SCA creates a JIT RBAC role assignment — your existing `az` CLI session picks up the elevated permissions automatically. -- **AWS:** SCA returns temporary credentials. Use `grant env` to export them: `eval $(grant env --provider aws)` +- **AWS:** SCA returns temporary credentials. Use `grant env` to export them: `eval $(grant env --provider aws)` — `grant env` is AWS-only, since Azure and GCP elevations return no credentials. +- **GCP:** SCA grants the role on the project, folder or organization — your existing `gcloud` CLI session picks it up. **Untested against a live GCP tenant.** ## Usage @@ -23,6 +24,7 @@ grant # Elevate for a specific provider grant --provider azure grant --provider aws +grant --provider gcp # Direct elevation with target and role grant --provider azure --target "Prod-EastUS" --role "Contributor" @@ -106,7 +108,7 @@ Running `grant` with no subcommand elevates cloud permissions (the core behavior |---------|-------------| | `grant` | Elevate cloud permissions (interactive, direct with `--target`/`--role`, or `--favorite`) | | `configure` | Configure Identity URL and username (optional — `login` auto-configures) | -| `env` | Elevate and output AWS credential export statements for `eval $(grant env)` | +| `env` | Elevate and output AWS credential export statements for `eval $(grant env)` (AWS only) | | `list` | List eligible targets and groups without elevation (`--provider`, `--groups`, `--output json`) | | `login` | Authenticate to Idira Identity (MFA handled interactively) | | `logout` | Clear cached tokens from keyring | @@ -176,7 +178,7 @@ favorites: | Azure CLI doesn't see new role after elevation | Refresh token: `az account get-access-token --output none` (or `az account clear && az login`) | | "No eligible targets found" | Verify SCA policies with your Idira admin; try without `--provider` to see all targets | | "Failed to elevate" | Check `grant status` for active sessions; verify target/role names | -| `grant env` errors for Azure | `env` is AWS-only — Azure doesn't return credentials, use `grant` directly | +| `grant env` errors for Azure/GCP | `env` is AWS-only — Azure and GCP return no credentials, use `grant` directly | | Permission denied accessing keyring (Linux) | Install and start `gnome-keyring` or `kwalletmanager` | ## Development diff --git a/cmd/env.go b/cmd/env.go index 1c9b7dc..4f60d2e 100644 --- a/cmd/env.go +++ b/cmd/env.go @@ -21,8 +21,8 @@ func newEnvCommand(runFn func(*cobra.Command, []string) error) *cobra.Command { Runs the full elevation flow, then prints only shell export statements suitable for eval. No human-readable messages are printed to stdout. -Only AWS is supported: other providers return no credentials — their -elevation applies to your existing native CLI session, so use 'grant'. +Only AWS is supported: Azure and GCP elevations return no credentials — +they apply to your existing az/gcloud CLI session, so use 'grant' instead. Usage: eval $(grant env --provider aws --target "Account" --role "AdminAccess") @@ -99,6 +99,8 @@ func cliForCSP(csp models.CSP) string { switch csp { case models.CSPAzure: return "az" + case models.CSPGCP: + return "gcloud" default: return "cloud provider" } diff --git a/cmd/env_test.go b/cmd/env_test.go index 487ea4c..6c93d29 100644 --- a/cmd/env_test.go +++ b/cmd/env_test.go @@ -93,6 +93,7 @@ func TestEnvDoesNotElevateForNonAWSProvider(t *testing.T) { provider string }{ {name: "azure", provider: "azure"}, + {name: "gcp", provider: "gcp"}, } for _, tt := range tests { diff --git a/cmd/favorites.go b/cmd/favorites.go index 3edca81..8da7205 100644 --- a/cmd/favorites.go +++ b/cmd/favorites.go @@ -79,7 +79,7 @@ func newFavoritesAddCommandWithRunner(runFn func(*cobra.Command, []string) error RunE: runFn, } - cmd.Flags().StringP("provider", "p", "", "Cloud provider: azure, aws (omit to show all)") + cmd.Flags().StringP("provider", "p", "", "Cloud provider: azure, aws, gcp (omit to show all)") cmd.Flags().StringP("target", "t", "", "Target name (subscription, resource group, etc.)") cmd.Flags().StringP("role", "r", "", "Role name") cmd.Flags().String("type", "", "Favorite type: cloud, groups (default: cloud)") diff --git a/cmd/list.go b/cmd/list.go index 06de2b4..1baa0e0 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -66,7 +66,7 @@ Examples: RunE: runFn, } - cmd.Flags().StringP("provider", "p", "", "Cloud provider: azure, aws (omit to show all)") + cmd.Flags().StringP("provider", "p", "", "Cloud provider: azure, aws, gcp (omit to show all)") cmd.Flags().Bool("groups", false, "Show only Entra ID groups") cmd.Flags().Bool("refresh", false, "Bypass eligibility cache and fetch fresh data") diff --git a/cmd/request_submit.go b/cmd/request_submit.go index 095807b..f42e9f7 100644 --- a/cmd/request_submit.go +++ b/cmd/request_submit.go @@ -216,9 +216,13 @@ func confirmSubmit() (bool, error) { func runRequestSubmit(cmd *cobra.Command, svc accessRequestService) error { provider, _ := cmd.Flags().GetString("provider") if provider != "" { - if _, err := parseProvider(provider); err != nil { + csp, err := parseProvider(provider) + if err != nil { return err } + if csp == models.CSPGCP { + return errors.New("grant request submit is not supported for GCP") + } } targetName, _ := cmd.Flags().GetString("target") @@ -577,6 +581,9 @@ func buildOnDemandRequest(ws *submitWorkspace) (models.OnDemandRequest, error) { ensureLeadingSlash(ws.WorkspaceID), }, }, nil + case "PROJECT", "FOLDER", "GCP_ORGANIZATION": + return models.OnDemandRequest{}, errors.New( + "grant request submit is not supported for GCP workspaces") default: return models.OnDemandRequest{}, fmt.Errorf( "interactive role selection not supported for workspace type %q; use --role-id", diff --git a/cmd/request_test.go b/cmd/request_test.go index 34d5338..84fe330 100644 --- a/cmd/request_test.go +++ b/cmd/request_test.go @@ -659,6 +659,24 @@ func TestBuildOnDemandRequest_UnsupportedType(t *testing.T) { } } +func TestBuildOnDemandRequest_GCPUnsupported(t *testing.T) { + for _, wt := range []string{"PROJECT", "FOLDER", "GCP_ORGANIZATION"} { + t.Run(wt, func(t *testing.T) { + _, err := buildOnDemandRequest(&submitWorkspace{ + WorkspaceID: "ws-1", + WorkspaceType: models.WorkspaceType(wt), + OrganizationID: "org-1", + }) + if err == nil { + t.Fatalf("expected error for GCP workspace type %q", wt) + } + if !strings.Contains(err.Error(), "GCP") { + t.Errorf("error should name GCP, got: %v", err) + } + }) + } +} + func TestBuildOnDemandRequest_SupportedTypes(t *testing.T) { tests := []struct { name string @@ -889,7 +907,7 @@ func TestRunRequestSubmit_InvalidProvider(t *testing.T) { root.AddCommand(cmd) _, err := executeCommand(root, "request", "submit", - "--provider", "gcp", + "--provider", "oracle", "--target", "Sub", "--role-id", "r1", "--reason", "test", "--date", "2026-04-21", "--timezone", "UTC", "--from", "09:00", "--to", "17:00", @@ -902,6 +920,36 @@ func TestRunRequestSubmit_InvalidProvider(t *testing.T) { } } +// TestRunRequestSubmit_GCPUnsupported pins that GCP is explicitly out of scope +// for 'grant request submit' and fails before any target resolution. +func TestRunRequestSubmit_GCPUnsupported(t *testing.T) { + original := resolveSubmitTargetFn + defer func() { resolveSubmitTargetFn = original }() + + resolveSubmitTargetFn = func(_ context.Context, _, _ string, _ bool) (*submitWorkspace, error) { + t.Fatal("resolveSubmitTarget should not be called for GCP") + return nil, nil + } + + svc := &mockAccessRequestService{} + cmd := NewRequestCommandWithDeps(svc) + root := newTestRootCommand() + root.AddCommand(cmd) + + _, err := executeCommand(root, "request", "submit", + "--provider", "gcp", + "--target", "Proj", "--role-id", "r1", + "--reason", "test", "--date", "2026-04-21", + "--timezone", "UTC", "--from", "09:00", "--to", "17:00", + "--yes") + if err == nil { + t.Fatal("expected error for GCP provider") + } + if !strings.Contains(err.Error(), "not supported for GCP") { + t.Errorf("error %q does not say GCP is unsupported", err.Error()) + } +} + func TestRunRequestSubmit_ConfirmationDenied(t *testing.T) { original := resolveSubmitTargetFn originalConfirm := confirmSubmitFn diff --git a/cmd/revoke.go b/cmd/revoke.go index 7b226c1..0238faa 100644 --- a/cmd/revoke.go +++ b/cmd/revoke.go @@ -46,7 +46,7 @@ Use 'grant status' to view session IDs.`, 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)") + cmd.Flags().StringP("provider", "p", "", "filter sessions by provider (azure, aws, gcp)") return cmd } diff --git a/cmd/root.go b/cmd/root.go index 6196b72..ccd3494 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -86,6 +86,7 @@ Examples: # Specify provider explicitly (cloud targets only) grant --provider azure grant --provider aws + grant --provider gcp # Bypass eligibility cache and fetch fresh data grant --refresh`, @@ -108,7 +109,7 @@ Examples: cmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "enable verbose output") cmd.PersistentFlags().StringVarP(&outputFormat, "output", "o", "text", "Output format: text, json") - cmd.Flags().StringP("provider", "p", "", "Cloud provider: azure, aws (omit to show all)") + cmd.Flags().StringP("provider", "p", "", "Cloud provider: azure, aws, gcp (omit to show all)") cmd.Flags().StringP("target", "t", "", "Target name (subscription, resource group, etc.)") cmd.Flags().StringP("role", "r", "", "Role name") cmd.Flags().StringP("favorite", "f", "", "Use a saved favorite (see 'grant favorites list')") @@ -263,7 +264,7 @@ func Execute() { } // supportedCSPs lists the cloud providers supported for elevation. -var supportedCSPs = []models.CSP{models.CSPAzure, models.CSPAWS} +var supportedCSPs = []models.CSP{models.CSPAzure, models.CSPAWS, models.CSPGCP} // fetchEligibility retrieves eligible targets. When provider is empty, all // supported CSPs are queried and results merged. When set, only that CSP is queried. @@ -362,8 +363,8 @@ type elevationResult struct { // // preElevate, when non-nil, is called with the resolved target immediately // before the elevation request is issued. Returning an error aborts the flow -// without creating a session — used by 'grant env' to reject targets whose -// provider returns no credentials before burning a real elevation. +// without creating a session — used by 'grant env' to reject non-AWS targets +// before burning a real elevation. func resolveAndElevate( flags *elevateFlags, profile *sdkmodels.IdsecProfile, @@ -872,6 +873,8 @@ func runElevateWithDeps( } case models.CSPAzure: fmt.Fprintf(cmd.OutOrStdout(), "\n Your az CLI session now has the elevated permissions.\n") + case models.CSPGCP: + fmt.Fprintf(cmd.OutOrStdout(), "\n The elevated permissions now apply to your GCP session.\n") } return nil diff --git a/cmd/root_elevate_test.go b/cmd/root_elevate_test.go index 1ae1ea3..44f58c4 100644 --- a/cmd/root_elevate_test.go +++ b/cmd/root_elevate_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "strings" + "sync" "testing" "time" @@ -853,7 +854,7 @@ func TestRootElevate_ProviderValidation(t *testing.T) { wantErr bool }{ { - name: "invalid provider - gcp", + name: "invalid provider - oracle", setupMocks: func() (*mockAuthLoader, *mockEligibilityLister, *config.Config) { authLoader := &mockAuthLoader{ token: &authmodels.IdsecToken{Token: "test-jwt"}, @@ -861,10 +862,31 @@ func TestRootElevate_ProviderValidation(t *testing.T) { cfg := config.DefaultConfig() return authLoader, nil, cfg }, + args: []string{"--provider", "oracle"}, + wantContain: []string{ + `provider "oracle" is not supported`, + "supported providers: azure, aws, gcp", + }, + wantErr: true, + }, + { + name: "valid provider - gcp explicit", + setupMocks: func() (*mockAuthLoader, *mockEligibilityLister, *config.Config) { + authLoader := &mockAuthLoader{ + token: &authmodels.IdsecToken{Token: "test-jwt"}, + } + eligibilityLister := &mockEligibilityLister{ + response: &models.EligibilityResponse{ + Response: []models.EligibleTarget{}, + Total: 0, + }, + } + cfg := config.DefaultConfig() + return authLoader, eligibilityLister, cfg + }, args: []string{"--provider", "gcp"}, wantContain: []string{ - `provider "gcp" is not supported`, - "supported providers: azure, aws", + "no eligible gcp targets found", }, wantErr: true, }, @@ -1214,6 +1236,161 @@ func TestFetchEligibility_ConcurrentExecution(t *testing.T) { } } +func TestFetchEligibility_GCPQueriesGCPOnly(t *testing.T) { + var mu sync.Mutex + var queried []models.CSP + + lister := &mockEligibilityLister{ + listFunc: func(ctx context.Context, csp models.CSP) (*models.EligibilityResponse, error) { + mu.Lock() + queried = append(queried, csp) + mu.Unlock() + return &models.EligibilityResponse{ + Response: []models.EligibleTarget{ + { + OrganizationID: "123456789012", + WorkspaceID: "proj-1", + WorkspaceName: "Project One", + WorkspaceType: models.WorkspaceTypeProject, + RoleInfo: models.RoleInfo{ID: "roles/viewer", Name: "Viewer"}, + }, + }, + Total: 1, + }, nil + }, + } + + targets, err := fetchEligibility(t.Context(), lister, "gcp") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(queried) != 1 || queried[0] != models.CSPGCP { + t.Fatalf("expected only GCP to be queried, got %v", queried) + } + if len(targets) != 1 { + t.Fatalf("expected 1 target, got %d", len(targets)) + } +} + +func TestFetchEligibility_AllProvidersIncludesGCP(t *testing.T) { + lister := &mockEligibilityLister{ + listFunc: func(ctx context.Context, csp models.CSP) (*models.EligibilityResponse, error) { + return &models.EligibilityResponse{ + Response: []models.EligibleTarget{ + { + WorkspaceName: string(csp) + " workspace", + RoleInfo: models.RoleInfo{ID: "role-" + string(csp), Name: "Role"}, + }, + }, + Total: 1, + }, nil + }, + } + + targets, err := fetchEligibility(t.Context(), lister, "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(targets) != 3 { + t.Fatalf("expected 3 merged targets, got %d", len(targets)) + } + + seen := map[models.CSP]bool{} + for _, tgt := range targets { + seen[tgt.CSP] = true + } + for _, want := range []models.CSP{models.CSPAzure, models.CSPAWS, models.CSPGCP} { + if !seen[want] { + t.Errorf("expected merged results to include CSP %q, got %v", want, seen) + } + } +} + +func TestFetchEligibility_SkipsFailingGCP(t *testing.T) { + lister := &mockEligibilityLister{ + listFunc: func(ctx context.Context, csp models.CSP) (*models.EligibilityResponse, error) { + if csp == models.CSPGCP { + return nil, errors.New("gcp not enabled for this tenant") + } + return &models.EligibilityResponse{ + Response: []models.EligibleTarget{ + { + WorkspaceName: string(csp) + " workspace", + RoleInfo: models.RoleInfo{ID: "role-" + string(csp), Name: "Role"}, + }, + }, + Total: 1, + }, nil + }, + } + + targets, err := fetchEligibility(t.Context(), lister, "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(targets) != 2 { + t.Fatalf("expected 2 targets after skipping GCP failure, got %d", len(targets)) + } + for _, tgt := range targets { + if tgt.CSP == models.CSPGCP { + t.Errorf("did not expect GCP targets, got %+v", tgt) + } + } +} + +func TestRootElevate_GCPGuidance(t *testing.T) { + authLoader := &mockAuthLoader{ + token: &authmodels.IdsecToken{Token: "test-jwt"}, + } + eligibilityLister := &mockEligibilityLister{ + response: &models.EligibilityResponse{ + Response: []models.EligibleTarget{ + { + OrganizationID: "123456789012", + WorkspaceID: "proj-1", + WorkspaceName: "My GCP Project", + WorkspaceType: models.WorkspaceTypeProject, + RoleInfo: models.RoleInfo{ID: "roles/editor", Name: "Editor"}, + }, + }, + Total: 1, + }, + } + elevateService := &mockElevateService{ + response: &models.ElevateResponse{ + Response: models.ElevateAccessResult{ + CSP: models.CSPGCP, + OrganizationID: "123456789012", + Results: []models.ElevateTargetResult{ + { + WorkspaceID: "proj-1", + RoleID: "roles/editor", + SessionID: "session-gcp-1", + }, + }, + }, + }, + } + + cfg := config.DefaultConfig() + cmd := NewRootCommandWithDeps(nil, authLoader, eligibilityLister, elevateService, nil, nil, nil, cfg) + + output, err := executeCommand(cmd, "--provider", "gcp", "--target", "My GCP Project", "--role", "Editor") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + for _, want := range []string{ + "Elevated to Editor on My GCP Project", + "Session ID: session-gcp-1", + "GCP session", + } { + if !strings.Contains(output, want) { + t.Errorf("output missing %q\ngot:\n%s", want, output) + } + } +} + func TestRootElevate_GroupsInteractiveMode(t *testing.T) { now := time.Now() expiresIn := commonmodels.IdsecRFC3339Time(now.Add(1 * time.Hour)) diff --git a/cmd/status.go b/cmd/status.go index 11e1976..d5527ea 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -24,7 +24,7 @@ func newStatusCommand(runFn func(*cobra.Command, []string) error) *cobra.Command RunE: runFn, } - cmd.Flags().StringP("provider", "p", "", "filter sessions by provider (azure, aws)") + cmd.Flags().StringP("provider", "p", "", "filter sessions by provider (azure, aws, gcp)") return cmd } @@ -247,8 +247,10 @@ func parseProvider(provider string) (scamodels.CSP, error) { return scamodels.CSPAzure, nil case "AWS": return scamodels.CSPAWS, nil + case "GCP": + return scamodels.CSPGCP, nil default: - return "", fmt.Errorf("invalid provider %q: must be one of: azure, aws", provider) + return "", fmt.Errorf("invalid provider %q: must be one of: azure, aws, gcp", provider) } } @@ -279,6 +281,8 @@ func formatProviderName(provider string) string { return "Azure" case "AWS": return "AWS" + case "GCP": + return "GCP" default: return provider } diff --git a/cmd/status_test.go b/cmd/status_test.go index 375d95c..df0a5a0 100644 --- a/cmd/status_test.go +++ b/cmd/status_test.go @@ -659,6 +659,42 @@ func TestStatusCommandIntegration(t *testing.T) { } +func TestParseProvider(t *testing.T) { + tests := []struct { + name string + provider string + want scamodels.CSP + wantErr bool + }{ + {name: "azure lowercase", provider: "azure", want: scamodels.CSPAzure}, + {name: "aws lowercase", provider: "aws", want: scamodels.CSPAWS}, + {name: "gcp lowercase", provider: "gcp", want: scamodels.CSPGCP}, + {name: "GCP uppercase", provider: "GCP", want: scamodels.CSPGCP}, + {name: "unsupported", provider: "oracle", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseProvider(tt.provider) + if tt.wantErr { + if err == nil { + t.Fatalf("expected error for provider %q", tt.provider) + } + if !strings.Contains(err.Error(), "must be one of: azure, aws, gcp") { + t.Errorf("error = %v, want it to list azure, aws, gcp", err) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Errorf("parseProvider(%q) = %q, want %q", tt.provider, got, tt.want) + } + }) + } +} + func TestStatusCommand_CachedEligibility(t *testing.T) { now := time.Now() expiresIn := commonmodels.IdsecRFC3339Time(now.Add(1 * time.Hour)) diff --git a/internal/sca/models/eligibility.go b/internal/sca/models/eligibility.go index 957bbf4..8bb9f89 100644 --- a/internal/sca/models/eligibility.go +++ b/internal/sca/models/eligibility.go @@ -9,6 +9,7 @@ type CSP string const ( CSPAzure CSP = "AZURE" CSPAWS CSP = "AWS" + CSPGCP CSP = "GCP" ) // WorkspaceType represents the type of cloud workspace. @@ -21,6 +22,11 @@ const ( WorkspaceTypeManagementGroup WorkspaceType = "MANAGEMENT_GROUP" WorkspaceTypeDirectory WorkspaceType = "DIRECTORY" WorkspaceTypeAccount WorkspaceType = "account" // Lowercase per AWS API spec + + // GCP workspace types, per the GCPEligibleTarget schema. + WorkspaceTypeProject WorkspaceType = "PROJECT" + WorkspaceTypeFolder WorkspaceType = "FOLDER" + WorkspaceTypeGCPOrganization WorkspaceType = "GCP_ORGANIZATION" ) // RoleInfo contains the ID and name of a role. diff --git a/internal/sca/models/eligibility_test.go b/internal/sca/models/eligibility_test.go index 2b9850a..1c427be 100644 --- a/internal/sca/models/eligibility_test.go +++ b/internal/sca/models/eligibility_test.go @@ -170,6 +170,116 @@ func TestEligibleTarget_JSONUnmarshal_RoleFieldFallback(t *testing.T) { } } +// TestEligibleTarget_GCPUnmarshal covers the GCPEligibleTarget schema +// (allOf: {organizationId, workspaceType} + CommonEligibleTarget). +func TestEligibleTarget_GCPUnmarshal(t *testing.T) { + t.Parallel() + tests := []struct { + name string + jsonInput string + wantOrgID string + wantWorkspaceID string + wantName string + wantType WorkspaceType + wantRoleID string + wantRoleName string + }{ + { + name: "GCP project with roleInfo field", + jsonInput: `{ + "organizationId": "123456789012", + "workspaceId": "my-gcp-project", + "workspaceName": "My GCP Project", + "workspaceType": "PROJECT", + "roleInfo": { + "id": "roles/viewer", + "name": "Viewer" + } + }`, + wantOrgID: "123456789012", + wantWorkspaceID: "my-gcp-project", + wantName: "My GCP Project", + wantType: WorkspaceTypeProject, + wantRoleID: "roles/viewer", + wantRoleName: "Viewer", + }, + { + name: "GCP folder with role field fallback", + jsonInput: `{ + "organizationId": "123456789012", + "workspaceId": "folders/987654321", + "workspaceName": "Engineering", + "workspaceType": "FOLDER", + "role": { + "id": "roles/editor", + "name": "Editor" + } + }`, + wantOrgID: "123456789012", + wantWorkspaceID: "folders/987654321", + wantName: "Engineering", + wantType: WorkspaceTypeFolder, + wantRoleID: "roles/editor", + wantRoleName: "Editor", + }, + { + name: "GCP organization workspace type", + jsonInput: `{ + "organizationId": "123456789012", + "workspaceId": "organizations/123456789012", + "workspaceName": "acme.example", + "workspaceType": "GCP_ORGANIZATION", + "role": { + "id": "roles/resourcemanager.organizationAdmin", + "name": "Organization Administrator" + } + }`, + wantOrgID: "123456789012", + wantWorkspaceID: "organizations/123456789012", + wantName: "acme.example", + wantType: WorkspaceTypeGCPOrganization, + wantRoleID: "roles/resourcemanager.organizationAdmin", + wantRoleName: "Organization Administrator", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var target EligibleTarget + if err := json.Unmarshal([]byte(tt.jsonInput), &target); err != nil { + t.Fatalf("unexpected unmarshal error: %v", err) + } + + if target.OrganizationID != tt.wantOrgID { + t.Errorf("OrganizationID = %q, want %q", target.OrganizationID, tt.wantOrgID) + } + if target.WorkspaceID != tt.wantWorkspaceID { + t.Errorf("WorkspaceID = %q, want %q", target.WorkspaceID, tt.wantWorkspaceID) + } + if target.WorkspaceName != tt.wantName { + t.Errorf("WorkspaceName = %q, want %q", target.WorkspaceName, tt.wantName) + } + if target.WorkspaceType != tt.wantType { + t.Errorf("WorkspaceType = %q, want %q", target.WorkspaceType, tt.wantType) + } + if target.RoleInfo.ID != tt.wantRoleID { + t.Errorf("RoleInfo.ID = %q, want %q", target.RoleInfo.ID, tt.wantRoleID) + } + if target.RoleInfo.Name != tt.wantRoleName { + t.Errorf("RoleInfo.Name = %q, want %q", target.RoleInfo.Name, tt.wantRoleName) + } + }) + } +} + +func TestCSPGCP_Constant(t *testing.T) { + t.Parallel() + if CSPGCP != "GCP" { + t.Errorf("CSPGCP = %q, want %q", CSPGCP, "GCP") + } +} + func TestEligibilityResponse_Pagination(t *testing.T) { t.Parallel() jsonInput := `{ diff --git a/internal/sca/service_test.go b/internal/sca/service_test.go index 7c6e47e..a441c36 100644 --- a/internal/sca/service_test.go +++ b/internal/sca/service_test.go @@ -773,6 +773,80 @@ func TestListEligibility_Pagination(t *testing.T) { } } +// TestListEligibility_GCPRouteAndPagination pins the route construction and +// nextToken pagination for GCP, matching the AWS/Azure behavior. +func TestListEligibility_GCPRouteAndPagination(t *testing.T) { + token := "gcppage2" + callCount := 0 + var routes []string + + mock := &mockHTTPClient{ + getFunc: func(ctx context.Context, route string, params interface{}) (*http.Response, error) { + callCount++ + routes = append(routes, route) + if callCount == 1 { + if params != nil { + t.Errorf("expected nil params on first call, got %v", params) + } + return jsonResponse(models.EligibilityResponse{ + Response: []models.EligibleTarget{ + { + OrganizationID: "123456789012", + WorkspaceID: "proj-1", + WorkspaceName: "Project One", + WorkspaceType: models.WorkspaceTypeProject, + RoleInfo: models.RoleInfo{ID: "roles/viewer", Name: "Viewer"}, + }, + }, + NextToken: &token, + Total: 2, + }), nil + } + p, ok := params.(map[string]string) + if !ok || p["nextToken"] != token { + t.Errorf("expected nextToken=%q on call %d, got %v", token, callCount, params) + } + return jsonResponse(models.EligibilityResponse{ + Response: []models.EligibleTarget{ + { + OrganizationID: "123456789012", + WorkspaceID: "folders/42", + WorkspaceName: "Engineering", + WorkspaceType: models.WorkspaceTypeFolder, + RoleInfo: models.RoleInfo{ID: "roles/editor", Name: "Editor"}, + }, + }, + NextToken: nil, + Total: 2, + }), nil + }, + } + + svc := &SCAAccessService{httpClient: mock} + result, err := svc.ListEligibility(t.Context(), models.CSPGCP) + + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if callCount != 2 { + t.Errorf("expected 2 API calls for pagination, got %d", callCount) + } + for _, route := range routes { + if route != "/api/access/GCP/eligibility" { + t.Errorf("route = %q, want %q", route, "/api/access/GCP/eligibility") + } + } + if len(result.Response) != 2 { + t.Fatalf("expected 2 targets across pages, got %d", len(result.Response)) + } + if result.Response[0].WorkspaceType != models.WorkspaceTypeProject { + t.Errorf("WorkspaceType = %q, want %q", result.Response[0].WorkspaceType, models.WorkspaceTypeProject) + } + if result.Total != 2 { + t.Errorf("Total = %d, want 2", result.Total) + } +} + func TestListSessions_Pagination(t *testing.T) { token := "sesspage2" callCount := 0 diff --git a/internal/ui/selector.go b/internal/ui/selector.go index 287bef4..b124b96 100644 --- a/internal/ui/selector.go +++ b/internal/ui/selector.go @@ -28,6 +28,12 @@ func FormatTargetOption(target models.EligibleTarget) string { prefix = "Resource" case "account": prefix = "Account" + case "project": + prefix = "Project" + case "folder": + prefix = "Folder" + case "gcp_organization": + prefix = "GCP Organization" default: prefix = string(target.WorkspaceType) } diff --git a/internal/ui/selector_test.go b/internal/ui/selector_test.go index f973f43..d037e54 100644 --- a/internal/ui/selector_test.go +++ b/internal/ui/selector_test.go @@ -89,6 +89,34 @@ func TestFormatTargetOption(t *testing.T) { }, want: "Account: Dev Account / Role: ReadOnly (aws)", }, + { + name: "GCP project with CSP tag", + target: models.EligibleTarget{ + CSP: models.CSPGCP, + WorkspaceName: "My GCP Project", + WorkspaceType: models.WorkspaceTypeProject, + RoleInfo: models.RoleInfo{ID: "9", Name: "Viewer"}, + }, + want: "Project: My GCP Project / Role: Viewer (gcp)", + }, + { + name: "GCP folder", + target: models.EligibleTarget{ + WorkspaceName: "Engineering", + WorkspaceType: models.WorkspaceTypeFolder, + RoleInfo: models.RoleInfo{ID: "10", Name: "Editor"}, + }, + want: "Folder: Engineering / Role: Editor", + }, + { + name: "GCP organization", + target: models.EligibleTarget{ + WorkspaceName: "acme.example", + WorkspaceType: models.WorkspaceTypeGCPOrganization, + RoleInfo: models.RoleInfo{ID: "11", Name: "Organization Administrator"}, + }, + want: "GCP Organization: acme.example / Role: Organization Administrator", + }, } for _, tt := range tests { From 07e98680ab96200f3995bdb6cd066d926e903085 Mon Sep 17 00:00:00 2001 From: Tim Schindler Date: Thu, 13 Aug 2026 18:17:44 +0200 Subject: [PATCH 3/4] fix: close GCP bypass in request submit and fail closed in grant env Addresses the PR #53 review. The GCP guard in 'grant request submit' only ran on the --provider flag and inside interactive role discovery, so omitting --provider and passing --role-id let a GCP workspace through both. rejectGCPWorkspace now runs immediately after target resolution, independent of the flag and of which role path is taken, and matches on the CSP tag as well as the GCP workspace types. requireAWSTarget now fails closed on an unresolved CSP instead of letting it reach the elevation, matching the intent of the pre-flight check. Also drops TestCSPGCP_Constant, which only asserted a constant equals its own literal. --- CLAUDE.md | 4 +- cmd/env.go | 14 +++-- cmd/env_test.go | 34 ++++++++++++ cmd/request_submit.go | 28 +++++++++- cmd/request_test.go | 74 +++++++++++++++++++++++++ internal/sca/models/eligibility_test.go | 7 --- 6 files changed, 147 insertions(+), 14 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index dd82dd1..cefb921 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,7 +70,7 @@ Custom `SCAAccessService` follows SDK conventions: ## CLI - `spf13/cobra` for CLI framework - `Iilun/survey/v2` for interactive prompts -- `grant env` — **AWS only**; performs elevation, outputs only `export` statements (no human text); usage: `eval $(grant env --provider aws)`; supports `--refresh`. Non-AWS targets are rejected by `requireAWSTarget` (passed as the `preElevate` hook to `resolveAndElevate`) *before* any elevation is issued, so no session is created; the `AccessCredentials == nil` check remains as a fallback +- `grant env` — **AWS only**; performs elevation, outputs only `export` statements (no human text); usage: `eval $(grant env --provider aws)`; supports `--refresh`. Non-AWS targets are rejected by `requireAWSTarget` (passed as the `preElevate` hook to `resolveAndElevate`) *before* any elevation is issued, so no session is created. It fails closed — an unresolved CSP is rejected too; the `AccessCredentials == nil` check remains as a fallback - `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 `), `--all`, or interactive multi-select; `--yes` skips confirmation - `grant request` — manage access requests through approval workflow; subcommands: `submit`, `list`, `get`, `cancel`, `approve`, `reject` @@ -87,7 +87,7 @@ Custom `SCAAccessService` follows SDK conventions: - `--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 (`supportedCSPs` in `cmd/root.go` — Azure, AWS, GCP) and merges results; a CSP that errors is skipped -- GCP: `list`/elevate/`status`/`revoke` only. Workspace types `PROJECT`/`FOLDER`/`GCP_ORGANIZATION`; no `accessCredentials` in the API spec, so `grant env` stays AWS-only and `grant request submit` rejects GCP. **Untested against a live GCP tenant** +- GCP: `list`/elevate/`status`/`revoke` only. Workspace types `PROJECT`/`FOLDER`/`GCP_ORGANIZATION`; no `accessCredentials` in the API spec, so `grant env` stays AWS-only and `grant request submit` rejects GCP (`rejectGCPWorkspace`, applied after target resolution so `--role-id` cannot skip it). **Untested against a live GCP tenant** - `--refresh` bypasses eligibility cache on `grant` and `grant env` - `fetchEligibility()` and `resolveTargetCSP()` in `cmd/root.go` — shared by root, env, and favorites diff --git a/cmd/env.go b/cmd/env.go index 4f60d2e..f87ae54 100644 --- a/cmd/env.go +++ b/cmd/env.go @@ -81,12 +81,18 @@ func NewEnvCommandWithDeps( }) } -// requireAWSTarget rejects non-AWS targets before an elevation is performed. -// An unresolved CSP is allowed through, so the post-elevation credential check -// remains the fallback. +// requireAWSTarget rejects any target that is not known to be AWS before an +// elevation is performed. It fails closed: an unresolved CSP is rejected rather +// than elevated speculatively, since the whole point of the pre-flight check is +// to avoid creating a session we cannot use. func requireAWSTarget(target *models.EligibleTarget) error { - if target.CSP == "" || target.CSP == models.CSPAWS { + switch target.CSP { + case models.CSPAWS: return nil + case "": + return fmt.Errorf( + "could not determine the cloud provider for target %q; grant env is only supported for AWS — run 'grant --provider aws' or pass --provider", + target.WorkspaceName) } provider := strings.ToLower(string(target.CSP)) return fmt.Errorf( diff --git a/cmd/env_test.go b/cmd/env_test.go index 6c93d29..d4bed6f 100644 --- a/cmd/env_test.go +++ b/cmd/env_test.go @@ -156,6 +156,40 @@ func TestEnvDoesNotElevateForNonAWSProvider(t *testing.T) { } } +// TestRequireAWSTarget pins the pre-flight decision for each CSP, including +// the fail-closed behavior for an unresolved CSP. +func TestRequireAWSTarget(t *testing.T) { + tests := []struct { + name string + csp models.CSP + wantErr bool + wantContain string + }{ + {name: "aws passes", csp: models.CSPAWS}, + {name: "azure rejected", csp: models.CSPAzure, wantErr: true, wantContain: "az CLI session"}, + {name: "gcp rejected", csp: models.CSPGCP, wantErr: true, wantContain: "gcloud CLI session"}, + {name: "unresolved CSP fails closed", csp: "", wantErr: true, wantContain: "could not determine the cloud provider"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := requireAWSTarget(&models.EligibleTarget{ + CSP: tt.csp, + WorkspaceName: "Some Workspace", + }) + if tt.wantErr && err == nil { + t.Fatalf("expected error for CSP %q", tt.csp) + } + if !tt.wantErr && err != nil { + t.Fatalf("unexpected error for CSP %q: %v", tt.csp, err) + } + if tt.wantContain != "" && !strings.Contains(err.Error(), tt.wantContain) { + t.Errorf("error %q missing %q", err.Error(), tt.wantContain) + } + }) + } +} + // TestEnvElevatesOnceForAWS is the companion to the pre-flight validation test. func TestEnvElevatesOnceForAWS(t *testing.T) { credsJSON := `{"aws_access_key":"ASIAXXX","aws_secret_access_key":"secret","aws_session_token":"tok"}` diff --git a/cmd/request_submit.go b/cmd/request_submit.go index f42e9f7..abc4a82 100644 --- a/cmd/request_submit.go +++ b/cmd/request_submit.go @@ -221,7 +221,7 @@ func runRequestSubmit(cmd *cobra.Command, svc accessRequestService) error { return err } if csp == models.CSPGCP { - return errors.New("grant request submit is not supported for GCP") + return errGCPRequestUnsupported } } @@ -239,6 +239,13 @@ func runRequestSubmit(cmd *cobra.Command, svc accessRequestService) error { return err } + // GCP is out of scope for access requests. This is the authoritative check: + // it runs whether or not --provider was given, and before the role is + // resolved, so --role-id cannot skip it. + if err := rejectGCPWorkspace(workspace); err != nil { + return err + } + // 2. Role if roleID == "" { if !ui.IsInteractive() { @@ -358,6 +365,25 @@ func resolveSubmitFields(cmd *cobra.Command) (*submitFields, error) { return f, nil } +// errGCPRequestUnsupported is returned for any GCP workspace reaching +// 'grant request submit'. +var errGCPRequestUnsupported = errors.New("grant request submit is not supported for GCP") + +// rejectGCPWorkspace rejects GCP workspaces. It matches on the CSP tag (set by +// the multi-provider fan-out) and on the GCP workspace types, so a workspace is +// caught even when eligibility was fetched for a single provider and left the +// CSP field empty. +func rejectGCPWorkspace(ws *submitWorkspace) error { + if ws.CSP == models.CSPGCP { + return errGCPRequestUnsupported + } + switch ws.WorkspaceType { + case models.WorkspaceTypeProject, models.WorkspaceTypeFolder, models.WorkspaceTypeGCPOrganization: + return errGCPRequestUnsupported + } + return nil +} + func resolveSubmitTarget(ctx context.Context, provider, targetName string, refresh bool) (*submitWorkspace, error) { _, scaSvc, _, err := bootstrapSCAService() if err != nil { diff --git a/cmd/request_test.go b/cmd/request_test.go index 84fe330..f68d96f 100644 --- a/cmd/request_test.go +++ b/cmd/request_test.go @@ -950,6 +950,80 @@ func TestRunRequestSubmit_GCPUnsupported(t *testing.T) { } } +// TestRunRequestSubmit_GCPWorkspaceWithRoleIDRejected covers the bypass around +// the --provider guard: no --provider, a GCP workspace picked by target +// resolution, and --role-id supplied so interactive role discovery (and its +// own GCP guard) never runs. The request must still be rejected, and never +// submitted. +func TestRunRequestSubmit_GCPWorkspaceWithRoleIDRejected(t *testing.T) { + tests := []struct { + name string + ws *submitWorkspace + }{ + { + name: "CSP tagged by multi-provider fan-out", + ws: &submitWorkspace{ + WorkspaceName: "My GCP Project", + WorkspaceID: "proj-1", + WorkspaceType: models.WorkspaceTypeProject, + CSP: models.CSPGCP, + OrganizationID: "123456789012", + }, + }, + { + name: "CSP untagged, GCP workspace type only", + ws: &submitWorkspace{ + WorkspaceName: "Engineering", + WorkspaceID: "folders/42", + WorkspaceType: models.WorkspaceTypeFolder, + OrganizationID: "123456789012", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + originalResolve := resolveSubmitTargetFn + originalRole := resolveRoleFn + defer func() { + resolveSubmitTargetFn = originalResolve + resolveRoleFn = originalRole + }() + + resolveSubmitTargetFn = func(_ context.Context, provider, _ string, _ bool) (*submitWorkspace, error) { + if provider != "" { + t.Errorf("expected empty provider, got %q", provider) + } + return tt.ws, nil + } + resolveRoleFn = func(_ context.Context, _ *submitWorkspace, _ bool) (string, string, error) { + t.Fatal("role resolution should not run for a GCP workspace") + return "", "", nil + } + + svc := &mockAccessRequestService{} + cmd := NewRequestCommandWithDeps(svc) + root := newTestRootCommand() + root.AddCommand(cmd) + + _, err := executeCommand(root, "request", "submit", + "--role-id", "roles/editor", + "--reason", "test", "--date", "2026-04-21", + "--timezone", "UTC", "--from", "09:00", "--to", "17:00", + "--yes") + if err == nil { + t.Fatal("expected error for GCP workspace") + } + if !strings.Contains(err.Error(), "not supported for GCP") { + t.Errorf("error %q does not say GCP is unsupported", err.Error()) + } + if svc.submitRequest != nil { + t.Errorf("request must not be submitted, got %+v", svc.submitRequest) + } + }) + } +} + func TestRunRequestSubmit_ConfirmationDenied(t *testing.T) { original := resolveSubmitTargetFn originalConfirm := confirmSubmitFn diff --git a/internal/sca/models/eligibility_test.go b/internal/sca/models/eligibility_test.go index 1c427be..65816b1 100644 --- a/internal/sca/models/eligibility_test.go +++ b/internal/sca/models/eligibility_test.go @@ -273,13 +273,6 @@ func TestEligibleTarget_GCPUnmarshal(t *testing.T) { } } -func TestCSPGCP_Constant(t *testing.T) { - t.Parallel() - if CSPGCP != "GCP" { - t.Errorf("CSPGCP = %q, want %q", CSPGCP, "GCP") - } -} - func TestEligibilityResponse_Pagination(t *testing.T) { t.Parallel() jsonInput := `{ From e23273291124229bf09ec37c7eb8a640f2da3882 Mon Sep 17 00:00:00 2001 From: Tim Schindler Date: Thu, 13 Aug 2026 21:51:37 +0200 Subject: [PATCH 4/4] fix(list): stamp CSP on single-provider eligibility fetch EligibleTarget.CSP is json:"-" and grant list never re-resolves it, so `grant list --provider aws -o json` emitted "provider": "". The single-provider branch of fetchEligibility now sets CSP just like the multi-CSP branch. --- cmd/list_test.go | 42 ++++++++++++++++++++++++++++++++++++++++ cmd/root.go | 5 +++++ cmd/root_elevate_test.go | 12 +++++++++--- 3 files changed, 56 insertions(+), 3 deletions(-) diff --git a/cmd/list_test.go b/cmd/list_test.go index 357dc23..29e99d3 100644 --- a/cmd/list_test.go +++ b/cmd/list_test.go @@ -269,3 +269,45 @@ func TestListCommand_MutualExclusivity(t *testing.T) { t.Fatal("expected error for --groups + --provider") } } + +// TestListCommand_JSONOutputSingleProvider guards the regression where +// `grant list --provider aws -o json` emitted an empty "provider" field. +func TestListCommand_JSONOutputSingleProvider(t *testing.T) { + auth := &mockAuthLoader{token: &authmodels.IdsecToken{Token: "jwt"}} + eligLister := &mockEligibilityLister{ + listFunc: func(ctx context.Context, csp models.CSP) (*models.EligibilityResponse, error) { + if csp != models.CSPAWS { + t.Errorf("unexpected CSP queried: %q", csp) + } + return &models.EligibilityResponse{ + Response: []models.EligibleTarget{{ + WorkspaceID: "123456789012", WorkspaceName: "Sandbox", + WorkspaceType: models.WorkspaceTypeAccount, + RoleInfo: models.RoleInfo{ID: "r-aws", Name: "ReadOnly"}, + }}, + Total: 1, + }, nil + }, + } + groupsElig := &mockGroupsEligibilityLister{response: &models.GroupsEligibilityResponse{}} + + cmd := NewListCommandWithDeps(auth, eligLister, groupsElig) + root := newTestRootCommand() + root.AddCommand(cmd) + + output, err := executeCommand(root, "list", "--provider", "aws", "--output", "json") + if err != nil { + t.Fatalf("unexpected error: %v\noutput: %s", err, output) + } + + var parsed listOutput + if err := json.Unmarshal([]byte(output), &parsed); err != nil { + t.Fatalf("invalid JSON: %v\n%s", err, output) + } + if len(parsed.Cloud) != 1 { + t.Fatalf("expected 1 cloud target, got %d", len(parsed.Cloud)) + } + if parsed.Cloud[0].Provider != "aws" { + t.Errorf("provider = %q, want aws", parsed.Cloud[0].Provider) + } +} diff --git a/cmd/root.go b/cmd/root.go index ccd3494..d5a9cd0 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -330,6 +330,11 @@ func fetchEligibility(ctx context.Context, eligLister eligibilityLister, provide } targets := make([]models.EligibleTarget, len(resp.Response)) copy(targets, resp.Response) + // CSP is json:"-" and is not re-resolved downstream (e.g. grant list -o + // json), so stamp it here exactly as the multi-CSP branch does. + for i := range targets { + targets[i].CSP = csp + } return targets, nil } diff --git a/cmd/root_elevate_test.go b/cmd/root_elevate_test.go index 44f58c4..164449e 100644 --- a/cmd/root_elevate_test.go +++ b/cmd/root_elevate_test.go @@ -1158,7 +1158,10 @@ func TestRootElevate_UsageAndFlags(t *testing.T) { } } -func TestFetchEligibility_SingleProviderOmitsCSPTag(t *testing.T) { +// TestFetchEligibility_SingleProviderSetsCSP guards the JSON "provider" field: +// EligibleTarget.CSP is json:"-" and is never re-resolved downstream, so the +// single-provider branch has to stamp it just like the multi-CSP branch does. +func TestFetchEligibility_SingleProviderSetsCSP(t *testing.T) { lister := &mockEligibilityLister{ response: &models.EligibilityResponse{ Response: []models.EligibleTarget{ @@ -1177,9 +1180,12 @@ func TestFetchEligibility_SingleProviderOmitsCSPTag(t *testing.T) { t.Fatalf("unexpected error: %v", err) } + if len(targets) != 1 { + t.Fatalf("expected 1 target, got %d", len(targets)) + } for _, tgt := range targets { - if tgt.CSP != "" { - t.Errorf("expected empty CSP on single-provider fetch, got %q", tgt.CSP) + if tgt.CSP != models.CSPAzure { + t.Errorf("CSP = %q, want %q", tgt.CSP, models.CSPAzure) } } }