Skip to content
Merged
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,23 @@ All notable changes to this project will be documented in this file.

## [Unreleased]

### Added

- `grant request` command group for managing access requests through the approval workflow
- `grant request submit` — submit a new access request with target selection from eligibility, reason, priority, date/time scheduling
- `grant request list` — list access requests with filtering (state, result, priority, role), sorting, and free-text search
- `grant request get <id>` — view full details of a specific access request
- `grant request cancel <id>` — cancel an open request with optional reason
- `grant request approve <id>` — approve a pending request with optional reason
- `grant request reject <id>` — reject a pending request with optional reason
- All `grant request` subcommands support `--output json` for machine-readable output
- 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
- Roles cached in `~/.grant/cache/ondemand_roles_<platform>_<sha256(workspaceID)>.json` (4h TTL)
- 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

### Fixed
Expand Down
29 changes: 27 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,22 @@ Custom `SCAAccessService` follows SDK conventions:
- `POST /api/access/elevate/groups` — request group membership elevation (response wrapped in `response` key, same as cloud elevation)
- **Headers:** `Authorization: Bearer {jwt}`, `X-API-Version: 2.0`, `Content-Type: application/json`

## Access Requests API (Workflows)
- **Base URL:** `https://{subdomain}.uar.{platform_domain}/api`
- **Package:** `internal/workflows/` — `AccessRequestService` (mirrors SCA service pattern with ISP client for "uar" service)
- **Models:** `internal/workflows/models/` — `AccessRequest`, `RequestState`, `RequestResult`, `SubmitAccessRequest`, `CancelAccessRequest`, `FinalizeAccessRequest`, `RequestFormResponse`
- **Endpoints:**
- `GET /api/workflows/request-forms` — get form structure for target category + request type
- `GET /api/workflows/requests` — list access requests (offset/limit pagination, filter/sort/freeText)
- `GET /api/workflows/requests/{requestId}` — get single request details
- `POST /api/workflows/requests` — submit new access request
- `POST /api/workflows/requests/{requestId}/cancel` — cancel an open request
- `POST /api/workflows/requests/{requestId}/finalize` — approve or reject a request
- **Pagination:** offset/limit (not nextToken); `ListRequests` fetches all pages automatically
- **DI interfaces:** `accessRequestService` in `cmd/interfaces.go`
- **Target category:** `CLOUD_CONSOLE` (hardcoded for v1)
- **Headers:** `Authorization: Bearer {jwt}`, `Content-Type: application/json`

## Testing
- TDD: write `_test.go` before `.go` for every package
- Table-driven tests
Expand All @@ -57,6 +73,15 @@ Custom `SCAAccessService` follows SDK conventions:
- `grant env` — performs elevation, outputs only `export` statements (no human text); usage: `eval $(grant env --provider aws)`; supports `--refresh`
- `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 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)
- `grant request approve [id]` / `grant request reject [id]` — finalize a request; optional `--reason`. Omitting `<id>` in a TTY opens a picker scoped to PENDING requests assigned to you (role=APPROVER)
- Request picker: `internal/ui/request_selector.go` mirrors the role-selector Format/Build/Select quartet; `resolveRequestIDFn` in `cmd/request_picker.go` is injectable for tests. Non-TTY invocation without `<id>` returns `ErrNotInteractive` with a hint to run `grant request list`
- `grant update` — self-update binary via GitHub Releases (`rhysd/go-github-selfupdate`); guards against dev builds
- `--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"`)
Expand All @@ -75,8 +100,8 @@ Custom `SCAAccessService` follows SDK conventions:
- `--output` / `-o` persistent flag on root command: `text` (default) or `json`
- Validated in `PersistentPreRunE`; JSON mode forces `IsTerminalFunc` to return false (non-interactive)
- `cmd/output.go` — `outputFormat` var, `isJSONOutput()`, `writeJSON(w, data)`
- `cmd/output_types.go` — JSON structs: `cloudElevationOutput`, `groupElevationJSON`, `sessionOutput`, `statusOutput`, `revocationOutput`, `favoriteOutput`, `awsCredentialOutput`
- All commands support JSON: root elevation, `env`, `status`, `revoke`, `favorites list`
- `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`
- `config.Favorite` has both `yaml:"..."` and `json:"..."` struct tags

## Cache
Expand Down
1 change: 1 addition & 0 deletions cmd/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,6 @@ func init() {
NewRevokeCommand(),
NewUpdateCommand(),
NewListCommand(),
NewRequestCommand(),
)
}
12 changes: 12 additions & 0 deletions cmd/interfaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"context"

