-
Notifications
You must be signed in to change notification settings - Fork 1
feat: add access request workflow commands #39
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 1 commit
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
f85e8d7
feat: add access request workflow commands (grant request)
aaearon 39b4c42
feat: harden grant request submit interactive UX
aaearon da5bacd
feat: default start time to now, end time to start + 1 hour
aaearon 8296314
fix: address reviewer feedback on ListRequests pagination and formatT…
Copilot c0390c5
refactor: workspace-based target selection for access requests
aaearon 8f471ed
fix: case-insensitive workspace type labels in selector
aaearon a0d2e1b
feat: interactive role selector for grant request submit
aaearon 426af27
feat: interactive request picker for cancel/approve/reject/get
aaearon 54eb3b3
fix: bootstrap after TTY check; parse timestamps for correct sort order
aaearon File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -12,5 +12,6 @@ func init() { | |
| NewRevokeCommand(), | ||
| NewUpdateCommand(), | ||
| NewListCommand(), | ||
| NewRequestCommand(), | ||
| ) | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,199 @@ | ||
| package cmd | ||
|
|
||
| import ( | ||
| "fmt" | ||
| "strings" | ||
| "text/tabwriter" | ||
|
|
||
| "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 truncates a timestamp to just the date+time portion (no microseconds). | ||
| func formatTimestamp(ts string) string { | ||
| if len(ts) > 19 { | ||
| return ts[:19] | ||
| } | ||
| 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, | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| package cmd | ||
|
|
||
| import ( | ||
| "fmt" | ||
|
|
||
| "github.com/spf13/cobra" | ||
| ) | ||
|
|
||
| func newRequestCancelCommand(svc accessRequestService) *cobra.Command { | ||
| cmd := &cobra.Command{ | ||
| Use: "cancel <requestId>", | ||
| Short: "Cancel an open access request", | ||
| Args: cobra.ExactArgs(1), | ||
| RunE: func(cmd *cobra.Command, args []string) error { | ||
| if svc == nil { | ||
| bootstrapped, err := bootstrapWorkflowsService() | ||
| if err != nil { | ||
| return err | ||
| } | ||
| svc = bootstrapped | ||
| } | ||
| return runRequestCancel(cmd, args[0], svc) | ||
| }, | ||
| } | ||
|
|
||
| cmd.Flags().String("reason", "", "Reason for cancellation") | ||
|
|
||
| return cmd | ||
| } | ||
|
|
||
| func runRequestCancel(cmd *cobra.Command, requestID string, svc accessRequestService) error { | ||
| ctx := cmd.Context() | ||
|
|
||
| var reason *string | ||
| if v, _ := cmd.Flags().GetString("reason"); v != "" { | ||
| reason = &v | ||
| } | ||
|
|
||
| log.Info("Canceling access request %s", requestID) | ||
|
|
||
| result, err := svc.CancelRequest(ctx, requestID, reason) | ||
| if err != nil { | ||
| return fmt.Errorf("failed to cancel request: %w", err) | ||
| } | ||
|
|
||
| if isJSONOutput() { | ||
| return writeJSON(cmd.OutOrStdout(), toAccessRequestOutput(result)) | ||
| } | ||
|
|
||
| fmt.Fprintf(cmd.OutOrStdout(), "Request %s canceled.\n", result.RequestID) | ||
| fmt.Fprintf(cmd.OutOrStdout(), "Result: %s\n", result.RequestResult) | ||
| return nil | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
formatTimestamp truncates the input to 19 characters, which will also strip timezone/offset information if the API ever returns RFC3339 timestamps with a trailing "Z" or "+/-HH:MM". If the goal is only to drop fractional seconds, consider parsing RFC3339/RFC3339Nano and re-formatting without subseconds while preserving the timezone, or only trimming the fractional part when present.