From 4768f3dd47abf0175323ee2a2e1a3e93c52fdbf1 Mon Sep 17 00:00:00 2001 From: The Magician Date: Fri, 21 Dec 2018 15:39:58 -0800 Subject: [PATCH] Add fine-grained audit config for projects. (#2731) --- google/iam_project.go | 3 +- google/provider.go | 1 + ...ce_google_project_iam_audit_config_test.go | 434 ++++++++++++++++++ google/resource_iam_audit_config.go | 247 ++++++++++ 4 files changed, 684 insertions(+), 1 deletion(-) create mode 100644 google/resource_google_project_iam_audit_config_test.go create mode 100644 google/resource_iam_audit_config.go diff --git a/google/iam_project.go b/google/iam_project.go index 476a458c..4463ca4a 100644 --- a/google/iam_project.go +++ b/google/iam_project.go @@ -51,7 +51,8 @@ func (u *ProjectIamUpdater) GetResourceIamPolicy() (*cloudresourcemanager.Policy func (u *ProjectIamUpdater) SetResourceIamPolicy(policy *cloudresourcemanager.Policy) error { _, err := u.Config.clientResourceManager.Projects.SetIamPolicy(u.resourceId, &cloudresourcemanager.SetIamPolicyRequest{ - Policy: policy, + Policy: policy, + UpdateMask: "bindings,etag,auditConfigs", }).Do() if err != nil { diff --git a/google/provider.go b/google/provider.go index 12e1c4ad..626ec03a 100644 --- a/google/provider.go +++ b/google/provider.go @@ -205,6 +205,7 @@ func ResourceMapWithErrors() (map[string]*schema.Resource, error) { "google_project_iam_policy": resourceGoogleProjectIamPolicy(), "google_project_iam_binding": ResourceIamBindingWithImport(IamProjectSchema, NewProjectIamUpdater, ProjectIdParseFunc), "google_project_iam_member": ResourceIamMemberWithImport(IamProjectSchema, NewProjectIamUpdater, ProjectIdParseFunc), + "google_project_iam_audit_config": ResourceIamAuditConfigWithImport(IamProjectSchema, NewProjectIamUpdater, ProjectIdParseFunc), "google_project_service": resourceGoogleProjectService(), "google_project_iam_custom_role": resourceGoogleProjectIamCustomRole(), "google_project_organization_policy": resourceGoogleProjectOrganizationPolicy(), diff --git a/google/resource_google_project_iam_audit_config_test.go b/google/resource_google_project_iam_audit_config_test.go new file mode 100644 index 00000000..1a86b121 --- /dev/null +++ b/google/resource_google_project_iam_audit_config_test.go @@ -0,0 +1,434 @@ +package google + +import ( + "fmt" + "strings" + "testing" + + "github.com/hashicorp/terraform/helper/acctest" + "github.com/hashicorp/terraform/helper/resource" +) + +func projectIamAuditConfigImportStep(resourceName, pid, service string) resource.TestStep { + return resource.TestStep{ + ResourceName: resourceName, + ImportStateId: fmt.Sprintf("%s %s", pid, service), + ImportState: true, + ImportStateVerify: true, + } +} + +// Test that an IAM audit config can be applied to a project +func TestAccProjectIamAuditConfig_basic(t *testing.T) { + t.Parallel() + + org := getTestOrgFromEnv(t) + pid := "terraform-" + acctest.RandString(10) + service := "cloudkms.googleapis.com" + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + // Create a new project + { + Config: testAccProject_create(pid, pname, org), + Check: resource.ComposeTestCheckFunc( + testAccProjectExistingPolicy(pid), + ), + }, + // Apply an IAM audit config + { + Config: testAccProjectAssociateAuditConfigBasic(pid, pname, org, service), + }, + projectIamAuditConfigImportStep("google_project_iam_audit_config.acceptance", pid, service), + }, + }) +} + +// Test that multiple IAM audit configs can be applied to a project, one at a time +func TestAccProjectIamAuditConfig_multiple(t *testing.T) { + t.Parallel() + + org := getTestOrgFromEnv(t) + pid := "terraform-" + acctest.RandString(10) + service := "cloudkms.googleapis.com" + service2 := "cloudsql.googleapis.com" + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + // Create a new project + { + Config: testAccProject_create(pid, pname, org), + Check: resource.ComposeTestCheckFunc( + testAccProjectExistingPolicy(pid), + ), + }, + // Apply an IAM audit config + { + Config: testAccProjectAssociateAuditConfigBasic(pid, pname, org, service), + }, + // Apply another IAM audit config + { + Config: testAccProjectAssociateAuditConfigMultiple(pid, pname, org, service, service2), + }, + projectIamAuditConfigImportStep("google_project_iam_audit_config.acceptance", pid, service), + projectIamAuditConfigImportStep("google_project_iam_audit_config.multiple", pid, service2), + }, + }) +} + +// Test that multiple IAM audit configs can be applied to a project all at once +func TestAccProjectIamAuditConfig_multipleAtOnce(t *testing.T) { + t.Parallel() + + org := getTestOrgFromEnv(t) + pid := "terraform-" + acctest.RandString(10) + service := "cloudkms.googleapis.com" + service2 := "cloudsql.googleapis.com" + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + // Create a new project + { + Config: testAccProject_create(pid, pname, org), + Check: resource.ComposeTestCheckFunc( + testAccProjectExistingPolicy(pid), + ), + }, + // Apply an IAM audit config + { + Config: testAccProjectAssociateAuditConfigMultiple(pid, pname, org, service, service2), + }, + projectIamAuditConfigImportStep("google_project_iam_audit_config.acceptance", pid, service), + projectIamAuditConfigImportStep("google_project_iam_audit_config.multiple", pid, service2), + }, + }) +} + +// Test that an IAM audit config can be updated once applied to a project +func TestAccProjectIamAuditConfig_update(t *testing.T) { + t.Parallel() + + org := getTestOrgFromEnv(t) + pid := "terraform-" + acctest.RandString(10) + service := "cloudkms.googleapis.com" + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + // Create a new project + { + Config: testAccProject_create(pid, pname, org), + Check: resource.ComposeTestCheckFunc( + testAccProjectExistingPolicy(pid), + ), + }, + // Apply an IAM audit config + { + Config: testAccProjectAssociateAuditConfigBasic(pid, pname, org, service), + }, + projectIamAuditConfigImportStep("google_project_iam_audit_config.acceptance", pid, service), + + // Apply an updated IAM audit config + { + Config: testAccProjectAssociateAuditConfigUpdated(pid, pname, org, service), + }, + projectIamAuditConfigImportStep("google_project_iam_audit_config.acceptance", pid, service), + + // Drop the original member + { + Config: testAccProjectAssociateAuditConfigDropMemberFromBasic(pid, pname, org, service), + }, + projectIamAuditConfigImportStep("google_project_iam_audit_config.acceptance", pid, service), + }, + }) +} + +// Test that an IAM audit config can be removed from a project +func TestAccProjectIamAuditConfig_remove(t *testing.T) { + t.Parallel() + + org := getTestOrgFromEnv(t) + pid := "terraform-" + acctest.RandString(10) + service := "cloudkms.googleapis.com" + service2 := "cloudsql.googleapis.com" + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + // Create a new project + { + Config: testAccProject_create(pid, pname, org), + Check: resource.ComposeTestCheckFunc( + testAccProjectExistingPolicy(pid), + ), + }, + // Apply multiple IAM audit configs + { + Config: testAccProjectAssociateAuditConfigMultiple(pid, pname, org, service, service2), + }, + projectIamAuditConfigImportStep("google_project_iam_audit_config.acceptance", pid, service), + projectIamAuditConfigImportStep("google_project_iam_audit_config.multiple", pid, service2), + + // Remove the audit configs + { + Config: testAccProject_create(pid, pname, org), + Check: resource.ComposeTestCheckFunc( + testAccProjectExistingPolicy(pid), + ), + }, + }, + }) +} + +// Test adding exempt first exempt member +func TestAccProjectIamAuditConfig_addFirstExemptMember(t *testing.T) { + t.Parallel() + + org := getTestOrgFromEnv(t) + pid := "terraform-" + acctest.RandString(10) + service := "cloudkms.googleapis.com" + members := []string{} + members2 := []string{"user:paddy@hashicorp.com"} + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + // Create a new project + { + Config: testAccProject_create(pid, pname, org), + Check: resource.ComposeTestCheckFunc( + testAccProjectExistingPolicy(pid), + ), + }, + // Apply IAM audit config with no members + { + Config: testAccProjectAssociateAuditConfigMembers(pid, pname, org, service, members), + }, + projectIamAuditConfigImportStep("google_project_iam_audit_config.acceptance", pid, service), + + // Apply IAM audit config with one member + { + Config: testAccProjectAssociateAuditConfigMembers(pid, pname, org, service, members2), + }, + projectIamAuditConfigImportStep("google_project_iam_audit_config.acceptance", pid, service), + }, + }) +} + +// test removing last exempt member +func TestAccProjectIamAuditConfig_removeLastExemptMember(t *testing.T) { + t.Parallel() + + org := getTestOrgFromEnv(t) + pid := "terraform-" + acctest.RandString(10) + service := "cloudkms.googleapis.com" + members2 := []string{} + members := []string{"user:paddy@hashicorp.com"} + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + // Create a new project + { + Config: testAccProject_create(pid, pname, org), + Check: resource.ComposeTestCheckFunc( + testAccProjectExistingPolicy(pid), + ), + }, + // Apply IAM audit config with member + { + Config: testAccProjectAssociateAuditConfigMembers(pid, pname, org, service, members), + }, + projectIamAuditConfigImportStep("google_project_iam_audit_config.acceptance", pid, service), + + // Apply IAM audit config with no members + { + Config: testAccProjectAssociateAuditConfigMembers(pid, pname, org, service, members2), + }, + projectIamAuditConfigImportStep("google_project_iam_audit_config.acceptance", pid, service), + }, + }) +} + +// test changing service with no exempt members +func TestAccProjectIamAuditConfig_updateNoExemptMembers(t *testing.T) { + t.Parallel() + + org := getTestOrgFromEnv(t) + pid := "terraform-" + acctest.RandString(10) + logType := "DATA_READ" + logType2 := "DATA_WRITE" + service := "cloudkms.googleapis.com" + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + // Create a new project + { + Config: testAccProject_create(pid, pname, org), + Check: resource.ComposeTestCheckFunc( + testAccProjectExistingPolicy(pid), + ), + }, + // Apply IAM audit config with DATA_READ + { + Config: testAccProjectAssociateAuditConfigLogType(pid, pname, org, service, logType), + }, + projectIamAuditConfigImportStep("google_project_iam_audit_config.acceptance", pid, service), + + // Apply IAM audit config with DATA_WRITe + { + Config: testAccProjectAssociateAuditConfigLogType(pid, pname, org, service, logType2), + }, + projectIamAuditConfigImportStep("google_project_iam_audit_config.acceptance", pid, service), + }, + }) +} + +func testAccProjectAssociateAuditConfigBasic(pid, name, org, service string) string { + return fmt.Sprintf(` +resource "google_project" "acceptance" { + project_id = "%s" + name = "%s" + org_id = "%s" +} + +resource "google_project_iam_audit_config" "acceptance" { + project = "${google_project.acceptance.project_id}" + service = "%s" + audit_log_config { + log_type = "DATA_READ" + exempted_members = [ + "user:paddy@hashicorp.com", + "user:paddy@carvers.co" + ] + } +} +`, pid, name, org, service) +} + +func testAccProjectAssociateAuditConfigMultiple(pid, name, org, service, service2 string) string { + return fmt.Sprintf(` +resource "google_project" "acceptance" { + project_id = "%s" + name = "%s" + org_id = "%s" +} + +resource "google_project_iam_audit_config" "acceptance" { + project = "${google_project.acceptance.project_id}" + service = "%s" + audit_log_config { + log_type = "DATA_READ" + exempted_members = [ + "user:paddy@hashicorp.com", + "user:paddy@carvers.co" + ] + } +} + +resource "google_project_iam_audit_config" "multiple" { + project = "${google_project.acceptance.project_id}" + service = "%s" + audit_log_config { + log_type = "DATA_WRITE" + } +} +`, pid, name, org, service, service2) +} + +func testAccProjectAssociateAuditConfigUpdated(pid, name, org, service string) string { + return fmt.Sprintf(` +resource "google_project" "acceptance" { + project_id = "%s" + name = "%s" + org_id = "%s" +} + +resource "google_project_iam_audit_config" "acceptance" { + project = "${google_project.acceptance.project_id}" + service = "%s" + audit_log_config { + log_type = "DATA_WRITE" + exempted_members = [ + "user:admin@hashicorptest.com", + "user:paddy@carvers.co" + ] + } +} +`, pid, name, org, service) +} + +func testAccProjectAssociateAuditConfigDropMemberFromBasic(pid, name, org, service string) string { + return fmt.Sprintf(` +resource "google_project" "acceptance" { + project_id = "%s" + name = "%s" + org_id = "%s" +} + +resource "google_project_iam_audit_config" "acceptance" { + project = "${google_project.acceptance.project_id}" + service = "%s" + audit_log_config { + log_type = "DATA_READ" + exempted_members = [ + "user:paddy@hashicorp.com", + ] + } +} +`, pid, name, org, service) +} + +func testAccProjectAssociateAuditConfigMembers(pid, name, org, service string, members []string) string { + var memberStr string + if len(members) > 0 { + for pos, member := range members { + members[pos] = "\"" + member + "\"," + } + memberStr = "\n exempted_members = [" + strings.Join(members, "\n") + "\n ]" + } + return fmt.Sprintf(` +resource "google_project" "acceptance" { + project_id = "%s" + name = "%s" + org_id = "%s" +} + +resource "google_project_iam_audit_config" "acceptance" { + project = "${google_project.acceptance.project_id}" + service = "%s" + audit_log_config { + log_type = "DATA_READ"%s + } +} +`, pid, name, org, service, memberStr) +} + +func testAccProjectAssociateAuditConfigLogType(pid, name, org, service, logType string) string { + return fmt.Sprintf(` +resource "google_project" "acceptance" { + project_id = "%s" + name = "%s" + org_id = "%s" +} + +resource "google_project_iam_audit_config" "acceptance" { + project = "${google_project.acceptance.project_id}" + service = "%s" + audit_log_config { + log_type = "%s" + } +} +`, pid, name, org, service, logType) +} diff --git a/google/resource_iam_audit_config.go b/google/resource_iam_audit_config.go new file mode 100644 index 00000000..77efa7a2 --- /dev/null +++ b/google/resource_iam_audit_config.go @@ -0,0 +1,247 @@ +package google + +import ( + "errors" + "fmt" + "log" + "strings" + + "github.com/hashicorp/terraform/helper/schema" + "google.golang.org/api/cloudresourcemanager/v1" +) + +var iamAuditConfigSchema = map[string]*schema.Schema{ + "service": { + Type: schema.TypeString, + Required: true, + }, + "audit_log_config": { + Type: schema.TypeSet, + Required: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "log_type": { + Type: schema.TypeString, + Required: true, + }, + "exempted_members": { + Type: schema.TypeSet, + Elem: &schema.Schema{Type: schema.TypeString}, + Optional: true, + }, + }, + }, + }, + "etag": { + Type: schema.TypeString, + Computed: true, + }, +} + +func ResourceIamAuditConfig(parentSpecificSchema map[string]*schema.Schema, newUpdaterFunc newResourceIamUpdaterFunc) *schema.Resource { + return &schema.Resource{ + Create: resourceIamAuditConfigCreate(newUpdaterFunc), + Read: resourceIamAuditConfigRead(newUpdaterFunc), + Update: resourceIamAuditConfigUpdate(newUpdaterFunc), + Delete: resourceIamAuditConfigDelete(newUpdaterFunc), + Schema: mergeSchemas(iamAuditConfigSchema, parentSpecificSchema), + } +} + +func ResourceIamAuditConfigWithImport(parentSpecificSchema map[string]*schema.Schema, newUpdaterFunc newResourceIamUpdaterFunc, resourceIdParser resourceIdParserFunc) *schema.Resource { + r := ResourceIamAuditConfig(parentSpecificSchema, newUpdaterFunc) + r.Importer = &schema.ResourceImporter{ + State: iamAuditConfigImport(resourceIdParser), + } + return r +} + +func resourceIamAuditConfigCreate(newUpdaterFunc newResourceIamUpdaterFunc) schema.CreateFunc { + return func(d *schema.ResourceData, meta interface{}) error { + config := meta.(*Config) + updater, err := newUpdaterFunc(d, config) + if err != nil { + return err + } + + p := getResourceIamAuditConfig(d) + err = iamPolicyReadModifyWrite(updater, func(ep *cloudresourcemanager.Policy) error { + ep.AuditConfigs = mergeAuditConfigs(append(ep.AuditConfigs, p)) + return nil + }) + if err != nil { + return err + } + d.SetId(updater.GetResourceId() + "/audit_config/" + p.Service) + return resourceIamAuditConfigRead(newUpdaterFunc)(d, meta) + } +} + +func resourceIamAuditConfigRead(newUpdaterFunc newResourceIamUpdaterFunc) schema.ReadFunc { + return func(d *schema.ResourceData, meta interface{}) error { + config := meta.(*Config) + updater, err := newUpdaterFunc(d, config) + if err != nil { + return err + } + + eAuditConfig := getResourceIamAuditConfig(d) + p, err := updater.GetResourceIamPolicy() + if err != nil { + if isGoogleApiErrorWithCode(err, 404) { + log.Printf("[DEBUG]: AuditConfig for service %q not found for non-existent resource %s, removing from state file.", eAuditConfig.Service, updater.DescribeResource()) + d.SetId("") + return nil + } + + return err + } + log.Printf("[DEBUG]: Retrieved policy for %s: %+v", updater.DescribeResource(), p) + + var ac *cloudresourcemanager.AuditConfig + for _, b := range p.AuditConfigs { + if b.Service != eAuditConfig.Service { + continue + } + ac = b + break + } + if ac == nil { + log.Printf("[DEBUG]: AuditConfig for service %q not found in policy for %s, removing from state file.", eAuditConfig.Service, updater.DescribeResource()) + d.SetId("") + return nil + } + d.Set("etag", p.Etag) + err = d.Set("audit_log_config", flattenAuditLogConfigs(ac.AuditLogConfigs)) + if err != nil { + return fmt.Errorf("Error flattening audit log config: %s", err) + } + d.Set("service", ac.Service) + return nil + } +} + +func iamAuditConfigImport(resourceIdParser resourceIdParserFunc) schema.StateFunc { + return func(d *schema.ResourceData, m interface{}) ([]*schema.ResourceData, error) { + if resourceIdParser == nil { + return nil, errors.New("Import not supported for this IAM resource.") + } + config := m.(*Config) + s := strings.Fields(d.Id()) + if len(s) != 2 { + d.SetId("") + return nil, fmt.Errorf("Wrong number of parts to AuditConfig id %s; expected 'resource_name service'.", s) + } + id, service := s[0], s[1] + + // Set the ID only to the first part so all IAM types can share the same resourceIdParserFunc. + d.SetId(id) + d.Set("service", service) + err := resourceIdParser(d, config) + if err != nil { + return nil, err + } + + // Set the ID again so that the ID matches the ID it would have if it had been created via TF. + // Use the current ID in case it changed in the resourceIdParserFunc. + d.SetId(d.Id() + "/audit_config/" + service) + return []*schema.ResourceData{d}, nil + } +} + +func resourceIamAuditConfigUpdate(newUpdaterFunc newResourceIamUpdaterFunc) schema.UpdateFunc { + return func(d *schema.ResourceData, meta interface{}) error { + config := meta.(*Config) + updater, err := newUpdaterFunc(d, config) + if err != nil { + return err + } + + ac := getResourceIamAuditConfig(d) + err = iamPolicyReadModifyWrite(updater, func(p *cloudresourcemanager.Policy) error { + var found bool + for pos, b := range p.AuditConfigs { + if b.Service != ac.Service { + continue + } + found = true + p.AuditConfigs[pos] = ac + break + } + if !found { + p.AuditConfigs = append(p.AuditConfigs, ac) + } + return nil + }) + if err != nil { + return err + } + + return resourceIamAuditConfigRead(newUpdaterFunc)(d, meta) + } +} + +func resourceIamAuditConfigDelete(newUpdaterFunc newResourceIamUpdaterFunc) schema.DeleteFunc { + return func(d *schema.ResourceData, meta interface{}) error { + config := meta.(*Config) + updater, err := newUpdaterFunc(d, config) + if err != nil { + return err + } + + ac := getResourceIamAuditConfig(d) + err = iamPolicyReadModifyWrite(updater, func(p *cloudresourcemanager.Policy) error { + toRemove := -1 + for pos, b := range p.AuditConfigs { + if b.Service != ac.Service { + continue + } + toRemove = pos + break + } + if toRemove < 0 { + log.Printf("[DEBUG]: Policy audit configs for %s did not include an audit config for service %q", updater.DescribeResource(), ac.Service) + return nil + } + + p.AuditConfigs = append(p.AuditConfigs[:toRemove], p.AuditConfigs[toRemove+1:]...) + return nil + }) + if err != nil { + if isGoogleApiErrorWithCode(err, 404) { + log.Printf("[DEBUG]: Resource %s is missing or deleted, marking policy audit config as deleted", updater.DescribeResource()) + return nil + } + return err + } + + return resourceIamAuditConfigRead(newUpdaterFunc)(d, meta) + } +} + +func getResourceIamAuditConfig(d *schema.ResourceData) *cloudresourcemanager.AuditConfig { + auditLogConfigSet := d.Get("audit_log_config").(*schema.Set) + auditLogConfigs := make([]*cloudresourcemanager.AuditLogConfig, auditLogConfigSet.Len()) + for x, y := range auditLogConfigSet.List() { + logConfig := y.(map[string]interface{}) + auditLogConfigs[x] = &cloudresourcemanager.AuditLogConfig{ + LogType: logConfig["log_type"].(string), + ExemptedMembers: convertStringArr(logConfig["exempted_members"].(*schema.Set).List()), + } + } + return &cloudresourcemanager.AuditConfig{ + AuditLogConfigs: auditLogConfigs, + Service: d.Get("service").(string), + } +} + +func flattenAuditLogConfigs(configs []*cloudresourcemanager.AuditLogConfig) *schema.Set { + res := schema.NewSet(schema.HashResource(iamAuditConfigSchema["audit_log_config"].Elem.(*schema.Resource)), []interface{}{}) + for _, conf := range configs { + res.Add(map[string]interface{}{ + "log_type": conf.LogType, + "exempted_members": schema.NewSet(schema.HashSchema(iamAuditConfigSchema["audit_log_config"].Elem.(*schema.Resource).Schema["exempted_members"].Elem.(*schema.Schema)), convertStringArrToInterface(conf.ExemptedMembers)), + }) + } + return res +}