"github.com/aaearon/grant-cli/internal/sca/models"
"github.com/aaearon/grant-cli/internal/workflows"
wfmodels "github.com/aaearon/grant-cli/internal/workflows/models"
"github.com/blang/semver"
sdkmodels "github.com/cyberark/idsec-sdk-golang/pkg/models"
authmodels "github.com/cyberark/idsec-sdk-golang/pkg/models/auth"
Expand Down Expand Up @@ -89,3 +91,13 @@ type unifiedSelector interface {
type selfUpdater interface {
UpdateSelf(current semver.Version, slug string) (*selfupdate.Release, error)
}

// accessRequestService interface for access request operations
type accessRequestService interface {
ListRequests(ctx context.Context, params workflows.ListRequestsParams) ([]wfmodels.AccessRequest, int, error)
GetRequest(ctx context.Context, requestID string) (*wfmodels.AccessRequest, error)
SubmitRequest(ctx context.Context, req *wfmodels.SubmitAccessRequest) (*wfmodels.AccessRequest, error)
CancelRequest(ctx context.Context, requestID string, reason *string) (*wfmodels.AccessRequest, error)
FinalizeRequest(ctx context.Context, requestID string, result string, reason *string) (*wfmodels.AccessRequest, error)
}

29 changes: 29 additions & 0 deletions cmd/output_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,32 @@ type favoriteOutput struct {
Group string `json:"group,omitempty"`
DirectoryID string `json:"directoryId,omitempty"`
}

// accessRequestOutput is the JSON representation of an access request.
type accessRequestOutput struct {
RequestID string `json:"requestId"`
TargetCategory string `json:"targetCategory"`
State string `json:"state"`
Result string `json:"result"`
Priority string `json:"priority,omitempty"`
Reason string `json:"reason,omitempty"`
Provider string `json:"provider,omitempty"`
Target string `json:"target,omitempty"`
Role string `json:"role,omitempty"`
RequestDate string `json:"requestDate,omitempty"`
Timezone string `json:"timezone,omitempty"`
TimeFrom string `json:"timeFrom,omitempty"`
TimeTo string `json:"timeTo,omitempty"`
FinalizationReason string `json:"finalizationReason,omitempty"`
RequestLink string `json:"requestLink,omitempty"`
CreatedBy string `json:"createdBy"`
CreatedAt string `json:"createdAt"`
UpdatedBy string `json:"updatedBy"`
UpdatedAt string `json:"updatedAt"`
}

// accessRequestListOutput is the JSON representation of a list of access requests.
type accessRequestListOutput struct {
Requests []accessRequestOutput `json:"requests"`
TotalCount int `json:"totalCount"`
}
208 changes: 208 additions & 0 deletions cmd/request.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
package cmd

import (
"fmt"
"strings"
"text/tabwriter"
"time"

"github.com/aaearon/grant-cli/internal/workflows"
"github.com/aaearon/grant-cli/internal/workflows/models"
"github.com/cyberark/idsec-sdk-golang/pkg/auth"
authmodels "github.com/cyberark/idsec-sdk-golang/pkg/models/auth"
"github.com/cyberark/idsec-sdk-golang/pkg/profiles"
"github.com/spf13/cobra"
)

// NewRequestCommand creates the "grant request" parent command.
func NewRequestCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "request",
Short: "Manage access requests",
Long: "Create, list, and manage access requests through the approval workflow.",
}

cmd.AddCommand(
newRequestListCommand(nil),
newRequestGetCommand(nil),
newRequestSubmitCommand(nil),
newRequestCancelCommand(nil),
newRequestApproveCommand(nil),
newRequestRejectCommand(nil),
)

return cmd
}

// NewRequestCommandWithDeps creates the request parent with injected dependencies for testing.
func NewRequestCommandWithDeps(reqSvc accessRequestService) *cobra.Command {
cmd := &cobra.Command{
Use: "request",
Short: "Manage access requests",
}

cmd.AddCommand(
newRequestListCommand(reqSvc),
newRequestGetCommand(reqSvc),
newRequestSubmitCommand(reqSvc),
newRequestCancelCommand(reqSvc),
newRequestApproveCommand(reqSvc),
newRequestRejectCommand(reqSvc),
)

return cmd
}

// bootstrapWorkflowsService creates an authenticated AccessRequestService.
func bootstrapWorkflowsService() (*workflows.AccessRequestService, error) {
loader := profiles.DefaultProfilesLoader()
profile, err := (*loader).LoadProfile("grant")
if err != nil {
return nil, fmt.Errorf("failed to load profile: %w", err)
}

ispAuth := auth.NewIdsecISPAuth(true)

_, err = ispAuth.Authenticate(profile, nil, &authmodels.IdsecSecret{Secret: ""}, false, true)
if err != nil {
return nil, fmt.Errorf("authentication failed: %w", err)
}

svc, err := workflows.NewAccessRequestService(ispAuth)
if err != nil {
return nil, fmt.Errorf("failed to create access request service: %w", err)
}

return svc, nil
}

// formatRequestTable writes a table of access requests to the command output.
func formatRequestTable(cmd *cobra.Command, requests []models.AccessRequest) {
w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "ID\tSTATE\tRESULT\tTARGET\tROLE\tPRIORITY\tCREATED BY\tCREATED AT")
for _, r := range requests {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
r.RequestID,
r.RequestState,
r.RequestResult,
r.DetailString("workspaceName"),
r.DetailString("roleName"),
r.DetailString("priority"),
r.CreatedBy,
formatTimestamp(r.CreatedAt),
)
}
w.Flush()
}

