Skip to content

Commit 10339da

Browse files
committed
feat: add IAM subcommand
For managing IAM policy bindings against any service.
1 parent 54ef1e0 commit 10339da

File tree

5 files changed

+306
-15
lines changed

5 files changed

+306
-15
lines changed

aipcli/command.go

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package aipcli
22

33
import (
4+
"context"
45
"encoding/base64"
56
"fmt"
67
"os"
@@ -45,7 +46,7 @@ func NewMethodCommand(
4546
Short: initialUpperCase(trimComment(comments[method.FullName()])),
4647
Long: comments[method.FullName()],
4748
}
48-
fromFile := cmd.Flags().StringP("from-file", "f", "", "path to a JSON file containing the request payload")
49+
fromFile := cmd.Flags().String("from-file", "", "path to a JSON file containing the request payload")
4950
_ = cmd.MarkFlagFilename("from-file", "json")
5051
setFlags(comments, cmd, nil, in.ProtoReflect().Descriptor(), in.ProtoReflect)
5152
cmd.RunE = func(cmd *cobra.Command, args []string) error {
@@ -58,21 +59,25 @@ func NewMethodCommand(
5859
return err
5960
}
6061
}
61-
conn, err := dial(cmd.Context())
62-
if err != nil {
63-
return err
64-
}
65-
LogRequest(cmd.Context(), in)
66-
if err := conn.Invoke(cmd.Context(), methodURI(method), in, out); err != nil {
67-
LogError(cmd.Context(), err)
68-
os.Exit(1)
69-
}
70-
LogResponse(cmd.Context(), out)
71-
return nil
62+
return invoke(cmd.Context(), methodURI(method), in, out)
7263
}
7364
return cmd
7465
}
7566

