diff --git a/cloudmock/gce/BUILD.bazel b/cloudmock/gce/BUILD.bazel index a87182fc0e..8ca8e68db9 100644 --- a/cloudmock/gce/BUILD.bazel +++ b/cloudmock/gce/BUILD.bazel @@ -6,6 +6,7 @@ go_library( importpath = "k8s.io/kops/cloudmock/gce", visibility = ["//visibility:public"], deps = [ + "//cloudmock/gce/mockcloudresourcemanager:go_default_library", "//cloudmock/gce/mockcompute:go_default_library", "//cloudmock/gce/mockdns:go_default_library", "//cloudmock/gce/mockiam:go_default_library", @@ -16,6 +17,7 @@ go_library( "//pkg/cloudinstances:go_default_library", "//upup/pkg/fi:go_default_library", "//upup/pkg/fi/cloudup/gce:go_default_library", + "//vendor/google.golang.org/api/cloudresourcemanager/v1:go_default_library", "//vendor/google.golang.org/api/compute/v1:go_default_library", "//vendor/google.golang.org/api/iam/v1:go_default_library", "//vendor/google.golang.org/api/storage/v1:go_default_library", diff --git a/cloudmock/gce/mock_gce_cloud.go b/cloudmock/gce/mock_gce_cloud.go index de7e5dca0f..c60f35e529 100644 --- a/cloudmock/gce/mock_gce_cloud.go +++ b/cloudmock/gce/mock_gce_cloud.go @@ -19,11 +19,13 @@ package gce import ( "fmt" + "google.golang.org/api/cloudresourcemanager/v1" compute "google.golang.org/api/compute/v1" "google.golang.org/api/iam/v1" "google.golang.org/api/storage/v1" v1 "k8s.io/api/core/v1" "k8s.io/klog/v2" + "k8s.io/kops/cloudmock/gce/mockcloudresourcemanager" mockcompute "k8s.io/kops/cloudmock/gce/mockcompute" "k8s.io/kops/cloudmock/gce/mockdns" "k8s.io/kops/cloudmock/gce/mockiam" @@ -42,10 +44,11 @@ type MockGCECloud struct { region string labels map[string]string - computeClient *mockcompute.MockClient - dnsClient *mockdns.MockClient - iamClient *iam.Service - storageClient *storage.Service + computeClient *mockcompute.MockClient + dnsClient *mockdns.MockClient + iamClient *iam.Service + storageClient *storage.Service + cloudResourceManagerClient *cloudresourcemanager.Service } var _ gce.GCECloud = &MockGCECloud{} @@ -53,12 +56,13 @@ var _ gce.GCECloud = &MockGCECloud{} // InstallMockGCECloud registers a MockGCECloud implementation for the specified region & project func InstallMockGCECloud(region string, project string) *MockGCECloud { c := &MockGCECloud{ - project: project, - region: region, - computeClient: mockcompute.NewMockClient(project), - dnsClient: mockdns.NewMockClient(), - iamClient: mockiam.New(project), - storageClient: mockstorage.New(), + project: project, + region: region, + computeClient: mockcompute.NewMockClient(project), + dnsClient: mockdns.NewMockClient(), + iamClient: mockiam.New(project), + storageClient: mockstorage.New(), + cloudResourceManagerClient: mockcloudresourcemanager.New(), } gce.CacheGCECloudInstance(region, project, c) return c @@ -117,6 +121,11 @@ func (c *MockGCECloud) IAM() *iam.Service { return c.iamClient } +// CloudResourceManager returns the client for the cloudresourcemanager API +func (c *MockGCECloud) CloudResourceManager() *cloudresourcemanager.Service { + return c.cloudResourceManagerClient +} + // CloudDNS returns the DNS client func (c *MockGCECloud) CloudDNS() gce.DNSClient { return c.dnsClient diff --git a/cloudmock/gce/mockcloudresourcemanager/BUILD.bazel b/cloudmock/gce/mockcloudresourcemanager/BUILD.bazel new file mode 100644 index 0000000000..319d3c2d8d --- /dev/null +++ b/cloudmock/gce/mockcloudresourcemanager/BUILD.bazel @@ -0,0 +1,17 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library") + +go_library( + name = "go_default_library", + srcs = [ + "api.go", + "projects.go", + ], + importpath = "k8s.io/kops/cloudmock/gce/mockcloudresourcemanager", + visibility = ["//visibility:public"], + deps = [ + "//cloudmock/gce/gcphttp:go_default_library", + "//vendor/google.golang.org/api/cloudresourcemanager/v1:go_default_library", + "//vendor/google.golang.org/api/option:go_default_library", + "//vendor/k8s.io/klog/v2:go_default_library", + ], +) diff --git a/cloudmock/gce/mockcloudresourcemanager/api.go b/cloudmock/gce/mockcloudresourcemanager/api.go new file mode 100644 index 0000000000..1f0db1a1ae --- /dev/null +++ b/cloudmock/gce/mockcloudresourcemanager/api.go @@ -0,0 +1,81 @@ +/* +Copyright 2021 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package mockcloudresourcemanager + +import ( + "context" + "fmt" + "net/http" + "strings" + + "google.golang.org/api/cloudresourcemanager/v1" + option "google.golang.org/api/option" + "k8s.io/klog/v2" +) + +// mockCloudResourceManagerService represents a mocked cloudresourcemanager client. +type mockCloudResourceManagerService struct { + svc *cloudresourcemanager.Service + + projects projects +} + +// New creates a new mock cloudresourcemanager client. +func New() *cloudresourcemanager.Service { + ctx := context.Background() + + s := &mockCloudResourceManagerService{} + + s.projects.Init() + + httpClient := &http.Client{Transport: s} + svc, err := cloudresourcemanager.NewService(ctx, option.WithHTTPClient(httpClient)) + if err != nil { + klog.Fatalf("failed to build mock cloudresourcemanager service: %v", err) + } + s.svc = svc + return svc +} + +func (s *mockCloudResourceManagerService) RoundTrip(request *http.Request) (*http.Response, error) { + url := request.URL + if url.Host != "cloudresourcemanager.googleapis.com" { + return nil, fmt.Errorf("unexpected host in request %#v", request) + } + + pathTokens := strings.Split(strings.TrimPrefix(url.Path, "/"), "/") + if len(pathTokens) >= 1 && pathTokens[0] == "v1" { + if len(pathTokens) >= 3 && pathTokens[1] == "projects" { + projectTokens := strings.Split(pathTokens[2], ":") + if len(projectTokens) == 2 { + projectID := projectTokens[0] + verb := projectTokens[1] + + if request.Method == "POST" && verb == "getIamPolicy" { + return s.projects.getIAMPolicy(projectID, request) + } + + if request.Method == "POST" && verb == "setIamPolicy" { + return s.projects.setIAMPolicy(projectID, request) + } + } + } + } + + // klog.Warningf("request: %s %s %#v", request.Method, request.URL, request) + return nil, fmt.Errorf("unhandled request %#v", request) +} diff --git a/cloudmock/gce/mockcloudresourcemanager/projects.go b/cloudmock/gce/mockcloudresourcemanager/projects.go new file mode 100644 index 0000000000..d0c570138d --- /dev/null +++ b/cloudmock/gce/mockcloudresourcemanager/projects.go @@ -0,0 +1,93 @@ +/* +Copyright 2021 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package mockcloudresourcemanager + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "net/http" + "sync" + + "google.golang.org/api/cloudresourcemanager/v1" + "k8s.io/kops/cloudmock/gce/gcphttp" +) + +type projects struct { + mutex sync.Mutex + + projectBindings map[string]*cloudresourcemanager.Policy +} + +func (s *projects) Init() { + s.projectBindings = make(map[string]*cloudresourcemanager.Policy) +} + +func (s *projects) getIAMPolicy(projectID string, request *http.Request) (*http.Response, error) { + s.mutex.Lock() + defer s.mutex.Unlock() + + bindings := s.projectBindings[projectID] + if bindings == nil { + bindings = &cloudresourcemanager.Policy{ + Etag: nextEtag(""), + } + } + + return gcphttp.OKResponse(bindings) +} + +func nextEtag(etag string) string { + hash := sha256.Sum256([]byte(etag)) + nextEtag := hex.EncodeToString(hash[:]) + return nextEtag +} + +func (s *projects) setIAMPolicy(projectID string, request *http.Request) (*http.Response, error) { + b, err := io.ReadAll(request.Body) + if err != nil { + return gcphttp.ErrorBadRequest("") + } + + req := &cloudresourcemanager.SetIamPolicyRequest{} + if err := json.Unmarshal(b, &req); err != nil { + return gcphttp.ErrorBadRequest("") + } + + s.mutex.Lock() + defer s.mutex.Unlock() + + oldBindings := s.projectBindings[projectID] + if oldBindings == nil { + oldBindings = &cloudresourcemanager.Policy{ + Etag: nextEtag(""), + } + } + + newBindings := req.Policy + + if oldBindings.Etag != newBindings.Etag { + // TODO: What is the actual error? + return gcphttp.ErrorNotFound("etag") + } + + newBindings.Etag = nextEtag(oldBindings.Etag) + s.projectBindings[projectID] = newBindings + + return gcphttp.OKResponse(newBindings) +} diff --git a/upup/pkg/fi/cloudup/gce/BUILD.bazel b/upup/pkg/fi/cloudup/gce/BUILD.bazel index 69beaf2e34..b92b243423 100644 --- a/upup/pkg/fi/cloudup/gce/BUILD.bazel +++ b/upup/pkg/fi/cloudup/gce/BUILD.bazel @@ -28,6 +28,7 @@ go_library( "//upup/pkg/fi:go_default_library", "//upup/pkg/fi/cloudup/gce/gcemetadata:go_default_library", "//vendor/golang.org/x/oauth2/google:go_default_library", + "//vendor/google.golang.org/api/cloudresourcemanager/v1:go_default_library", "//vendor/google.golang.org/api/compute/v1:go_default_library", "//vendor/google.golang.org/api/dns/v1:go_default_library", "//vendor/google.golang.org/api/googleapi:go_default_library", diff --git a/upup/pkg/fi/cloudup/gce/gce_cloud.go b/upup/pkg/fi/cloudup/gce/gce_cloud.go index 74ae1872b7..f0d7b15f61 100644 --- a/upup/pkg/fi/cloudup/gce/gce_cloud.go +++ b/upup/pkg/fi/cloudup/gce/gce_cloud.go @@ -25,6 +25,7 @@ import ( "strings" "golang.org/x/oauth2/google" + "google.golang.org/api/cloudresourcemanager/v1" compute "google.golang.org/api/compute/v1" "google.golang.org/api/iam/v1" oauth2 "google.golang.org/api/oauth2/v2" @@ -50,6 +51,9 @@ type GCECloud interface { // ServiceAccount returns the email for the service account that the instances will run under ServiceAccount() (string, error) + + // CloudResourceManager returns the client for the cloudresourcemanager API + CloudResourceManager() *cloudresourcemanager.Service } type gceCloudImplementation struct { @@ -58,6 +62,9 @@ type gceCloudImplementation struct { iam *iam.Service dns *dnsClientImpl + // cloudResourceManager is the client for the cloudresourcemanager API + cloudResourceManager *cloudresourcemanager.Service + region string project string @@ -144,6 +151,12 @@ func NewGCECloud(region string, project string, labels map[string]string) (GCECl } c.dns = dnsClient + cloudResourceManager, err := cloudresourcemanager.NewService(ctx) + if err != nil { + return nil, fmt.Errorf("error building cloudresourcemanager API client: %w", err) + } + c.cloudResourceManager = cloudResourceManager + CacheGCECloudInstance(region, project, c) { @@ -193,11 +206,16 @@ func (c *gceCloudImplementation) IAM() *iam.Service { return c.iam } -// NameService returns the DNS client +// CloudDNS returns the DNS client func (c *gceCloudImplementation) CloudDNS() DNSClient { return c.dns } +// CloudResourceManager returns the client for the cloudresourcemanager API +func (c *gceCloudImplementation) CloudResourceManager() *cloudresourcemanager.Service { + return c.cloudResourceManager +} + // Region returns private struct element region. func (c *gceCloudImplementation) Region() string { return c.region diff --git a/upup/pkg/fi/cloudup/gcetasks/BUILD.bazel b/upup/pkg/fi/cloudup/gcetasks/BUILD.bazel index 80d204f273..0ded164cfd 100644 --- a/upup/pkg/fi/cloudup/gcetasks/BUILD.bazel +++ b/upup/pkg/fi/cloudup/gcetasks/BUILD.bazel @@ -20,6 +20,8 @@ go_library( "instancetemplate_fitask.go", "network.go", "network_fitask.go", + "projectiambinding.go", + "projectiambinding_fitask.go", "router.go", "router_fitask.go", "serviceaccount.go", @@ -43,6 +45,7 @@ go_library( "//upup/pkg/fi/cloudup/gce:go_default_library", "//upup/pkg/fi/cloudup/terraform:go_default_library", "//upup/pkg/fi/cloudup/terraformWriter:go_default_library", + "//vendor/google.golang.org/api/cloudresourcemanager/v1:go_default_library", "//vendor/google.golang.org/api/compute/v1:go_default_library", "//vendor/google.golang.org/api/iam/v1:go_default_library", "//vendor/google.golang.org/api/storage/v1:go_default_library", @@ -53,6 +56,7 @@ go_library( go_test( name = "go_default_test", srcs = [ + "projectiambinding_test.go", "serviceaccount_test.go", "storagebucketiam_test.go", ], diff --git a/upup/pkg/fi/cloudup/gcetasks/projectiambinding.go b/upup/pkg/fi/cloudup/gcetasks/projectiambinding.go new file mode 100644 index 0000000000..53d464e0f6 --- /dev/null +++ b/upup/pkg/fi/cloudup/gcetasks/projectiambinding.go @@ -0,0 +1,174 @@ +/* +Copyright 2021 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package gcetasks + +import ( + "context" + "fmt" + + "google.golang.org/api/cloudresourcemanager/v1" + "k8s.io/klog/v2" + "k8s.io/kops/upup/pkg/fi" + "k8s.io/kops/upup/pkg/fi/cloudup/gce" + "k8s.io/kops/upup/pkg/fi/cloudup/terraform" +) + +// ProjectIAMBinding represents an IAM rule on a project +// +kops:fitask +type ProjectIAMBinding struct { + Name *string + Lifecycle fi.Lifecycle + + Project *string + Member *string + Role *string +} + +var _ fi.CompareWithID = &ProjectIAMBinding{} + +func (e *ProjectIAMBinding) CompareWithID() *string { + return e.Name +} + +func (e *ProjectIAMBinding) Find(c *fi.Context) (*ProjectIAMBinding, error) { + ctx := context.TODO() + + cloud := c.Cloud.(gce.GCECloud) + + projectID := fi.StringValue(e.Project) + member := fi.StringValue(e.Member) + role := fi.StringValue(e.Role) + + klog.V(2).Infof("Checking IAM for project %q", projectID) + options := &cloudresourcemanager.GetIamPolicyRequest{Options: &cloudresourcemanager.GetPolicyOptions{RequestedPolicyVersion: 3}} + policy, err := cloud.CloudResourceManager().Projects.GetIamPolicy(projectID, options).Context(ctx).Do() + if err != nil { + if gce.IsNotFound(err) { + return nil, nil + } + return nil, fmt.Errorf("error checking IAM for project %s: %w", projectID, err) + } + + changed := patchCRMPolicy(policy, member, role) + if changed { + return nil, nil + } + + actual := &ProjectIAMBinding{} + actual.Project = e.Project + actual.Member = e.Member + actual.Role = e.Role + + // Ignore "system" fields + actual.Name = e.Name + actual.Lifecycle = e.Lifecycle + + return actual, nil +} + +func (e *ProjectIAMBinding) Run(c *fi.Context) error { + return fi.DefaultDeltaRunMethod(e, c) +} + +func (_ *ProjectIAMBinding) CheckChanges(a, e, changes *ProjectIAMBinding) error { + if fi.StringValue(e.Project) == "" { + return fi.RequiredField("Project") + } + if fi.StringValue(e.Member) == "" { + return fi.RequiredField("Member") + } + if fi.StringValue(e.Role) == "" { + return fi.RequiredField("Role") + } + return nil +} + +func (_ *ProjectIAMBinding) RenderGCE(t *gce.GCEAPITarget, a, e, changes *ProjectIAMBinding) error { + ctx := context.TODO() + + projectID := fi.StringValue(e.Project) + member := fi.StringValue(e.Member) + role := fi.StringValue(e.Role) + + request := &cloudresourcemanager.GetIamPolicyRequest{} + policy, err := t.Cloud.CloudResourceManager().Projects.GetIamPolicy(projectID, request).Context(ctx).Do() + if err != nil { + return fmt.Errorf("error getting IAM policy for project %s: %w", projectID, err) + } + + changed := patchCRMPolicy(policy, member, role) + + if !changed { + klog.Warningf("did not need to change policy (concurrent change?)") + return nil + } + + klog.V(2).Infof("updating IAM for project %s", projectID) + if _, err := t.Cloud.CloudResourceManager().Projects.SetIamPolicy(projectID, &cloudresourcemanager.SetIamPolicyRequest{Policy: policy}).Context(ctx).Do(); err != nil { + return fmt.Errorf("error updating IAM for project %s: %w", projectID, err) + } + + return nil +} + +// terraformProjectIAMBinding is the model for a terraform google_project_iam_binding rule +type terraformProjectIAMBinding struct { + Project string `json:"project,omitempty" cty:"project"` + Role string `json:"role,omitempty" cty:"role"` + Member string `json:"member,omitempty" cty:"member"` +} + +func (_ *ProjectIAMBinding) RenderTerraform(t *terraform.TerraformTarget, a, e, changes *ProjectIAMBinding) error { + tf := &terraformProjectIAMBinding{ + Project: fi.StringValue(e.Project), + Role: fi.StringValue(e.Role), + Member: fi.StringValue(e.Member), + } + + return t.RenderResource("google_project_iam_binding", *e.Name, tf) +} + +func patchCRMPolicy(policy *cloudresourcemanager.Policy, wantMember string, wantRole string) bool { + for _, binding := range policy.Bindings { + if binding.Condition != nil { + continue + } + if binding.Role != wantRole { + continue + } + exists := false + for _, member := range binding.Members { + if member == wantMember { + exists = true + } + } + if exists { + return false + } + + if !exists { + binding.Members = append(binding.Members, wantMember) + return true + } + } + + policy.Bindings = append(policy.Bindings, &cloudresourcemanager.Binding{ + Members: []string{wantMember}, + Role: wantRole, + }) + return true +} diff --git a/upup/pkg/fi/cloudup/gcetasks/projectiambinding_fitask.go b/upup/pkg/fi/cloudup/gcetasks/projectiambinding_fitask.go new file mode 100644 index 0000000000..29a5fba7ca --- /dev/null +++ b/upup/pkg/fi/cloudup/gcetasks/projectiambinding_fitask.go @@ -0,0 +1,52 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +/* +Copyright The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by fitask. DO NOT EDIT. + +package gcetasks + +import ( + "k8s.io/kops/upup/pkg/fi" +) + +// ProjectIAMBinding + +var _ fi.HasLifecycle = &ProjectIAMBinding{} + +// GetLifecycle returns the Lifecycle of the object, implementing fi.HasLifecycle +func (o *ProjectIAMBinding) GetLifecycle() fi.Lifecycle { + return o.Lifecycle +} + +// SetLifecycle sets the Lifecycle of the object, implementing fi.SetLifecycle +func (o *ProjectIAMBinding) SetLifecycle(lifecycle fi.Lifecycle) { + o.Lifecycle = lifecycle +} + +var _ fi.HasName = &ProjectIAMBinding{} + +// GetName returns the Name of the object, implementing fi.HasName +func (o *ProjectIAMBinding) GetName() *string { + return o.Name +} + +// String is the stringer function for the task, producing readable output using fi.TaskAsString +func (o *ProjectIAMBinding) String() string { + return fi.TaskAsString(o) +} diff --git a/upup/pkg/fi/cloudup/gcetasks/projectiambinding_test.go b/upup/pkg/fi/cloudup/gcetasks/projectiambinding_test.go new file mode 100644 index 0000000000..bb58afefdc --- /dev/null +++ b/upup/pkg/fi/cloudup/gcetasks/projectiambinding_test.go @@ -0,0 +1,61 @@ +/* +Copyright 2021 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package gcetasks + +import ( + "testing" + + gcemock "k8s.io/kops/cloudmock/gce" + "k8s.io/kops/upup/pkg/fi" +) + +func TestProjectIAMBinding(t *testing.T) { + project := "testproject" + region := "us-test1" + + cloud := gcemock.InstallMockGCECloud(region, project) + + // We define a function so we can rebuild the tasks, because we modify in-place when running + buildTasks := func() map[string]fi.Task { + binding := &ProjectIAMBinding{ + Lifecycle: fi.LifecycleSync, + + Project: fi.String("testproject"), + Member: fi.String("serviceAccount:foo@testproject.iam.gserviceaccount.com"), + Role: fi.String("roles/owner"), + } + + return map[string]fi.Task{ + "binding": binding, + } + } + + { + allTasks := buildTasks() + checkHasChanges(t, cloud, allTasks) + } + + { + allTasks := buildTasks() + runTasks(t, cloud, allTasks) + } + + { + allTasks := buildTasks() + checkNoChanges(t, cloud, allTasks) + } +}