diff --git a/CHANGELOG.md b/CHANGELOG.md index c6e8d64..3f2dbe8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ 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 + ### 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/CLAUDE.md b/CLAUDE.md index a709068..150be8e 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[]`) @@ -84,7 +84,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. 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` @@ -108,7 +108,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 (`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/README.md b/README.md index ddd0736..ee4e54a 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" @@ -110,7 +112,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 | @@ -180,7 +182,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 2843c17..199f3d5 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: 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") 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,37 @@ func NewEnvCommandWithDeps( }) } +// 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 { + 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( + "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" + case models.CSPGCP: + return "gcloud" + default: + return "cloud provider" + } +} + func runEnvWithDeps( cmd *cobra.Command, flags *elevateFlags, @@ -87,7 +122,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 +130,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..d4bed6f 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,191 @@ 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"}, + {name: "gcp", provider: "gcp"}, } + + 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) + } + }) + } +} + +// 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"}` + + 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/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/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/request_submit.go b/cmd/request_submit.go index b73ffbe..f01e95f 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 errGCPRequestUnsupported + } } targetName, _ := cmd.Flags().GetString("target") @@ -235,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() { @@ -354,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 { @@ -577,6 +607,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 749e87a..d9121ba 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,110 @@ 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()) + } +} + +// 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/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 5e0c244..8124b3d 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. @@ -329,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 } @@ -358,8 +364,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 non-AWS targets +// before burning a real elevation. func resolveAndElevate( flags *elevateFlags, profile *sdkmodels.IdsecProfile, @@ -368,6 +378,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 +454,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, @@ -860,6 +878,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..164449e 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, }, @@ -1136,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{ @@ -1155,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) } } } @@ -1214,6 +1242,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 ba7e918..4f29520 100644 --- a/cmd/status_test.go +++ b/cmd/status_test.go @@ -658,6 +658,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 3b60b90..a419b61 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..65816b1 100644 --- a/internal/sca/models/eligibility_test.go +++ b/internal/sca/models/eligibility_test.go @@ -170,6 +170,109 @@ 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 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 9bcda7d..b73b7df 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 {