From 61bd3b2ff49dfc3272c727902d84c6856360b3d5 Mon Sep 17 00:00:00 2001 From: camthornton Date: Wed, 12 Aug 2026 19:01:08 +0000 Subject: [PATCH] securitycenter: add google_scc_notification_service_account resource Adds the google_scc_notification_service_account resource to retrieve the SCC notification service account email across organization and project scopes, preventing permission errors when configuring Pub/Sub IAM bindings ahead of NotificationConfig deployment. Fixes b/536845649 Signed-off-by: camthornton --- ...source_scc_notification_service_account.go | 264 ++++++++++++++++++ ...scc_notification_service_account_meta.yaml | 10 + ...e_scc_notification_service_account_test.go | 143 ++++++++++ ...notification_service_account.html.markdown | 96 +++++++ 4 files changed, 513 insertions(+) create mode 100644 mmv1/third_party/terraform/services/securitycenter/resource_scc_notification_service_account.go create mode 100644 mmv1/third_party/terraform/services/securitycenter/resource_scc_notification_service_account_meta.yaml create mode 100644 mmv1/third_party/terraform/services/securitycenter/resource_scc_notification_service_account_test.go create mode 100644 mmv1/third_party/terraform/website/docs/r/scc_notification_service_account.html.markdown diff --git a/mmv1/third_party/terraform/services/securitycenter/resource_scc_notification_service_account.go b/mmv1/third_party/terraform/services/securitycenter/resource_scc_notification_service_account.go new file mode 100644 index 000000000000..789dc63bca21 --- /dev/null +++ b/mmv1/third_party/terraform/services/securitycenter/resource_scc_notification_service_account.go @@ -0,0 +1,264 @@ +package securitycenter + +import ( + "context" + "fmt" + "log" + "strconv" + "strings" + "time" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/customdiff" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-provider-google/google/registry" + rmClient "github.com/hashicorp/terraform-provider-google/google/services/resourcemanager/client" + "github.com/hashicorp/terraform-provider-google/google/services/serviceusage" + "github.com/hashicorp/terraform-provider-google/google/tpgresource" + transport_tpg "github.com/hashicorp/terraform-provider-google/google/transport" +) + +func ResourceSecurityCenterNotificationServiceAccount() *schema.Resource { + return &schema.Resource{ + Create: resourceSecurityCenterNotificationServiceAccountCreate, + Read: resourceSecurityCenterNotificationServiceAccountRead, + Delete: resourceSecurityCenterNotificationServiceAccountDelete, + + Importer: &schema.ResourceImporter{ + StateContext: ResourceSecurityCenterNotificationServiceAccountImport, + }, + + Timeouts: &schema.ResourceTimeout{ + Create: schema.DefaultTimeout(20 * time.Minute), + Read: schema.DefaultTimeout(10 * time.Minute), + Delete: schema.DefaultTimeout(20 * time.Minute), + }, + + CustomizeDiff: customdiff.All( + resourceSecurityCenterNotificationServiceAccountCustomizeDiff, + ), + + Schema: map[string]*schema.Schema{ + "organization": { + Type: schema.TypeString, + Optional: true, + ForceNew: true, + ExactlyOneOf: []string{"organization", "project"}, + }, + "project": { + Type: schema.TypeString, + Optional: true, + Computed: true, + ForceNew: true, + ExactlyOneOf: []string{"organization", "project"}, + }, + "email": { + Type: schema.TypeString, + Computed: true, + Description: `The email address of the Cloud Security Command Center Notification service account.`, + }, + "member": { + Type: schema.TypeString, + Computed: true, + Description: `The Identity of the Cloud Security Command Center Notification service account in the form 'serviceAccount:{email}'. This value is often used to refer to the service account in order to grant IAM permissions.`, + }, + }, + UseJSONNumber: true, + } +} + +// resourceSecurityCenterNotificationServiceAccountCustomizeDiff defaults the 'project' attribute +// to the provider-level default project only when 'organization' is not configured in HCL. +// We check GetRawConfig() rather than GetOk() so that unknown/computed values during planning +// are properly detected as configured, preventing plan inconsistencies. +func resourceSecurityCenterNotificationServiceAccountCustomizeDiff(ctx context.Context, diff *schema.ResourceDiff, meta interface{}) error { + rawConfig := diff.GetRawConfig() + if !rawConfig.IsNull() { + if !rawConfig.GetAttr("organization").IsNull() { + return nil + } + } + return tpgresource.DefaultProviderProject(ctx, diff, meta) +} + +// getSccNotificationServiceAccountProjectNumber resolves a string Project ID (e.g., "my-project") +// to a numerical Project Number (e.g., "123456789"). If the input is already numerical digits, +// it returns immediately without making an API call. +// This is required because SCC notification service accounts strictly embed the numerical project +// number in their email addresses (service-@...). +func getSccNotificationServiceAccountProjectNumber(d *schema.ResourceData, config *transport_tpg.Config, project, userAgent string) (string, error) { + if _, err := strconv.ParseInt(project, 10, 64); err == nil { + return project, nil + } + + log.Printf("[DEBUG] Retrieving project number for SCC notification service account by doing a GET with the project id %q", project) + billingProject := project + if bp, err := tpgresource.GetBillingProject(d, config); err == nil { + billingProject = bp + } + + getProjectCall := rmClient.NewClient(config, userAgent).Projects.Get(project) + if config.UserProjectOverride { + getProjectCall.Header().Add("X-Goog-User-Project", billingProject) + } + projectCall, err := getProjectCall.Do() + if err != nil { + return "", fmt.Errorf("Failed to retrieve project %s: %w", project, err) + } + + return strconv.FormatInt(projectCall.ProjectNumber, 10), nil +} + +// getSccNotificationServiceAccountDetails inspects the configured scope ('organization' or 'project') and computes: +// 1. The target ServiceUsage endpoint URL for :generateServiceIdentity. +// 2. The deterministic SCC service account email address. +// 3. The canonical Terraform state ID (matching the email address). +// +// While :generateServiceIdentity provisions the P4SA in IAM as a side effect, ServiceUsage's API +// response does not populate .email for multi-agent SCC tags. Therefore, we compute the email +// deterministically using the numerical org or project ID. +func getSccNotificationServiceAccountDetails(d *schema.ResourceData, config *transport_tpg.Config, userAgent string) (string, string, string, error) { + if v, ok := d.GetOk("organization"); ok { + org := v.(string) + url, err := tpgresource.ReplaceVars(d, config, "{{ServiceUsageBasePath}}organizations/{{organization}}/services/securitycenter.googleapis.com:generateServiceIdentity") + if err != nil { + return "", "", "", err + } + email := fmt.Sprintf("service-org-%s@gcp-sa-scc-notification.iam.gserviceaccount.com", org) + parentId := fmt.Sprintf("organizations/%s", org) + return url, email, parentId, nil + } + + if v, ok := d.GetOk("project"); ok { + project := v.(string) + projectNumber, err := getSccNotificationServiceAccountProjectNumber(d, config, project, userAgent) + if err != nil { + return "", "", "", err + } + url, err := tpgresource.ReplaceVars(d, config, "{{ServiceUsageBasePath}}projects/{{project}}/services/securitycenter.googleapis.com:generateServiceIdentity") + if err != nil { + return "", "", "", err + } + email := fmt.Sprintf("service-%s@gcp-sa-scc-notification.iam.gserviceaccount.com", projectNumber) + parentId := fmt.Sprintf("projects/%s", project) + return url, email, parentId, nil + } + + return "", "", "", fmt.Errorf("one of organization or project must be specified") +} + +func resourceSecurityCenterNotificationServiceAccountCreate(d *schema.ResourceData, meta interface{}) error { + config := meta.(*transport_tpg.Config) + userAgent, err := tpgresource.GenerateUserAgentString(d, config.UserAgent) + if err != nil { + return err + } + + url, email, parentId, err := getSccNotificationServiceAccountDetails(d, config, userAgent) + if err != nil { + return err + } + + billingProject := "" + if bp, err := tpgresource.GetBillingProject(d, config); err == nil { + billingProject = bp + } + + // Call ServiceUsage :generateServiceIdentity to trigger Service Agent Manager (SAM) + // to provision the identity in IAM immediately. This ensures the service account exists + // before Terraform applies downstream resources like google_pubsub_topic_iam_member. + res, err := transport_tpg.SendRequest(transport_tpg.SendRequestOptions{ + Config: config, + Method: "POST", + Project: billingProject, + RawURL: url, + UserAgent: userAgent, + Timeout: d.Timeout(schema.TimeoutCreate), + }) + if err != nil { + return fmt.Errorf("Error creating Cloud SCC Notification Service Account: %s", err) + } + + var opRes map[string]interface{} + err = serviceusage.ServiceUsageOperationWaitTimeWithResponse( + config, res, &opRes, billingProject, "Creating Cloud SCC Notification Service Account", userAgent, + d.Timeout(schema.TimeoutCreate)) + if err != nil { + return err + } + + d.SetId(email) + if err := d.Set("email", email); err != nil { + return fmt.Errorf("Error setting email: %s", err) + } + if err := d.Set("member", "serviceAccount:"+email); err != nil { + return fmt.Errorf("Error setting member: %s", err) + } + + log.Printf("[DEBUG] Created Cloud SCC Notification Service Account %q for parent %q", email, parentId) + return nil +} + +func resourceSecurityCenterNotificationServiceAccountRead(d *schema.ResourceData, meta interface{}) error { + config := meta.(*transport_tpg.Config) + userAgent, err := tpgresource.GenerateUserAgentString(d, config.UserAgent) + if err != nil { + return err + } + + _, email, _, err := getSccNotificationServiceAccountDetails(d, config, userAgent) + if err != nil { + return err + } + + d.SetId(email) + if err := d.Set("email", email); err != nil { + return fmt.Errorf("Error setting email: %s", err) + } + if err := d.Set("member", "serviceAccount:"+email); err != nil { + return fmt.Errorf("Error setting member: %s", err) + } + return nil +} + +func resourceSecurityCenterNotificationServiceAccountDelete(d *schema.ResourceData, meta interface{}) error { + return nil +} + +func ResourceSecurityCenterNotificationServiceAccountImport(ctx context.Context, d *schema.ResourceData, meta interface{}) ([]*schema.ResourceData, error) { + id := d.Id() + if strings.HasPrefix(id, "organizations/") { + if err := d.Set("organization", strings.TrimPrefix(id, "organizations/")); err != nil { + return nil, fmt.Errorf("Error setting organization: %s", err) + } + } else if strings.HasPrefix(id, "projects/") { + if err := d.Set("project", strings.TrimPrefix(id, "projects/")); err != nil { + return nil, fmt.Errorf("Error setting project: %s", err) + } + } else if strings.Contains(id, "@gcp-sa-scc-notification.iam.gserviceaccount.com") { + parts := strings.Split(id, "@") + accountPart := parts[0] + if strings.HasPrefix(accountPart, "service-org-") { + if err := d.Set("organization", strings.TrimPrefix(accountPart, "service-org-")); err != nil { + return nil, fmt.Errorf("Error setting organization: %s", err) + } + } else if strings.HasPrefix(accountPart, "service-") { + if err := d.Set("project", strings.TrimPrefix(accountPart, "service-")); err != nil { + return nil, fmt.Errorf("Error setting project: %s", err) + } + } else { + return nil, fmt.Errorf("Unsupported import format %q for google_scc_notification_service_account", id) + } + } else { + return nil, fmt.Errorf("Unsupported import format %q for google_scc_notification_service_account: expected organizations/{org_id}, projects/{project_id}, or email address", id) + } + return []*schema.ResourceData{d}, nil +} + +func init() { + registry.Schema{ + Name: "google_scc_notification_service_account", + ProductName: "securitycenter", + Type: registry.SchemaTypeResource, + Schema: ResourceSecurityCenterNotificationServiceAccount(), + }.Register() +} diff --git a/mmv1/third_party/terraform/services/securitycenter/resource_scc_notification_service_account_meta.yaml b/mmv1/third_party/terraform/services/securitycenter/resource_scc_notification_service_account_meta.yaml new file mode 100644 index 000000000000..6bd37624c556 --- /dev/null +++ b/mmv1/third_party/terraform/services/securitycenter/resource_scc_notification_service_account_meta.yaml @@ -0,0 +1,10 @@ +resource: 'google_scc_notification_service_account' +generation_type: 'handwritten' +api_service_name: 'serviceusage.googleapis.com' +api_version: 'v1beta1' +api_resource_type_kind: 'Service' +fields: + - field: 'organization' + - field: 'project' + - field: 'email' + - field: 'member' diff --git a/mmv1/third_party/terraform/services/securitycenter/resource_scc_notification_service_account_test.go b/mmv1/third_party/terraform/services/securitycenter/resource_scc_notification_service_account_test.go new file mode 100644 index 000000000000..2b83a73a9624 --- /dev/null +++ b/mmv1/third_party/terraform/services/securitycenter/resource_scc_notification_service_account_test.go @@ -0,0 +1,143 @@ +package securitycenter_test + +import ( + "context" + "fmt" + "regexp" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-provider-google/google/acctest" + "github.com/hashicorp/terraform-provider-google/google/envvar" + "github.com/hashicorp/terraform-provider-google/google/services/securitycenter" +) + +func TestSecurityCenterNotificationServiceAccount_importIdParsing(t *testing.T) { + t.Parallel() + + cases := []struct { + id string + expectedKey string + expectedValue string + expectError bool + }{ + { + id: "organizations/1234567890", + expectedKey: "organization", + expectedValue: "1234567890", + }, + { + id: "projects/my-test-proj", + expectedKey: "project", + expectedValue: "my-test-proj", + }, + { + id: "service-org-1234567890@gcp-sa-scc-notification.iam.gserviceaccount.com", + expectedKey: "organization", + expectedValue: "1234567890", + }, + { + id: "service-12345@gcp-sa-scc-notification.iam.gserviceaccount.com", + expectedKey: "project", + expectedValue: "12345", + }, + { + id: "invalid-import-id", + expectError: true, + }, + } + + for _, tc := range cases { + t.Run(tc.id, func(t *testing.T) { + d := schema.TestResourceDataRaw(t, securitycenter.ResourceSecurityCenterNotificationServiceAccount().Schema, map[string]interface{}{}) + d.SetId(tc.id) + + res, err := securitycenter.ResourceSecurityCenterNotificationServiceAccountImport(context.Background(), d, nil) + if tc.expectError { + if err == nil { + t.Fatalf("expected error for id %q, got nil", tc.id) + } + return + } + + if err != nil { + t.Fatalf("unexpected error for id %q: %v", tc.id, err) + } + if len(res) != 1 { + t.Fatalf("expected 1 resource data, got %d", len(res)) + } + val := res[0].Get(tc.expectedKey).(string) + if val != tc.expectedValue { + t.Fatalf("expected %s=%q, got %q", tc.expectedKey, tc.expectedValue, val) + } + }) + } +} + +func TestAccSecurityCenterNotificationServiceAccount_basic(t *testing.T) { + t.Parallel() + + org := envvar.GetTestOrgFromEnv(t) + + acctest.VcrTest(t, resource.TestCase{ + PreCheck: func() { acctest.AccTestPreCheck(t) }, + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories(t), + Steps: []resource.TestStep{ + { + Config: testAccSecurityCenterNotificationServiceAccount_basic(org), + Check: resource.ComposeTestCheckFunc( + resource.TestMatchResourceAttr("google_scc_notification_service_account.scc_sa", "email", regexp.MustCompile(`^service-org-[0-9]+@gcp-sa-scc-notification\.iam\.gserviceaccount\.com$`)), + resource.TestMatchResourceAttr("google_scc_notification_service_account.scc_sa", "member", regexp.MustCompile(`^serviceAccount:service-org-[0-9]+@gcp-sa-scc-notification\.iam\.gserviceaccount\.com$`)), + ), + }, + { + ResourceName: "google_scc_notification_service_account.scc_sa", + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +func TestAccSecurityCenterNotificationServiceAccount_project(t *testing.T) { + t.Parallel() + + project := envvar.GetTestProjectFromEnv() + + acctest.VcrTest(t, resource.TestCase{ + PreCheck: func() { acctest.AccTestPreCheck(t) }, + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories(t), + Steps: []resource.TestStep{ + { + Config: testAccSecurityCenterNotificationServiceAccount_project(project), + Check: resource.ComposeTestCheckFunc( + resource.TestMatchResourceAttr("google_scc_notification_service_account.scc_sa", "email", regexp.MustCompile(`^service-[0-9]+@gcp-sa-scc-notification\.iam\.gserviceaccount\.com$`)), + resource.TestMatchResourceAttr("google_scc_notification_service_account.scc_sa", "member", regexp.MustCompile(`^serviceAccount:service-[0-9]+@gcp-sa-scc-notification\.iam\.gserviceaccount\.com$`)), + ), + }, + { + ResourceName: "google_scc_notification_service_account.scc_sa", + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"project"}, + }, + }, + }) +} + +func testAccSecurityCenterNotificationServiceAccount_basic(org string) string { + return fmt.Sprintf(` +resource "google_scc_notification_service_account" "scc_sa" { + organization = "%s" +} +`, org) +} + +func testAccSecurityCenterNotificationServiceAccount_project(project string) string { + return fmt.Sprintf(` +resource "google_scc_notification_service_account" "scc_sa" { + project = "%s" +} +`, project) +} diff --git a/mmv1/third_party/terraform/website/docs/r/scc_notification_service_account.html.markdown b/mmv1/third_party/terraform/website/docs/r/scc_notification_service_account.html.markdown new file mode 100644 index 000000000000..d0f4b9e42561 --- /dev/null +++ b/mmv1/third_party/terraform/website/docs/r/scc_notification_service_account.html.markdown @@ -0,0 +1,96 @@ +--- +subcategory: "Security Command Center" +description: |- + Generates and retrieves the email and member string of the Security Command Center Notification Service Account. +--- + +# google_scc_notification_service_account + +Generates and retrieves the email and member string of the Security Command Center Notification Service Account (`gcp-sa-scc-notification.iam.gserviceaccount.com`). + +~> **Note:** Once created, this resource cannot be updated or destroyed. These +actions are a no-op. + +~> **Note:** This resource can be used to provision the Security Command Center Notification Service Account in IAM before configuring notification configs or applying IAM policy bindings to Pub/Sub topics, avoiding "permission denied" errors when using automated pipelines. + +To get more information about Security Command Center Notification Service Accounts, see: + +* [API documentation](https://cloud.google.com/service-usage/docs/reference/rest/v1beta1/services/generateServiceIdentity) + +## Example Usage - Scc Notification Service Account Organization + +```hcl +resource "google_scc_notification_service_account" "scc_sa" { + organization = "1234567890" +} + +resource "google_pubsub_topic" "scc_notifications" { + name = "scc-notifications-topic" +} + +resource "google_pubsub_topic_iam_member" "scc_pubsub_publisher" { + topic = google_pubsub_topic.scc_notifications.id + role = "roles/pubsub.publisher" + member = google_scc_notification_service_account.scc_sa.member +} + +resource "google_scc_notification_config" "custom_notification_config" { + config_id = "my-config" + organization = "1234567890" + description = "My custom SCC Finding Notification Configuration" + pubsub_topic = google_pubsub_topic.scc_notifications.id + + depends_on = [ + google_pubsub_topic_iam_member.scc_pubsub_publisher + ] +} +``` + +## Example Usage - Scc Notification Service Account Project + +```hcl +resource "google_scc_notification_service_account" "scc_sa" { + project = "my-project-id" +} +``` + +## Argument Reference + +The following arguments are supported: + +* `organization` - (Optional, ForceNew) The organization ID for which the service account should be generated. Exactly one of `organization` or `project` must be specified. + +* `project` - (Optional, ForceNew) The project ID or number for which the service account should be generated. Exactly one of `organization` or `project` must be specified. If omitted and `organization` is not specified, it defaults to the provider project. + +## Attributes Reference + +In addition to the arguments listed above, the following computed attributes are exported: + +* `id` - an identifier for the resource with format `{{email}}` + +* `email` - The email address of the Cloud Security Command Center Notification service account. + +* `member` - The Identity of the Cloud Security Command Center Notification service account in the form `serviceAccount:{email}`. This value is often used to refer to the service account in order to grant IAM permissions. + +## Import + +Notification Service Accounts can be imported using their parent scope or email address: + +* `organizations/{{organization}}` +* `projects/{{project}}` +* `{{email}}` + +In Terraform v1.5.0 and later, use an [`import` block](https://developer.hashicorp.com/terraform/language/import) to import Notification Service Accounts using one of the formats above. For example: + +```tf +import { + id = "organizations/1234567890" + to = google_scc_notification_service_account.default +} +``` + +When using the [`terraform import` command](https://developer.hashicorp.com/terraform/cli/commands/import), Notification Service Accounts can be imported using one of the formats above. For example: + +``` +$ terraform import google_scc_notification_service_account.default organizations/1234567890 +```