internal/apiclient/iam.go (339 lines of code) (raw):
// Copyright 2022 Google LLC
//
// 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 apiclient
import (
"encoding/json"
"fmt"
"internal/clilog"
"io"
"net/http"
"net/url"
"path"
"regexp"
"strings"
)
// condition for Bindings
type condition struct {
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
Expression string `json:"expression,omitempty"`
}
// binding for IAM Roles
type roleBinding struct {
Role string `json:"role,omitempty"`
Members []string `json:"members,omitempty"`
Condition *condition `json:"condition,omitempty"`
}
// IamPolicy holds the response
type iamPolicy struct {
Version int `json:"version,omitempty"`
Etag string `json:"etag,omitempty"`
Bindings []roleBinding `json:"bindings,omitempty"`
}
// SetIamPolicy holds the request to set IAM
type setIamPolicy struct {
Policy iamPolicy `json:"policy,omitempty"`
}
func iamServiceAccountExists(iamname string) (code int, err error) {
var resp *http.Response
var req *http.Request
projectid, _, err := getNameAndProject(iamname)
if err != nil {
clilog.Error.Println(err)
return -1, err
}
getendpoint := fmt.Sprintf("https://iam.googleapis.com/v1/projects/%s/serviceAccounts/%s", projectid, iamname)
contentType := "application/json"
client, err := getHttpClient()
if err != nil {
clilog.Error.Println(err)
return -1, err
}
if DryRun() {
return 200, nil
}
req, err = http.NewRequest("GET", getendpoint, nil)
if err != nil {
clilog.Error.Println("error in client: ", err)
return -1, err
}
req, err = setAuthHeader(req)
if err != nil {
clilog.Error.Println(err)
return -1, err
}
clilog.Debug.Println("Content-Type : ", contentType)
req.Header.Set("Content-Type", contentType)
resp, err = client.Do(req)
if err != nil {
clilog.Error.Println("error connecting: ", err)
return resp.StatusCode, err
}
if resp != nil {
defer resp.Body.Close()
}
if resp == nil {
return -1, fmt.Errorf("error in response: Response was null")
}
respBody, err := io.ReadAll(resp.Body)
if err != nil {
clilog.Error.Println("error in response: ", err)
return -1, err
} else if resp.StatusCode > 399 && resp.StatusCode != 404 {
clilog.Error.Printf("status code %d, error in response: %s\n", resp.StatusCode, string(respBody))
return resp.StatusCode, fmt.Errorf("status code %d, error in response: %s", resp.StatusCode, string(respBody))
} else {
return resp.StatusCode, nil
}
}
// setIAMPermission set permissions for a member
func setIAMPermission(endpoint string, name string, memberName string, role string, memberType string) (err error) {
u, _ := url.Parse(endpoint)
u.Path = path.Join(u.Path, name+":getIamPolicy")
ClientPrintHttpResponse.Set(false)
getIamPolicyBody, err := HttpClient(u.String())
ClientPrintHttpResponse.Set(GetCmdPrintHttpResponseSetting())
if err != nil {
clilog.Error.Println(err)
return err
}
getIamPolicy := iamPolicy{}
err = json.Unmarshal(getIamPolicyBody, &getIamPolicy)
if err != nil {
clilog.Error.Println(err)
return err
}
foundRole := false
for i, binding := range getIamPolicy.Bindings {
if binding.Role == role {
// found members with the role already, add the new SA to the role
getIamPolicy.Bindings[i].Members = append(binding.Members, memberType+":"+memberName)
foundRole = true
}
}
// no members with the role, add a new one
if !foundRole {
binding := roleBinding{}
binding.Role = role
binding.Members = append(binding.Members, memberType+":"+memberName)
getIamPolicy.Bindings = append(getIamPolicy.Bindings, binding)
}
u, _ = url.Parse(endpoint)
u.Path = path.Join(u.Path, name+":setIamPolicy")
setIamPolicy := setIamPolicy{}
setIamPolicy.Policy = getIamPolicy
setIamPolicyBody, err := json.Marshal(setIamPolicy)
if err != nil {
clilog.Error.Println(err)
return err
}
ClientPrintHttpResponse.Set(false)
_, err = HttpClient(u.String(), string(setIamPolicyBody))
ClientPrintHttpResponse.Set(GetCmdPrintHttpResponseSetting())
return err
}
// setProjectIAMPermission
func setProjectIAMPermission(project string, memberName string, role string) (err error) {
getendpoint := fmt.Sprintf("https://cloudresourcemanager.googleapis.com/v1/projects/%s:getIamPolicy", project)
setendpoint := fmt.Sprintf("https://cloudresourcemanager.googleapis.com/v1/projects/%s:setIamPolicy", project)
// this method treats errors as info since this is not a blocking problem
ClientPrintHttpResponse.Set(false)
// Get the current IAM policies for the project
respBody, err := HttpClient(getendpoint, "")
if err != nil {
clilog.Debug.Printf("error getting IAM policies for the project %s: %v", project, err)
return err
}
// binding for IAM Roles
type roleBinding struct {
Role string `json:"role,omitempty"`
Members []string `json:"members,omitempty"`
Condition *condition `json:"condition,omitempty"`
}
// IamPolicy holds the response
type iamPolicy struct {
Version int `json:"version,omitempty"`
Etag string `json:"etag,omitempty"`
Bindings []roleBinding `json:"bindings,omitempty"`
}
// iamPolicyRequest holds the request to set IAM
type iamPolicyRequest struct {
Policy iamPolicy `json:"policy,omitempty"`
}
policy := iamPolicy{}
err = json.Unmarshal(respBody, &policy)
if err != nil {
clilog.Debug.Println(err)
return err
}
binding := roleBinding{}
binding.Role = role
binding.Members = append(binding.Members, "serviceAccount:"+memberName)
policy.Bindings = append(policy.Bindings, binding)
policyRequest := iamPolicyRequest{}
policyRequest.Policy = policy
policyRequestBody, err := json.Marshal(policyRequest)
if err != nil {
clilog.Debug.Println(err)
return err
}
_, err = HttpClient(setendpoint, string(policyRequestBody))
if err != nil {
clilog.Debug.Printf("error setting IAM policies for the project %s: %v", project, err)
return err
}
ClientPrintHttpResponse.Set(GetCmdPrintHttpResponseSetting())
return nil
}
// CreateServiceAccount
func CreateServiceAccount(iamname string) (err error) {
var statusCode int
projectid, displayname, err := getNameAndProject(iamname)
if err != nil {
return err
}
if statusCode, err = iamServiceAccountExists(iamname); err != nil {
return err
}
switch statusCode {
case 200:
return nil
case 404:
createendpoint := fmt.Sprintf("https://iam.googleapis.com/v1/projects/%s/serviceAccounts", projectid)
iamPayload := []string{}
iamPayload = append(iamPayload, "\"accountId\":\""+displayname+"\"")
iamPayload = append(iamPayload, "\"serviceAccount\": {\"displayName\": \""+displayname+"\"}")
payload := "{" + strings.Join(iamPayload, ",") + "}"
ClientPrintHttpResponse.Set(false)
defer ClientPrintHttpResponse.Set(GetCmdPrintHttpResponseSetting())
if _, err = HttpClient(createendpoint, payload); err != nil {
clilog.Error.Println(err)
return err
}
return nil
default:
return fmt.Errorf("unable to fetch service account details, err: %d", statusCode)
}
}
// SetConnectorIAMPermission set permissions for a member on a connection
func SetConnectorIAMPermission(name string, memberName string, iamRole string, memberType string) (err error) {
var role string
switch iamRole {
case "admin":
role = "roles/connectors.admin"
case "invoker":
role = "roles/connectors.invoker"
case "viewer":
role = "roles/connectors.viewer"
default: // assume this is a custom role definition
re := regexp.MustCompile(`projects\/([a-zA-Z0-9_-]+)\/roles\/([a-zA-Z0-9_-]+)`)
result := re.FindString(iamRole)
if result == "" {
return fmt.Errorf("custom role must be of the format projects/{project-id}/roles/{role-name}")
}
role = iamRole
}
return setIAMPermission(GetBaseConnectorURL(), name, memberName, role, memberType)
}
// SetPubSubIAMPermission set permissions for a SA on a topic
func SetPubSubIAMPermission(project string, topic string, memberName string) (err error) {
endpoint := fmt.Sprintf("https://pubsub.googleapis.com/v1/projects/%s/topics", project)
const memberType = "serviceAccount"
const role = "roles/pubsub.publisher"
return setIAMPermission(endpoint, topic, memberName, role, memberType)
}
// SetSecretManagerIAMPermission set permissions for a SA on a secret
func SetSecretManagerIAMPermission(project string, secretName string, memberName string) (err error) {
endpoint := fmt.Sprintf("https://secretmanager.googleapis.com/v1/projects/%s/secrets", project)
const memberType = "serviceAccount"
const role1 = "roles/secretmanager.secretAccessor"
const role2 = "roles/secretmanager.viewer"
if err = setIAMPermission(endpoint, secretName, memberName, role1, memberType); err != nil {
return err
}
return setIAMPermission(endpoint, secretName, memberName, role2, memberType)
}
// SetBigQueryIAMPermission
func SetBigQueryIAMPermission(project string, datasetid string, memberName string) (err error) {
endpoint := fmt.Sprintf("https://bigquery.googleapis.com/bigquery/v2/projects/%s/datasets/%s", project, datasetid)
const role = "WRITER"
var content []byte
defer ClientPrintHttpResponse.Set(GetCmdPrintHttpResponseSetting())
ClientPrintHttpResponse.Set(false)
// first fetch the information
respBody, err := HttpClient(endpoint)
if err != nil {
return err
}
type accessType struct {
Role string `json:"role,omitempty"`
IamMember *string `json:"iamMember,omitempty"`
UserByEmail *string `json:"userByEmail,omitempty"`
SpecialGroup *string `json:"specialGroup,omitempty"`
GroupByEmail *string `json:"groupByEmail,omitempty"`
}
type datasetType struct {
Access []accessType `json:"access,omitempty"`
}
dataset := datasetType{}
if err = json.Unmarshal(respBody, &dataset); err != nil {
return err
}
access := accessType{}
access.Role = role
access.UserByEmail = new(string)
*access.UserByEmail = memberName
// merge the updates
dataset.Access = append(dataset.Access, access)
if content, err = json.Marshal(dataset); err != nil {
return err
}
// patch the update
if _, err = HttpClient(endpoint, string(content), "PATCH"); err != nil {
return err
}
return nil
}
// SetCloudStorageIAMPermission
func SetCloudStorageIAMPermission(project string, memberName string) (err error) {
// the connector currently requires storage.buckets.list. other built-in roles didn't have this permission
const role = "roles/storage.admin"
return setProjectIAMPermission(project, memberName, role)
}
// SetCloudSQLIAMPermission
func SetCloudSQLIAMPermission(project string, memberName string) (err error) {
const role = "roles/cloudsql.editor"
return setProjectIAMPermission(project, memberName, role)
}
// SetCloudSpannerIAMPermission
func SetCloudSpannerIAMPermission(project string, memberName string) (err error) {
const role = "roles/spanner.databaseUser"
return setProjectIAMPermission(project, memberName, role)
}
// SetIntegrationInvokerPermission
func SetIntegrationInvokerPermission(project string, memberName string) (err error) {
const role = "roles/integrations.integrationInvoker"
return setProjectIAMPermission(project, memberName, role)
}
func getNameAndProject(iamFullName string) (projectid string, name string, err error) {
riam := regexp.MustCompile(`^[a-zA-Z0-9-]{6,30}$`)
parts := strings.Split(iamFullName, "@")
if len(parts) != 2 {
return "", "", fmt.Errorf("invalid iam name, %s", parts)
}
name = parts[0]
projectid = strings.ReplaceAll(parts[1], ".iam.gserviceaccount.com", "") // strings.Split(parts[1], ".iam.gserviceaccount.com")[0]
if name == "" || projectid == "" {
return "", "", fmt.Errorf("invalid iam name %s, %s", name, projectid)
}
if ok := riam.Match([]byte(name)); !ok {
return "", "", fmt.Errorf("the ID must be between 6 and 30 characters")
}
return projectid, name, nil
}
// GetDefaultServiceAccount
func GetComputeEngineDefaultServiceAccount(projectId string) (serviceAccount string, err error) {
getendpoint := fmt.Sprintf("https://cloudresourcemanager.googleapis.com/v3/projects/%s", projectId)
// Get the project number
ClientPrintHttpResponse.Set(false)
defer ClientPrintHttpResponse.Set(GetCmdPrintHttpResponseSetting())
respBody, err := HttpClient(getendpoint)
if err != nil {
clilog.Debug.Printf("error getting details for the project %s: %v", projectId, err)
return serviceAccount, err
}
type projectResponse struct {
Name string `json:"name,omitempty"`
Parent string `json:"parent,omitempty"`
ProjectId string `json:"projectId,omitempty"`
State string `json:"state,omitempty"`
DisplayName string `json:"displayName,omitempty"`
CreateTime string `json:"createTime,omitempty"`
UpdateTime string `json:"updateTime,omitempty"`
DeleteTime string `json:"deleteTime,omitempty"`
Etag string `json:"etag,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
}
p := projectResponse{}
err = json.Unmarshal(respBody, &p)
if err != nil {
clilog.Debug.Println(err)
return serviceAccount, err
}
if p.Name == "" {
return serviceAccount, fmt.Errorf("project number was not available")
}
// get the project number
projectNumber := strings.Split(p.Name, "/")[1]
serviceAccount = fmt.Sprintf("%s-compute@developer.gserviceaccount.com", projectNumber)
return serviceAccount, nil
}