// formatRequestDetail writes a detailed view of a single access request.
func formatRequestDetail(cmd *cobra.Command, r *models.AccessRequest) {
w := cmd.OutOrStdout()
fmt.Fprintf(w, "Request ID: %s\n", r.RequestID)
fmt.Fprintf(w, "State: %s\n", r.RequestState)
fmt.Fprintf(w, "Result: %s\n", r.RequestResult)
fmt.Fprintf(w, "Category: %s\n", r.TargetCategory)

if v := r.DetailString("locationType"); v != "" {
fmt.Fprintf(w, "Provider: %s\n", v)
}
if v := r.DetailString("workspaceName"); v != "" {
fmt.Fprintf(w, "Target: %s\n", v)
}
if v := r.DetailString("roleName"); v != "" {
fmt.Fprintf(w, "Role: %s\n", v)
}
if v := r.DetailString("reason"); v != "" {
fmt.Fprintf(w, "Reason: %s\n", v)
}
if v := r.DetailString("priority"); v != "" {
fmt.Fprintf(w, "Priority: %s\n", v)
}
if v := r.DetailString("requestDate"); v != "" {
fmt.Fprintf(w, "Request Date: %s\n", v)
}
if v := r.DetailString("timezone"); v != "" {
fmt.Fprintf(w, "Timezone: %s\n", v)
}
if v := r.DetailString("timeFrom"); v != "" {
fmt.Fprintf(w, "Time From: %s\n", v)
}
if v := r.DetailString("timeTo"); v != "" {
fmt.Fprintf(w, "Time To: %s\n", v)
}

fmt.Fprintf(w, "Created By: %s\n", r.CreatedBy)
fmt.Fprintf(w, "Created At: %s\n", formatTimestamp(r.CreatedAt))
fmt.Fprintf(w, "Updated By: %s\n", r.UpdatedBy)
fmt.Fprintf(w, "Updated At: %s\n", formatTimestamp(r.UpdatedAt))

if r.FinalizationReason != "" {
fmt.Fprintf(w, "Finalization: %s\n", r.FinalizationReason)
}
if r.RequestLink != "" {
fmt.Fprintf(w, "Link: %s\n", r.RequestLink)
}

if len(r.AssignedApprovers) > 0 {
names := make([]string, len(r.AssignedApprovers))
for i, a := range r.AssignedApprovers {
if a.EntityDisplayName != "" {
names[i] = fmt.Sprintf("%s (%s)", a.EntityDisplayName, a.EntityEmail)
} else {
names[i] = a.EntityName
}
}
fmt.Fprintf(w, "Approvers: %s\n", strings.Join(names, ", "))
}

if len(r.RequestApprovers) > 0 {
for _, a := range r.RequestApprovers {
name := a.Approver.EntityDisplayName
if name == "" {
name = a.Approver.EntityName
}
fmt.Fprintf(w, "Acted: %s - %s\n", name, a.Result)
}
}
}

// formatTimestamp strips fractional seconds from a timestamp while preserving
// timezone offset information. RFC3339 timestamps (with Z or ±HH:MM timezone
// offset) are parsed with RFC3339Nano and reformatted as RFC3339 (no subseconds),
// keeping the original offset. Non-RFC3339 timestamps have the fractional-seconds
// portion trimmed if present.
func formatTimestamp(ts string) string {
if t, err := time.Parse(time.RFC3339Nano, ts); err == nil {
return t.Format("2006-01-02T15:04:05Z07:00")
}
// Non-RFC3339 timestamps (e.g. no timezone): trim fractional seconds if present.
if i := strings.IndexByte(ts, '.'); i >= 0 {
return ts[:i]
}
return ts
}

// toAccessRequestOutput converts a model to the JSON output type.
func toAccessRequestOutput(r *models.AccessRequest) accessRequestOutput {
return accessRequestOutput{
RequestID: r.RequestID,
TargetCategory: r.TargetCategory,
State: string(r.RequestState),
Result: string(r.RequestResult),
Priority: r.DetailString("priority"),
Reason: r.DetailString("reason"),
Provider: r.DetailString("locationType"),
Target: r.DetailString("workspaceName"),
Role: r.DetailString("roleName"),
RequestDate: r.DetailString("requestDate"),
Timezone: r.DetailString("timezone"),
TimeFrom: r.DetailString("timeFrom"),
TimeTo: r.DetailString("timeTo"),
FinalizationReason: r.FinalizationReason,
RequestLink: r.RequestLink,
CreatedBy: r.CreatedBy,
CreatedAt: r.CreatedAt,
UpdatedBy: r.UpdatedBy,
UpdatedAt: r.UpdatedAt,
}
}
Loading
Loading