Skip to content
Open
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
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,23 @@ $ docker pull public.ecr.aws/conductorone/cone:<version>

To authenticate to Cone, run `cone login <tenant-name or tenant-url>`, 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 <entitlement-alias> --justification "Project work" --dry-run
cone drop <entitlement-alias> --justification "Role change" --dry-run
cone task deny <task-id> --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.
Expand Down
9 changes: 3 additions & 6 deletions cmd/cone/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
27 changes: 27 additions & 0 deletions cmd/cone/config_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
3 changes: 1 addition & 2 deletions cmd/cone/generate_alias.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
174 changes: 111 additions & 63 deletions cmd/cone/get_drop_task.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
}
Comment on lines +291 to 298

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Suggestion: previewMutations is documented as returning "true when the caller must return without making a change", and the other five call sites use if previewMutations(...) { return nil }. Here (and in runDrop at line 343) the return value is discarded and the gate is duplicated as a separate dryRunEnabled(cmd) check, so any future change to previewMutations' gating would silently diverge in exactly the two commands that create tasks. if previewMutations(...) { return nil, nil } keeps a single source of truth.


// 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) {
Expand All @@ -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.")
}

Expand All @@ -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
}
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 1 addition & 2 deletions cmd/cone/install_mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,9 @@ Requires 'cone login <tenant>' 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 {
Expand Down
4 changes: 4 additions & 0 deletions cmd/cone/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
Expand All @@ -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")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Suggestion: as a root persistent flag, --dry-run now shows up under "Global Flags" in every command's help (cone login --help, cone secret create --help, …) while PersistentPreRunE rejects it at runtime for all but eight commands. Consider cmd.PersistentFlags().MarkHidden plus per-command re-exposure, or at least mentioning the supported command list in the flag usage string, so the help output matches what actually works.


err := initConfig(cliCmd)
if err != nil {
Expand Down
Loading
Loading