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
- You run a custom or third-party security scanner (SBOM/CVE scanner, secret scanner, config-drift detector) and need its results to land in SCC as first-class findings under a dedicated source.
- You want finding notifications streamed to Pub/Sub so that SOAR, a ticketing webhook, or a Cloud Function can react to new HIGH/CRITICAL findings in near-real-time.
- You are standardizing org-level security posture as code and want sources, their notification routing, and writer IAM to live in the same review-gated Terraform as the rest of your landing zone.
- You need to grant a least-privilege finding-writer identity (one service account, one source) rather than the broad
securitycenter.adminrole.
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 config — live/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 config — live/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
- Least-privilege writers, not org admins. Use
finding_writer_membersto grantroles/securitycenter.findingsEditoron the specific source rather than handing scannerssecuritycenter.adminat the org level — the IAM binding here is scoped to the source resource name, which is the whole point. - Filter aggressively at the notification, not downstream. Notifications are billed and processed per finding; keep
notification_severitiestight (HIGH/CRITICAL) and letstate = "ACTIVE"drop resolved findings so you do not flood Pub/Sub and pay for noise you would discard anyway. - Treat sources as immutable and permanent. The SCC API has no delete for sources, so a
terraform destroyonly removes the source from state — choosesource_display_namedeliberately and avoid churning module instances, or you will accumulate orphaned sources in the org. - Grant the managed SA publish rights explicitly. SCC publishes through a Google-managed service account exposed via the
service_accountoutput; wire that into apubsub.publisherbinding on the topic (as shown above) or the notification config will silently fail to deliver. - Pin the source ref and review filter changes. Consume the module at a tagged
?ref=v1.0.0and gatenotification_filter_overrideedits through PR review — a malformed CEL filter is accepted at apply time but quietly matches nothing, which is a dangerous failure mode for a security signal. - Keep org-scoped resources in a dedicated state. Sources, notification configs, and their IAM all live at the organization level; isolate this module’s state from project-scoped workloads so a project teardown can never blast-radius your security finding pipeline.