67+
func invoke(ctx context.Context, uri string, request, response proto.Message) error {
68+
conn, err := dial(ctx)
69+
if err != nil {
70+
return err
71+
}
72+
logRequest(ctx, request)
73+
if err := conn.Invoke(ctx, uri, request, response); err != nil {
74+
logError(ctx, err)
75+
return err
76+
}
77+
logResponse(ctx, response)
78+
return nil
79+
}
80+
7681
func serviceUse(service protoreflect.ServiceDescriptor) string {
7782
result := string(service.Name())
7883
result = strings.TrimSuffix(result, "Service")

aipcli/iam.go

Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
package aipcli
2+
3+
import (
4+
"os"
5+
"strings"
6+
7+
"github.com/spf13/cobra"
8+
"google.golang.org/genproto/googleapis/iam/v1"
9+
"google.golang.org/protobuf/encoding/protojson"
10+
)
11+
12+
// NewIAMCommand returns a *cobra.Command for the google.iam.v1.IAMPolicy service.
13+
func NewIAMCommand() *cobra.Command {
14+
cmd := &cobra.Command{
15+
Use: "iam",
16+
Short: "IAM policy service",
17+
Long: strings.TrimSpace(`
18+
Manages Identity and Access Management (IAM) policies.
19+
20+
Any implementation of an API that offers access control features
21+
implements the google.iam.v1.IAMPolicy interface.
22+
23+
Access control is applied when a principal (user or service account), takes
24+
some action on a resource exposed by a service. Resources, identified by
25+
URI-like names, are the unit of access control specification. Service
26+
implementations can choose the granularity of access control and the
27+
supported permissions for their resources.
28+
29+
For example one database service may allow access control to be
30+
specified only at the Table level, whereas another might allow access control
31+
to also be specified at the Column level.
32+
33+
This is intentionally not a CRUD style API because access control policies
34+
are created and deleted implicitly with the resources to which they are
35+
attached.
36+
`),
37+
}
38+
cmd.AddCommand(newSetIAMPolicyCommand())
39+
cmd.AddCommand(newGetIAMPolicyCommand())
40+
cmd.AddCommand(newAddIAMPolicyBindingCommand())
41+
cmd.AddCommand(newRemoveIAMPolicyBindingCommand())
42+
return cmd
43+
}
44+
45+
func newGetIAMPolicyCommand() *cobra.Command {
46+
cmd := &cobra.Command{
47+
Use: "get-iam-policy",
48+
Short: "get the IAM policy for a resource",
49+
Long: strings.TrimSpace(`
50+
Get the IAM policy associated with a resource.
51+
52+
The output includes an "etag" identifier that is used to check for
53+
concurrent policy updates. An edited policy should include the same
54+
etag so that set-iam-policy applies the changes to the correct policy
55+
version.
56+
57+
This command can fail for the following reasons:
58+
59+
▪ The resource specified does not exist.
60+
▪ The active account does not have permission to access the given
61+
resource's IAM policies.
62+
`),
63+
}
64+
resource := cmd.Flags().String("resource", "", "the resource for which the policy is being requested")
65+
_ = cmd.MarkFlagRequired("resource")
66+
_ = cmd.RegisterFlagCompletionFunc("resource", completeResource)
67+
cmd.RunE = func(cmd *cobra.Command, args []string) error {
68+
return invoke(
69+
cmd.Context(),
70+
"/google.iam.v1.IAMPolicy/GetIamPolicy",
71+
&iam.GetIamPolicyRequest{Resource: *resource},
72+
&iam.Policy{},
73+
)
74+
}
75+
return cmd
76+
}
77+
78+
func newSetIAMPolicyCommand() *cobra.Command {
79+
cmd := &cobra.Command{
80+
Use: "set-iam-policy",
81+
Short: "set the IAM policy for a resource",
82+
Long: strings.TrimSpace(`
83+
Set the IAM policy associated with a resource.
84+
85+
This command can fail for the following reasons:
86+
87+
▪ The resource specified does not exist.
88+
▪ The active account does not have permission to access the given
89+
resource's IAM policies.
90+
`),
91+
}
92+
resource := cmd.Flags().String("resource", "", "the resource for which the policy is being specified")
93+
_ = cmd.MarkFlagRequired("resource")
94+
_ = cmd.RegisterFlagCompletionFunc("resource", completeResource)
95+
policyFile := cmd.Flags().String("policy-file", "", "path to a local JSON file containing a valid policy")
96+
_ = cmd.MarkFlagRequired("policy-file")
97+
_ = cmd.MarkFlagFilename("policy-file", "json")
98+
cmd.RunE = func(cmd *cobra.Command, args []string) error {
99+
data, err := os.ReadFile(*policyFile)
100+
if err != nil {
101+
return err
102+
}
103+
var policy iam.Policy
104+
if err := protojson.Unmarshal(data, &policy); err != nil {
105+
return err
106+
}
107+
return invoke(
108+
cmd.Context(),
109+
"/google.iam.v1.IAMPolicy/SetIamPolicy",
110+
&iam.SetIamPolicyRequest{Resource: *resource, Policy: &policy},
111+
&iam.Policy{},
112+
)
113+
}
114+
return cmd
115+
}
116+
117+
func newAddIAMPolicyBindingCommand() *cobra.Command {
118+
cmd := &cobra.Command{
119+
Use: "add-iam-policy-binding",
120+
Short: "add an IAM policy binding to the IAM policy of a resource",
121+
Long: strings.TrimSpace(`
122+
Adds an IAM policy binding to the IAM policy of a resource.
123+
124+
One binding consists of a member, a role, and an optional condition.
125+
126+
This command can fail for the following reasons:
127+
128+
▪ The resource specified does not exist.
129+
▪ The active account does not have permission to access the given
130+
resource's IAM policies.
131+
`),
132+
}
133+
resource := cmd.Flags().String("resource", "", "the resource for which the policy binding is being added")
134+
_ = cmd.MarkFlagRequired("resource")
135+
_ = cmd.RegisterFlagCompletionFunc("resource", completeResource)
136+
member := cmd.Flags().String("member", "", "principal to add the binding for")
137+
_ = cmd.MarkFlagRequired("member")
138+
_ = cmd.RegisterFlagCompletionFunc("member", completeMember)
139+
role := cmd.Flags().String("role", "", "role name to assign to the principal")
140+
_ = cmd.MarkFlagRequired("role")
141+
_ = cmd.RegisterFlagCompletionFunc("role", completeRole)
142+
cmd.RunE = func(cmd *cobra.Command, args []string) error {
143+
var policy iam.Policy
144+
if err := invoke(
145+
cmd.Context(),
146+
"/google.iam.v1.IAMPolicy/GetIamPolicy",
147+
&iam.GetIamPolicyRequest{Resource: *resource},
148+
&policy,
149+
); err != nil {
150+
return err
151+
}
152+
addBinding(&policy, *member, *role)
153+
return invoke(
154+
cmd.Context(),
155+
"/google.iam.v1.IAMPolicy/SetIamPolicy",
156+
&iam.SetIamPolicyRequest{Resource: *resource, Policy: &policy},
157+
&iam.Policy{},
158+
)
159+
}
160+
return cmd
161+
}
162+
163+
func newRemoveIAMPolicyBindingCommand() *cobra.Command {
164+
cmd := &cobra.Command{
165+
Use: "remove-iam-policy-binding",
166+
Short: "remove an IAM policy binding from the IAM policy of a resource",
167+
Long: strings.TrimSpace(`
168+
Removes an IAM policy binding from the IAM policy of a resource.
169+
One binding consists of a member, a role, and an optional condition.
170+
171+
This command can fail for the following reasons:
172+
173+
▪ The resource specified does not exist.
174+
▪ The active account does not have permission to access the given
175+
resource's IAM policies.
176+
`),
177+
}
178+
resource := cmd.Flags().String("resource", "", "the resource for which the policy binding is being removed")
179+
_ = cmd.MarkFlagRequired("resource")
180+
_ = cmd.RegisterFlagCompletionFunc("resource", completeResource)
181+
member := cmd.Flags().String("member", "", "principal to remove the binding for")
182+
_ = cmd.MarkFlagRequired("member")
183+
_ = cmd.RegisterFlagCompletionFunc("member", completeMember)
184+
role := cmd.Flags().String("role", "", "role name to remove the principal from")
185+
_ = cmd.MarkFlagRequired("role")
186+
_ = cmd.RegisterFlagCompletionFunc("role", completeRole)
187+
cmd.RunE = func(cmd *cobra.Command, args []string) error {
188+
var policy iam.Policy
189+
if err := invoke(
190+
cmd.Context(),
191+
"/google.iam.v1.IAMPolicy/GetIamPolicy",
192+
&iam.GetIamPolicyRequest{Resource: *resource},
193+
&policy,
194+
); err != nil {
195+
return err
196+
}
197+
removeBinding(&policy, *member, *role)
198+
return invoke(
199+
cmd.Context(),
200+
"/google.iam.v1.IAMPolicy/SetIamPolicy",
201+
&iam.SetIamPolicyRequest{Resource: *resource, Policy: &policy},
202+
&iam.Policy{},
203+
)
204+
}
205+
return cmd
206+
}
207+
208+
func completeResource(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
209+
return cobra.AppendActiveHelp(
210+
nil,
211+
"Please enter a valid resource name. Example: resources/1234",
212+
), cobra.ShellCompDirectiveNoFileComp
213+
}
214+
215+
func completeMember(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
216+
return cobra.AppendActiveHelp(
217+
nil,
218+
"Please enter a valid member. Example: email:[email protected]",
219+
), cobra.ShellCompDirectiveNoFileComp
220+
}
221+
222+
func completeRole(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
223+
// TODO: Use the QueryGrantableRoles method to auto-complete grantable roles.
224+
return cobra.AppendActiveHelp(
225+
nil,
226+
"Please enter a valid role name. Example: roles/example.admin",
227+
), cobra.ShellCompDirectiveNoFileComp
228+
}
229+
230+
func addBinding(policy *iam.Policy, member, role string) {
231+
// look for existing binding with this role and member
232+
for _, binding := range policy.Bindings {
233+
if binding.Role == role {
234+
for _, bindingMember := range binding.Members {
235+
if bindingMember == member {
236+
// already have a binding with this role and member
237+
return
238+
}
239+
}
240+
// already have a binding with this role, but not the member
241+
binding.Members = append(binding.Members, member)
242+
return
243+
}
244+
}
245+
// add a new binding with this role and member
246+
policy.Bindings = append(policy.Bindings, &iam.Binding{
247+
Role: role,
248+
Members: []string{member},
249+
})
250+
}
251+
252+
func removeBinding(policy *iam.Policy, member, role string) {
253+
for _, binding := range policy.Bindings {
254+
if binding.Role == role {
255+
binding.Members = removeMember(binding.Members, member)
256+
if len(binding.Members) == 0 {
257+
policy.Bindings = removeRole(policy.Bindings, role)
258+
}
259+
return
260+
}
261+
}
262+
}
263+
264+
func removeMember(members []string, member string) []string {
265+
for i, candidate := range members {
266+
if candidate == member {
267+
return append(members[:i], members[i+1:]...)
268+
}
269+
}
270+
return members
271+
}
272+
273+
func removeRole(bindings []*iam.Binding, role string) []*iam.Binding {
274+
for i, binding := range bindings {
275+
if binding.Role == role {
276+
return append(bindings[:i], bindings[i+1:]...)
277+
}
278+
}
279+
return bindings
280+
}

aipcli/log.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ func Logf(ctx context.Context, format string, args ...interface{}) {
1818
_, _ = fmt.Fprintf(os.Stderr, ">> %s\n", fmt.Sprintf(format, args...))
1919
}
2020

21-
func LogRequest(ctx context.Context, request proto.Message) {
21+
func logRequest(ctx context.Context, request proto.Message) {
2222
if !ConfigFromContext(ctx).Runtime.Verbose {
2323
return
2424
}
@@ -30,11 +30,11 @@ func LogRequest(ctx context.Context, request proto.Message) {
3030
}
3131
}
3232

33-
func LogResponse(ctx context.Context, request proto.Message) {
33+
func logResponse(ctx context.Context, request proto.Message) {
3434
fmt.Println(protojson.MarshalOptions{Multiline: true}.Format(request))
3535
}
3636

37-
func LogError(ctx context.Context, err error) {
37+
func logError(ctx context.Context, err error) {
3838
_, _ = fmt.Fprintf(os.Stderr, "%v\n", err)
3939
for _, detail := range status.Convert(err).Details() {
4040
_, _ = fmt.Fprintf(os.Stderr, "%v\n", detail)

cmd/examplectl/root.go

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/gencli/rootcommand.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ func GenerateRootCommandFile(gen *protogen.Plugin, config aipcli.CompilerConfig)
2525
GoImportPath: "go.einride.tech/aip-cli/aipcli",
2626
GoName: "Config",
2727
})
28+
newIAMCommand := g.QualifiedGoIdent(protogen.GoIdent{
29+
GoImportPath: "go.einride.tech/aip-cli/aipcli",
30+
GoName: "NewIAMCommand",
31+
})
2832
g.P()
2933
g.P("func NewRootCommand() *", cobraCommand, " {")
3034
g.P("cmd := &", cobraCommand, "{")
@@ -48,6 +52,7 @@ func GenerateRootCommandFile(gen *protogen.Plugin, config aipcli.CompilerConfig)
4852
g.P("}())")
4953
}
5054
}
55+
g.P("cmd.AddCommand(", newIAMCommand, "())")
5156
g.P("return cmd")
5257
g.P("}")
5358
g.P()

0 commit comments

Comments
 (0)