GCE: Project IAM Binding task

This allows us to grant a project-level permission to a service account.
This commit is contained in:
justinsb 2021-12-13 12:41:08 -05:00
parent 0cecb07d90
commit faeeb1fe80
11 changed files with 523 additions and 11 deletions

View File

@ -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",

View File

@ -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

View File

@ -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",
],
)

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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",

View File

@ -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

View File

@ -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",
],

View File

@ -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
}

View File

@ -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)
}

View File

@ -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)
}
}