IaC GCP

Terraform Module: GCP Security Command Center — codify SCC sources and notifications as version-controlled findings infrastructure

Quick take — Wrap GCP Security Command Center in a reusable Terraform module: organization-scoped scc_source for custom findings, Pub/Sub notification configs with severity filters, and IAM bindings for finding writers. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.

Quickstart (copy-paste)

Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):

provider "google" {
  project = "my-project"
  region  = "us-central1"
}

module "scc" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-scc?ref=v1.0.0"

  organization_id     = "..."  # Numeric GCP organization ID that owns the SCC source.
  source_display_name = "..."  # Unique, human-readable name for the source (1-64 chars).
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

Google Cloud Security Command Center (SCC) is GCP’s centralized security and risk management plane. It ingests findings from built-in detectors (Security Health Analytics, Event Threat Detection, Container Threat Detection, Web Security Scanner) and from third-party or first-party integrations, then surfaces them as Finding objects grouped under Source objects at the organization level. A Source is the logical producer of findings — for example “Acme Vulnerability Scanner” or “Internal IAM Drift Detector” — and every finding must belong to exactly one source.

The piece you typically own in IaC is the plumbing around SCC, not the managed detectors themselves. That plumbing is: the custom sources that your own scanners and pipelines write findings into (google_scc_source), the notification configs that fan findings out to Pub/Sub for downstream automation (google_scc_notification_config), and the IAM bindings that let a specific service account write findings into a specific source (google_scc_source_iam_member). These three resources are fiddly: sources are immutable in awkward ways, notification configs need an exactly-correct CEL filter string, and the IAM binding has to be scoped to the source resource name rather than the org. Wrapping them in a module makes the source display name, the Pub/Sub topic, the severity filter, and the writer identity into a handful of typed, validated inputs — so every team that needs to push findings into SCC gets a consistent, reviewable footprint instead of hand-crafted console clicks.

When to use it

Do not use this module to enable the built-in SCC tier (Standard/Premium/Enterprise) or to configure Security Health Analytics modules — those are org-settings / module-settings concerns handled by different resources and are usually a one-time org bootstrap, not a per-team module instance.

Module structure

terraform-module-gcp-scc/
├── versions.tf
├── main.tf
├── variables.tf
└── outputs.tf

versions.tf

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    google = {
      source  = "hashicorp/google"
      version = "~> 5.0"
    }
  }
}

main.tf

locals {
  # SCC sources, notification configs and IAM are all organization-scoped.
  org_id = var.organization_id

  # Build a CEL filter for the notification config. If the caller passes an
  # explicit filter we use it verbatim; otherwise we synthesize one from the
  # requested severities and source so only relevant findings are streamed.
  severity_filter = length(var.notification_severities) > 0 ? join(
    " OR ",
    [for s in var.notification_severities : format("severity = \"%s\"", s)]
  ) : ""

  default_filter = trimspace(join(" AND ", compact([
    "state = \"ACTIVE\"",
    length(var.notification_severities) > 0 ? format("(%s)", local.severity_filter) : "",
  ])))

  notification_filter = coalesce(
    var.notification_filter_override,
    local.default_filter,
  )

  create_notification = var.notification_pubsub_topic != null
}

# The custom finding source. display_name is the human label shown in the
# SCC console; it must be unique within the organization. Note: a source
# cannot be deleted via the API, so destroy only removes it from state.
resource "google_scc_source" "this" {
  organization = local.org_id
  display_name = var.source_display_name
  description  = var.source_description
}

# Stream findings produced under this source (and matching the filter) to a
# Pub/Sub topic for downstream automation. Created only when a topic is given.
resource "google_scc_notification_config" "this" {
  count = local.create_notification ? 1 : 0

  config_id    = var.notification_config_id
  organization = local.org_id
  description  = var.notification_description
  pubsub_topic = var.notification_pubsub_topic

  streaming_config {
    filter = local.notification_filter
  }
}

# Grant a single identity permission to create/update findings under THIS
# source only (least privilege vs. org-wide securitycenter roles).
resource "google_scc_source_iam_member" "finding_writers" {
  for_each = toset(var.finding_writer_members)

  organization = local.org_id
  source       = google_scc_source.this.id
  role         = "roles/securitycenter.findingsEditor"
  member       = each.value
}

