internal/policygen/iam.go (218 lines of code) (raw):

// Copyright 2021 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 policygen import ( "encoding/json" "fmt" "os/exec" "path/filepath" "sort" "strings" "github.com/GoogleCloudPlatform/healthcare-data-protection-suite/internal/runner" "github.com/GoogleCloudPlatform/healthcare-data-protection-suite/internal/template" "github.com/GoogleCloudPlatform/healthcare-data-protection-suite/internal/terraform" "github.com/hashicorp/terraform/states" ) type root struct { Type string ID string } // All IAM members associated with a single role. type roleBindings map[string][]string func generateIAMPolicies(rn runner.Runner, resources []*states.Resource, outputPath, templateDir string) error { bindings, err := allBindings(rn, resources) if err != nil { return err } for root, rbs := range bindings { outputFolder := fmt.Sprintf("%s_%s", root.Type, root.ID) // Generate policies for allowed roles. data := map[string]interface{}{ // organizations/1234, folders/1234, projects/1234 "target": fmt.Sprintf("%ss/%s", root.Type, root.ID), "roles": rbs, // Also prepend type and id in the policy name to make it unique across multiple policies for the same role. "suffix": fmt.Sprintf("%s_%s", root.Type, root.ID), } in := filepath.Join(templateDir, "forseti", "tf_based", "iam_allow_roles.yaml") out := filepath.Join(outputPath, outputFolder, "iam_allow_roles.yaml") if err := template.WriteFile(in, out, data); err != nil { return err } // Generate policies for allowed bindings for each role. for role, members := range rbs { // Removes any prefix before the role name ('roles/' or 'projects/<my-project>/roles/' for a custom role). // Prepend 'custom_' if custom role. // Replaces '.' with '_' and turns each character to lower case. // E.g. roles/orgpolicy.policyViewer --> orgpolicy_policyviewer // projects/<my-project>/roles/osLoginProjectGet_6afd --> custom_osloginprojectget_6afd suffix := role // Predefined roles, e.g. roles/orgpolicy.policyViewer. if strings.HasPrefix(suffix, "roles/") { suffix = strings.TrimPrefix(suffix, "roles/") } else { // Custom roles, e.g. projects/<my-project>/roles/osLoginProjectGet_6afd. segs := strings.Split(suffix, "/") suffix = "custom_" + segs[len(segs)-1] } suffix = strings.ToLower(strings.Replace(suffix, ".", "_", -1)) data := map[string]interface{}{ // organizations/1234, folders/1234, projects/1234 "target": fmt.Sprintf("%ss/%s", root.Type, root.ID), // Also prepend type and id in the policy name to make it unique across multiple policies for the same role. "suffix": fmt.Sprintf("%s_%s_%s", root.Type, root.ID, suffix), "role": role, "members": members, } in := filepath.Join(templateDir, "forseti", "tf_based", "iam_allow_bindings.yaml") out := filepath.Join(outputPath, outputFolder, fmt.Sprintf("iam_allow_bindings_%s.yaml", suffix)) if err := template.WriteFile(in, out, data); err != nil { return err } } } return nil } func allBindings(rn runner.Runner, resources []*states.Resource) (map[root]roleBindings, error) { // All roles associated with a root resource (organization, folder or project). var allBindings = make(map[root]roleBindings) typeToIDField := map[string]string{ "project": "project", "folder": "folder", "organization": "org_id", } for rootType, idField := range typeToIDField { iamMembers, err := members(rn, resources, rootType, idField) if err != nil { return nil, err } iamBindings, err := bindings(rn, resources, rootType, idField) if err != nil { return nil, err } // Add iamBindings to iamMembers. // If iamMembers have members for the same root and role, replace it with the value from iamBindings. for root, bindings := range iamBindings { for role, members := range bindings { // Init the roleBindings map if it didn't exist. if _, ok := iamMembers[root]; !ok { iamMembers[root] = make(roleBindings) } iamMembers[root][role] = members } } for root, bindings := range iamMembers { for role, members := range bindings { // Remove duplicated members for the same role. bindings[role] = unique(members) } allBindings[root] = bindings } } return allBindings, nil } func unique(in []string) []string { keys := make(map[string]bool) var out []string for _, s := range in { if _, exists := keys[s]; !exists { keys[s] = true out = append(out, s) } } sort.Strings(out) return out } // members returns role bindings map for google_%s_iam_member (non-authoritative). func members(rn runner.Runner, resources []*states.Resource, rootType, idField string) (map[root]roleBindings, error) { var bindings = make(map[root]roleBindings) resourceType := fmt.Sprintf("google_%s_iam_member", rootType) // non-authoritative instances, err := terraform.GetInstancesForType(resources, resourceType) if err != nil { return nil, fmt.Errorf("get resource instances for type %q: %v", resourceType, err) } for _, ins := range instances { if err := validateMandatoryStringFields(ins, []string{idField, "role", "member"}); err != nil { return nil, err } id, err := normalizeID(rn, rootType, ins[idField].(string)) // Type checked in validate function. if err != nil { return nil, fmt.Errorf("normalize root resource ID: %v", err) } key := root{Type: rootType, ID: id} // Init the roleBindings map if it didn't exist. if _, ok := bindings[key]; !ok { bindings[key] = make(roleBindings) } role := ins["role"].(string) bindings[key][role] = append(bindings[key][role], ins["member"].(string)) } return bindings, nil } // bindings returns role bindings map for google_%s_iam_binding (authoritative). func bindings(rn runner.Runner, resources []*states.Resource, rootType, idField string) (map[root]roleBindings, error) { var bindings = make(map[root]roleBindings) resourceType := fmt.Sprintf("google_%s_iam_binding", rootType) // authoritative for a given role instances, err := terraform.GetInstancesForType(resources, resourceType) if err != nil { return nil, fmt.Errorf("get resource instances for type %q: %v", resourceType, err) } for _, ins := range instances { if err := validateMandatoryStringFields(ins, []string{idField, "role"}); err != nil { return nil, err } if err := validateMandatoryStringLists(ins, []string{"members"}); err != nil { return nil, err } id, err := normalizeID(rn, rootType, ins[idField].(string)) // Type checked in validate function. if err != nil { return nil, fmt.Errorf("normalize root resource ID: %v", err) } key := root{Type: rootType, ID: id} // Init the roleBindings map if it didn't exist. if _, ok := bindings[key]; !ok { bindings[key] = make(roleBindings) } role := ins["role"].(string) var members []string for _, s := range ins["members"].([]interface{}) { members = append(members, s.(string)) // Type checked in validate function. } // There should not be more than one instance of google_%s_iam_binding for the same resource // across all states. But we append all members just in case. bindings[key][role] = append(bindings[key][role], members...) } return bindings, nil } // validateMandatoryStringFields checks the presence of mandatory fields and assert string type. func validateMandatoryStringFields(instance map[string]interface{}, mandatoryFields []string) error { for _, k := range mandatoryFields { field, ok := instance[k] if !ok { return fmt.Errorf("mandatory field %q missing from instance: %v", k, instance) } if _, ok := field.(string); !ok { return fmt.Errorf("value for %q should be a string, got %T", k, field) } } return nil } // validateMandatoryLists checks the presence of mandatory fields and assert []interface{} type. func validateMandatoryStringLists(instance map[string]interface{}, mandatoryFields []string) error { for _, k := range mandatoryFields { field, ok := instance[k] if !ok { return fmt.Errorf("mandatory field %q missing from instance: %v", k, instance) } lst, ok := field.([]interface{}) if !ok { return fmt.Errorf("value for %q should be a []interface{}, got %T", k, field) } for _, s := range lst { if _, ok := s.(string); !ok { return fmt.Errorf("%q should be a string, got %T", s, s) } } } return nil } func normalizeID(rn runner.Runner, t, id string) (string, error) { var err error nid := id // For projects, the ID in the state is the project ID, but we need project number in policies. if t == "project" { if nid, err = projectNumber(rn, id); err != nil { return "", err } } else if t == "folder" { // For folders, the ID can be either {folder_id} or folders/{folder_id}. Remove the 'folders/' prefix if exists. nid = strings.TrimPrefix(id, "folders/") } return nid, nil } func projectNumber(rn runner.Runner, id string) (string, error) { cmd := exec.Command("gcloud", "projects", "describe", id, "--format", "json") out, err := rn.CmdOutput(cmd) if err != nil { return "", fmt.Errorf("failed to get project number for project %q: %v", id, err) } var p struct { ProjectNumber string `json:"projectNumber"` } if err := json.Unmarshal(out, &p); err != nil { return "", fmt.Errorf("failed to parse project number from gcloud output: %v", err) } if p.ProjectNumber == "" { return "", fmt.Errorf("project number is empty") } return p.ProjectNumber, nil }