From 28aab48762e5d101985f132bb27318cde8adaaf7 Mon Sep 17 00:00:00 2001 From: Guillaume Alaux Date: Wed, 1 Apr 2026 08:13:27 +0200 Subject: [PATCH] "Assume role" full support added Modified commands - `iam role create`: supports two new fields - `iam role update`: supports two new fields - `iam role show`: supports two new fields - `iam role assume`: entirely new Example to update a role: bin/exo iam role update \ 23ed17a2-4cc8-43ff-9b15-41fe831f9c4a \ --max-session-ttl 1801 \ --assume-role-policy '{"rules": [ { "action": "allow", "expression": "identity.role_id == '\''aa0d0794-8a81-4e1a-9782-3822b167b58f'\''" } ]}' --- cmd/iam/iam.go | 71 +++++++++++++++++++++++++++++ cmd/iam/iam_role_assume.go | 93 ++++++++++++++++++++++++++++++++++++++ cmd/iam/iam_role_create.go | 54 ++++++++++++++++------ cmd/iam/iam_role_show.go | 48 ++++++++++++++------ cmd/iam/iam_role_update.go | 86 ++++++++++++++++++++--------------- 5 files changed, 288 insertions(+), 64 deletions(-) create mode 100644 cmd/iam/iam_role_assume.go diff --git a/cmd/iam/iam.go b/cmd/iam/iam.go index 16984057a..6aaf00ea2 100644 --- a/cmd/iam/iam.go +++ b/cmd/iam/iam.go @@ -112,3 +112,74 @@ func iamPolicyFromJSON(data []byte) (*v3.IAMPolicy, error) { return &policy, nil } + +type iamAssumeRolePolicyOutput struct { + Rules []iamPolicyServiceRuleOutput `json:"rules"` +} + +func (o *iamAssumeRolePolicyOutput) ToJSON() { output.JSON(o) } +func (o *iamAssumeRolePolicyOutput) ToText() { output.Text(o) } +func (o *iamAssumeRolePolicyOutput) ToTable() { + t := table.NewTable(os.Stdout) + + t.SetHeader([]string{ + "Rule Action", + "Rule Expression", + }) + + // use underlying tablewriter.Render to display table even with empty rows. + defer t.Table.Render() + + for _, rule := range o.Rules { + t.Append([]string{ + rule.Action, + rule.Expression, + }) + } +} + +func iamAssumeRolePolicyToOutput(policy *v3.IAMAssumeRolePolicy) *iamAssumeRolePolicyOutput { + if policy == nil { + return nil + } + + out := &iamAssumeRolePolicyOutput{ + Rules: []iamPolicyServiceRuleOutput{}, + } + + for _, rule := range policy.Rules { + out.Rules = append(out.Rules, iamPolicyServiceRuleOutput{ + Action: string(rule.Action), + Expression: rule.Expression, + }) + } + + return out +} + +func iamAssumeRolePolicyFromJSON(data []byte) (*v3.IAMAssumeRolePolicy, error) { + var obj iamAssumeRolePolicyOutput + err := json.Unmarshal(data, &obj) + if err != nil { + return nil, fmt.Errorf("failed to parse assume role policy: %w", err) + } + + policy := v3.IAMAssumeRolePolicy{} + + if len(obj.Rules) > 0 { + policy.Rules = []v3.IAMServicePolicyRule{} + for _, rl := range obj.Rules { + rule := v3.IAMServicePolicyRule{ + Action: v3.IAMServicePolicyRuleAction(rl.Action), + } + + if rl.Expression != "" { + rule.Expression = rl.Expression + } + + policy.Rules = append(policy.Rules, rule) + } + } + + return &policy, nil +} diff --git a/cmd/iam/iam_role_assume.go b/cmd/iam/iam_role_assume.go new file mode 100644 index 000000000..bc2d423c0 --- /dev/null +++ b/cmd/iam/iam_role_assume.go @@ -0,0 +1,93 @@ +package iam + +import ( + "errors" + "github.com/spf13/cobra" + + exocmd "github.com/exoscale/cli/cmd" + "github.com/exoscale/cli/pkg/account" + "github.com/exoscale/cli/pkg/globalstate" + "github.com/exoscale/cli/pkg/output" + v3 "github.com/exoscale/egoscale/v3" +) + +type iamRoleAssumeOutput struct { + Key string `json:"key"` + Name string `json:"name"` + OrgID string `json:"org-id"` + RoleID string `json:"role-id"` + Secret string `json:"secret"` + ExpiresAT string `json:"expires-at"` +} + +func (o *iamRoleAssumeOutput) ToJSON() { output.JSON(o) } +func (o *iamRoleAssumeOutput) ToText() { output.Text(o) } +func (o *iamRoleAssumeOutput) ToTable() { output.Table(o) } + +type iamRoleAssumeCmd struct { + exocmd.CliCommandSettings `cli-cmd:"-"` + + Role string `cli-arg:"#" cli-usage:"ID|NAME"` + + Ttl int64 `cli-flag:"ttl" cli-usage:"Time To Live for the requested key in seconds"` + + _ bool `cli-cmd:"assume"` +} + +func (c *iamRoleAssumeCmd) CmdAliases() []string { return nil } + +func (c *iamRoleAssumeCmd) CmdShort() string { + return "Request generation of key/secret allowing calls as of target role" +} + +func (c *iamRoleAssumeCmd) CmdLong() string { + return "Request generation of key/secret allowing calls as of target role" +} + +func (c *iamRoleAssumeCmd) CmdPreRun(cmd *cobra.Command, args []string) error { + return exocmd.CliCommandDefaultPreRun(c, cmd, args) +} + +func (c *iamRoleAssumeCmd) CmdRun(cmd *cobra.Command, _ []string) error { + if c.Role == "" { + return errors.New("role not provided") + } + + if c.Ttl <= 0 { + return errors.New("no valid TTL provided") + } + + ctx := exocmd.GContext + client, err := exocmd.SwitchClientZoneV3(ctx, globalstate.EgoscaleV3Client, v3.ZoneName(account.CurrentAccount.DefaultZone)) + if err != nil { + return err + } + + assumeRoleRq := v3.AssumeIAMRoleRequest{} + + if cmd.Flags().Changed(exocmd.MustCLICommandFlagName(c, &c.Ttl)) { + assumeRoleRq.Ttl = c.Ttl + } + + apiKey, err := client.AssumeIAMRole(ctx, v3.UUID(c.Role), assumeRoleRq) + if err != nil { + return err + } + + out := iamRoleAssumeOutput{ + Key: apiKey.Key, + Name: apiKey.Name, + OrgID: apiKey.OrgID, + RoleID: apiKey.RoleID, + Secret: apiKey.Secret, + ExpiresAT: apiKey.ExpiresAT, + } + + return c.OutputFunc(&out, nil) +} + +func init() { + cobra.CheckErr(exocmd.RegisterCLICommand(iamRoleCmd, &iamRoleAssumeCmd{ + CliCommandSettings: exocmd.DefaultCLICmdSettings(), + })) +} diff --git a/cmd/iam/iam_role_create.go b/cmd/iam/iam_role_create.go index d34e24b8a..f6c31cb29 100644 --- a/cmd/iam/iam_role_create.go +++ b/cmd/iam/iam_role_create.go @@ -21,12 +21,14 @@ type iamRoleCreateCmd struct { _ bool `cli-cmd:"create"` - Name string `cli-arg:"#" cli-usage:"NAME"` - Description string `cli-flag:"description" cli-usage:"Role description"` - Permissions []string `cli-flag:"permissions" cli-usage:"Role permissions"` - Editable bool `cli-flag:"editable" cli-usage:"Set --editable=false do prevent editing Policy after creation"` - Labels map[string]string `cli-flag:"label" cli-usage:"Role labels (format: key=value)"` - Policy string `cli-flag:"policy" cli-usage:"Role policy (use '-' to read from STDIN)"` + Name string `cli-arg:"#" cli-usage:"NAME"` + Description string `cli-flag:"description" cli-usage:"Role description"` + Permissions []string `cli-flag:"permissions" cli-usage:"Role permissions"` + Editable bool `cli-flag:"editable" cli-usage:"Set --editable=false do prevent editing Policy after creation"` + Labels map[string]string `cli-flag:"label" cli-usage:"Role labels (format: key=value)"` + Policy string `cli-flag:"policy" cli-usage:"Role policy (use '-' to read from STDIN)"` + AssumeRolePolicy string `cli-flag:"assume-role-policy" cli-usage:"Assume Role policy (use '-' to read from STDIN)"` + MaxSessionTtl int64 `cli-flag:"max-session-ttl" cli-usage:"Maximum TTL requester is allowed to ask for when assuming a role (0 implies default)"` } func (c *iamRoleCreateCmd) CmdAliases() []string { return nil } @@ -37,9 +39,9 @@ func (c *iamRoleCreateCmd) CmdShort() string { func (c *iamRoleCreateCmd) CmdLong() string { return fmt.Sprintf(`This command creates a new IAM Role. -To read the Policy from STDIN, append '-' to the '--policy' flag. +To read a policy from STDIN, append '-' to the '--policy' or '--assume-role-policy' flag. -Pro Tip: you can reuse an existing role policy by providing the output of the show command as input: +Pro Tip: you can reuse an existing policy by providing the output of the show command as input: exo iam role show --policy --output-format json | exo iam role create --policy - @@ -63,6 +65,7 @@ func (c *iamRoleCreateCmd) CmdRun(cmd *cobra.Command, _ []string) error { } var policy *v3.IAMPolicy + var assumeRolePolicy *v3.IAMAssumeRolePolicy // Policy is optional, if not set API will default to `allow all` if c.Policy != "" { @@ -85,18 +88,43 @@ func (c *iamRoleCreateCmd) CmdRun(cmd *cobra.Command, _ []string) error { } } + if c.AssumeRolePolicy != "" { + // If Assume Role Policy value is `-` read from STDIN + if c.AssumeRolePolicy == "-" { + inputReader := cmd.InOrStdin() + b, err := io.ReadAll(inputReader) + if err != nil { + return fmt.Errorf("failed to read assume role policy from stdin: %w", err) + } + + c.AssumeRolePolicy = string(b) + } + + var err error + + assumeRolePolicy, err = iamAssumeRolePolicyFromJSON([]byte(c.AssumeRolePolicy)) + if err != nil { + return fmt.Errorf("failed to parse IAM assume role policy: %w", err) + } + } + role := v3.CreateIAMRoleRequest{ - Name: c.Name, - Editable: &c.Editable, - Labels: c.Labels, - Permissions: c.Permissions, - Policy: policy, + Name: c.Name, + Editable: &c.Editable, + Labels: c.Labels, + Permissions: c.Permissions, + Policy: policy, + AssumeRolePolicy: assumeRolePolicy, } if c.Description != "" { role.Description = c.Description } + if c.MaxSessionTtl != 0 { + role.MaxSessionTtl = c.MaxSessionTtl + } + op, err := client.CreateIAMRole(ctx, role) if err != nil { return err diff --git a/cmd/iam/iam_role_show.go b/cmd/iam/iam_role_show.go index ee34cc389..653ddd715 100644 --- a/cmd/iam/iam_role_show.go +++ b/cmd/iam/iam_role_show.go @@ -16,13 +16,15 @@ import ( ) type iamRoleShowOutput struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - Editable bool `json:"editable"` - Labels map[string]string `json:"labels"` - Permissions []string `json:"permission"` - Policy *iamPolicyOutput `json:"policy" output:"-"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Editable bool `json:"editable"` + Labels map[string]string `json:"labels"` + Permissions []string `json:"permission"` + MaxSessionTtl int64 `json:"max-session-ttl"` + Policy *iamPolicyOutput `json:"policy" output:"-"` + AssumeRolePolicy *iamAssumeRolePolicyOutput `json:"assume-role-policy" output:"-"` } func (o *iamRoleShowOutput) ToJSON() { output.JSON(o) } @@ -33,6 +35,10 @@ func (o *iamRoleShowOutput) ToTable() { fmt.Println("\nPolicy:") o.Policy.ToTable() } + if o.AssumeRolePolicy != nil { + fmt.Println("\nAssume Role Policy:") + o.AssumeRolePolicy.ToTable() + } } func iamPolicyToOutput(policy *v3.IAMPolicy) *iamPolicyOutput { @@ -70,7 +76,8 @@ type iamRoleShowCmd struct { _ bool `cli-cmd:"show"` - Policy bool `cli-flag:"policy" cli-usage:"Print IAM Role policy"` + Policy bool `cli-flag:"policy" cli-usage:"Print IAM Role policy"` + AssumeRolePolicy bool `cli-flag:"assume-role-policy" cli-usage:"Print IAM Assume Role policy"` Role string `cli-arg:"#" cli-usage:"ID|NAME"` } @@ -114,6 +121,8 @@ func (c *iamRoleShowCmd) CmdRun(_ *cobra.Command, _ []string) error { } policy := iamPolicyToOutput(role.Policy) + assumeRolePolicy := iamAssumeRolePolicyToOutput(role.AssumeRolePolicy) + if c.Policy { if policy == nil { return errors.New("role policy not found") @@ -121,14 +130,23 @@ func (c *iamRoleShowCmd) CmdRun(_ *cobra.Command, _ []string) error { return c.OutputFunc(policy, nil) } + if c.AssumeRolePolicy { + if assumeRolePolicy == nil { + return errors.New("role assume role policy not found") + } + return c.OutputFunc(assumeRolePolicy, nil) + } + out := iamRoleShowOutput{ - ID: role.ID.String(), - Description: role.Description, - Editable: utils.DefaultBool(role.Editable, false), - Labels: role.Labels, - Name: role.Name, - Permissions: role.Permissions, - Policy: policy, + ID: role.ID.String(), + Description: role.Description, + Editable: utils.DefaultBool(role.Editable, false), + Labels: role.Labels, + Name: role.Name, + Permissions: role.Permissions, + MaxSessionTtl: role.MaxSessionTtl, + Policy: policy, + AssumeRolePolicy: assumeRolePolicy, } return c.OutputFunc(&out, nil) diff --git a/cmd/iam/iam_role_update.go b/cmd/iam/iam_role_update.go index 67161c71f..683cd0399 100644 --- a/cmd/iam/iam_role_update.go +++ b/cmd/iam/iam_role_update.go @@ -21,10 +21,12 @@ type iamRoleUpdateCmd struct { Role string `cli-arg:"#" cli-usage:"ID|NAME"` - Description string `cli-flag:"description" cli-usage:"Role description"` - Permissions []string `cli-flag:"permissions" cli-usage:"Role permissions"` - Labels map[string]string `cli-flag:"label" cli-usage:"Role labels (format: key=value)"` - Policy string `cli-flag:"policy" cli-usage:"Role policy (use '-' to read from STDIN)"` + Description string `cli-flag:"description" cli-usage:"Role description"` + Permissions []string `cli-flag:"permissions" cli-usage:"Role permissions"` + Labels map[string]string `cli-flag:"label" cli-usage:"Role labels (format: key=value)"` + Policy string `cli-flag:"policy" cli-usage:"Role policy (use '-' to read from STDIN)"` + AssumeRolePolicy string `cli-flag:"assume-role-policy" cli-usage:"Assume Role policy (use '-' to read from STDIN)"` + MaxSessionTtl int64 `cli-flag:"max-session-ttl" cli-usage:"Maximum TTL requester is allowed to ask for when assuming a role"` _ bool `cli-cmd:"update"` } @@ -37,7 +39,7 @@ func (c *iamRoleUpdateCmd) CmdShort() string { func (c *iamRoleUpdateCmd) CmdLong() string { return fmt.Sprintf(`This command updates an IAM Role. -When you supply '-' as a flag argument to '--policy', the new policy will be read from STDIN. +To read a policy from STDIN, append '-' to the '--policy' or '--assume-role-policy' flag. Supported output template annotations: %s`, strings.Join(output.TemplateAnnotations(&iamPolicyOutput{}), ", ")) @@ -78,6 +80,28 @@ func (c *iamRoleUpdateCmd) CmdRun(cmd *cobra.Command, _ []string) error { if cmd.Flags().Changed(exocmd.MustCLICommandFlagName(c, &c.Permissions)) { updateRole.Permissions = c.Permissions } + if cmd.Flags().Changed(exocmd.MustCLICommandFlagName(c, &c.MaxSessionTtl)) { + updateRole.MaxSessionTtl = c.MaxSessionTtl + } + + if c.AssumeRolePolicy != "" { + if c.AssumeRolePolicy == "-" { + inputReader := cmd.InOrStdin() + b, err := io.ReadAll(inputReader) + if err != nil { + return fmt.Errorf("failed to read assume role policy from stdin: %w", err) + } + + c.AssumeRolePolicy = string(b) + } + + assumeRolePolicy, err := iamAssumeRolePolicyFromJSON([]byte(c.AssumeRolePolicy)) + if err != nil { + return fmt.Errorf("failed to parse IAM assume role policy: %w", err) + } + + updateRole.AssumeRolePolicy = assumeRolePolicy + } op, err := client.UpdateIAMRole(ctx, role.ID, updateRole) if err != nil { @@ -90,42 +114,32 @@ func (c *iamRoleUpdateCmd) CmdRun(cmd *cobra.Command, _ []string) error { return err } - // If we don't need to update Policy we can exit now - if c.Policy == "" { - if !globalstate.Quiet { - return (&iamRoleShowCmd{ - CliCommandSettings: c.CliCommandSettings, - Role: role.ID.String(), - }).CmdRun(nil, nil) - } + if c.Policy != "" { + if c.Policy == "-" { + inputReader := cmd.InOrStdin() + b, err := io.ReadAll(inputReader) + if err != nil { + return fmt.Errorf("failed to read policy from stdin: %w", err) + } - return nil - } + c.Policy = string(b) + } - if c.Policy == "-" { - inputReader := cmd.InOrStdin() - b, err := io.ReadAll(inputReader) + policy, err := iamPolicyFromJSON([]byte(c.Policy)) if err != nil { - return fmt.Errorf("failed to read policy from stdin: %w", err) + return fmt.Errorf("failed to parse IAM policy: %w", err) } - c.Policy = string(b) - } - - policy, err := iamPolicyFromJSON([]byte(c.Policy)) - if err != nil { - return fmt.Errorf("failed to parse IAM policy: %w", err) - } - - op, err = client.UpdateIAMRolePolicy(ctx, role.ID, *policy) - if err != nil { - return err - } - utils.DecorateAsyncOperation("Updating IAM role policy...", func() { - _, err = client.Wait(ctx, op, v3.OperationStateSuccess) - }) - if err != nil { - return err + op, err = client.UpdateIAMRolePolicy(ctx, role.ID, *policy) + if err != nil { + return err + } + utils.DecorateAsyncOperation("Updating IAM role policy...", func() { + _, err = client.Wait(ctx, op, v3.OperationStateSuccess) + }) + if err != nil { + return err + } } if !globalstate.Quiet {