variables.tf

variable "organization_id" {
  description = "GCP organization ID that owns the SCC source (numeric, e.g. \"123456789012\")."
  type        = string

  validation {
    condition     = can(regex("^[0-9]{8,20}$", var.organization_id))
    error_message = "organization_id must be the numeric org ID (8-20 digits), not 'organizations/<id>'."
  }
}

variable "source_display_name" {
  description = "Human-readable display name for the SCC source. Must be unique within the organization."
  type        = string

  validation {
    condition     = length(var.source_display_name) > 0 && length(var.source_display_name) <= 64
    error_message = "source_display_name must be 1-64 characters."
  }
}

variable "source_description" {
  description = "Free-text description of what produces findings for this source."
  type        = string
  default     = "Custom Security Command Center finding source managed by Terraform."

  validation {
    condition     = length(var.source_description) <= 1024
    error_message = "source_description must be at most 1024 characters."
  }
}

variable "notification_pubsub_topic" {
  description = "Full Pub/Sub topic to stream findings to, e.g. \"projects/my-proj/topics/scc-findings\". Set to null to skip creating a notification config."
  type        = string
  default     = null

  validation {
    condition     = var.notification_pubsub_topic == null || can(regex("^projects/[^/]+/topics/[^/]+$", var.notification_pubsub_topic))
    error_message = "notification_pubsub_topic must be of the form projects/<project>/topics/<topic>."
  }
}

variable "notification_config_id" {
  description = "Stable ID for the notification config (lowercase letters, numbers, hyphens; 1-128 chars)."
  type        = string
  default     = "scc-findings-notify"

  validation {
    condition     = can(regex("^[a-z][a-z0-9-]{0,127}$", var.notification_config_id))
    error_message = "notification_config_id must start with a lowercase letter and contain only lowercase letters, numbers and hyphens (1-128 chars)."
  }
}

variable "notification_description" {
  description = "Description attached to the SCC notification config."
  type        = string
  default     = "Streams active SCC findings to Pub/Sub (managed by Terraform)."
}

variable "notification_severities" {
  description = "Severities to include in the auto-generated notification filter. Ignored if notification_filter_override is set."
  type        = list(string)
  default     = ["HIGH", "CRITICAL"]

  validation {
    condition = alltrue([
      for s in var.notification_severities :
      contains(["CRITICAL", "HIGH", "MEDIUM", "LOW"], s)
    ])
    error_message = "Each severity must be one of CRITICAL, HIGH, MEDIUM, LOW."
  }
}

variable "notification_filter_override" {
  description = "Optional raw CEL filter for the notification config. When set, it fully replaces the severity-derived filter."
  type        = string
  default     = null
}

variable "finding_writer_members" {
  description = "IAM members granted roles/securitycenter.findingsEditor on this source (e.g. [\"serviceAccount:scanner@proj.iam.gserviceaccount.com\"])."
  type        = list(string)
  default     = []

  validation {
    condition = alltrue([
      for m in var.finding_writer_members :
      can(regex("^(serviceAccount:|user:|group:|principal:|principalSet:)", m))
    ])
    error_message = "Each member must be a fully-qualified IAM principal (serviceAccount:, user:, group:, principal:, principalSet:)."
  }
}

outputs.tf

output "source_id" {
  description = "Full resource name of the SCC source, e.g. organizations/123/sources/456. Use this as the parent when creating findings."
  value       = google_scc_source.this.id
}

output "source_name" {
  description = "The server-assigned name of the SCC source (same canonical form as source_id)."
  value       = google_scc_source.this.name
}

output "source_display_name" {
  description = "Display name of the SCC source as shown in the console."
  value       = google_scc_source.this.display_name
}

output "notification_config_id" {
  description = "Resource name of the SCC notification config, or null when no Pub/Sub topic was supplied."
  value       = local.create_notification ? google_scc_notification_config.this[0].name : null
}

output "notification_filter" {
  description = "The effective CEL filter applied to the notification config."
  value       = local.create_notification ? local.notification_filter : null
}

