Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file.

## [Unreleased]

### Changed

- `--output json` is now a pure serialisation flag; it no longer forces non-interactive mode. Interactive pickers and prompts (e.g. `grant request get -o json` with no ID, `grant request submit -o json` without `--target`/`--role`) work in a TTY, writing prompts to stderr and JSON to stdout.

### Added

- `grant request` command group for managing access requests through the approval workflow
Expand All @@ -17,8 +21,9 @@ All notable changes to this project will be documented in this file.
- New `internal/workflows/` package implementing the CyberArk Access Requests API client (`/api/workflows/requests`)
- Interactive role selector for `grant request submit`: after workspace selection, fuzzy-filterable list of requestable roles is fetched from the SCA on-demand role discovery endpoints (`/api/cloud/resources/ondemand`, `/api/cloud/cloud-roles/ondemand`)
- Supported workspace types: `DIRECTORY` (azure_ad), `ACCOUNT` (aws), `MANAGEMENT_GROUP` (azure_resource)
- Other Azure-resource scopes (subscription, resource group, resource) still require `--role-id` until validated
- Interactive role selection now also supports `SUBSCRIPTION`, `RESOURCE_GROUP`, and `RESOURCE` workspaces (uses naive 2-level ancestors; custom roles scoped to intermediate management groups may not appear — use `--role-id` for those)
- Roles cached in `~/.grant/cache/ondemand_roles_<platform>_<sha256(workspaceID)>.json` (4h TTL)
- `grant request submit --refresh` bypasses the on-demand role and eligibility caches (mirrors `grant --refresh`)
- Interactive request picker for `grant request cancel`, `approve`, `reject`, and `get` — omit the `<requestId>` positional argument in a terminal to pick from a scoped, fuzzy-filterable list (cancel: open requests you created; approve/reject: pending requests assigned to you; get: any request). Non-TTY invocation still requires the positional argument.

## [0.6.1] - 2026-04-08
Expand Down
8 changes: 4 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,9 @@ Custom `SCAAccessService` follows SDK conventions:
- `grant list` — list eligible targets and groups without triggering elevation; supports `--provider`, `--groups`, `--refresh`, `--output json`; used by LLMs to discover available targets programmatically
- `grant revoke` — revoke sessions: direct (`grant revoke <id>`), `--all`, or interactive multi-select; `--yes` skips confirmation
- `grant request` — manage access requests through approval workflow; subcommands: `submit`, `list`, `get`, `cancel`, `approve`, `reject`
- `grant request submit` — submit on-demand access request; workspace selector uses SCA eligibility (deduplicated to unique workspaces); after workspace selection, interactive role selector fetches roles via SCA on-demand endpoints (GET `/api/cloud/resources/ondemand` for `azure_ad`/`aws`, POST `/api/cloud/cloud-roles/ondemand` for `azure_resource`); shows summary + confirmation before submitting; flags: `--target`, `--role-id`, `--role`, `--provider`, `--reason`, `--priority`, `--date`, `--timezone`, `--from`, `--to`, `--yes`
- Interactive role selection supports `DIRECTORY`, `ACCOUNT` (AWS), and `MANAGEMENT_GROUP` workspaces; other Azure-resource scopes (subscription, resource group, resource) require `--role-id`
- On-demand role cache: `~/.grant/cache/ondemand_roles_<platform>_<sha256(workspaceID)>.json` (4h TTL); no `--refresh` flag — delete manually to invalidate
- `grant request submit` — submit on-demand access request; workspace selector uses SCA eligibility (deduplicated to unique workspaces); after workspace selection, interactive role selector fetches roles via SCA on-demand endpoints (GET `/api/cloud/resources/ondemand` for `azure_ad`/`aws`, POST `/api/cloud/cloud-roles/ondemand` for `azure_resource`); shows summary + confirmation before submitting; flags: `--target`, `--role-id`, `--role`, `--provider`, `--reason`, `--priority`, `--date`, `--timezone`, `--from`, `--to`, `--yes`, `--refresh`
- Interactive role selection supports `DIRECTORY`, `ACCOUNT` (AWS), `MANAGEMENT_GROUP`, `SUBSCRIPTION`, `RESOURCE_GROUP`, and `RESOURCE` workspaces (azure_resource scopes use naive 2-level ancestors; custom roles scoped to intermediate management groups may be missing — fall back to `--role-id`)
- On-demand role cache: `~/.grant/cache/ondemand_roles_<platform>_<sha256(workspaceID)>.json` (4h TTL); `--refresh` on `grant request submit` bypasses the cache
- `grant request list` — list access requests; flags: `--state`, `--result`, `--priority`, `--role` (CREATOR/APPROVER), `--search`, `--sort`, `--desc`
- `grant request get [id]` — get full request details; omitting `<id>` in a TTY opens a fuzzy-filterable picker of all your requests
- `grant request cancel [id]` — cancel an open request; optional `--reason`. Omitting `<id>` in a TTY opens a picker scoped to STARTING/RUNNING/PENDING requests you created (role=CREATOR)
Expand All @@ -98,7 +98,7 @@ Custom `SCAAccessService` follows SDK conventions:

## JSON Output
- `--output` / `-o` persistent flag on root command: `text` (default) or `json`
- Validated in `PersistentPreRunE`; JSON mode forces `IsTerminalFunc` to return false (non-interactive)
- Validated in `PersistentPreRunE`. `--output json` is a pure serialisation flag; it does not affect interactivity — interactive prompts still run in a TTY and write to stderr while JSON goes to stdout
- `cmd/output.go` — `outputFormat` var, `isJSONOutput()`, `writeJSON(w, data)`
- `cmd/output_types.go` — JSON structs: `cloudElevationOutput`, `groupElevationJSON`, `sessionOutput`, `statusOutput`, `revocationOutput`, `favoriteOutput`, `awsCredentialOutput`, `accessRequestOutput`, `accessRequestListOutput`
- All commands support JSON: root elevation, `env`, `status`, `revoke`, `favorites list`, `request list`, `request get`, `request submit`, `request cancel`, `request approve`, `request reject`
Expand Down
3 changes: 0 additions & 3 deletions cmd/request_picker.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,6 @@ var resolveRequestIDFn = resolveRequestIDInteractive
// shows the interactive picker, returning the chosen request ID.
func resolveRequestIDInteractive(ctx context.Context, svc accessRequestService, scope pickerScope) (string, error) {
if !ui.IsInteractive() {
if isJSONOutput() {
return "", errors.New("request ID is required with --output json; run `grant request list --output json` to find it")
}
return "", fmt.Errorf("%w; pass the request ID as a positional argument (run `grant request list` to find it)", ui.ErrNotInteractive)
}

Expand Down
22 changes: 0 additions & 22 deletions cmd/request_picker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,28 +45,6 @@ func TestResolveRequestIDInteractive_NonInteractive(t *testing.T) {
}
}

func TestResolveRequestIDInteractive_JSONMode(t *testing.T) {
withInteractiveTTY(t, false)
orig := outputFormat
outputFormat = "json"
t.Cleanup(func() { outputFormat = orig })

svc := &capturingMockAccessRequestService{}
_, err := resolveRequestIDInteractive(t.Context(), svc, pickerScope{emptyMsg: "access requests"})
if err == nil {
t.Fatal("expected error")
}
if errors.Is(err, ui.ErrNotInteractive) {
t.Errorf("JSON mode error should not wrap ErrNotInteractive")
}
if !strings.Contains(err.Error(), "--output json") {
t.Errorf("expected --output json hint, got %v", err)
}
if strings.Contains(err.Error(), "requires a terminal") {
t.Errorf("JSON mode error should not mention terminal: %v", err)
}
}

func TestResolveRequestIDInteractive_EmptyList(t *testing.T) {
withInteractiveTTY(t, true)

Expand Down
43 changes: 31 additions & 12 deletions cmd/request_submit.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ func newRequestSubmitCommand(svc accessRequestService) *cobra.Command {
cmd.Flags().String("from", "", "Start time (HH:MM)")
cmd.Flags().String("to", "", "End time (HH:MM)")
cmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt")
cmd.Flags().Bool("refresh", false, "Bypass on-demand role and eligibility caches")

return cmd
}
Expand Down Expand Up @@ -223,12 +224,13 @@ func runRequestSubmit(cmd *cobra.Command, svc accessRequestService) error {
targetName, _ := cmd.Flags().GetString("target")
roleID, _ := cmd.Flags().GetString("role-id")
roleName, _ := cmd.Flags().GetString("role")
refresh, _ := cmd.Flags().GetBool("refresh")

ctx, cancel := context.WithTimeout(cmd.Context(), apiTimeout)
defer cancel()

// 1. Workspace
workspace, err := resolveSubmitTargetFn(ctx, provider, targetName)
workspace, err := resolveSubmitTargetFn(ctx, provider, targetName, refresh)
if err != nil {
return err
}
Expand All @@ -238,7 +240,7 @@ func runRequestSubmit(cmd *cobra.Command, svc accessRequestService) error {
if !ui.IsInteractive() {
return errors.New("non-interactive mode requires --role-id")
}
resolvedID, resolvedName, err := resolveRoleFn(ctx, workspace)
resolvedID, resolvedName, err := resolveRoleFn(ctx, workspace, refresh)
if err != nil {
return fmt.Errorf("%w; retry with --role-id to bypass interactive role selection", err)
}
Expand Down Expand Up @@ -352,7 +354,7 @@ func resolveSubmitFields(cmd *cobra.Command) (*submitFields, error) {
return f, nil
}

func resolveSubmitTarget(ctx context.Context, provider, targetName string) (*submitWorkspace, error) {
func resolveSubmitTarget(ctx context.Context, provider, targetName string, refresh bool) (*submitWorkspace, error) {
_, scaSvc, _, err := bootstrapSCAService()
if err != nil {
return nil, fmt.Errorf("failed to bootstrap SCA service: %w", err)
Expand All @@ -362,7 +364,7 @@ func resolveSubmitTarget(ctx context.Context, provider, targetName string) (*sub
if cfg == nil {
cfg = config.DefaultConfig()
}
cachedLister := buildCachedLister(cfg, false, scaSvc, nil)
cachedLister := buildCachedLister(cfg, refresh, scaSvc, nil)

fetchCtx, fetchCancel := context.WithTimeout(ctx, apiTimeout)
defer fetchCancel()
Expand Down Expand Up @@ -487,7 +489,7 @@ func buildRequestDetails(ws *submitWorkspace, roleID, roleName string, f *submit

// resolveSubmitRole fetches on-demand roles for the selected workspace and
// prompts the user to choose one. Returns the role's resource_id and resource_name.
func resolveSubmitRole(ctx context.Context, ws *submitWorkspace) (roleID, roleName string, _ error) {
func resolveSubmitRole(ctx context.Context, ws *submitWorkspace, refresh bool) (roleID, roleName string, _ error) {
req, err := buildOnDemandRequest(ws)
if err != nil {
return "", "", err
Expand All @@ -508,7 +510,7 @@ func resolveSubmitRole(ctx context.Context, ws *submitWorkspace) (roleID, roleNa
if cacheErr == nil {
ttl := config.ParseCacheTTL(cfg)
store := cache.NewStore(cacheDir, ttl)
lister = cache.NewCachedRolesLister(scaSvc, store, common.GetLogger("grant", -1))
lister = cache.NewCachedRolesLister(scaSvc, store, refresh, common.GetLogger("grant", -1))
}

fetchCtx, cancel := context.WithTimeout(ctx, apiTimeout)
Expand All @@ -527,7 +529,12 @@ func resolveSubmitRole(ctx context.Context, ws *submitWorkspace) (roleID, roleNa
}

// buildOnDemandRequest maps a workspace into the on-demand discovery request.
// Only directory / AWS account / management-group workspaces are supported in v1.
// ensureLeadingSlash returns s with exactly one leading slash.
func ensureLeadingSlash(s string) string {
return "/" + strings.TrimLeft(s, "/")
}

// buildOnDemandRequest maps a workspace into the on-demand discovery request.
func buildOnDemandRequest(ws *submitWorkspace) (models.OnDemandRequest, error) {
wt := strings.ToUpper(string(ws.WorkspaceType))
switch wt {
Expand All @@ -550,14 +557,26 @@ func buildOnDemandRequest(ws *submitWorkspace) (models.OnDemandRequest, error) {
OrgID: ws.OrganizationID,
ResourceType: "management_group",
Ancestors: []string{
"/" + ws.OrganizationID,
"/" + ws.WorkspaceID,
ensureLeadingSlash(ws.OrganizationID),
ensureLeadingSlash(ws.WorkspaceID),
},
}, nil
case "SUBSCRIPTION", "RESOURCE_GROUP", "RESOURCE":
return models.OnDemandRequest{}, fmt.Errorf(
"interactive role selection not supported for %s workspaces yet; use --role-id (see docs/handoff-ondemand-roles-poc.md)",
strings.ToLower(wt))
resourceType := map[string]string{
"SUBSCRIPTION": "subscription",
"RESOURCE_GROUP": "resource_group",
"RESOURCE": "resource",
}[wt]
return models.OnDemandRequest{
WorkspaceID: ws.WorkspaceID,
PlatformName: "azure_resource",
OrgID: ws.OrganizationID,
ResourceType: resourceType,
Ancestors: []string{
ensureLeadingSlash(ws.OrganizationID),
ensureLeadingSlash(ws.WorkspaceID),
},
Comment on lines +574 to +578
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For Azure resource scopes, Ancestors is built as "/" + ws.WorkspaceID. In this codebase, subscription workspace IDs can already start with a leading slash (e.g. "/subscriptions/..."), which would produce a double-slash ancestor ("//subscriptions/..."). Normalize to exactly one leading slash (e.g. only prefix when missing, or use strings.TrimPrefix/TrimLeft) before building the ancestors.

Copilot uses AI. Check for mistakes.
}, nil
default:
return models.OnDemandRequest{}, fmt.Errorf(
"interactive role selection not supported for workspace type %q; use --role-id",
Expand Down
84 changes: 42 additions & 42 deletions cmd/request_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -501,7 +501,7 @@ func TestRunRequestSubmit_NonInteractive(t *testing.T) {
original := resolveSubmitTargetFn
defer func() { resolveSubmitTargetFn = original }()

resolveSubmitTargetFn = func(_ context.Context, _, _ string) (*submitWorkspace, error) {
resolveSubmitTargetFn = func(_ context.Context, _, _ string, _ bool) (*submitWorkspace, error) {
return &submitWorkspace{
WorkspaceName: "Test Sub",
WorkspaceID: "ws-1",
Expand Down Expand Up @@ -539,7 +539,7 @@ func TestRunRequestSubmit_JSONOutput(t *testing.T) {
original := resolveSubmitTargetFn
defer func() { resolveSubmitTargetFn = original }()

resolveSubmitTargetFn = func(_ context.Context, _, _ string) (*submitWorkspace, error) {
resolveSubmitTargetFn = func(_ context.Context, _, _ string, _ bool) (*submitWorkspace, error) {
return &submitWorkspace{
WorkspaceName: "Test Sub",
WorkspaceID: "ws-1",
Expand Down Expand Up @@ -586,7 +586,7 @@ func TestRunRequestSubmit_ServiceError(t *testing.T) {
original := resolveSubmitTargetFn
defer func() { resolveSubmitTargetFn = original }()

resolveSubmitTargetFn = func(_ context.Context, _, _ string) (*submitWorkspace, error) {
resolveSubmitTargetFn = func(_ context.Context, _, _ string, _ bool) (*submitWorkspace, error) {
return &submitWorkspace{
WorkspaceName: "Sub",
WorkspaceID: "ws-1",
Expand Down Expand Up @@ -620,7 +620,7 @@ func TestRunRequestSubmit_MissingFlags_NonInteractive(t *testing.T) {
original := resolveSubmitTargetFn
defer func() { resolveSubmitTargetFn = original }()

resolveSubmitTargetFn = func(_ context.Context, _, _ string) (*submitWorkspace, error) {
resolveSubmitTargetFn = func(_ context.Context, _, _ string, _ bool) (*submitWorkspace, error) {
return &submitWorkspace{
WorkspaceName: "Sub",
WorkspaceID: "ws-1",
Expand All @@ -646,46 +646,38 @@ func TestRunRequestSubmit_MissingFlags_NonInteractive(t *testing.T) {
}

func TestBuildOnDemandRequest_UnsupportedType(t *testing.T) {
tests := []struct {
name string
wt models.WorkspaceType
}{
{"subscription", models.WorkspaceTypeSubscription},
{"resource_group", models.WorkspaceType("RESOURCE_GROUP")},
{"resource", models.WorkspaceType("RESOURCE")},
_, err := buildOnDemandRequest(&submitWorkspace{
WorkspaceID: "ws-1",
WorkspaceType: models.WorkspaceType("SOMETHING_NEW"),
OrganizationID: "org-1",
})
if err == nil {
t.Fatal("expected error for unknown workspace type")
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := buildOnDemandRequest(&submitWorkspace{
WorkspaceID: "ws-1",
WorkspaceType: tt.wt,
OrganizationID: "org-1",
})
if err == nil {
t.Fatalf("expected error for %s", tt.name)
}
if !strings.Contains(err.Error(), "not supported") {
t.Errorf("error should mention not supported: %v", err)
}
if !strings.Contains(err.Error(), "--role-id") {
t.Errorf("error should point to --role-id: %v", err)
}
})
if !strings.Contains(err.Error(), "--role-id") {
t.Errorf("error should point to --role-id: %v", err)
}
}

func TestBuildOnDemandRequest_SupportedTypes(t *testing.T) {
tests := []struct {
name string
wt models.WorkspaceType
wsID string
orgID string
wantPlatform string
wantAnces int
name string
wt models.WorkspaceType
wsID string
orgID string
wantPlatform string
wantResourceType string
wantAnces int
}{
{"directory", models.WorkspaceType("DIRECTORY"), "dir-1", "dir-1", "azure_ad", 0},
{"account", models.WorkspaceType("ACCOUNT"), "123", "123", "aws", 0},
{"management_group", models.WorkspaceType("MANAGEMENT_GROUP"), "providers/Microsoft.Management/managementGroups/root", "dir-456", "azure_resource", 2},
{"directory", models.WorkspaceType("DIRECTORY"), "dir-1", "dir-1", "azure_ad", "", 0},
{"account", models.WorkspaceType("ACCOUNT"), "123", "123", "aws", "", 0},
// Real IDs have no leading slash for management groups.
{"management_group", models.WorkspaceType("MANAGEMENT_GROUP"), "providers/Microsoft.Management/managementGroups/root", "dir-456", "azure_resource", "management_group", 2},
// Real subscription/RG/resource IDs include a leading slash (e.g. /subscriptions/...).
{"subscription_noslash", models.WorkspaceType("SUBSCRIPTION"), "subscriptions/abc-123", "dir-456", "azure_resource", "subscription", 2},
{"subscription_slash", models.WorkspaceType("SUBSCRIPTION"), "/subscriptions/abc-123", "dir-456", "azure_resource", "subscription", 2},
{"resource_group", models.WorkspaceType("RESOURCE_GROUP"), "/subscriptions/abc/resourceGroups/rg1", "dir-456", "azure_resource", "resource_group", 2},
{"resource", models.WorkspaceType("RESOURCE"), "/subscriptions/abc/resourceGroups/rg1/providers/Microsoft.Compute/virtualMachines/vm1", "dir-456", "azure_resource", "resource", 2},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Expand All @@ -700,9 +692,17 @@ func TestBuildOnDemandRequest_SupportedTypes(t *testing.T) {
if req.PlatformName != tt.wantPlatform {
t.Errorf("platform: got %q want %q", req.PlatformName, tt.wantPlatform)
}
if tt.wantResourceType != "" && req.ResourceType != tt.wantResourceType {
t.Errorf("resourceType: got %q want %q", req.ResourceType, tt.wantResourceType)
}
if len(req.Ancestors) != tt.wantAnces {
t.Errorf("ancestors: got %d want %d", len(req.Ancestors), tt.wantAnces)
}
for _, a := range req.Ancestors {
if strings.HasPrefix(a, "//") {
t.Errorf("ancestor has double slash: %q", a)
}
}
})
}
}
Expand All @@ -718,7 +718,7 @@ func TestRunRequestSubmit_InteractiveRoleSelection(t *testing.T) {
}()
ui.IsTerminalFunc = func(fd uintptr) bool { return true }

resolveSubmitTargetFn = func(_ context.Context, _, _ string) (*submitWorkspace, error) {
resolveSubmitTargetFn = func(_ context.Context, _, _ string, _ bool) (*submitWorkspace, error) {
return &submitWorkspace{
WorkspaceName: "Dir",
WorkspaceID: "dir-1",
Expand All @@ -727,7 +727,7 @@ func TestRunRequestSubmit_InteractiveRoleSelection(t *testing.T) {
OrganizationID: "dir-1",
}, nil
}
resolveRoleFn = func(_ context.Context, ws *submitWorkspace) (string, string, error) {
resolveRoleFn = func(_ context.Context, ws *submitWorkspace, _ bool) (string, string, error) {
if ws.WorkspaceID != "dir-1" {
t.Errorf("expected ws dir-1, got %s", ws.WorkspaceID)
}
Expand Down Expand Up @@ -878,7 +878,7 @@ func TestRunRequestSubmit_InvalidProvider(t *testing.T) {
original := resolveSubmitTargetFn
defer func() { resolveSubmitTargetFn = original }()

resolveSubmitTargetFn = func(_ context.Context, _, _ string) (*submitWorkspace, error) {
resolveSubmitTargetFn = func(_ context.Context, _, _ string, _ bool) (*submitWorkspace, error) {
t.Fatal("resolveSubmitTarget should not be called with invalid provider")
return nil, nil
}
Expand Down Expand Up @@ -910,7 +910,7 @@ func TestRunRequestSubmit_ConfirmationDenied(t *testing.T) {
confirmSubmitFn = originalConfirm
}()

resolveSubmitTargetFn = func(_ context.Context, _, _ string) (*submitWorkspace, error) {
resolveSubmitTargetFn = func(_ context.Context, _, _ string, _ bool) (*submitWorkspace, error) {
return &submitWorkspace{
WorkspaceName: "Test Sub",
WorkspaceID: "ws-1",
Expand Down Expand Up @@ -954,7 +954,7 @@ func TestRunRequestSubmit_YesFlagSkipsConfirmation(t *testing.T) {
confirmSubmitFn = originalConfirm
}()

resolveSubmitTargetFn = func(_ context.Context, _, _ string) (*submitWorkspace, error) {
resolveSubmitTargetFn = func(_ context.Context, _, _ string, _ bool) (*submitWorkspace, error) {
return &submitWorkspace{
WorkspaceName: "Test Sub",
WorkspaceID: "ws-1",
Expand Down
Loading
Loading