diff --git a/README.md b/README.md index c399b8a0..fe9da3eb 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,23 @@ $ docker pull public.ecr.aws/conductorone/cone: To authenticate to Cone, run `cone login `, passing in the name (such as `example.conductor.one`) or URL (such as `https://example.conductor.one`) of your ConductorOne instance and follow the prompts. +# Previewing task changes + +Before creating an access request or changing a task, add `--dry-run` to print +the resolved tenant, active profile, and planned change without sending a +mutation: + +```shell +cone get --justification "Project work" --dry-run +cone drop --justification "Role change" --dry-run +cone task deny --comment "Duplicate request" --dry-run +``` + +`--dry-run` is available for `get`, `drop`, `task approve`, `task deny`, +`task comment`, `task escalate`, `generate-alias`, and `install-mcp`. It +validates request inputs and may read tenant state, but does not send a tenant +mutation. + # Getting started with Cone Run `cone help` to see the full list of available Cone commands. diff --git a/cmd/cone/config.go b/cmd/cone/config.go index 453f6549..6fc6ed91 100644 --- a/cmd/cone/config.go +++ b/cmd/cone/config.go @@ -42,14 +42,11 @@ func initConfig(cmd *cobra.Command) error { viper.AddConfigPath(defaultConfigPath()) } - err := viper.ReadInConfig() - if err != nil { + if err := viper.ReadInConfig(); err != nil { notFoundErr := &viper.ConfigFileNotFoundError{} - // Explicitly ignore the not found error case - if ok := errors.As(err, notFoundErr); ok { - return nil + if !errors.As(err, notFoundErr) { + return fmt.Errorf("fatal error config file: %w", err) } - return fmt.Errorf("fatal error config file: %w", err) } if err := viper.BindPFlags(cmd.PersistentFlags()); err != nil { diff --git a/cmd/cone/config_test.go b/cmd/cone/config_test.go new file mode 100644 index 00000000..c3e8b3d5 --- /dev/null +++ b/cmd/cone/config_test.go @@ -0,0 +1,27 @@ +package main + +import ( + "testing" + + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +func TestInitConfigBindsFlagsWithoutConfigFile(t *testing.T) { + viper.Reset() + t.Cleanup(viper.Reset) + t.Setenv("CONE_CONFIG_PATH", t.TempDir()) + + cmd := &cobra.Command{} + + cmd.PersistentFlags().Bool("debug", false, "") + if err := initConfig(cmd); err != nil { + t.Fatalf("initConfig: %v", err) + } + if err := cmd.ParseFlags([]string{"--debug"}); err != nil { + t.Fatalf("ParseFlags: %v", err) + } + if !viper.GetBool("debug") { + t.Fatal("--debug was not bound when no config file exists") + } +} diff --git a/cmd/cone/generate_alias.go b/cmd/cone/generate_alias.go index a9105355..7e70553c 100644 --- a/cmd/cone/generate_alias.go +++ b/cmd/cone/generate_alias.go @@ -372,13 +372,12 @@ Filtering options: cmd.Flags().Bool("force", false, "Override ALL existing aliases (including AWS permission sets)") cmd.Flags().Bool("force-non-aws", false, "Override existing aliases for non-AWS entitlements") cmd.Flags().Bool("skip-aws", false, "Skip AWS permission sets entirely") - cmd.Flags().Bool("dry-run", false, "Preview changes without making them") cmd.Flags().StringSlice("resource-type", []string{}, "Only process entitlements with these resource types") cmd.Flags().StringSlice("entitlement-id", []string{}, "Process only these entitlements") cmd.MarkFlagsMutuallyExclusive("force", "force-non-aws") - return cmd + return supportsDryRun(cmd) } // generateAlias generates an alias using the given format and values. diff --git a/cmd/cone/get_drop_task.go b/cmd/cone/get_drop_task.go index fee38e4a..9c74e89b 100644 --- a/cmd/cone/get_drop_task.go +++ b/cmd/cone/get_drop_task.go @@ -2,6 +2,7 @@ package main import ( "context" + "encoding/json" "errors" "fmt" "strings" @@ -66,7 +67,7 @@ func taskCmd(cmd *cobra.Command) *cobra.Command { addForceTaskCreateFlag(cmd) addEntitlementDetailsFlag(cmd) addFormDataFlag(cmd) - return cmd + return supportsDryRun(cmd) } func strToDur(duration string) (*time.Duration, error) { @@ -198,66 +199,105 @@ func getValidDuration(ctx context.Context, v *viper.Viper, maxProvisionTime *tim return &durationInput, nil } -func runGet(cmd *cobra.Command, args []string) error { - _, _, v, err := cmdContext(cmd) +type getTaskInput struct { + appUserID string + justification string + duration string + emergencyAccess bool + requestData map[string]any + formDataRaw string +} + +func buildGetTaskInput(ctx context.Context, c client.C1Client, v *viper.Viper, appID, entitlementID, userID, justification string) (*getTaskInput, error) { + entitlement, err := c.GetEntitlement(ctx, appID, entitlementID) if err != nil { - return err + return nil, err } - return runTask(cmd, args, func(c client.C1Client, ctx context.Context, appId string, entitlementId string, userId string, justification string) (*shared.Task, error) { - duration := v.GetString(durationFlag) - emergencyAccess := v.GetBool(emergencyAccessFlag) + justification, err = getValidJustification(ctx, v, justification) + if err != nil { + return nil, err + } - entitlement, err := c.GetEntitlement(ctx, appId, entitlementId) - if err != nil { - return nil, err - } + durationStr := client.StringFromPtr(entitlement.DurationGrant) + var maxProvision *time.Duration + if maxProvisionTime, err := time.ParseDuration(durationStr); err == nil { + maxProvision = &maxProvisionTime + } - justification, err = getValidJustification(ctx, v, justification) - if err != nil { - return nil, err - } + validDuration, err := getValidDuration(ctx, v, maxProvision, v.GetString(durationFlag)) + if err != nil { + return nil, err + } - // entitlement.DurationGrant is assumed to be nil or a non-zero parsable string - durationStr := client.StringFromPtr(entitlement.DurationGrant) - var maxProvision *time.Duration - maxProvisionTime, err := time.ParseDuration(durationStr) - if err == nil { - maxProvision = &maxProvisionTime - } + appUserID, err := getAppUserId(ctx, c, v, appID, userID) + if err != nil { + return nil, err + } + + duration := "" + if validDuration != nil { + duration = fmt.Sprintf("%ds", int(validDuration.Seconds())) + } - validDuration, err := getValidDuration(ctx, v, maxProvision, duration) + formDataRaw := v.GetString(formDataFlag) + var requestData map[string]any + if formDataRaw != "" { + requestData, err = parseFormDataFlag(formDataRaw) if err != nil { return nil, err } + } + + return &getTaskInput{ + appUserID: appUserID, + justification: justification, + duration: duration, + emergencyAccess: v.GetBool(emergencyAccessFlag), + requestData: requestData, + formDataRaw: formDataRaw, + }, nil +} - appUserId, err := getAppUserId(ctx, c, v, appId, userId) +func (r *getTaskInput) previewDetails(userID string) []string { + details := []string{ + nonEmptyDetail("Requested for user", userID), + nonEmptyDetail("Justification", r.justification), + nonEmptyDetail("App user", r.appUserID), + nonEmptyDetail("Requested duration", r.duration), + } + if r.emergencyAccess { + details = append(details, "Emergency access: true") + } + if len(r.requestData) > 0 { + requestData, _ := json.Marshal(r.requestData) + details = append(details, "Request data: "+string(requestData)) + } + return details +} + +func runGet(cmd *cobra.Command, args []string) error { + _, _, v, err := cmdContext(cmd) + if err != nil { + return err + } + + return runTask(cmd, args, func(c client.C1Client, ctx context.Context, appID string, entitlementID string, userID string, justification string) (*shared.Task, error) { + input, err := buildGetTaskInput(ctx, c, v, appID, entitlementID, userID, justification) if err != nil { return nil, err } - apiDuration := "" - if validDuration != nil { - // API expects seconds formated like "1s" - seconds := int(validDuration.Seconds()) - apiDuration = fmt.Sprintf("%ds", seconds) + if dryRunEnabled(cmd) { + previewMutations(cmd, c, v, mutation{ + Action: "Create access request", + Target: fmt.Sprintf("app %s, entitlement %s", appID, entitlementID), + Details: input.previewDetails(userID), + }) + return nil, nil } - // Collect form data if provided via flags - var requestData map[string]any - formDataFlagValue := v.GetString(formDataFlag) - - // Only send requestData if user explicitly provided form data - if formDataFlagValue != "" { - var err error - requestData, err = parseFormDataFlag(formDataFlagValue) - if err != nil { - return nil, err - } - } - - // Create the task with initial form data (if any) - accessRequest, err := c.CreateGrantTask(ctx, appId, entitlementId, userId, appUserId, justification, apiDuration, emergencyAccess, requestData) + accessRequest, err := c.CreateGrantTask(ctx, appID, entitlementID, userID, input.appUserID, input.justification, input.duration, input.emergencyAccess, input.requestData) if err != nil { errorBody := err.Error() if strings.Contains(errorBody, durationErrorMessage) { @@ -269,34 +309,23 @@ func runGet(cmd *cobra.Command, args []string) error { } task := accessRequest.TaskView.Task - - // Check if the task has form fields hasFormFields := len(formFields(task.Form)) > 0 - if hasFormFields { - // Collect form fields if not already provided - if len(requestData) == 0 { + if len(input.requestData) == 0 { collectedData, err := collectFormFields(ctx, v, task.Form) if err != nil { return nil, fmt.Errorf("error collecting form fields: %w", err) } if len(collectedData) > 0 { - // Update the task with the collected form data taskID := client.StringFromPtr(task.ID) - _, err := c.UpdateTaskRequestData(ctx, taskID, collectedData) - if err != nil { + if _, err := c.UpdateTaskRequestData(ctx, taskID, collectedData); err != nil { return nil, fmt.Errorf("error updating task with form data: %w", err) } } - } else { - // Validate that provided form data matches the form structure - if err := validateFormData(task.Form, requestData); err != nil { - pterm.Warning.Printf("Form data validation warning: %v\n", err) - } + } else if err := validateFormData(task.Form, input.requestData); err != nil { + pterm.Warning.Printf("Form data validation warning: %v\n", err) } - } else if formDataFlagValue != "" { - // Form data was provided but task doesn't have form fields - // The data was already sent on task creation and will be ignored by the API + } else if input.formDataRaw != "" { logging.Debugf("Form data was provided via --form-data flag, but this entitlement does not require form fields. The data was sent but may be ignored by the API.") } @@ -305,8 +334,25 @@ func runGet(cmd *cobra.Command, args []string) error { } func runDrop(cmd *cobra.Command, args []string) error { - return runTask(cmd, args, func(c client.C1Client, ctx context.Context, appId string, entitlementId string, userId string, justification string) (*shared.Task, error) { - accessRequest, err := c.CreateRevokeTask(ctx, appId, entitlementId, userId, justification) + _, _, v, err := cmdContext(cmd) + if err != nil { + return err + } + + return runTask(cmd, args, func(c client.C1Client, ctx context.Context, appID string, entitlementID string, userID string, justification string) (*shared.Task, error) { + if dryRunEnabled(cmd) { + previewMutations(cmd, c, v, mutation{ + Action: "Create access revocation request", + Target: fmt.Sprintf("app %s, entitlement %s", appID, entitlementID), + Details: []string{ + nonEmptyDetail("Requested for user", userID), + nonEmptyDetail("Justification", justification), + }, + }) + return nil, nil + } + + accessRequest, err := c.CreateRevokeTask(ctx, appID, entitlementID, userID, justification) if err != nil { return nil, err } @@ -391,11 +437,13 @@ func runTask( return nil } } - task, err := run(c, ctx, appId, entitlementId, client.StringFromPtr(resp.UserID), justification) if err != nil { return err } + if task == nil { + return nil + } if v.GetBool(extraDetailsFlag) { err = printExtraTaskDetails(ctx, v, c, appId, entitlementId) diff --git a/cmd/cone/install_mcp.go b/cmd/cone/install_mcp.go index 4984ced6..886e7752 100644 --- a/cmd/cone/install_mcp.go +++ b/cmd/cone/install_mcp.go @@ -24,10 +24,9 @@ Requires 'cone login ' to have been run first.`, } cmd.Flags().String("scope", "user", "Claude Code scope: user or project") - cmd.Flags().Bool("dry-run", false, "Print what would happen without doing it") cmd.Flags().Bool("manual", false, "Print config snippet instead of running claude CLI") - return cmd + return supportsDryRun(cmd) } func installMCPRun(cmd *cobra.Command, _ []string) error { diff --git a/cmd/cone/main.go b/cmd/cone/main.go index 89de04f8..08af0028 100644 --- a/cmd/cone/main.go +++ b/cmd/cone/main.go @@ -43,6 +43,9 @@ func runCli(ctx context.Context) int { Short: "Cone interacts with the ConductorOne API to manage access to entitlements.", Version: version, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + if dryRunEnabled(cmd) && !dryRunSupported(cmd) { + return fmt.Errorf("--dry-run is not supported by %q", cmd.CommandPath()) + } cmd.SetContext(ctx) return nil }, @@ -58,6 +61,7 @@ func runCli(ctx context.Context) int { cliCmd.PersistentFlags().StringP("output", "o", "table", "Output format. Valid values: table, json, json-pretty, wide.") cliCmd.PersistentFlags().Bool("debug", false, "Enable HTTP debug logging") cliCmd.PersistentFlags().String("log-level", "", "Set log level (debug, info, warn, error)") + cliCmd.PersistentFlags().Bool(dryRunFlag, false, "Preview supported mutations without sending them") err := initConfig(cliCmd) if err != nil { diff --git a/cmd/cone/mutation.go b/cmd/cone/mutation.go new file mode 100644 index 00000000..cbb8afc6 --- /dev/null +++ b/cmd/cone/mutation.go @@ -0,0 +1,70 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +const dryRunFlag = "dry-run" + +const dryRunAnnotation = "cone.conductorone.com/dry-run" + +// mutation describes a change that a command would make. It stays deliberately +// human-readable: cone's preview is a safety check, not a transport trace. +type mutation struct { + Action string + Target string + Details []string +} + +type tenantTarget interface { + BaseURL() string +} + +func supportsDryRun(cmd *cobra.Command) *cobra.Command { + if cmd.Annotations == nil { + cmd.Annotations = make(map[string]string) + } + cmd.Annotations[dryRunAnnotation] = "true" + return cmd +} + +func dryRunSupported(cmd *cobra.Command) bool { + return cmd.Annotations[dryRunAnnotation] == "true" +} + +func dryRunEnabled(cmd *cobra.Command) bool { + enabled, _ := cmd.Flags().GetBool(dryRunFlag) + return enabled +} + +// previewMutations prints the resolved tenant, profile, and planned mutations, +// then reports true when the caller must return without making a change. +func previewMutations(cmd *cobra.Command, c tenantTarget, v *viper.Viper, changes ...mutation) bool { + if !dryRunEnabled(cmd) { + return false + } + + out := cmd.OutOrStdout() + _, _ = fmt.Fprintln(out, "Dry run — no changes will be made.") + _, _ = fmt.Fprintf(out, "\nTenant: %s\nProfile: %s\n\nPlan:\n", c.BaseURL(), v.GetString("profile")) + for _, change := range changes { + _, _ = fmt.Fprintf(out, " %s: %s\n", change.Action, change.Target) + for _, detail := range change.Details { + if detail != "" { + _, _ = fmt.Fprintf(out, " %s\n", detail) + } + } + } + return true +} + +func nonEmptyDetail(label, value string) string { + if strings.TrimSpace(value) == "" { + return "" + } + return label + ": " + value +} diff --git a/cmd/cone/mutation_test.go b/cmd/cone/mutation_test.go new file mode 100644 index 00000000..6b2041d1 --- /dev/null +++ b/cmd/cone/mutation_test.go @@ -0,0 +1,73 @@ +package main + +import ( + "bytes" + "testing" + + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +type previewClient struct{ baseURL string } + +func (c previewClient) BaseURL() string { return c.baseURL } + +func TestPreviewMutations(t *testing.T) { + cmd := &cobra.Command{} + cmd.PersistentFlags().Bool(dryRunFlag, false, "") + if err := cmd.ParseFlags([]string{"--dry-run"}); err != nil { + t.Fatalf("ParseFlags: %v", err) + } + out := &bytes.Buffer{} + cmd.SetOut(out) + v := viper.New() + v.Set("profile", "admin") + + if !previewMutations(cmd, previewClient{baseURL: "https://tenant.example"}, v, mutation{ + Action: "Approve task", + Target: "task T-1", + Details: []string{"Comment: reviewed"}, + }) { + t.Fatal("previewMutations = false, want true when --dry-run is set") + } + want := "Dry run — no changes will be made.\n\nTenant: https://tenant.example\nProfile: admin\n\nPlan:\n Approve task: task T-1\n Comment: reviewed\n" + if got := out.String(); got != want { + t.Fatalf("preview output = %q, want %q", got, want) + } +} + +func TestPreviewMutationsDisabled(t *testing.T) { + cmd := &cobra.Command{} + out := &bytes.Buffer{} + cmd.SetOut(out) + v := viper.New() + if previewMutations(cmd, previewClient{baseURL: "https://tenant.example"}, v, mutation{}) { + t.Fatal("previewMutations = true, want false without --dry-run") + } + if got := out.String(); got != "" { + t.Fatalf("preview wrote %q without --dry-run", got) + } +} + +func TestDryRunSupportIsScopedToPreviewCommands(t *testing.T) { + for _, cmd := range []*cobra.Command{ + getCmd(), + dropCmd(), + approveTasksCmd(), + denyTasksCmd(), + tasksCommentCmd(), + escalateTasksCmd(), + generateAliasCmd(), + installMCPCmd(), + } { + if !dryRunSupported(cmd) { + t.Fatalf("%s does not support --dry-run", cmd.CommandPath()) + } + } + + for _, cmd := range []*cobra.Command{tasksCmd(), getTasksCmd(), secretCmd()} { + if dryRunSupported(cmd) { + t.Fatalf("%s unexpectedly supports --dry-run", cmd.CommandPath()) + } + } +} diff --git a/cmd/cone/task_approve_deny.go b/cmd/cone/task_approve_deny.go index c86f1d0b..d2024ae8 100644 --- a/cmd/cone/task_approve_deny.go +++ b/cmd/cone/task_approve_deny.go @@ -20,7 +20,7 @@ func approveTasksCmd() *cobra.Command { addCommentFlag(cmd) addWaitFlag(cmd) - return cmd + return supportsDryRun(cmd) } func denyTasksCmd() *cobra.Command { @@ -32,11 +32,11 @@ func denyTasksCmd() *cobra.Command { addCommentFlag(cmd) addWaitFlag(cmd) - return cmd + return supportsDryRun(cmd) } func runApproveTasks(cmd *cobra.Command, args []string) error { - return runApproveDeny(cmd, args, func(c client.C1Client, ctx context.Context, taskId string, comment string, policyId string) (*shared.Task, error) { + return runApproveDeny(cmd, args, "Approve task", func(c client.C1Client, ctx context.Context, taskId string, comment string, policyId string) (*shared.Task, error) { approveResp, err := c.ApproveTask(ctx, taskId, comment, policyId) if err != nil { return nil, err @@ -46,7 +46,7 @@ func runApproveTasks(cmd *cobra.Command, args []string) error { } func runDenyTasks(cmd *cobra.Command, args []string) error { - return runApproveDeny(cmd, args, func(c client.C1Client, ctx context.Context, taskId string, comment string, policyId string) (*shared.Task, error) { + return runApproveDeny(cmd, args, "Deny task", func(c client.C1Client, ctx context.Context, taskId string, comment string, policyId string) (*shared.Task, error) { approveResp, err := c.DenyTask(ctx, taskId, comment, policyId) if err != nil { return nil, err @@ -58,6 +58,7 @@ func runDenyTasks(cmd *cobra.Command, args []string) error { func runApproveDeny( cmd *cobra.Command, args []string, + action string, run func(c client.C1Client, ctx context.Context, taskId string, comment string, policyId string) (*shared.Task, error), ) error { ctx, c, v, err := cmdContext(cmd) @@ -80,6 +81,13 @@ func runApproveDeny( if taskResp.TaskView.Task.Policy == nil || taskResp.TaskView.Task.Policy.Current == nil { return errors.New("task does not have a current policy step id and cannot be approved or denied") } + if previewMutations(cmd, c, v, mutation{ + Action: action, + Target: "task " + taskId, + Details: []string{nonEmptyDetail("Comment", comment)}, + }) { + return nil + } task, err := run(c, ctx, taskId, comment, client.StringFromPtr(taskResp.TaskView.Task.Policy.Current.ID)) if err != nil { diff --git a/cmd/cone/task_comment.go b/cmd/cone/task_comment.go index 07a793ff..0f3fe9c6 100644 --- a/cmd/cone/task_comment.go +++ b/cmd/cone/task_comment.go @@ -16,7 +16,7 @@ func tasksCommentCmd() *cobra.Command { RunE: tasksCommentRun, } - return cmd + return supportsDryRun(cmd) } func tasksCommentRun(cmd *cobra.Command, args []string) error { @@ -31,6 +31,13 @@ func tasksCommentRun(cmd *cobra.Command, args []string) error { taskId := args[0] comment := args[1] + if previewMutations(cmd, c, v, mutation{ + Action: "Comment on task", + Target: "task " + taskId, + Details: []string{nonEmptyDetail("Comment", comment)}, + }) { + return nil + } userResp, err := c.CommentOnTask(ctx, taskId, comment) if err != nil { diff --git a/cmd/cone/task_escalate.go b/cmd/cone/task_escalate.go index a582d52c..6bf16124 100644 --- a/cmd/cone/task_escalate.go +++ b/cmd/cone/task_escalate.go @@ -15,7 +15,7 @@ func escalateTasksCmd() *cobra.Command { Short: "Escalate an access request task to emergency access", RunE: runEscalateTasks, } - return cmd + return supportsDryRun(cmd) } func runEscalateTasks(cmd *cobra.Command, args []string) error { @@ -29,6 +29,12 @@ func runEscalateTasks(cmd *cobra.Command, args []string) error { } taskId := args[0] + if previewMutations(cmd, c, v, mutation{ + Action: "Escalate task to emergency access", + Target: "task " + taskId, + }) { + return nil + } userResp, err := c.EscalateTask(ctx, taskId) if err != nil {