Skip to content

Commit

Permalink
feat: add IAM subcommand
Browse files Browse the repository at this point in the history
For managing IAM policy bindings against any service.
  • Loading branch information
odsod committed Jul 28, 2022
1 parent 54ef1e0 commit 10339da
Show file tree
Hide file tree
Showing 5 changed files with 306 additions and 15 deletions.
29 changes: 17 additions & 12 deletions aipcli/command.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package aipcli

import (
"context"
"encoding/base64"
"fmt"
"os"
Expand Down Expand Up @@ -45,7 +46,7 @@ func NewMethodCommand(
Short: initialUpperCase(trimComment(comments[method.FullName()])),
Long: comments[method.FullName()],
}
fromFile := cmd.Flags().StringP("from-file", "f", "", "path to a JSON file containing the request payload")
fromFile := cmd.Flags().String("from-file", "", "path to a JSON file containing the request payload")
_ = cmd.MarkFlagFilename("from-file", "json")
setFlags(comments, cmd, nil, in.ProtoReflect().Descriptor(), in.ProtoReflect)
cmd.RunE = func(cmd *cobra.Command, args []string) error {
Expand All @@ -58,21 +59,25 @@ func NewMethodCommand(
return err
}
}
conn, err := dial(cmd.Context())
if err != nil {
return err
}
LogRequest(cmd.Context(), in)
if err := conn.Invoke(cmd.Context(), methodURI(method), in, out); err != nil {
LogError(cmd.Context(), err)
os.Exit(1)
}
LogResponse(cmd.Context(), out)
return nil
return invoke(cmd.Context(), methodURI(method), in, out)
}
return cmd
}

func invoke(ctx context.Context, uri string, request, response proto.Message) error {
conn, err := dial(ctx)
if err != nil {
return err
}
logRequest(ctx, request)
if err := conn.Invoke(ctx, uri, request, response); err != nil {
logError(ctx, err)
return err
}
logResponse(ctx, response)
return nil
}

func serviceUse(service protoreflect.ServiceDescriptor) string {
result := string(service.Name())
result = strings.TrimSuffix(result, "Service")
Expand Down
280 changes: 280 additions & 0 deletions aipcli/iam.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,280 @@
package aipcli

import (
"os"
"strings"

"github.com/spf13/cobra"
"google.golang.org/genproto/googleapis/iam/v1"
"google.golang.org/protobuf/encoding/protojson"
)

// NewIAMCommand returns a *cobra.Command for the google.iam.v1.IAMPolicy service.
func NewIAMCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "iam",
Short: "IAM policy service",
Long: strings.TrimSpace(`
Manages Identity and Access Management (IAM) policies.
Any implementation of an API that offers access control features
implements the google.iam.v1.IAMPolicy interface.
Access control is applied when a principal (user or service account), takes
some action on a resource exposed by a service. Resources, identified by
URI-like names, are the unit of access control specification. Service
implementations can choose the granularity of access control and the
supported permissions for their resources.
For example one database service may allow access control to be
specified only at the Table level, whereas another might allow access control
to also be specified at the Column level.
This is intentionally not a CRUD style API because access control policies
are created and deleted implicitly with the resources to which they are
attached.
`),
}
cmd.AddCommand(newSetIAMPolicyCommand())
cmd.AddCommand(newGetIAMPolicyCommand())
cmd.AddCommand(newAddIAMPolicyBindingCommand())
cmd.AddCommand(newRemoveIAMPolicyBindingCommand())
return cmd
}

func newGetIAMPolicyCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "get-iam-policy",
Short: "get the IAM policy for a resource",
Long: strings.TrimSpace(`
Get the IAM policy associated with a resource.
The output includes an "etag" identifier that is used to check for
concurrent policy updates. An edited policy should include the same
etag so that set-iam-policy applies the changes to the correct policy
version.
This command can fail for the following reasons:
▪ The resource specified does not exist.
▪ The active account does not have permission to access the given
resource's IAM policies.
`),
}
resource := cmd.Flags().String("resource", "", "the resource for which the policy is being requested")
_ = cmd.MarkFlagRequired("resource")
_ = cmd.RegisterFlagCompletionFunc("resource", completeResource)
cmd.RunE = func(cmd *cobra.Command, args []string) error {
return invoke(
cmd.Context(),
"/google.iam.v1.IAMPolicy/GetIamPolicy",
&iam.GetIamPolicyRequest{Resource: *resource},
&iam.Policy{},
)
}
return cmd
}

func newSetIAMPolicyCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "set-iam-policy",
Short: "set the IAM policy for a resource",
Long: strings.TrimSpace(`
Set the IAM policy associated with a resource.
This command can fail for the following reasons:
▪ The resource specified does not exist.
▪ The active account does not have permission to access the given
resource's IAM policies.
`),
}
resource := cmd.Flags().String("resource", "", "the resource for which the policy is being specified")
_ = cmd.MarkFlagRequired("resource")
_ = cmd.RegisterFlagCompletionFunc("resource", completeResource)
policyFile := cmd.Flags().String("policy-file", "", "path to a local JSON file containing a valid policy")
_ = cmd.MarkFlagRequired("policy-file")
_ = cmd.MarkFlagFilename("policy-file", "json")
cmd.RunE = func(cmd *cobra.Command, args []string) error {
data, err := os.ReadFile(*policyFile)
if err != nil {
return err
}
var policy iam.Policy
if err := protojson.Unmarshal(data, &policy); err != nil {
return err
}
return invoke(
cmd.Context(),
"/google.iam.v1.IAMPolicy/SetIamPolicy",
&iam.SetIamPolicyRequest{Resource: *resource, Policy: &policy},
&iam.Policy{},
)
}
return cmd
}

func newAddIAMPolicyBindingCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "add-iam-policy-binding",
Short: "add an IAM policy binding to the IAM policy of a resource",
Long: strings.TrimSpace(`
Adds an IAM policy binding to the IAM policy of a resource.
One binding consists of a member, a role, and an optional condition.
This command can fail for the following reasons:
▪ The resource specified does not exist.
▪ The active account does not have permission to access the given
resource's IAM policies.
`),
}
resource := cmd.Flags().String("resource", "", "the resource for which the policy binding is being added")
_ = cmd.MarkFlagRequired("resource")
_ = cmd.RegisterFlagCompletionFunc("resource", completeResource)
member := cmd.Flags().String("member", "", "principal to add the binding for")
_ = cmd.MarkFlagRequired("member")
_ = cmd.RegisterFlagCompletionFunc("member", completeMember)
role := cmd.Flags().String("role", "", "role name to assign to the principal")
_ = cmd.MarkFlagRequired("role")
_ = cmd.RegisterFlagCompletionFunc("role", completeRole)
cmd.RunE = func(cmd *cobra.Command, args []string) error {
var policy iam.Policy
if err := invoke(
cmd.Context(),
"/google.iam.v1.IAMPolicy/GetIamPolicy",
&iam.GetIamPolicyRequest{Resource: *resource},
&policy,
); err != nil {
return err
}
addBinding(&policy, *member, *role)
return invoke(
cmd.Context(),
"/google.iam.v1.IAMPolicy/SetIamPolicy",
&iam.SetIamPolicyRequest{Resource: *resource, Policy: &policy},
&iam.Policy{},
)
}
return cmd
}

func newRemoveIAMPolicyBindingCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "remove-iam-policy-binding",
Short: "remove an IAM policy binding from the IAM policy of a resource",
Long: strings.TrimSpace(`
Removes an IAM policy binding from the IAM policy of a resource.
One binding consists of a member, a role, and an optional condition.
This command can fail for the following reasons:
▪ The resource specified does not exist.
▪ The active account does not have permission to access the given
resource's IAM policies.
`),
}
resource := cmd.Flags().String("resource", "", "the resource for which the policy binding is being removed")
_ = cmd.MarkFlagRequired("resource")
_ = cmd.RegisterFlagCompletionFunc("resource", completeResource)
member := cmd.Flags().String("member", "", "principal to remove the binding for")
_ = cmd.MarkFlagRequired("member")
_ = cmd.RegisterFlagCompletionFunc("member", completeMember)
role := cmd.Flags().String("role", "", "role name to remove the principal from")
_ = cmd.MarkFlagRequired("role")
_ = cmd.RegisterFlagCompletionFunc("role", completeRole)
cmd.RunE = func(cmd *cobra.Command, args []string) error {
var policy iam.Policy
if err := invoke(
cmd.Context(),
"/google.iam.v1.IAMPolicy/GetIamPolicy",
&iam.GetIamPolicyRequest{Resource: *resource},
&policy,
); err != nil {
return err
}
removeBinding(&policy, *member, *role)
return invoke(
cmd.Context(),
"/google.iam.v1.IAMPolicy/SetIamPolicy",
&iam.SetIamPolicyRequest{Resource: *resource, Policy: &policy},
&iam.Policy{},
)
}
return cmd
}