output "service_account" {
  description = "The Google-managed service account that SCC uses to publish to the Pub/Sub topic; grant it roles/pubsub.publisher on the topic."
  value       = local.create_notification ? google_scc_notification_config.this[0].service_account : null
}

How to use it

module "security_command_center" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-scc?ref=v1.0.0"

  organization_id     = "123456789012"
  source_display_name = "KloudVin SBOM Scanner"
  source_description  = "CVE/SBOM findings emitted by the build-time supply-chain scanner."

  # Stream only actionable findings to Pub/Sub for the SOAR pipeline.
  notification_pubsub_topic = "projects/kv-security-prod/topics/scc-findings"
  notification_config_id    = "kv-sbom-high-crit"
  notification_severities   = ["HIGH", "CRITICAL"]

  # Least-privilege writer: only the scanner SA can push findings here.
  finding_writer_members = [
    "serviceAccount:sbom-scanner@kv-security-prod.iam.gserviceaccount.com",
  ]
}

# Downstream: allow SCC's managed SA to publish to the topic the module routes to.
resource "google_pubsub_topic_iam_member" "scc_publisher" {
  project = "kv-security-prod"
  topic   = "scc-findings"
  role    = "roles/pubsub.publisher"
  member  = "serviceAccount:${module.security_command_center.service_account}"
}

# Downstream: a Cloud Function subscription that reacts to streamed findings,
# tagged with the source name so it can correlate back to the producer.
resource "google_pubsub_subscription" "soar_intake" {
  name  = "soar-intake-${module.security_command_center.notification_config_id != null ? "active" : "disabled"}"
  topic = "scc-findings"

  labels = {
    scc_source = lower(replace(module.security_command_center.source_display_name, " ", "-"))
  }

  ack_deadline_seconds = 30
}

With Terragrunt

Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.

1. Root configlive/terragrunt.hcl (inherited by every module):

remote_state {
  backend = "gcs"
  generate = { path = "backend.tf", if_exists = "overwrite" }
  config = {
    # ...gcs state bucket/container + key per path...
  }
}

2. Module configlive/prod/scc/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-scc?ref=v1.0.0"
}

inputs = {
  organization_id = "..."
  source_display_name = "..."
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/scc && terragrunt apply        # this module
terragrunt run-all apply                      # every module under live/prod

Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.

Inputs

Name Type Default Required Description
organization_id string Yes Numeric GCP organization ID that owns the SCC source.
source_display_name string Yes Unique, human-readable name for the source (1-64 chars).
source_description string "Custom Security Command Center finding source managed by Terraform." No Description of what produces findings for this source (≤1024 chars).
notification_pubsub_topic string null No Full Pub/Sub topic (projects/<p>/topics/<t>); null skips the notification config.
notification_config_id string "scc-findings-notify" No Stable ID for the notification config (lowercase, digits, hyphens).
notification_description string "Streams active SCC findings to Pub/Sub (managed by Terraform)." No Description attached to the notification config.
notification_severities list(string) ["HIGH", "CRITICAL"] No Severities folded into the auto-generated CEL filter.
notification_filter_override string null No Raw CEL filter that fully replaces the severity-derived filter.
finding_writer_members list(string) [] No IAM principals granted findingsEditor on this source.

Outputs

Name Description
source_id Full resource name (organizations/<org>/sources/<id>); use as the finding parent.
source_name Server-assigned canonical name of the source.
source_display_name Display name shown in the SCC console.
notification_config_id Resource name of the notification config, or null when no topic was supplied.
notification_filter Effective CEL filter applied to the notification config.
service_account Google-managed SA SCC uses to publish; grant it pubsub.publisher on the topic.

Enterprise scenario

A financial-services org runs a custom container image scanner in their CI pipeline and needs every CRITICAL CVE to open a ServiceNow incident within minutes. They instantiate this module once per scanner with source_display_name = "ImageGuard Scanner", route ["CRITICAL"] findings to a scc-imageguard-crit Pub/Sub topic, and grant only the CI service account findingsEditor on the resulting source. A Cloud Function subscribed to that topic creates the incident, while the source_id output is fed into the scanner’s deploy config so it writes findings to exactly the right parent — giving auditors a single, version-controlled lineage from scan to source to notification to ticket.

Best practices

TerraformGCPSecurity Command CenterModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading