diff --git a/.golangci.yml b/.golangci.yml index 4ab47e29..0bd596e6 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -47,8 +47,8 @@ linters-settings: - name: var-naming arguments: [["ID", "URL", "HTTP", "API"], []] - tenv: - all: true + usetesting: + allow: [] varcheck: exported-fields: false # this appears to improperly detect exported variables as unused when they are used from a package with the same name @@ -88,7 +88,7 @@ linters: - nosprintfhostport # Checks for misuse of Sprintf to construct a host with port in a URL. - predeclared # find code that shadows one of Go's predeclared identifiers - revive # Fast, configurable, extensible, flexible, and beautiful linter for Go. Drop-in replacement of golint. - - tenv # tenv is analyzer that detects using os.Setenv instead of t.Setenv since Go1.17 + - usetesting # usetesting is analyzer that detects using os.Setenv instead of t.Setenv since Go1.17 - tparallel # tparallel detects inappropriate usage of t.Parallel() method in your Go test codes - unconvert # Remove unnecessary type conversions - usestdlibvars # detect the possibility to use variables/constants from the Go standard library diff --git a/cmd/cone/aws_credentials.go b/cmd/cone/aws_credentials.go new file mode 100644 index 00000000..f9e4033c --- /dev/null +++ b/cmd/cone/aws_credentials.go @@ -0,0 +1,371 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/conductorone/conductorone-sdk-go/pkg/models/shared" + "github.com/conductorone/cone/pkg/client" + "github.com/pterm/pterm" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// AWSCredentials represents the structure of AWS temporary credentials. +// that will be output in JSON format. +type AWSCredentials struct { + Version int `json:"Version"` + AccessKeyID string `json:"AccessKeyId"` + SecretAccessKey string `json:"SecretAccessKey"` + SessionToken string `json:"SessionToken"` + Expiration string `json:"Expiration"` +} + +// RoleCredentialsResponse represents the response from AWS SSO get-role-credentials API. +type RoleCredentialsResponse struct { + RoleCredentials struct { + AccessKeyID string `json:"accessKeyId"` + SecretAccessKey string `json:"secretAccessKey"` + SessionToken string `json:"sessionToken"` + Expiration int64 `json:"expiration"` + } `json:"roleCredentials"` +} + +// awsCredentialsCmd creates the cobra command for getting AWS credentials. +// Usage: cone aws-credentials . +func awsCredentialsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "aws-credentials ", + Short: "Get AWS credentials for a profile", + RunE: awsCredentialsRun, + } + return cmd +} + +// awsCredentialsRun is the main function that handles getting AWS credentials. +// It verifies access, reads AWS config, and retrieves temporary credentials. +func awsCredentialsRun(cmd *cobra.Command, args []string) error { + ctx, _, _, err := cmdContext(cmd) + if err != nil { + return err + } + + if err := validateArgLenth(1, args, cmd); err != nil { + return err + } + + profileName := args[0] + + // Check if user has access to this permission set + hasAccess, err := checkC1Access(ctx, profileName) + if err != nil { + return fmt.Errorf("failed to check C1 access: %w", err) + } + + if !hasAccess { + fmt.Fprintf(os.Stderr, "You do not have access to this permission set.\n") + fmt.Fprintf(os.Stderr, "To request access, run: cone get %s --wait\n", profileName) + fmt.Fprintf(os.Stderr, "This will allow you to specify justification and duration for your access request.\n") + return fmt.Errorf("access denied: please request access using 'cone get %s --wait'", profileName) + } + + awsConfigDir := filepath.Join(os.Getenv("HOME"), ".aws") + configPath := filepath.Join(awsConfigDir, "config") + + configContent, err := os.ReadFile(configPath) + if err != nil { + return fmt.Errorf("failed to read AWS config: %w", err) + } + + configStr := string(configContent) + profileSection := fmt.Sprintf("[profile %s]", profileName) + profileConfig := extractProfileConfig(configStr, profileSection) + + accountID := extractConeSSOAccountID(profileConfig) + roleName := extractConeSSORoleName(profileConfig) + ssoStartURL := extractConeSSOStartURL(profileConfig) + ssoRegion := extractConeSSORegion(profileConfig) + + if accountID == "" || roleName == "" || ssoStartURL == "" { + return fmt.Errorf("missing required SSO configuration for profile %s", profileName) + } + + if err := verifySSOSession(ssoStartURL, ssoRegion); err != nil { + return fmt.Errorf("SSO session verification failed: %w", err) + } + + creds, err := getTemporaryCredentials(accountID, roleName, ssoRegion) + if err != nil { + return fmt.Errorf("failed to get temporary credentials: %w", err) + } + + output := AWSCredentials{ + Version: 1, + AccessKeyID: creds.AccessKeyID, + SecretAccessKey: creds.SecretAccessKey, + SessionToken: creds.SessionToken, + Expiration: creds.Expiration, + } + + jsonOutput, err := json.Marshal(output) + if err != nil { + return fmt.Errorf("failed to marshal credentials: %w", err) + } + + pterm.Println(string(jsonOutput)) + return nil +} + +// extractProfileConfig extracts the configuration section for a specific AWS profile. +// from the AWS config file. +func extractProfileConfig(config, profileSection string) string { + lines := strings.Split(config, "\n") + var profileLines []string + inProfile := false + + for _, line := range lines { + if line == profileSection { + inProfile = true + continue + } + if inProfile { + if strings.HasPrefix(line, "[") { + break + } + profileLines = append(profileLines, line) + } + } + + return strings.Join(profileLines, "\n") +} + +// extractConeSSOAccountID extracts the AWS account ID from the profile configuration. +// This is used to identify which AWS account to get credentials for. +func extractConeSSOAccountID(profileConfig string) string { + for _, line := range strings.Split(profileConfig, "\n") { + if strings.HasPrefix(line, "cone_sso_account_id") { + parts := strings.Split(line, "=") + if len(parts) == 2 { + return strings.TrimSpace(parts[1]) + } + } + } + return "" +} + +// extractConeSSORoleName extracts the AWS role name from the profile configuration. +// This is the role that will be assumed when getting credentials. +func extractConeSSORoleName(profileConfig string) string { + for _, line := range strings.Split(profileConfig, "\n") { + if strings.HasPrefix(line, "cone_sso_role_name") { + parts := strings.Split(line, "=") + if len(parts) == 2 { + return strings.TrimSpace(parts[1]) + } + } + } + return "" +} + +// extractConeSSOStartURL extracts the AWS SSO start URL from the profile configuration. +// This is the URL used to initiate the SSO login process. +func extractConeSSOStartURL(profileConfig string) string { + for _, line := range strings.Split(profileConfig, "\n") { + if strings.HasPrefix(line, "cone_sso_start_url") { + parts := strings.Split(line, "=") + if len(parts) == 2 { + return strings.TrimSpace(parts[1]) + } + } + } + return "" +} + +// extractConeSSORegion extracts the AWS region from the profile configuration. +// Defaults to us-east-1 if not specified. +func extractConeSSORegion(profileConfig string) string { + for _, line := range strings.Split(profileConfig, "\n") { + if strings.HasPrefix(line, "cone_sso_region") { + parts := strings.Split(line, "=") + if len(parts) == 2 { + return strings.TrimSpace(parts[1]) + } + } + } + return "us-east-1" // Default region +} + +// getSSOToken retrieves a valid SSO token from the AWS SSO cache. +// It looks for a token that matches the given start URL and hasn't expired. +func getSSOToken(ssoStartURL string) (string, error) { + cacheDir := filepath.Join(os.Getenv("HOME"), ".aws", "sso", "cache") + files, err := os.ReadDir(cacheDir) + if err != nil { + return "", fmt.Errorf("failed to read SSO cache directory: %w", err) + } + + for _, file := range files { + if !file.IsDir() && strings.HasSuffix(file.Name(), ".json") { + content, err := os.ReadFile(filepath.Join(cacheDir, file.Name())) + if err != nil { + continue + } + + var cache struct { + AccessToken string `json:"accessToken"` + ExpiresAt time.Time `json:"expiresAt"` + StartURL string `json:"startUrl"` + } + if err := json.Unmarshal(content, &cache); err != nil { + continue + } + + if cache.StartURL == ssoStartURL && cache.ExpiresAt.After(time.Now()) { + return cache.AccessToken, nil + } + } + } + + return "", fmt.Errorf("no valid SSO token found for %s", ssoStartURL) +} + +// getTemporaryCredentials retrieves temporary AWS credentials using AWS SSO. +// It handles the SSO login process if needed and returns the credentials. +func getTemporaryCredentials(accountID, roleName, ssoRegion string) (*AWSCredentials, error) { + ssoStartURL := viper.GetString("aws_sso_start_url") + if ssoStartURL == "" { + return nil, fmt.Errorf("missing AWS SSO URL. Please run 'cone config-aws set-sso-url ' first") + } + + token, err := getSSOToken(ssoStartURL) + if err != nil { + loginCmd := exec.Command("aws", "sso", "login", "--sso-session", "cone-sso") + loginCmd.Stdout = nil + loginCmd.Stderr = nil + _ = loginCmd.Run() // ignore output, just try to login + token, err = getSSOToken(ssoStartURL) + if err != nil { + return nil, fmt.Errorf("failed to get token after login: %w", err) + } + } + + cmd := exec.Command("aws", "sso", "get-role-credentials", + "--access-token", token, + "--account-id", accountID, + "--role-name", roleName, + "--region", ssoRegion, + "--output", "json") + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err = cmd.Run() + if err != nil { + if strings.Contains(stderr.String(), "AccessDenied") { + return nil, fmt.Errorf("access denied: you don't have access to this role") + } + return nil, fmt.Errorf("failed to get credentials: %w\nCommand output: %s\nError output: %s", + err, stdout.String(), stderr.String()) + } + + var response RoleCredentialsResponse + if err := json.Unmarshal(stdout.Bytes(), &response); err != nil { + return nil, fmt.Errorf("failed to parse credentials: %w\nCommand output: %s", + err, stdout.String()) + } + + creds := &AWSCredentials{ + Version: 1, + AccessKeyID: response.RoleCredentials.AccessKeyID, + SecretAccessKey: response.RoleCredentials.SecretAccessKey, + SessionToken: response.RoleCredentials.SessionToken, + Expiration: time.UnixMilli(response.RoleCredentials.Expiration).Format(time.RFC3339), + } + + return creds, nil +} + +// checkC1Access verifies if the user has access to the requested AWS profile. +// by checking their grants in ConductorOne. +func checkC1Access(ctx context.Context, profileName string) (bool, error) { + // Create a temporary command with the necessary flags for cmdContext + cmd := &cobra.Command{ + Use: "temp", + } + cmd.PersistentFlags().StringP("profile", "p", "default", "The config profile to use.") + cmd.PersistentFlags().BoolP("non-interactive", "i", false, "Disable prompts.") + cmd.PersistentFlags().String("client-id", "", "Client ID") + cmd.PersistentFlags().String("client-secret", "", "Client secret") + cmd.PersistentFlags().String("api-endpoint", "", "Override the API endpoint") + cmd.PersistentFlags().StringP("output", "o", "table", "Output format. Valid values: table, json, json-pretty, wide.") + cmd.PersistentFlags().Bool("debug", false, "Enable debug logging") + cmd.SetContext(ctx) + _, c1Client, _, err := cmdContext(cmd) + if err != nil { + return false, fmt.Errorf("failed to get C1 client: %w", err) + } + + // Get current user ID + userIntro, err := c1Client.AuthIntrospect(ctx) + if err != nil { + return false, fmt.Errorf("failed to get user info: %w", err) + } + userID := client.StringFromPtr(userIntro.UserID) + + // Search for the entitlement by alias (profile name) + entitlements, err := c1Client.SearchEntitlements(ctx, &client.SearchEntitlementsFilter{ + EntitlementAlias: profileName, + GrantedStatus: shared.GrantedStatusAll, + }) + if err != nil { + return false, fmt.Errorf("failed to search entitlements: %w", err) + } + + if len(entitlements) == 0 { + return false, fmt.Errorf("no entitlements found matching profile name: %s", profileName) + } + + // Check grants for each matching entitlement + for _, entitlement := range entitlements { + grants, err := c1Client.GetGrantsForIdentity(ctx, client.StringFromPtr(entitlement.Entitlement.AppID), client.StringFromPtr(entitlement.Entitlement.ID), userID) + if err != nil { + return false, fmt.Errorf("failed to check grants: %w", err) + } + + // Check if user has an active grant + for _, grant := range grants { + if grant.CreatedAt != nil && grant.DeletedAt == nil { + return true, nil + } + } + } + + return false, nil +} + +// verifySSOSession checks if the AWS SSO session is properly configured. +// in the AWS config file. +func verifySSOSession(ssoStartURL, ssoRegion string) error { + awsConfigDir := filepath.Join(os.Getenv("HOME"), ".aws") + configPath := filepath.Join(awsConfigDir, "config") + configContent, err := os.ReadFile(configPath) + if err != nil { + return fmt.Errorf("failed to read AWS config: %w", err) + } + + configStr := string(configContent) + sessionSection := "[sso-session cone-sso]" + if !strings.Contains(configStr, sessionSection) { + return fmt.Errorf("SSO session configuration not found in AWS config") + } + + return nil +} diff --git a/cmd/cone/cmd.go b/cmd/cone/cmd.go index 0f363248..3c1a91f6 100644 --- a/cmd/cone/cmd.go +++ b/cmd/cone/cmd.go @@ -44,3 +44,37 @@ func validateArgLenth(expectedCount int, args []string, cmd *cobra.Command) erro return fmt.Errorf("expected %d arguments, got %d\n%s", expectedCount, len(args), cmd.UsageString()) } + +func rootCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "cone", + Short: "Cone interacts with the ConductorOne API to manage access to entitlements.", + SilenceUsage: true, + SilenceErrors: true, + } + + cmd.PersistentFlags().StringP("profile", "p", "default", "The config profile to use.") + cmd.PersistentFlags().BoolP("non-interactive", "i", false, "Disable prompts.") + cmd.PersistentFlags().String("client-id", "", "Client ID") + cmd.PersistentFlags().String("client-secret", "", "Client secret") + cmd.PersistentFlags().String("api-endpoint", "", "Override the API endpoint") + cmd.PersistentFlags().StringP("output", "o", "table", "Output format. Valid values: table, json, json-pretty, wide.") + cmd.PersistentFlags().Bool("debug", false, "Enable debug logging") + + cmd.AddCommand(getCmd()) + cmd.AddCommand(dropCmd()) + cmd.AddCommand(configAwsCmd()) + cmd.AddCommand(whoAmICmd()) + cmd.AddCommand(getUserCmd()) + cmd.AddCommand(searchEntitlementsCmd()) + cmd.AddCommand(tasksCmd()) + cmd.AddCommand(loginCmd()) + cmd.AddCommand(hasCmd()) + cmd.AddCommand(tokenCmd()) + cmd.AddCommand(terraformCmd()) + cmd.AddCommand(decryptCredentialCmd()) + cmd.AddCommand(awsCredentialsCmd()) + cmd.AddCommand(generateAliasCmd()) + + return cmd +} diff --git a/cmd/cone/config.go b/cmd/cone/config.go index 453f6549..d5b3fc31 100644 --- a/cmd/cone/config.go +++ b/cmd/cone/config.go @@ -7,12 +7,14 @@ import ( "path/filepath" "strings" + "github.com/pterm/pterm" "github.com/spf13/cobra" "github.com/spf13/viper" ) const ( - envPrefix = "cone" + envPrefix = "cone" + nativeIntegrationMode = "native" ) func defaultConfigPath() string { @@ -97,3 +99,154 @@ func getCredentials(v *viper.Viper) (string, string, error) { } return clientId, clientSecret, nil } + +// showAWSConfigCmd creates the command for displaying all AWS configuration settings. +func showAWSConfigCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "show", + Short: "Show all AWS configuration settings", + RunE: func(cmd *cobra.Command, args []string) error { + raw, _ := cmd.Flags().GetBool("raw") + if raw { + // Get the config file path + configFile := viper.ConfigFileUsed() + if configFile == "" { + return fmt.Errorf("no config file found") + } + + // Read and print the raw YAML content + content, err := os.ReadFile(configFile) + if err != nil { + return fmt.Errorf("error reading config file: %w", err) + } + pterm.Println(string(content)) + return nil + } + + // Get SSO start URL + ssoStartURL := viper.GetString("aws_sso_start_url") + if ssoStartURL == "" { + pterm.Warning.Println("AWS SSO start URL is not set") + } else { + pterm.Info.Printf("AWS SSO start URL: %s\n", ssoStartURL) + } + + // Get integration mode + integrationMode := viper.GetString("aws_integration_mode") + if integrationMode == "" { + integrationMode = nativeIntegrationMode // Default to native if not set + } + pterm.Info.Printf("AWS integration mode: %s\n", integrationMode) + + // Show default behavior + pterm.Println("\nDefault behavior:") + if integrationMode == "cone" { + pterm.Println("- AWS profiles will be created automatically") + pterm.Println("- Uses Cone's credential process for authentication") + } else { + pterm.Println("- No AWS profiles will be created") + pterm.Println("- Uses AWS CLI's native SSO integration") + } + + return nil + }, + } + + // Add the raw flag + cmd.Flags().Bool("raw", false, "Show raw YAML content of the config file") + return cmd +} + +// configAwsCmd creates the main AWS configuration command. +func configAwsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "config-aws", + Short: "Configure AWS settings", + } + cmd.AddCommand(setAWSSSOStartURLCmd()) + cmd.AddCommand(getAWSSSOStartURLCmd()) + cmd.AddCommand(setAWSIntegrationModeCmd()) + cmd.AddCommand(getAWSIntegrationModeCmd()) + cmd.AddCommand(showAWSConfigCmd()) + return cmd +} + +// setAWSSSOStartURLCmd creates the command for setting the AWS SSO start URL. +func setAWSSSOStartURLCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "set-sso-url ", + Short: "Set the AWS SSO start URL", + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) != 1 { + return fmt.Errorf("must provide a URL") + } + url := args[0] + viper.Set("aws_sso_start_url", url) + if err := viper.WriteConfig(); err != nil { + return err + } + pterm.Info.Printf("AWS SSO start URL set to: %s\n", url) + return nil + }, + } + return cmd +} + +// getAWSSSOStartURLCmd creates the command for retrieving the current AWS SSO start URL. +func getAWSSSOStartURLCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "get-sso-url", + Short: "Get the current AWS SSO start URL", + RunE: func(cmd *cobra.Command, args []string) error { + url := viper.GetString("aws_sso_start_url") + if url == "" { + pterm.Warning.Println("AWS SSO start URL is not set") + return nil + } + pterm.Info.Printf("AWS SSO start URL: %s\n", url) + return nil + }, + } + return cmd +} + +// setAWSIntegrationModeCmd creates the command for setting the AWS integration mode. +func setAWSIntegrationModeCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "set-integration-mode ", + Short: "Set the AWS integration mode (cone|native)", + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) != 1 { + return fmt.Errorf("must provide a mode (cone|native)") + } + mode := strings.ToLower(args[0]) + if mode != "cone" && mode != "native" { + return fmt.Errorf("mode must be either 'cone' or 'native'") + } + viper.Set("aws_integration_mode", mode) + if err := viper.WriteConfig(); err != nil { + return err + } + pterm.Info.Printf("AWS integration mode set to: %s\n", mode) + return nil + }, + } + return cmd +} + +// getAWSIntegrationModeCmd creates the command for retrieving the current AWS integration mode. +func getAWSIntegrationModeCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "get-integration-mode", + Short: "Get the current AWS integration mode", + RunE: func(cmd *cobra.Command, args []string) error { + mode := viper.GetString("aws_integration_mode") + if mode == "" { + mode = "native" // Default to native if not set + } + pterm.Info.Printf("AWS integration mode: %s\n", mode) + return nil + }, + } + return cmd +} diff --git a/cmd/cone/generate_alias.go b/cmd/cone/generate_alias.go new file mode 100644 index 00000000..ad51719c --- /dev/null +++ b/cmd/cone/generate_alias.go @@ -0,0 +1,527 @@ +package main + +import ( + "bufio" + "context" + "fmt" + "os" + "regexp" + "strings" + + "github.com/conductorone/conductorone-sdk-go/pkg/models/shared" + "github.com/conductorone/cone/pkg/client" + "github.com/pterm/pterm" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// Stats tracks the progress of alias generation. +type Stats struct { + Total int + Processed int + Skipped int + Updated int + Failed int + Errors []string +} + +// Generic entitlement words that should be replaced with resource names. +var genericEntitlementWords = []string{ + "member", + "assignment", + "access", + "role", + "group", +} + +// Words to remove from display names. +var wordsToRemove = []string{ + "Role member", "role member", "RoleMember", "roleMember", + "Group member", "group member", "GroupMember", "groupMember", + "Member", "member", + "Role", "role", + "Group", "group", +} + +// cleanText processes a string to create a valid alias by: +// 1. Removing text in parentheses +// 2. Removing unnecessary words +// 3. Converting to lowercase +// 4. Replacing spaces with hyphens +// 5. Removing invalid characters +// 6. Ensuring proper length and format. +func cleanText(text string) string { + // Remove anything in parentheses + text = regexp.MustCompile(`\s*\([^)]*\)`).ReplaceAllString(text, "") + + // Remove unnecessary words + for _, word := range wordsToRemove { + text = strings.ReplaceAll(text, word, "") + } + + // Clean up text + text = strings.TrimSpace(text) + text = regexp.MustCompile(`\s+`).ReplaceAllString(text, " ") + + // Convert to lowercase and replace spaces with hyphens + text = strings.ToLower(text) + text = strings.ReplaceAll(text, " ", "-") + + // Remove any characters that aren't allowed + text = regexp.MustCompile(`[^a-z0-9\-_\.]`).ReplaceAllString(text, "") + + // Remove any double hyphens + text = strings.ReplaceAll(text, "--", "-") + + // Ensure it starts and ends with a letter or digit + text = strings.Trim(text, "-_.") + if text == "" { + text = "entitlement" // fallback if we end up with empty string + } + + // Ensure it's not too long + if len(text) > 63 { + text = text[:63] + // Make sure we don't end with a hyphen + text = strings.TrimRight(text, "-") + } + + return text +} + +// isGenericEntitlement checks if an entitlement name is too generic to be useful. +func isGenericEntitlement(name string) bool { + name = strings.ToLower(strings.TrimSpace(name)) + for _, word := range genericEntitlementWords { + if name == word { + return true + } + } + return false +} + +// getResourceName extracts and cleans the resource name from an entitlement. +func getResourceName(e *client.EntitlementWithBindings) string { + if appResource := client.GetExpanded[shared.AppResource](e, client.ExpandedAppResource); appResource != nil && appResource.DisplayName != nil { + return cleanText(*appResource.DisplayName) + } + // Fallback to app name if no resource name is available + if app := client.GetExpanded[shared.App](e, client.ExpandedApp); app != nil && app.DisplayName != nil { + return cleanText(*app.DisplayName) + } + return "resource" // Final fallback +} + +// generateAliasRun is the main function that handles alias generation. +func generateAliasRun(cmd *cobra.Command, args []string) error { + ctx, c, v, err := cmdContext(cmd) + if err != nil { + return err + } + + // Verify admin permissions + if err := verifyAdminPermissions(ctx, c, v); err != nil { + return err + } + + // Get all requestable entitlements + entitlements, err := c.SearchEntitlements(ctx, &client.SearchEntitlementsFilter{ + GrantedStatus: shared.GrantedStatusAll, + AppEntitlementExpandMask: shared.AppEntitlementExpandMask{Paths: []string{"app_id", "app_resource_type_id", "app_resource_id"}}, + }) + if err != nil { + return fmt.Errorf("failed to search entitlements: %w", err) + } + if len(entitlements) == 0 { + pterm.Warning.Println("No requestable entitlements found.") + return nil + } + + // Initialize stats and get command flags + stats := &Stats{ + Total: len(entitlements), + Errors: make([]string, 0), + } + flags := getCommandFlags(v) + + pterm.Info.Printf("Processing %d entitlements...\n", stats.Total) + + processedEntitlements := make(map[string]bool) + for i, e := range entitlements { + // Process each entitlement + if err := processEntitlement(ctx, c, e, flags, stats, processedEntitlements); err != nil { + stats.Failed++ + stats.Errors = append(stats.Errors, err.Error()) + } + + // Show progress every 10 items + if (i+1)%10 == 0 { + pterm.Info.Printf("Processed %d/%d entitlements...\n", i+1, stats.Total) + } + } + + // Print summary + printSummary(stats) + + return nil +} + +// CommandFlags holds all the command line flags. +type CommandFlags struct { + ResourceTypes []string + EntitlementIDs []string + Schema string + Format string + Separator string + Force bool + ForceNonAWS bool + SkipAWS bool + DryRun bool +} + +// getCommandFlags extracts all command line flags. +func getCommandFlags(v *viper.Viper) CommandFlags { + return CommandFlags{ + ResourceTypes: v.GetStringSlice("resource-type"), + EntitlementIDs: v.GetStringSlice("entitlement-id"), + Schema: v.GetString("schema"), + Format: v.GetString("format"), + Separator: v.GetString("separator"), + Force: v.GetBool("force"), + ForceNonAWS: v.GetBool("force-non-aws"), + SkipAWS: v.GetBool("skip-aws"), + DryRun: v.GetBool("dry-run"), + } +} + +// processEntitlement handles the processing of a single entitlement. +func processEntitlement(ctx context.Context, c client.C1Client, e *client.EntitlementWithBindings, flags CommandFlags, stats *Stats, processedEntitlements map[string]bool) error { + ent := e.Entitlement + if ent.DisplayName == nil || ent.AppID == nil || ent.ID == nil { + stats.Skipped++ + return nil + } + + // Apply filters + if len(flags.EntitlementIDs) > 0 && !contains(flags.EntitlementIDs, *ent.ID) { + stats.Skipped++ + return nil + } + + // Get app and resource type info + app := client.GetExpanded[shared.App](e, client.ExpandedApp) + appResourceType := client.GetExpanded[shared.AppResourceType](e, client.ExpandedAppResourceType) + if app == nil || app.DisplayName == nil || appResourceType == nil || appResourceType.DisplayName == nil { + stats.Skipped++ + return nil + } + + // Filter by resource type + if len(flags.ResourceTypes) > 0 && !contains(flags.ResourceTypes, *appResourceType.DisplayName) { + stats.Skipped++ + return nil + } + + // Clean up the display name + displayName := cleanText(*ent.DisplayName) + displayName = strings.TrimSuffix(displayName, "-access") + displayName = strings.TrimSuffix(displayName, "-permissionset") + + // Get resource name and check for generic entitlements + resourceName := getResourceName(e) + if isGenericEntitlement(*ent.DisplayName) { + displayName = resourceName + pterm.Info.Printf("Using resource name '%s' instead of generic entitlement name '%s'\n", + resourceName, *ent.DisplayName) + } + + // Generate alias + var alias string + var resourceNameGenerated string + + // Get resource name if available + if appResource := client.GetExpanded[shared.AppResource](e, client.ExpandedAppResource); appResource != nil && appResource.DisplayName != nil { + resourceNameGenerated = cleanText(*appResource.DisplayName) + } else { + resourceNameGenerated = cleanText(*app.DisplayName) + } + + // Check if we need to use resource name instead of entitlement name + if isGenericEntitlement(*ent.DisplayName) { + displayName = resourceNameGenerated + pterm.Info.Printf("Using resource name '%s' instead of generic entitlement name '%s'\n", + resourceNameGenerated, *ent.DisplayName) + } + + // Generate the alias based on schema + appName := cleanText(*app.DisplayName) + switch flags.Schema { + case "app-entitlement": + alias = generateAlias("%a-%e", flags.Separator, map[string]string{ + "a": appName, + "e": displayName, + }) + case "resource-entitlement": + alias = generateAlias("%r-%e", flags.Separator, map[string]string{ + "r": resourceNameGenerated, + "e": displayName, + }) + case "app-resource-entitlement": + alias = generateAlias("%a-%r-%e", flags.Separator, map[string]string{ + "a": appName, + "r": resourceNameGenerated, + "e": displayName, + }) + case "resource-type-entitlement": + alias = generateAlias("%t-%e", flags.Separator, map[string]string{ + "t": cleanText(*appResourceType.DisplayName), + "e": displayName, + }) + default: + alias = generateAlias(flags.Format, flags.Separator, map[string]string{ + "a": appName, + "r": resourceNameGenerated, + "t": cleanText(*appResourceType.DisplayName), + "e": displayName, + }) + } + + // Check if this is an AWS permission set + isAWSPermissionSet := strings.ToLower(*appResourceType.DisplayName) == "account" + + // Skip AWS permission sets if requested + if flags.SkipAWS && isAWSPermissionSet { + stats.Skipped++ + return nil + } + + // Skip if alias is already set and not forcing + if ent.Alias != nil && *ent.Alias != "" { + if !flags.Force && !flags.ForceNonAWS && !isAWSPermissionSet { + stats.Skipped++ + return nil + } + } + + // Skip if we've already processed this alias + if processedEntitlements[alias] { + stats.Skipped++ + return nil + } + processedEntitlements[alias] = true + + // Update the alias + if !flags.DryRun { + req := &shared.UpdateAppEntitlementRequest{ + AppEntitlement: &shared.AppEntitlementInput{ + Alias: &alias, + }, + UpdateMask: stringPtr("alias"), + } + if err := c.UpdateEntitlement(ctx, *ent.AppID, *ent.ID, req); err != nil { + return fmt.Errorf("failed to update %s: %w", *ent.DisplayName, err) + } + } + stats.Updated++ + stats.Processed++ + + return nil +} + +// verifyAdminPermissions checks if the user has admin permissions. +func verifyAdminPermissions(ctx context.Context, c client.C1Client, v *viper.Viper) error { + // In non-interactive mode, skip user prompt and check permissions directly + if v.GetBool(nonInteractiveFlag) { + // Just check actual permissions without prompting + isAdmin, err := checkAdminPermissions(ctx, c) + if err != nil { + return fmt.Errorf("failed to check admin permissions: %w", err) + } + if !isAdmin { + return fmt.Errorf("you do not have super admin or app admin permissions. Use --help for more information") + } + return nil + } + + // Interactive mode: prompt user first, then check permissions + // Prompt the user + pterm.Info.Print("Are you a super admin or app admin? (yes/no): ") + reader := bufio.NewReader(os.Stdin) + answer, _ := reader.ReadString('\n') + answer = strings.TrimSpace(strings.ToLower(answer)) + if answer != "yes" { + return fmt.Errorf("you must be a super admin or app admin to run this command") + } + + // Check actual permissions + isAdmin, err := checkAdminPermissions(ctx, c) + if err != nil { + return fmt.Errorf("failed to check admin permissions: %w", err) + } + if !isAdmin { + return fmt.Errorf("you do not have super admin or app admin permissions. Please contact your administrator") + } + + return nil +} + +// printSummary prints the final summary of the alias generation process. +func printSummary(stats *Stats) { + pterm.Info.Printf("\nSummary:\n") + pterm.Info.Printf("Total entitlements: %d\n", stats.Total) + pterm.Info.Printf("Processed: %d\n", stats.Processed) + pterm.Info.Printf("Skipped: %d\n", stats.Skipped) + pterm.Info.Printf("Updated: %d\n", stats.Updated) + pterm.Info.Printf("Failed: %d\n", stats.Failed) + + if len(stats.Errors) > 0 { + pterm.Error.Printf("\nErrors:\n") + for _, err := range stats.Errors { + pterm.Error.Printf("- %s\n", err) + } + } +} + +// Helper functions. +func contains(slice []string, str string) bool { + for _, s := range slice { + if s == str { + return true + } + } + return false +} + +func stringPtr(s string) *string { return &s } + +// generateAliasCmd creates the cobra command for alias generation. +func generateAliasCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "generate-alias", + Short: "Generate aliases for entitlements in ConductorOne", + Long: `Generate aliases for entitlements in ConductorOne. +This command will: +- Generate aliases based on the selected schema +- By default, skip entitlements that already have aliases (except AWS permission sets) +- Show progress and summary statistics + +Available alias schemas (--schema): +- resource-entitlement: resource name + entitlement name (default) +- app-entitlement: app name + entitlement name +- app-resource-entitlement: app name + resource name + entitlement name +- resource-type-entitlement: resource type + entitlement name +- custom: Use a custom format string with --format + +Format placeholders: +- %a: App name +- %r: Resource name +- %t: Resource type +- %e: Entitlement name +- %s: Any separator (default: "-") + +Example formats: +- "%r-%e" (default): resource-entitlement +- "%a-%e": app-entitlement +- "%a-%r-%e": app-resource-entitlement +- "%t-%e": resource-type-entitlement + +The alias format can be customized using flags: +- --schema: Choose a predefined schema or "custom" +- --format: Custom format string (only used with schema=custom) +- --separator: Custom separator (default: "-") +- --force: Override ALL existing aliases (including AWS permission sets) +- --force-non-aws: Override existing aliases for non-AWS entitlements +- --skip-aws: Skip AWS permission sets entirely +- --dry-run: Preview changes without making them + +Filtering options: +- --resource-type: Only process entitlements with specific resource types +- --entitlement-id: Process only specific entitlements`, + RunE: generateAliasRun, + } + + // Add flags + cmd.Flags().String("schema", "resource-entitlement", "Alias schema to use (resource-entitlement, app-entitlement, app-resource-entitlement, resource-type-entitlement, custom)") + cmd.Flags().String("format", "%r-%e", "Custom format string for alias generation (only used with schema=custom)") + cmd.Flags().String("separator", "-", "Separator to use between components") + 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") + + // Mark flags as mutually exclusive + cmd.MarkFlagsMutuallyExclusive("force", "force-non-aws") + + return cmd +} + +// generateAlias generates an alias using the given format and values. +func generateAlias(format, separator string, values map[string]string) string { + // Replace placeholders with values + result := format + for key, value := range values { + result = strings.ReplaceAll(result, "%"+key, value) + } + // Replace separator placeholder + result = strings.ReplaceAll(result, "%s", separator) + return result +} + +// checkAdminPermissions checks if the user has admin permissions. +func checkAdminPermissions(ctx context.Context, c client.C1Client) (bool, error) { + // Get user's identity + userIntro, err := c.AuthIntrospect(ctx) + if err != nil { + return false, fmt.Errorf("failed to get user identity: %w", err) + } + + // Check if user is a super admin or app admin + for _, role := range userIntro.Roles { + // Check for roles that indicate super admin access + if strings.HasPrefix(role, "role/c1.api.tenant.v1.Tenant:owner") || + strings.HasPrefix(role, "role/c1.api.auth.v1.Auth:owner") || + // Check for app admin roles + strings.HasPrefix(role, "role/c1.api.app.v1.Apps:owner") || + strings.HasPrefix(role, "role/c1.api.app.v1.AppEntitlements:owner") { + return true, nil + } + } + + // Check if user is an app admin for any app + apps, err := c.ListApps(ctx) + if err != nil { + return false, fmt.Errorf("failed to list apps: %w", err) + } + + for _, app := range apps { + if app.ID == nil { + continue + } + + // Get app users + appUsers, err := c.ListAppUsers(ctx, *app.ID) + if err != nil { + continue // Skip this app if we can't get users + } + + // Check if user is an admin for this app + for _, appUser := range appUsers { + if appUser.IdentityUserID != nil && userIntro.UserID != nil && *appUser.IdentityUserID == *userIntro.UserID { + // Check if user has admin role in their profile + if profile, ok := appUser.Profile["roles"]; ok { + if roles, ok := profile.([]interface{}); ok { + for _, role := range roles { + if roleStr, ok := role.(string); ok && roleStr == "admin" { + return true, nil + } + } + } + } + } + } + } + + return false, nil +} diff --git a/cmd/cone/get_drop_task.go b/cmd/cone/get_drop_task.go index beefbb4c..974da42c 100644 --- a/cmd/cone/get_drop_task.go +++ b/cmd/cone/get_drop_task.go @@ -21,9 +21,9 @@ import ( const durationErrorMessage = "grant duration must be less than or equal to max provision time" const durationInputTip = "We accept a sequence of decimal numbers, each with optional fraction and a unit suffix," + "such as \"12h\", \"1w2d\" or \"2h45m\". Valid units are (m)inutes, (h)ours, (d)ays, (w)eeks." -const justificationWarningMessage = "Please provide a justification when requesting access to an entitlement." +const justificationWarningMessage = "please provide a justification when requesting access to an entitlement" const justificationInputTip = "You can add a justification using -j or --justification" -const appUserMultipleUsersWarningMessage = "This app has multiple users. Please select any one. " +const appUserMultipleUsersWarningMessage = "this app has multiple users. Please select any one. " func getCmd() *cobra.Command { cmd := &cobra.Command{ @@ -254,34 +254,136 @@ func runDrop(cmd *cobra.Command, args []string) error { }) } -func printExtraTaskDetails(ctx context.Context, v *viper.Viper, c client.C1Client, appId string, entitlementId string) error { - outputManager := output.NewManager(ctx, v) +// createAWSSSOProfileIfNeeded checks if the entitlement is an AWS permission set and creates the profile if needed. +func createAWSSSOProfileIfNeeded(ctx context.Context, c client.C1Client, task *shared.Task, outputManager output.Manager) error { + if task.TaskType.TaskTypeGrant == nil { + return nil + } - appVal, err := c.GetApp(ctx, appId) - if err != nil { - return err + grantTask := task.TaskType.TaskTypeGrant + if grantTask.AppID == nil || grantTask.AppEntitlementID == nil { + // Skip if required fields are missing - not an error for this function + return nil } - entitlementVal, err := c.GetEntitlement(ctx, appId, entitlementId) + appID := *grantTask.AppID + entitlementID := *grantTask.AppEntitlementID + + // Get the entitlement details + entitlement, err := c.GetEntitlement(ctx, appID, entitlementID) if err != nil { - return err + return fmt.Errorf("failed to get entitlement details: %w", err) } - app := App{app: appVal, client: c} - err = outputManager.Output(ctx, &app, output.WithTransposeTable()) - if err != nil { - return err + // Check for nil pointers before dereferencing + if entitlement.AppResourceTypeID == nil { + // Not an error condition, just skip AWS SSO profile creation + return nil } - entitlement := Entitlement{entitlement: entitlementVal, client: c} - err = outputManager.Output(ctx, &entitlement, output.WithTransposeTable()) + // Get the resource type + resourceType, err := c.GetResourceType(ctx, appID, *entitlement.AppResourceTypeID) if err != nil { - return err + return fmt.Errorf("failed to get resource type: %w", err) + } + + // Check if this is an AWS permission set + if client.IsAWSPermissionSet(entitlement, resourceType) { + if entitlement.AppResourceID == nil { + return fmt.Errorf("entitlement AppResourceID is nil, cannot create AWS SSO profile") + } + + // Get the resource details + resource, err := c.GetResource(ctx, appID, *entitlement.AppResourceTypeID, *entitlement.AppResourceID) + if err != nil { + return fmt.Errorf("failed to get resource details: %w", err) + } + + if err := client.CreateAWSSSOProfile(entitlement, resource); err != nil { + return fmt.Errorf("failed to create AWS SSO profile: %w", err) + } + } + return nil +} + +// handleWaitBehavior manages the waiting state for task completion. +func handleWaitBehavior(ctx context.Context, c client.C1Client, task *shared.Task, outputManager output.Manager) error { + // Validate input parameters + if task == nil || task.ID == nil { + return fmt.Errorf("task or task ID is nil") + } + + spinner, _ := pterm.DefaultSpinner.Start("Waiting for task to complete...") + defer func() { _ = spinner.Stop() }() + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(2 * time.Second): + // Continue with polling + } + + updatedTask, err := c.GetTask(ctx, *task.ID) + if err != nil { + return err + } + + // Check for nil pointers before dereferencing + if updatedTask.TaskView == nil || updatedTask.TaskView.Task == nil { + return fmt.Errorf("received incomplete task response") + } + + if updatedTask.TaskView.Task.State == nil { + return fmt.Errorf("task state is nil") + } + + if *updatedTask.TaskView.Task.State == shared.TaskStateTaskStateClosed { + if updatedTask.TaskView.Task.TaskType.TaskTypeGrant != nil { + taskOutcome := updatedTask.TaskView.Task.TaskType.TaskTypeGrant.Outcome + if taskOutcome == nil { + return fmt.Errorf("task closed but no outcome provided") + } + + switch *taskOutcome { + case shared.TaskTypeGrantOutcomeGrantOutcomeGranted: + spinner.Success("Entitlement granted successfully.") + // Create AWS SSO profile now that grant is successful + if err := createAWSSSOProfileIfNeeded(ctx, c, updatedTask.TaskView.Task, outputManager); err != nil { + pterm.Warning.Printf("Failed to create AWS SSO profile: %v\n", err) + } + case shared.TaskTypeGrantOutcomeGrantOutcomeDenied: + spinner.Fail("Entitlement request was denied.") + return fmt.Errorf("entitlement request was denied") + default: + spinner.Fail(fmt.Sprintf("Task completed with unexpected outcome: %s", *taskOutcome)) + return fmt.Errorf("task completed with unexpected outcome: %s", *taskOutcome) + } + } else if updatedTask.TaskView.Task.TaskType.TaskTypeRevoke != nil { + taskOutcome := updatedTask.TaskView.Task.TaskType.TaskTypeRevoke.Outcome + if taskOutcome == nil { + return fmt.Errorf("task closed but no outcome provided") + } + + switch *taskOutcome { + case shared.TaskTypeRevokeOutcomeRevokeOutcomeRevoked: + spinner.Success("Entitlement revoked successfully.") + case shared.TaskTypeRevokeOutcomeRevokeOutcomeDenied: + spinner.Fail("Entitlement revoke request was denied.") + return fmt.Errorf("entitlement revoke request was denied") + default: + spinner.Fail(fmt.Sprintf("Task completed with unexpected outcome: %s", *taskOutcome)) + return fmt.Errorf("task completed with unexpected outcome: %s", *taskOutcome) + } + } + break + } } return nil } +// runTask executes the task and handles the response. func runTask( cmd *cobra.Command, args []string, @@ -304,53 +406,37 @@ func runTask( return err } - userID := client.StringFromPtr(resp.UserID) - - forceCreate := v.GetBool(forceFlag) - if !forceCreate { - grants, err := c.GetGrantsForIdentity(ctx, appId, entitlementId, userID) - if err != nil { - return err - } - grantCount := 0 - for _, grant := range grants { - // We only want to check if user has a grant - if client.StringFromPtr(grant.AppEntitlementID) != "" { - grantCount++ - } - } - - // If this is get, and they have grants, just exit - if cmd.Name() == getCmd().Name() && grantCount > 0 { - pterm.Println("You already have access to this entitlement. Use --force to override this check.") - return nil - } - - if cmd.Name() == dropCmd().Name() && grantCount == 0 { - pterm.Println("You do not have existing grants to drop for this entitlement. Use --force to override this check.") - return nil - } - } - task, err := run(c, ctx, appId, entitlementId, client.StringFromPtr(resp.UserID), justification) if err != nil { + // Check if this is a duplicate grant error and force flag is not set + errorMsg := err.Error() + force := v.GetBool(forceFlag) + if !force && (strings.Contains(strings.ToLower(errorMsg), "already granted") || + strings.Contains(strings.ToLower(errorMsg), "already exists") || + strings.Contains(strings.ToLower(errorMsg), "duplicate")) { + return fmt.Errorf("%s. Use --force flag to override this check", err.Error()) + } return err } + outputManager := output.NewManager(ctx, v) + + // Show detailed app and entitlement information if requested if v.GetBool(extraDetailsFlag) { - err = printExtraTaskDetails(ctx, v, c, appId, entitlementId) + err = showDetailedEntitlementInfo(ctx, c, appId, entitlementId, v) if err != nil { - return err + pterm.Warning.Printf("Failed to show detailed entitlement information: %v\n", err) } } - outputManager := output.NewManager(ctx, v) taskResp := Task{task: task, client: c} err = outputManager.Output(ctx, &taskResp, output.WithTransposeTable()) if err != nil { return err } + // Note: AWS SSO profile creation moved to handleWaitBehavior to occur only after successful grant completion + if wait, _ := cmd.Flags().GetBool("wait"); wait { err = handleWaitBehavior(ctx, c, task, outputManager) if err != nil { @@ -488,52 +574,6 @@ func getEntitlementDetails(ctx context.Context, c client.C1Client, v *viper.Vipe return entitlementId, appId, nil } -func handleWaitBehavior(ctx context.Context, c client.C1Client, task *shared.Task, outputManager output.Manager) error { - spinner, _ := pterm.DefaultSpinner.Start("Waiting for ticket to close.") - var taskItem *shared.Task - for { - select { - case <-ctx.Done(): - return ctx.Err() - case <-time.After(1 * time.Second): - } - task, err := c.GetTask(ctx, client.StringFromPtr(task.ID)) - if err != nil { - return err - } - - taskItem = task.TaskView.Task - taskResp := Task{task: taskItem, client: c} - err = outputManager.Output(ctx, &taskResp, output.WithTransposeTable()) - if err != nil { - return err - } - - if *taskItem.State == shared.TaskStateTaskStateClosed { - break - } - } - if taskItem.TaskType.TaskTypeGrant != nil { - taskOutcome := taskItem.TaskType.TaskTypeGrant.Outcome - if *taskOutcome == shared.TaskTypeGrantOutcomeGrantOutcomeGranted { - spinner.Success("Entitlement granted successfully.") - } else { - spinner.Fail(fmt.Sprintf("Failed to grant entitlement %s", string(*taskOutcome))) - return fmt.Errorf("failed to grant entitlement %s", string(*taskOutcome)) - } - } - if taskItem.TaskType.TaskTypeRevoke != nil { - taskOutcome := taskItem.TaskType.TaskTypeRevoke.Outcome - if *taskOutcome == shared.TaskTypeRevokeOutcomeRevokeOutcomeRevoked { - spinner.Success("Entitlement revoked succesfully.") - } else { - spinner.Fail(fmt.Sprintf("Failed to revoke entitlement %s", string(*taskOutcome))) - return fmt.Errorf("failed to revoke entitlement %s", string(*taskOutcome)) - } - } - return nil -} - var processStateToString = map[shared.Processing]string{ "TASK_PROCESSING_TYPE_UNSPECIFIED": "Unknown Processing", "TASK_PROCESSING_TYPE_PROCESSING": "Processing", @@ -571,3 +611,64 @@ func multipleEntitlmentsFoundError(alias string, query string) error { } return fmt.Errorf("multiple entitlements found, please specify an entitlement id and app id") } + +// showDetailedEntitlementInfo displays detailed information about the app and entitlement. +func showDetailedEntitlementInfo(ctx context.Context, c client.C1Client, appID string, entitlementID string, v *viper.Viper) error { + // Skip detailed output for JSON formats to avoid mixing plain text with structured output + outputFormat := v.GetString("output") + if outputFormat == output.JSON || outputFormat == output.JSONPretty { + return nil + } + + pterm.DefaultSection.Println("Entitlement Details") + + // Get app details + app, err := c.GetApp(ctx, appID) + if err != nil { + return fmt.Errorf("failed to get app details: %w", err) + } + + // Get entitlement details + entitlement, err := c.GetEntitlement(ctx, appID, entitlementID) + if err != nil { + return fmt.Errorf("failed to get entitlement details: %w", err) + } + + // Display app information + pterm.DefaultBasicText.Printf("App: %s\n", client.StringFromPtr(app.DisplayName)) + if app.Description != nil && *app.Description != "" { + pterm.DefaultBasicText.Printf("App Description: %s\n", client.StringFromPtr(app.Description)) + } + + // Display entitlement information + pterm.DefaultBasicText.Printf("Entitlement: %s\n", client.StringFromPtr(entitlement.DisplayName)) + if entitlement.Description != nil && *entitlement.Description != "" { + pterm.DefaultBasicText.Printf("Entitlement Description: %s\n", client.StringFromPtr(entitlement.Description)) + } + + // Show resource type and resource information if available + if entitlement.AppResourceTypeID != nil { + resourceType, err := c.GetResourceType(ctx, appID, *entitlement.AppResourceTypeID) + if err == nil { + pterm.DefaultBasicText.Printf("Resource Type: %s\n", client.StringFromPtr(resourceType.DisplayName)) + + if entitlement.AppResourceID != nil { + resource, err := c.GetResource(ctx, appID, *entitlement.AppResourceTypeID, *entitlement.AppResourceID) + if err == nil { + pterm.DefaultBasicText.Printf("Resource: %s\n", client.StringFromPtr(resource.DisplayName)) + if resource.Description != nil && *resource.Description != "" { + pterm.DefaultBasicText.Printf("Resource Description: %s\n", client.StringFromPtr(resource.Description)) + } + } + } + } + } + + // Show duration information if available + if entitlement.DurationGrant != nil && *entitlement.DurationGrant != "" { + pterm.DefaultBasicText.Printf("Max Grant Duration: %s\n", *entitlement.DurationGrant) + } + + pterm.Println() // Add spacing before task output + return nil +} diff --git a/cmd/cone/main.go b/cmd/cone/main.go index 9d09d497..80843995 100644 --- a/cmd/cone/main.go +++ b/cmd/cone/main.go @@ -36,44 +36,19 @@ func main() { } func runCli(ctx context.Context) int { - cliCmd := &cobra.Command{ - Use: "cone", - Short: "Cone interacts with the ConductorOne API to manage access to entitlements.", - Version: version, - PersistentPreRunE: func(cmd *cobra.Command, args []string) error { - cmd.SetContext(ctx) - return nil - }, - SilenceUsage: true, - SilenceErrors: true, + cliCmd := rootCmd() + cliCmd.Version = version + cliCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { + cmd.SetContext(ctx) + return nil } - cliCmd.PersistentFlags().StringP("profile", "p", "default", "The config profile to use.") - cliCmd.PersistentFlags().BoolP("non-interactive", "i", false, "Disable prompts.") - cliCmd.PersistentFlags().String("client-id", "", "Client ID") - cliCmd.PersistentFlags().String("client-secret", "", "Client secret") - cliCmd.PersistentFlags().String("api-endpoint", "", "Override the API endpoint") - cliCmd.PersistentFlags().StringP("output", "o", "table", "Output format. Valid values: table, json, json-pretty, wide.") - cliCmd.PersistentFlags().Bool("debug", false, "Enable debug logging") - err := initConfig(cliCmd) if err != nil { fmt.Fprintln(os.Stderr, err.Error()) return 1 } - cliCmd.AddCommand(getCmd()) - cliCmd.AddCommand(dropCmd()) - cliCmd.AddCommand(whoAmICmd()) - cliCmd.AddCommand(getUserCmd()) - cliCmd.AddCommand(searchEntitlementsCmd()) - cliCmd.AddCommand(tasksCmd()) - cliCmd.AddCommand(loginCmd()) - cliCmd.AddCommand(hasCmd()) - cliCmd.AddCommand(tokenCmd()) - cliCmd.AddCommand(terraformCmd()) - cliCmd.AddCommand(decryptCredentialCmd()) - err = cliCmd.ExecuteContext(ctx) if err != nil { _, _, v, _ := cmdContext(cliCmd) diff --git a/cmd/cone/search_entitlements.go b/cmd/cone/search_entitlements.go index 3747f351..128977f6 100644 --- a/cmd/cone/search_entitlements.go +++ b/cmd/cone/search_entitlements.go @@ -11,8 +11,20 @@ import ( func searchEntitlementsCmd() *cobra.Command { cmd := &cobra.Command{ Use: "search", - Short: "", - RunE: searchEntitlementsRun, + Short: "Search for entitlements in ConductorOne", + Long: `Search for entitlements in ConductorOne using various filters. +This command allows you to: +- Search by entitlement name or alias +- Filter by app name +- Show only granted or not granted entitlements +- Include deleted entitlements + +The search results will show: +- Whether you have access to each entitlement +- The entitlement's alias and display name +- The app it belongs to +- The resource type and resource name`, + RunE: searchEntitlementsRun, } addEntitlementAliasFlag(cmd) addQueryFlag(cmd) @@ -29,37 +41,85 @@ func searchEntitlementsRun(cmd *cobra.Command, args []string) error { if err != nil { return err } - if err := validateArgLenth(0, args, cmd); err != nil { - return err - } query := v.GetString(queryFlag) alias := v.GetString(entitlementAliasFlag) - grantedStatus := client.GrantedStatusAll + if len(args) == 1 { + alias = args[0] + } + + grantedStatus := shared.GrantedStatusAll if v.GetBool(grantedFlag) { - grantedStatus = client.GrantedStatusGranted + grantedStatus = shared.GrantedStatusGranted } else if v.GetBool(notGrantedFlag) { - grantedStatus = client.GrantedStatusNotGranted + grantedStatus = shared.GrantedStatusNotGranted } - // TODO(morgabra) 2-phase search: Accept a positional arg: - // 1. Test if it's a direct alias - // 2. Use it as a query - entitlements, err := c.SearchEntitlements(ctx, &client.SearchEntitlementsFilter{ - Query: query, - EntitlementAlias: alias, - GrantedStatus: grantedStatus, - AppDisplayName: v.GetString(appDisplayNameFlag), - IncludeDeleted: v.GetBool(includeDeletedFlag), - AppEntitlementExpandMask: shared.AppEntitlementExpandMask{Paths: []string{"app_id", "app_resource_type_id", "app_resource_id"}}, - }) - if err != nil { - return err + // Phase 1: Try exact alias match first if an alias is provided + var entitlements []*client.EntitlementWithBindings + if alias != "" { + exactMatchEntitlements, err := c.SearchEntitlements(ctx, &client.SearchEntitlementsFilter{ + EntitlementAlias: alias, + GrantedStatus: grantedStatus, + AppDisplayName: v.GetString(appDisplayNameFlag), + IncludeDeleted: v.GetBool(includeDeletedFlag), + AppEntitlementExpandMask: shared.AppEntitlementExpandMask{Paths: []string{"app_id", "app_resource_type_id", "app_resource_id"}}, + }) + if err != nil { + return err + } + entitlements = exactMatchEntitlements + } + + // Phase 2: If no exact matches found and we have a query, try query search + if len(entitlements) == 0 && query != "" { + queryEntitlements, err := c.SearchEntitlements(ctx, &client.SearchEntitlementsFilter{ + Query: query, + GrantedStatus: grantedStatus, + AppDisplayName: v.GetString(appDisplayNameFlag), + IncludeDeleted: v.GetBool(includeDeletedFlag), + AppEntitlementExpandMask: shared.AppEntitlementExpandMask{Paths: []string{"app_id", "app_resource_type_id", "app_resource_id"}}, + }) + if err != nil { + return err + } + entitlements = queryEntitlements + } + + // If still no results and we have both alias and query, try combined search + if len(entitlements) == 0 && alias != "" && query != "" { + combinedEntitlements, err := c.SearchEntitlements(ctx, &client.SearchEntitlementsFilter{ + Query: query, + EntitlementAlias: alias, + GrantedStatus: grantedStatus, + AppDisplayName: v.GetString(appDisplayNameFlag), + IncludeDeleted: v.GetBool(includeDeletedFlag), + AppEntitlementExpandMask: shared.AppEntitlementExpandMask{Paths: []string{"app_id", "app_resource_type_id", "app_resource_id"}}, + }) + if err != nil { + return err + } + entitlements = combinedEntitlements + } + + // If no alias or query provided, show all entitlements + if len(entitlements) == 0 && alias == "" && query == "" { + allEntitlements, err := c.SearchEntitlements(ctx, &client.SearchEntitlementsFilter{ + GrantedStatus: grantedStatus, + AppDisplayName: v.GetString(appDisplayNameFlag), + IncludeDeleted: v.GetBool(includeDeletedFlag), + AppEntitlementExpandMask: shared.AppEntitlementExpandMask{Paths: []string{"app_id", "app_resource_type_id", "app_resource_id"}}, + }) + if err != nil { + return err + } + entitlements = allEntitlements } + + outputManager := output.NewManager(ctx, v) resp := &ExpandedEntitlementsResponse{ Entitlements: entitlements, } - outputManager := output.NewManager(ctx, v) err = outputManager.Output(ctx, resp) if err != nil { return err diff --git a/cmd/cone/task_approve_deny.go b/cmd/cone/task_approve_deny.go index 9a442b1a..c43e5f67 100644 --- a/cmd/cone/task_approve_deny.go +++ b/cmd/cone/task_approve_deny.go @@ -3,8 +3,11 @@ package main import ( "context" "errors" + "fmt" + "github.com/pterm/pterm" "github.com/spf13/cobra" + "github.com/spf13/viper" "github.com/conductorone/conductorone-sdk-go/pkg/models/shared" "github.com/conductorone/cone/pkg/client" @@ -36,17 +39,103 @@ func denyTasksCmd() *cobra.Command { } 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, func(c client.C1Client, ctx context.Context, taskId string, comment string, policyId string, v *viper.Viper) (*shared.Task, error) { + // Only show info message for non-JSON output to avoid corrupting structured output + outputFormat := v.GetString("output") + if outputFormat != output.JSON && outputFormat != output.JSONPretty { + pterm.Info.Printf("Starting task approval process for task %s\n", taskId) + } + + taskResp, err := c.GetTask(ctx, taskId) + if err != nil { + return nil, err + } + // Only show debug message for non-JSON output to avoid corrupting structured output + if outputFormat != output.JSON && outputFormat != output.JSONPretty { + pterm.Debug.Printf("Got task details: %+v\n", taskResp.TaskView.Task) + } + + var appID, entitlementID string + switch { + case taskResp.TaskView.Task.TaskType.TaskTypeGrant != nil: + // Handle grant task + grantTask := taskResp.TaskView.Task.TaskType.TaskTypeGrant + if grantTask.AppID == nil || grantTask.AppEntitlementID == nil { + return nil, fmt.Errorf("grant task is missing required AppID or AppEntitlementID") + } + appID = *grantTask.AppID + entitlementID = *grantTask.AppEntitlementID + case taskResp.TaskView.Task.TaskType.TaskTypeRevoke != nil: + // Handle revoke task + revokeTask := taskResp.TaskView.Task.TaskType.TaskTypeRevoke + if revokeTask.AppID == nil || revokeTask.AppEntitlementID == nil { + return nil, fmt.Errorf("revoke task is missing required AppID or AppEntitlementID") + } + appID = *revokeTask.AppID + entitlementID = *revokeTask.AppEntitlementID + default: + return nil, fmt.Errorf("unsupported task type") + } + pterm.Debug.Printf("App ID: %s, Entitlement ID: %s\n", appID, entitlementID) + + if appID == "" || entitlementID == "" { + return nil, fmt.Errorf("could not determine app ID or entitlement ID from task") + } + + entitlement, err := c.GetEntitlement(ctx, appID, entitlementID) + if err != nil { + return nil, err + } + pterm.Debug.Printf("Got entitlement details: %+v\n", entitlement) + approveResp, err := c.ApproveTask(ctx, taskId, comment, policyId) if err != nil { return nil, err } + pterm.Success.Println("Task approved successfully") + + // Check for nil pointers before dereferencing + if entitlement.AppResourceTypeID == nil { + pterm.Warning.Println("Entitlement AppResourceTypeID is nil, skipping AWS SSO profile creation") + return approveResp.TaskView.Task, nil + } + + resourceType, err := c.GetResourceType(ctx, appID, *entitlement.AppResourceTypeID) + if err != nil { + pterm.Warning.Printf("Failed to get resource type details: %v\n", err) + return approveResp.TaskView.Task, nil + } + pterm.Debug.Printf("Got resource type details: %+v\n", resourceType) + + if client.IsAWSPermissionSet(entitlement, resourceType) { + pterm.Info.Println("Detected AWS permission set, getting resource details...") + + if entitlement.AppResourceID == nil { + pterm.Warning.Println("Entitlement AppResourceID is nil, cannot create AWS SSO profile") + return approveResp.TaskView.Task, nil + } + + resource, err := c.GetResource(ctx, appID, *entitlement.AppResourceTypeID, *entitlement.AppResourceID) + if err != nil { + pterm.Warning.Printf("Failed to get resource details: %v\n", err) + return approveResp.TaskView.Task, nil + } + + if err := client.CreateAWSSSOProfile(entitlement, resource); err != nil { + pterm.Warning.Printf("Failed to create AWS SSO profile: %v\n", err) + } else { + pterm.Success.Printf("Successfully created AWS SSO profile for entitlement %s\n", *entitlement.DisplayName) + } + } else { + pterm.Debug.Println("Not an AWS permission set") + } + return approveResp.TaskView.Task, nil }) } 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, func(c client.C1Client, ctx context.Context, taskId string, comment string, policyId string, v *viper.Viper) (*shared.Task, error) { approveResp, err := c.DenyTask(ctx, taskId, comment, policyId) if err != nil { return nil, err @@ -58,7 +147,7 @@ func runDenyTasks(cmd *cobra.Command, args []string) error { func runApproveDeny( cmd *cobra.Command, args []string, - run func(c client.C1Client, ctx context.Context, taskId string, comment string, policyId string) (*shared.Task, error), + run func(c client.C1Client, ctx context.Context, taskId string, comment string, policyId string, v *viper.Viper) (*shared.Task, error), ) error { ctx, c, v, err := cmdContext(cmd) if err != nil { @@ -81,7 +170,7 @@ func runApproveDeny( return errors.New("task does not have a current policy step id and cannot be approved or denied") } - task, err := run(c, ctx, taskId, comment, client.StringFromPtr(taskResp.TaskView.Task.PolicyInstance.PolicyStepInstance.ID)) + task, err := run(c, ctx, taskId, comment, client.StringFromPtr(taskResp.TaskView.Task.PolicyInstance.PolicyStepInstance.ID), v) if err != nil { return err } diff --git a/cmd/cone/task_search.go b/cmd/cone/task_search.go index 72b941ee..dc16e370 100644 --- a/cmd/cone/task_search.go +++ b/cmd/cone/task_search.go @@ -13,8 +13,21 @@ import ( func searchTasksCmd() *cobra.Command { cmd := &cobra.Command{ Use: "search", - Short: "Search for tasks using various filters", - RunE: searchTasksRun, + Short: "Search for tasks in ConductorOne", + Long: `Search for tasks in ConductorOne using various filters. +This command allows you to: +- Search by task type (grant, revoke, certify) +- Filter by task state (open, closed) +- Filter by app, entitlement, resource, or user +- Search for specific access reviews +- Include deleted tasks + +The search results will show: +- Task type and state +- Related apps, entitlements, and resources +- Assignees and subjects +- Creation and update timestamps`, + RunE: searchTasksRun, } addAccessReviewIDsFlag(cmd) diff --git a/cmd/cone/token.go b/cmd/cone/token.go index 3e3afa65..84799e70 100644 --- a/cmd/cone/token.go +++ b/cmd/cone/token.go @@ -18,8 +18,16 @@ type Token struct { func tokenCmd() *cobra.Command { cmd := &cobra.Command{ Use: "token", - Short: "", - RunE: tokenRun, + Short: "Get a ConductorOne API access token", + Long: `Get a ConductorOne API access token using your client credentials. +This command is useful for: +- Debugging authentication issues +- Getting a token to use with other tools +- Verifying your credentials are working correctly + +The token is obtained using OAuth2 client credentials flow and can be used to make API calls to ConductorOne. +Use --raw flag to get just the bearer token without any formatting.`, + RunE: tokenRun, } addRawTokenFlag(cmd) return cmd diff --git a/pkg/client/client.go b/pkg/client/client.go index 1edc0447..9f2ac820 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -63,7 +63,7 @@ type C1Client interface { GetUser(ctx context.Context, userID string) (*shared.User, error) GetEntitlement(ctx context.Context, appID string, entitlementID string) (*shared.AppEntitlement, error) SearchEntitlements(ctx context.Context, filter *SearchEntitlementsFilter) ([]*EntitlementWithBindings, error) - GetResource(ctx context.Context, appID string, resourceID string, resourceTypeID string) (*shared.AppResource, error) + GetResource(ctx context.Context, appID string, resourceTypeID string, resourceID string) (*shared.AppResource, error) GetResourceType(ctx context.Context, appID string, resourceTypeID string) (*shared.AppResourceType, error) GetApp(ctx context.Context, appID string) (*shared.App, error) GetTask(ctx context.Context, taskId string) (*shared.TaskServiceGetResponse, error) @@ -96,6 +96,7 @@ type C1Client interface { ListAppUserCredentials(ctx context.Context, appID string, appUserID string) ([]shared.AppUserCredential, error) ListPolicies(ctx context.Context) ([]shared.Policy, error) ListEntitlements(ctx context.Context, appId string) ([]shared.AppEntitlement, error) + UpdateEntitlement(ctx context.Context, appID, entitlementID string, req *shared.UpdateAppEntitlementRequest) error } func (c *client) BaseURL() string { diff --git a/pkg/client/entitlement.go b/pkg/client/entitlement.go index d57a7246..ebb91148 100644 --- a/pkg/client/entitlement.go +++ b/pkg/client/entitlement.go @@ -221,3 +221,18 @@ func (c *client) ListEntitlements(ctx context.Context, appId string) ([]shared.A return entitlements, nil } + +func (c *client) UpdateEntitlement(ctx context.Context, appID, entitlementID string, req *shared.UpdateAppEntitlementRequest) error { + resp, err := c.sdk.AppEntitlements.Update(ctx, operations.C1APIAppV1AppEntitlementsUpdateRequest{ + AppID: appID, + ID: entitlementID, + UpdateAppEntitlementRequest: req, + }) + if err != nil { + return err + } + if err := NewHTTPError(resp.RawResponse); err != nil { + return err + } + return nil +} diff --git a/pkg/client/error.go b/pkg/client/error.go index c597f480..7152e97f 100644 --- a/pkg/client/error.go +++ b/pkg/client/error.go @@ -55,7 +55,7 @@ func HandleErrors(ctx context.Context, v *viper.Viper, input error) error { return input } outputType := v.GetString("output") - if outputType != "json" && outputType != output.JSONPretty { + if outputType != output.JSON && outputType != output.JSONPretty { return input } var jsonError []byte diff --git a/pkg/client/task.go b/pkg/client/task.go index 0a9f54fb..81d1682b 100644 --- a/pkg/client/task.go +++ b/pkg/client/task.go @@ -2,11 +2,22 @@ package client import ( "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" "github.com/conductorone/conductorone-sdk-go/pkg/models/operations" "github.com/conductorone/conductorone-sdk-go/pkg/models/shared" + "github.com/pterm/pterm" + "github.com/spf13/viper" + + "github.com/conductorone/cone/pkg/output" ) +const nativeIntegrationMode = "native" + func (c *client) GetTask(ctx context.Context, taskId string) (*shared.TaskServiceGetResponse, error) { resp, err := c.sdk.Task.Get(ctx, operations.C1APITaskV1TaskServiceGetRequest{ID: taskId}) if err != nil { @@ -158,3 +169,145 @@ func (c *client) EscalateTask(ctx context.Context, taskID string) (*shared.TaskS } return resp.TaskServiceActionResponse, nil } + +func IsAWSPermissionSet(entitlement *shared.AppEntitlement, resourceType *shared.AppResourceType) bool { + if entitlement == nil || resourceType == nil { + return false + } + + // Check resource type display name + if resourceType.DisplayName != nil { + if strings.Contains(strings.ToLower(*resourceType.DisplayName), "aws permission set") { + return true + } + } + + // Check SourceConnectorIds for AWS SSO permission set ARNs + if entitlement.SourceConnectorIds != nil { + for _, value := range entitlement.SourceConnectorIds { + if strings.Contains(value, "arn:aws:sso:::permissionSet/") { + return true + } + } + } + + return false +} + +// CreateAWSSSOProfile creates an AWS SSO profile for a permission set. +func CreateAWSSSOProfile(entitlement *shared.AppEntitlement, resource *shared.AppResource) error { + if entitlement == nil || resource == nil { + return errors.New("entitlement and resource are required") + } + + // Check integration mode + integrationMode := viper.GetString("aws_integration_mode") + if integrationMode == "" { + integrationMode = nativeIntegrationMode // Default to native if not set + } + + if integrationMode == nativeIntegrationMode { + // In native mode, we don't create profiles + return nil + } + + // Get AWS account ID and permission set ARN from sourceConnectorIds + var accountID, permissionSetARN string + for _, value := range entitlement.SourceConnectorIds { + parts := strings.Split(value, "|") + if len(parts) == 2 { + accountID = parts[0] + permissionSetARN = parts[1] + break + } + } + + if accountID == "" || permissionSetARN == "" { + return errors.New("could not find AWS account ID or permission set ARN in sourceConnectorIds") + } + + // Extract role name from entitlement display name (everything before first space) + if entitlement.DisplayName == nil { + return errors.New("entitlement DisplayName is nil, cannot create AWS SSO profile") + } + displayName := *entitlement.DisplayName // Store safely after nil check + roleName := strings.Split(displayName, " ")[0] + + // Get AWS account name from resource display name + accountName := "aws" + if resource.DisplayName != nil { + accountName = strings.ToLower(strings.ReplaceAll(*resource.DisplayName, " ", "-")) + } + + // Create a profile/session name based on the account name and role name + profileName := fmt.Sprintf("%s-%s", accountName, strings.ToLower(roleName)) + + // Get the SSO start URL from Viper config + ssoStartURL := viper.GetString("aws_sso_start_url") + if ssoStartURL == "" { + return errors.New("AWS SSO start URL is not configured. Please run 'cone config-aws set-sso-url '") + } + + // Create the AWS config directory if it doesn't exist + awsConfigDir := filepath.Join(os.Getenv("HOME"), ".aws") + if err := os.MkdirAll(awsConfigDir, 0700); err != nil { + return fmt.Errorf("failed to create AWS config directory: %w", err) + } + + // Read existing config file + configPath := filepath.Join(awsConfigDir, "config") + configContent, err := os.ReadFile(configPath) + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to read AWS config file: %w", err) + } + + // Check if profile already exists + configStr := string(configContent) + if strings.Contains(configStr, fmt.Sprintf("[profile %s]", profileName)) { + // Only show info message for non-JSON output to avoid corrupting structured output + outputFormat := viper.GetString("output") + if outputFormat != output.JSON && outputFormat != output.JSONPretty { + pterm.Info.Printf("AWS profile '%s' already exists\n", profileName) + } + return nil + } + + // Check if SSO session already exists + ssoSessionExists := strings.Contains(configStr, "[sso-session cone-sso]") + + // Create new profile configuration + newProfile := fmt.Sprintf(` +[profile %s] +credential_process = cone aws-credentials "%s" +cone_sso_account_id = %s +cone_sso_role_name = %s +cone_sso_region = us-east-1 +cone_sso_start_url = %s +cone_sso_registration_scopes = sso:account:access +sso_session = cone-sso +region = us-east-1 +output = json +`, profileName, profileName, accountID, roleName, ssoStartURL) + + // Add SSO session configuration if it doesn't exist + if !ssoSessionExists { + newProfile += fmt.Sprintf(` +[sso-session cone-sso] +sso_start_url = %s +sso_region = us-east-1 +sso_registration_scopes = sso:account:access +`, ssoStartURL) + } + + // Append new profile to config file + if err := os.WriteFile(configPath, append(configContent, []byte(newProfile)...), 0600); err != nil { + return fmt.Errorf("failed to write AWS config file: %w", err) + } + + // Only show success message for non-JSON output to avoid corrupting structured output + outputFormat := viper.GetString("output") + if outputFormat != output.JSON && outputFormat != output.JSONPretty { + pterm.Success.Printf("Successfully created AWS SSO profile for entitlement %s\n", displayName) + } + return nil +} diff --git a/pkg/output/output.go b/pkg/output/output.go index 4ff4758b..334680a2 100644 --- a/pkg/output/output.go +++ b/pkg/output/output.go @@ -11,7 +11,10 @@ type Manager interface { Output(ctx context.Context, out interface{}, opts ...outputOption) error } -const JSONPretty = "json-pretty" +const ( + JSON = "json" + JSONPretty = "json-pretty" +) func NewManager(ctx context.Context, v *viper.Viper) Manager { var area *pterm.AreaPrinter @@ -22,7 +25,7 @@ func NewManager(ctx context.Context, v *viper.Viper) Manager { switch v.GetString("output") { case "table": return &tableManager{area: area, isWide: false} - case "json": + case JSON: return &jsonManager{} case JSONPretty: return &jsonManager{pretty: true}