func completeResource(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
return cobra.AppendActiveHelp(
nil,
"Please enter a valid resource name. Example: resources/1234",
), cobra.ShellCompDirectiveNoFileComp
}

func completeMember(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
return cobra.AppendActiveHelp(
nil,
"Please enter a valid member. Example: email:[email protected]",
), cobra.ShellCompDirectiveNoFileComp
}

func completeRole(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
// TODO: Use the QueryGrantableRoles method to auto-complete grantable roles.
return cobra.AppendActiveHelp(
nil,
"Please enter a valid role name. Example: roles/example.admin",
), cobra.ShellCompDirectiveNoFileComp
}

func addBinding(policy *iam.Policy, member, role string) {
// look for existing binding with this role and member
for _, binding := range policy.Bindings {
if binding.Role == role {
for _, bindingMember := range binding.Members {
if bindingMember == member {
// already have a binding with this role and member
return
}
}
// already have a binding with this role, but not the member
binding.Members = append(binding.Members, member)
return
}
}
// add a new binding with this role and member
policy.Bindings = append(policy.Bindings, &iam.Binding{
Role: role,
Members: []string{member},
})
}

func removeBinding(policy *iam.Policy, member, role string) {
for _, binding := range policy.Bindings {
if binding.Role == role {
binding.Members = removeMember(binding.Members, member)
if len(binding.Members) == 0 {
policy.Bindings = removeRole(policy.Bindings, role)
}
return
}
}
}

func removeMember(members []string, member string) []string {
for i, candidate := range members {
if candidate == member {
return append(members[:i], members[i+1:]...)
}
}
return members
}

func removeRole(bindings []*iam.Binding, role string) []*iam.Binding {
for i, binding := range bindings {
if binding.Role == role {
return append(bindings[:i], bindings[i+1:]...)
}
}
return bindings
}
6 changes: 3 additions & 3 deletions aipcli/log.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ func Logf(ctx context.Context, format string, args ...interface{}) {
_, _ = fmt.Fprintf(os.Stderr, ">> %s\n", fmt.Sprintf(format, args...))
}

func LogRequest(ctx context.Context, request proto.Message) {
func logRequest(ctx context.Context, request proto.Message) {
if !ConfigFromContext(ctx).Runtime.Verbose {
return
}
Expand All @@ -30,11 +30,11 @@ func LogRequest(ctx context.Context, request proto.Message) {
}
}

func LogResponse(ctx context.Context, request proto.Message) {
func logResponse(ctx context.Context, request proto.Message) {
fmt.Println(protojson.MarshalOptions{Multiline: true}.Format(request))
}

func LogError(ctx context.Context, err error) {
func logError(ctx context.Context, err error) {
_, _ = fmt.Fprintf(os.Stderr, "%v\n", err)
for _, detail := range status.Convert(err).Details() {
_, _ = fmt.Fprintf(os.Stderr, "%v\n", detail)
Expand Down
1 change: 1 addition & 0 deletions cmd/examplectl/root.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions internal/gencli/rootcommand.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ func GenerateRootCommandFile(gen *protogen.Plugin, config aipcli.CompilerConfig)
GoImportPath: "go.einride.tech/aip-cli/aipcli",
GoName: "Config",
})
newIAMCommand := g.QualifiedGoIdent(protogen.GoIdent{
GoImportPath: "go.einride.tech/aip-cli/aipcli",
GoName: "NewIAMCommand",
})
g.P()
g.P("func NewRootCommand() *", cobraCommand, " {")
g.P("cmd := &", cobraCommand, "{")
Expand All @@ -48,6 +52,7 @@ func GenerateRootCommandFile(gen *protogen.Plugin, config aipcli.CompilerConfig)
g.P("}())")
}
}
g.P("cmd.AddCommand(", newIAMCommand, "())")
g.P("return cmd")
g.P("}")
g.P()
Expand Down

0 comments on commit 10339da

Please sign in to comment.