From 32e35107c27d4996a14c875509b2608223b9cb31 Mon Sep 17 00:00:00 2001 From: Modular Magician Date: Thu, 5 Nov 2020 01:01:38 +0000 Subject: [PATCH] Add support for google_project_default_service_accounts resource (#4167) Co-authored-by: Thiago Carvalho Signed-off-by: Modular Magician --- .changelog/4167.txt | 3 + google/provider.go | 1 + google/provider_test.go | 7 + ...google_project_default_service_accounts.go | 202 ++++++++++++++ ...e_project_default_service_accounts_test.go | 247 ++++++++++++++++++ ...ect_default_service_accounts.html.markdown | 68 +++++ website/google.erb | 4 + 7 files changed, 532 insertions(+) create mode 100644 .changelog/4167.txt create mode 100644 google/resource_google_project_default_service_accounts.go create mode 100644 google/resource_google_project_default_service_accounts_test.go create mode 100644 website/docs/r/google_project_default_service_accounts.html.markdown diff --git a/.changelog/4167.txt b/.changelog/4167.txt new file mode 100644 index 00000000000..61e0bc438df --- /dev/null +++ b/.changelog/4167.txt @@ -0,0 +1,3 @@ +```release-note:new-resource +`google_project_default_service_accounts` +``` diff --git a/google/provider.go b/google/provider.go index ea3c16f6b1e..a058cda1d2c 100644 --- a/google/provider.go +++ b/google/provider.go @@ -1011,6 +1011,7 @@ func ResourceMapWithErrors() (map[string]*schema.Resource, error) { "google_organization_iam_audit_config": ResourceIamAuditConfig(IamOrganizationSchema, NewOrganizationIamUpdater, OrgIdParseFunc), "google_organization_policy": resourceGoogleOrganizationPolicy(), "google_project": resourceGoogleProject(), + "google_project_default_service_accounts": resourceGoogleProjectDefaultServiceAccounts(), "google_project_iam_policy": ResourceIamPolicy(IamPolicyProjectSchema, NewProjectIamPolicyUpdater, ProjectIdParseFunc), "google_project_iam_binding": ResourceIamBindingWithBatching(IamProjectSchema, NewProjectIamUpdater, ProjectIdParseFunc, IamBatchingEnabled), "google_project_iam_member": ResourceIamMemberWithBatching(IamProjectSchema, NewProjectIamUpdater, ProjectIdParseFunc, IamBatchingEnabled), diff --git a/google/provider_test.go b/google/provider_test.go index 533d584dd5f..505a5585a06 100644 --- a/google/provider_test.go +++ b/google/provider_test.go @@ -911,3 +911,10 @@ func skipIfVcr(t *testing.T) { t.Skipf("VCR enabled, skipping test: %s", t.Name()) } } + +func sleepInSecondsForTest(t int) resource.TestCheckFunc { + return func(s *terraform.State) error { + time.Sleep(time.Duration(t) * time.Second) + return nil + } +} diff --git a/google/resource_google_project_default_service_accounts.go b/google/resource_google_project_default_service_accounts.go new file mode 100644 index 00000000000..618fa471448 --- /dev/null +++ b/google/resource_google_project_default_service_accounts.go @@ -0,0 +1,202 @@ +package google + +import ( + "fmt" + "strings" + "time" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" + cloudresourcemanager "google.golang.org/api/cloudresourcemanager/v1" + "google.golang.org/api/iam/v1" +) + +// resourceGoogleProjectDefaultServiceAccounts returns a *schema.Resource that allows a customer +// to manage all the default serviceAccounts. +// It does mean that terraform tried to perform the action in the SA at some point but does not ensure that +// all defaults serviceAccounts where managed. Eg.: API was activated after project creation. +func resourceGoogleProjectDefaultServiceAccounts() *schema.Resource { + return &schema.Resource{ + Create: resourceGoogleProjectDefaultServiceAccountsCreate, + Read: schema.Noop, + Update: schema.Noop, + Delete: resourceGoogleProjectDefaultServiceAccountsDelete, + + Timeouts: &schema.ResourceTimeout{ + Create: schema.DefaultTimeout(10 * time.Minute), + Read: schema.DefaultTimeout(10 * time.Minute), + Delete: schema.DefaultTimeout(10 * time.Minute), + }, + + Schema: map[string]*schema.Schema{ + "project": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + ValidateFunc: validateProjectID(), + Description: `The project ID where service accounts are created.`, + }, + "action": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + ValidateFunc: validation.StringInSlice([]string{"DEPRIVILEGE", "DELETE", "DISABLE"}, false), + Description: `The action to be performed in the default service accounts. Valid values are: DEPRIVILEGE, DELETE, DISABLE. + Note that DEPRIVILEGE action will ignore the REVERT configuration in the restore_policy.`, + }, + "restore_policy": { + Type: schema.TypeString, + Optional: true, + Default: "REVERT", + ValidateFunc: validation.StringInSlice([]string{"NONE", "REVERT"}, false), + Description: `The action to be performed in the default service accounts on the resource destroy. + Valid values are NONE and REVERT. If set to REVERT it will attempt to restore all default SAs but in the DEPRIVILEGE action.`, + }, + "service_accounts": { + Type: schema.TypeMap, + Computed: true, + Description: `The Service Accounts changed by this resource. It is used for revert the action on the destroy.`, + }, + }, + } +} + +func resourceGoogleProjectDefaultServiceAccountsDoAction(d *schema.ResourceData, meta interface{}, action, uniqueID, email, project string) error { + config := meta.(*Config) + userAgent, err := generateUserAgentString(d, config.userAgent) + if err != nil { + return err + } + + serviceAccountSelfLink := fmt.Sprintf("projects/%s/serviceAccounts/%s", project, uniqueID) + switch action { + case "DELETE": + _, err := config.NewIamClient(userAgent).Projects.ServiceAccounts.Delete(serviceAccountSelfLink).Do() + if err != nil { + return fmt.Errorf("cannot delete service account %s: %v", serviceAccountSelfLink, err) + } + case "UNDELETE": + _, err := config.NewIamClient(userAgent).Projects.ServiceAccounts.Undelete(serviceAccountSelfLink, &iam.UndeleteServiceAccountRequest{}).Do() + if err != nil { + return fmt.Errorf("cannot undelete service account %s: %v", serviceAccountSelfLink, err) + } + case "DISABLE": + _, err := config.NewIamClient(userAgent).Projects.ServiceAccounts.Disable(serviceAccountSelfLink, &iam.DisableServiceAccountRequest{}).Do() + if err != nil { + return fmt.Errorf("cannot disable service account %s: %v", serviceAccountSelfLink, err) + } + case "ENABLE": + _, err := config.NewIamClient(userAgent).Projects.ServiceAccounts.Enable(serviceAccountSelfLink, &iam.EnableServiceAccountRequest{}).Do() + if err != nil { + return fmt.Errorf("cannot enable service account %s: %v", serviceAccountSelfLink, err) + } + case "DEPRIVILEGE": + iamPolicy, err := config.NewResourceManagerClient(userAgent).Projects.GetIamPolicy(project, &cloudresourcemanager.GetIamPolicyRequest{}).Do() + if err != nil { + return fmt.Errorf("cannot get IAM policy on project %s: %v", project, err) + } + + // Creates a new slice with all members but the service account + for _, bind := range iamPolicy.Bindings { + newMembers := []string{} + for _, member := range bind.Members { + if member != fmt.Sprintf("serviceAccount:%s", email) { + newMembers = append(newMembers, member) + } + } + bind.Members = newMembers + } + _, err = config.NewResourceManagerClient(userAgent).Projects.SetIamPolicy(project, &cloudresourcemanager.SetIamPolicyRequest{}).Do() + if err != nil { + return fmt.Errorf("cannot update IAM policy on project %s: %v", project, err) + } + default: + return fmt.Errorf("action %s is not a valid action", action) + } + + return nil +} + +func resourceGoogleProjectDefaultServiceAccountsCreate(d *schema.ResourceData, meta interface{}) error { + config := meta.(*Config) + userAgent, err := generateUserAgentString(d, config.userAgent) + if err != nil { + return err + } + pid := d.Get("project").(string) + action := d.Get("action").(string) + + serviceAccounts, err := resourceGoogleProjectDefaultServiceAccountsList(config, d, userAgent) + if err != nil { + return fmt.Errorf("error listing service accounts on project %s: %v", pid, err) + } + changedServiceAccounts := make(map[string]interface{}) + for _, sa := range serviceAccounts { + // As per documentation https://cloud.google.com/iam/docs/service-accounts#default + // we have just two default SAs and the e-mail may change. So, it is been filtered + // by the Display Name + if isDefaultServiceAccount(sa.DisplayName) { + err := resourceGoogleProjectDefaultServiceAccountsDoAction(d, meta, action, sa.UniqueId, sa.Email, pid) + if err != nil { + return fmt.Errorf("error doing action %s on Service Account %s: %v", action, sa.Email, err) + } + changedServiceAccounts[sa.UniqueId] = sa.Email + } + } + if err := d.Set("service_accounts", changedServiceAccounts); err != nil { + return fmt.Errorf("error setting service_accounts: %s", err) + } + d.SetId(prefixedProject(pid)) + + return nil +} + +func resourceGoogleProjectDefaultServiceAccountsList(config *Config, d *schema.ResourceData, userAgent string) ([]*iam.ServiceAccount, error) { + pid := d.Get("project").(string) + response, err := config.NewIamClient(userAgent).Projects.ServiceAccounts.List(prefixedProject(pid)).Do() + if err != nil { + return nil, fmt.Errorf("failed to list service accounts on project %q: %v", pid, err) + } + return response.Accounts, nil +} + +func resourceGoogleProjectDefaultServiceAccountsDelete(d *schema.ResourceData, meta interface{}) error { + if d.Get("restore_policy").(string) == "NONE" { + d.SetId("") + return nil + } + + pid := d.Get("project").(string) + for saUniqueID, saEmail := range d.Get("service_accounts").(map[string]interface{}) { + origAction := d.Get("action").(string) + newAction := "" + // We agreed to not revert the DEPRIVILEGE because Morgante said it is not required. + // It may be an enhancement. https://github.com/hashicorp/terraform-provider-google/issues/4135#issuecomment-709480278 + if origAction == "DISABLE" { + newAction = "ENABLE" + } else if origAction == "DELETE" { + newAction = "UNDELETE" + } + if newAction != "" { + err := resourceGoogleProjectDefaultServiceAccountsDoAction(d, meta, newAction, saUniqueID, saEmail.(string), pid) + if err != nil { + return fmt.Errorf("error doing action %s on Service Account %s: %v", newAction, saUniqueID, err) + } + } + } + + d.SetId("") + + return nil +} + +func isDefaultServiceAccount(displayName string) bool { + gceDefaultSA := "compute engine default service account" + appEngineDefaultSA := "app engine default service account" + saDisplayName := strings.ToLower(displayName) + if saDisplayName == gceDefaultSA || saDisplayName == appEngineDefaultSA { + return true + } + + return false +} diff --git a/google/resource_google_project_default_service_accounts_test.go b/google/resource_google_project_default_service_accounts_test.go new file mode 100644 index 00000000000..10ae316a88d --- /dev/null +++ b/google/resource_google_project_default_service_accounts_test.go @@ -0,0 +1,247 @@ +package google + +import ( + "fmt" + "strings" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" + cloudresourcemanager "google.golang.org/api/cloudresourcemanager/v1" +) + +func TestAccResourceGoogleProjectDefaultServiceAccountsBasic(t *testing.T) { + t.Parallel() + + resourceName := "google_project_default_service_accounts.acceptance" + org := getTestOrgFromEnv(t) + project := fmt.Sprintf("tf-project-%d", randInt(t)) + billingAccount := getTestBillingAccountFromEnv(t) + + vcrTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: testAccCheckGoogleProjectDefaultServiceAccountsBasic(org, project, billingAccount), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "id", "projects/"+project), + resource.TestCheckResourceAttrSet(resourceName, "project"), + resource.TestCheckResourceAttrSet(resourceName, "action"), + resource.TestCheckResourceAttrSet(resourceName, "restore_policy"), + ), + }, + }, + }) +} + +func testAccCheckGoogleProjectDefaultServiceAccountsBasic(org, project, billingAccount string) string { + return fmt.Sprintf(` +resource "google_project" "acceptance" { + project_id = "%s" + name = "%s" + org_id = "%s" + billing_account = "%s" +} + +resource "google_project_default_service_accounts" "acceptance" { + project = google_project.acceptance.project_id + action = "DISABLE" +} +`, project, project, org, billingAccount) +} + +func TestAccResourceGoogleProjectDefaultServiceAccountsDisable(t *testing.T) { + t.Parallel() + + org := getTestOrgFromEnv(t) + project := fmt.Sprintf("tf-project-%d", randInt(t)) + billingAccount := getTestBillingAccountFromEnv(t) + action := "DISABLE" + restorePolicy := "REVERT" + + vcrTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckGoogleProjectDefaultServiceAccountsRevert(t, project, action), + Steps: []resource.TestStep{ + { + Config: testAccCheckGoogleProjectDefaultServiceAccountsAdvanced(org, project, billingAccount, action, restorePolicy), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("google_project_default_service_accounts.acceptance", "id", "projects/"+project), + resource.TestCheckResourceAttrSet("google_project_default_service_accounts.acceptance", "project"), + resource.TestCheckResourceAttr("google_project_default_service_accounts.acceptance", "action", action), + resource.TestCheckResourceAttrSet("google_project_default_service_accounts.acceptance", "project"), + sleepInSecondsForTest(5), + testAccCheckGoogleProjectDefaultServiceAccountsChanges(t, project, action), + ), + }, + }, + }) +} + +func TestAccResourceGoogleProjectDefaultServiceAccountsDelete(t *testing.T) { + t.Parallel() + + org := getTestOrgFromEnv(t) + project := fmt.Sprintf("tf-project-%d", randInt(t)) + billingAccount := getTestBillingAccountFromEnv(t) + action := "DELETE" + restorePolicy := "REVERT" + + vcrTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckGoogleProjectDefaultServiceAccountsRevert(t, project, action), + Steps: []resource.TestStep{ + { + Config: testAccCheckGoogleProjectDefaultServiceAccountsAdvanced(org, project, billingAccount, action, restorePolicy), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("google_project_default_service_accounts.acceptance", "id", "projects/"+project), + resource.TestCheckResourceAttrSet("google_project_default_service_accounts.acceptance", "project"), + resource.TestCheckResourceAttr("google_project_default_service_accounts.acceptance", "action", action), + resource.TestCheckResourceAttrSet("google_project_default_service_accounts.acceptance", "project"), + sleepInSecondsForTest(10), + testAccCheckGoogleProjectDefaultServiceAccountsChanges(t, project, action), + ), + }, + }, + }) +} + +func TestAccResourceGoogleProjectDefaultServiceAccountsDeprivilege(t *testing.T) { + t.Parallel() + + org := getTestOrgFromEnv(t) + project := fmt.Sprintf("tf-project-%d", randInt(t)) + billingAccount := getTestBillingAccountFromEnv(t) + action := "DEPRIVILEGE" + restorePolicy := "REVERT" + + vcrTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckGoogleProjectDefaultServiceAccountsRevert(t, project, action), + Steps: []resource.TestStep{ + { + Config: testAccCheckGoogleProjectDefaultServiceAccountsAdvanced(org, project, billingAccount, action, restorePolicy), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("google_project_default_service_accounts.acceptance", "id", "projects/"+project), + resource.TestCheckResourceAttrSet("google_project_default_service_accounts.acceptance", "project"), + resource.TestCheckResourceAttr("google_project_default_service_accounts.acceptance", "action", action), + resource.TestCheckResourceAttrSet("google_project_default_service_accounts.acceptance", "project"), + sleepInSecondsForTest(5), + testAccCheckGoogleProjectDefaultServiceAccountsChanges(t, project, action), + ), + }, + }, + }) +} + +func testAccCheckGoogleProjectDefaultServiceAccountsAdvanced(org, project, billingAccount, action, restorePolicy string) string { + return fmt.Sprintf(` +resource "google_project" "acceptance" { + project_id = "%s" + name = "%s" + org_id = "%s" + billing_account = "%s" +} + +resource "google_project_service" "acceptance" { + project = google_project.acceptance.project_id + service = "compute.googleapis.com" + + disable_dependent_services = true +} + +resource "google_project_default_service_accounts" "acceptance" { + depends_on = [google_project_service.acceptance] + project = google_project.acceptance.project_id + action = "%s" + restore_policy = "%s" +} +`, project, project, org, billingAccount, action, restorePolicy) +} + +func testAccCheckGoogleProjectDefaultServiceAccountsChanges(t *testing.T, project, action string) func(s *terraform.State) error { + return func(s *terraform.State) error { + config := googleProviderConfig(t) + response, err := config.NewIamClient(config.userAgent).Projects.ServiceAccounts.List(prefixedProject(project)).Do() + if err != nil { + return fmt.Errorf("failed to list service accounts on project %q: %v", project, err) + } + for _, sa := range response.Accounts { + if testAccIsDefaultServiceAccount(sa.DisplayName) { + switch action { + case "DISABLE": + if !sa.Disabled { + return fmt.Errorf("compute engine default service account is not disabled, disable field is %t", sa.Disabled) + } + case "DELETE": + return fmt.Errorf("compute engine default service account is not deleted") + case "DEPRIVILEGE": + iamPolicy, err := config.NewResourceManagerClient(config.userAgent).Projects.GetIamPolicy(project, &cloudresourcemanager.GetIamPolicyRequest{}).Do() + if err != nil { + return fmt.Errorf("cannot get IAM policy on project %s: %v", project, err) + } + for _, bind := range iamPolicy.Bindings { + for _, member := range bind.Members { + if member == fmt.Sprintf("serviceAccount:%s", sa.Email) { + return fmt.Errorf("compute engine default service account is not deprivileged") + } + } + } + return nil + } + } + } + return nil + } +} + +// Test if actions were reverted properly +func testAccCheckGoogleProjectDefaultServiceAccountsRevert(t *testing.T, project, action string) func(s *terraform.State) error { + return func(s *terraform.State) error { + config := googleProviderConfig(t) + response, err := config.NewIamClient(config.userAgent).Projects.ServiceAccounts.List(prefixedProject(project)).Do() + if err != nil { + return fmt.Errorf("failed to list service accounts on project %q: %v", project, err) + } + for _, sa := range response.Accounts { + if testAccIsDefaultServiceAccount(sa.DisplayName) { + // We agreed to not revert the DEPRIVILEGE action because will be hard to track the roles over the time + if action == "DISABLE" { + if sa.Disabled { + return fmt.Errorf("compute engine default service account is not enabled, disable field is %t", sa.Disabled) + } + } else if action == "DELETE" { + // A deleted service account was found meaning the undelete action triggered + // on destroy worked + return nil + } + } + } + // if action is DELETE, the service account should be found in the previous loop + // due to undelete action + if action == "DELETE" { + return fmt.Errorf("service account changes were not reverted after destroy") + } + + return nil + } +} + +// testAccIsDefaultServiceAccount is a helper function to facilitate TDD when there is a need +// to update how we determine whether it's a default SA or not. +// If you follow TDD, it is going to be different from isDefaultServiceAccount func while coding +// but they must be identical before commit/push +func testAccIsDefaultServiceAccount(displayName string) bool { + gceDefaultSA := "compute engine default service account" + appEngineDefaultSA := "app engine default service account" + saDisplayName := strings.ToLower(displayName) + if saDisplayName == gceDefaultSA || saDisplayName == appEngineDefaultSA { + return true + } + + return false +} diff --git a/website/docs/r/google_project_default_service_accounts.html.markdown b/website/docs/r/google_project_default_service_accounts.html.markdown new file mode 100644 index 00000000000..5cec6f1a06a --- /dev/null +++ b/website/docs/r/google_project_default_service_accounts.html.markdown @@ -0,0 +1,68 @@ +--- +subcategory: "Cloud Platform" +layout: "google" +page_title: "Google: google_project_default_service_accounts" +sidebar_current: "docs-google-project-default-service-accounts-x" +description: |- + Allows management of Google Cloud Platform project default service accounts. +--- + +# google_project_default_service_accounts + +Allows management of Google Cloud Platform project default service accounts. + +When certain service APIs are enabled, Google Cloud Platform automatically creates service accounts to help get started, but +this is not recommended for production environments as per [Google's documentation](https://cloud.google.com/iam/docs/service-accounts#default). +See the [Organization documentation](https://cloud.google.com/resource-manager/docs/quickstarts) for more details. +~> This resource works on a best-effort basis, as no API formally describes the default service accounts. If the default service accounts change their name or additional service accounts are added, this resource will need to be updated. + +## Example Usage + +```hcl +resource "google_project_default_service_accounts" "my_project" { + project = "my-project-id" + action = "DELETE" +} +``` + +To enable the default service accounts on the resource destroy: + +```hcl +resource "google_project_default_service_accounts" "my_project" { + project = "my-project-id" + action = "DISABLE" + restore_policy = "REVERT" +} + +``` + +## Argument Reference + +The following arguments are supported: + +- `project` - (Required) The project ID where service accounts are created. + +- `action` - (Required) The action to be performed in the default service accounts. Valid values are: `DEPRIVILEGE`, `DELETE`, `DISABLE`. Note that `DEPRIVILEGE` action will ignore the REVERT configuration in the restore_policy + +- `restore_policy` - (Optional) The action to be performed in the default service accounts on the resource destroy. Valid values are `NONE` and `REVERT`. If set to `REVERT` it will attempt to restore all default SAs but in the `DEPRIVILEGE` action. + +## Attributes Reference + +In addition to the arguments listed above, the following computed attributes are +exported: + +- `id` - an identifier for the resource with format `projects/{{project}}` +- `service_accounts` - The Service Accounts changed by this resource. It is used for `REVERT` the `action` on the destroy. + +## Timeouts + +This resource provides the following +[Timeouts](/docs/configuration/resources.html#timeouts) configuration options: + +- `create` - Default is 10 minutes. +- `update` - Default is 10 minutes. +- `delete` - Default is 10 minutes. + +## Import + +This resource does not support import diff --git a/website/google.erb b/website/google.erb index 1fca53c41eb..5fcab030ee5 100644 --- a/website/google.erb +++ b/website/google.erb @@ -1038,6 +1038,10 @@ google_project +
  • + google_project_default_service_accounts +
  • +
  • google_project_iam