IAM and Secrets

Quote

“Give an agent raw cloud access and you get the same thing you get when you hand a developer raw Terraform — well-intentioned decisions made without context.”

Kelsey Hightower, Twitter

Assumed variables

All HCL blocks in this note reference the following variables, defined elsewhere in the Terraform configuration:

  • var.project_id — the GCP project ID
  • var.db_password — the database password value (marked sensitive in the variable definition)
  • var.dd_api_key — the Datadog API key (empty string disables Datadog resources)

Required APIs and permissions

Before creating these resources, the following APIs must be enabled on the GCP project:

  • iam.googleapis.com — Identity and Access Management
  • secretmanager.googleapis.com — Secret Manager

The Terraform service account itself needs roles/iam.serviceAccountAdmin, roles/secretmanager.admin, and roles/resourcemanager.projectIamAdmin to create and manage these resources.


flowchart LR
    SA1[data-pipeline-pipeline]
    SA2[data-pipeline-dashboard]
    SA3[data-pipeline-airflow]
    SA4[data-pipeline-ci]

    subgraph Resource-Level Bindings
        S1[db-password secret]
    end

    subgraph Project-Level Bindings
        R1[roles/run.invoker]
        R2[roles/run.developer]
        R3[roles/logging.viewer]
        R4[roles/artifactregistry.writer]
    end

    subgraph Target Resources
        CR[Cloud Run Jobs & Services]
        AR[Artifact Registry]
        SM[Secret Manager]
    end

    SA1 -->|secretAccessor| S1
    SA2 -->|secretAccessor| S1
    SA3 --> R1 & R2 & R3
    SA4 --> R4 & R2
    SA4 -->|actAs| SA1
    R1 & R2 --> CR
    R3 --> CR
    R4 --> AR
    S1 --> SM

Service Accounts

A service account is a non-human identity that a workload runs as. The standard GCP pattern is one service account per service, each with the minimum permissions it needs. The service accounts themselves have no permissions by default — they only gain access through explicit IAM bindings.

Naming convention

Prefix service account IDs with the project or workload name (e.g., data-pipeline-pipeline, data-pipeline-dashboard). This makes it easy to filter and audit accounts with gcloud iam service-accounts list --filter="email~data-pipeline".

google_service_account

The google_service_account resource creates a GCP service account. Changing the account_id argument forces Terraform to destroy and recreate the account, which invalidates all existing IAM bindings and keys — plan changes carefully.

google_service_account | Core workload accounts

Three service accounts serve the core workloads: the data pipeline, the dashboard, and the orchestrator.

Create the pipeline workload service account.

resource "google_service_account" "pipeline" {
  account_id   = "data-pipeline-pipeline"
  display_name = "the data pipeline project Pipeline"
}
ArgumentRequiredDescription
account_idYesCreates the email <account_id>@<project>.iam.gserviceaccount.com. Must be 6–30 characters, lowercase letters, digits, and hyphens.
display_nameNoHuman-readable name shown in the GCP console.
descriptionNoLonger description of the account’s purpose. Visible in console and API responses.

The three core accounts and their workload assignments:

AccountUsed byPurpose
data-pipeline-pipelineCloud Run pipeline job + SQL VMRuns data pipeline, accesses database password in Secret Manager
data-pipeline-dashboardCloud Run dashboard serviceRuns Blazor app, accesses database password in Secret Manager
data-pipeline-airflowAirflow GCE VMTriggers Cloud Run jobs, views logs

google_service_account | Multi-environment pattern

For broader deployments, define one service account per workload type with descriptive names and descriptions.

Create the pipeline runner service account for Airflow DAGs and Cloud Run containers.

resource "google_service_account" "pipeline_runner" {
  account_id   = "pipeline-runner"
  display_name = "Pipeline Runner"
  description  = "Used by Airflow DAGs and Cloud Run pipeline containers"
}

Create the dashboard read-only service account for the Blazor frontend.

resource "google_service_account" "dashboard" {
  account_id   = "dashboard-reader"
  display_name = "Dashboard Read-Only"
  description  = "Used by Blazor dashboard — read access only"
}

Create the Pub/Sub push invoker service account.

resource "google_service_account" "pubsub_invoker" {
  account_id   = "pubsub-invoker"
  display_name = "Pub/Sub Push Invoker"
}

Never use primitive roles

Never assign roles/editor or roles/owner to service accounts. These grant thousands of permissions across all project resources.

Use fine-grained predefined roles

Assign the narrowest predefined role that covers the workload’s needs (e.g., roles/bigquery.dataEditor, roles/storage.objectCreator). If no predefined role is narrow enough, create a custom role.

IAM Bindings

IAM bindings connect a member (service account) to a role (set of permissions) on a resource (project, secret, etc.). There are two scopes:

ScopeResource typeExampleVisible in
Project-levelgoogle_project_iam_memberroles/run.invoker for AirflowIAM → Permissions page
Resource-levelgoogle_secret_manager_secret_iam_memberroles/secretmanager.secretAccessor for pipeline on a specific secretSecret Manager → secret → Permissions tab

Resource-level bindings are more restrictive — they apply only to one resource, not the whole project — and don’t appear on the project IAM page. You must inspect the specific resource’s permissions tab.

Resource vs project IAM

data-pipeline-pipeline and data-pipeline-dashboard appear to have “no roles” in the GCP Console’s project IAM page, but they have resource-level bindings on specific secrets. This is intentional and more secure — they can only access their specific secrets, not any other project resources.

google_secret_manager_secret_iam_member

The google_secret_manager_secret_iam_member resource grants a single role to a single member on a specific secret. This is the non-authoritative form — it adds the binding without removing existing ones.

google_secret_manager_secret_iam_member | Pipeline secret access

Grants the pipeline service account read access to the database password secret. The binding is scoped to this one secret — the pipeline cannot access any other secret in the project.

Grant secret read access to the pipeline SA on the db-password secret.

resource "google_secret_manager_secret_iam_member" "pipeline_secret" {
  secret_id = google_secret_manager_secret.db_password.id
  role      = "roles/secretmanager.secretAccessor"
  member    = "serviceAccount:${google_service_account.pipeline.email}"
}
ArgumentRequiredDescription
secret_idYesThe specific secret this binding applies to. Not project-wide — only this one secret.
roleYesroles/secretmanager.secretAccessor allows reading secret versions. Cannot create, delete, or modify secrets.
memberYesThe serviceAccount: prefix is required IAM syntax. Other prefixes include user: for humans and group: for Google Groups.

google_project_iam_member

The google_project_iam_member resource grants a single role to a single member at the project level. This is the non-authoritative form — safe to use alongside other bindings managed outside Terraform.

iam_member vs iam_binding vs iam_policy

Terraform offers three IAM resource variants per resource type:

  • iam_member (non-authoritative): Adds one member to one role. Safe — does not affect other members.
  • iam_binding (semi-authoritative): Sets the complete member list for one role. Removes members not in the list.
  • iam_policy (fully authoritative): Sets the entire IAM policy. Removes all bindings not in the config.

Use iam_member unless you need to enforce an exact member list. Authoritative resources can lock out other principals including the Terraform service account itself.

google_project_iam_member | Airflow project-level roles

The Airflow service account needs project-level roles to trigger and monitor Cloud Run jobs.

Grant the Airflow SA the Cloud Run invoker role at project level.

resource "google_project_iam_member" "airflow_run_invoker" {
  project = var.project_id
  role    = "roles/run.invoker"
  member  = "serviceAccount:${google_service_account.airflow.email}"
}
ArgumentRequiredDescription
projectYesThe GCP project ID. Use var.project_id — never hardcode.
roleYesThe IAM role to grant.
memberYesThe principal receiving the role.

The three roles assigned to the Airflow account:

RoleWhat it allows
roles/run.invokerExecute (invoke) Cloud Run services and jobs. Airflow uses this to trigger pipeline runs via CloudRunExecuteJobOperator.
roles/run.developerDeploy new revisions of Cloud Run services/jobs. Needed because the Airflow operator updates job overrides (passing --step arguments).
roles/logging.viewerRead logs from Cloud Logging. Allows Airflow to display Cloud Run job logs in its UI.

google_project_iam_member | Multi-environment roles

In broader deployments, grant specific roles to each service account — never roles/editor or roles/owner.

Grant the pipeline SA BigQuery data editor access at project level.

resource "google_project_iam_member" "pipeline_bq_editor" {
  project = var.project_id
  role    = "roles/bigquery.dataEditor"
  member  = "serviceAccount:${google_service_account.pipeline_runner.email}"
}

Grant the dashboard SA BigQuery read-only access at project level.

resource "google_project_iam_member" "dashboard_bq_viewer" {
  project = var.project_id
  role    = "roles/bigquery.dataViewer"
  member  = "serviceAccount:${google_service_account.dashboard.email}"
}

Grant the pipeline SA GCS object creator access at project level.

resource "google_project_iam_member" "pipeline_gcs_writer" {
  project = var.project_id
  role    = "roles/storage.objectCreator"
  member  = "serviceAccount:${google_service_account.pipeline_runner.email}"
}

for_each for IAM Bindings

When a service account needs multiple roles, use for_each over a set instead of individual resources:

locals {
  pipeline_roles = toset([
    "roles/bigquery.dataEditor",
    "roles/storage.objectCreator",
    "roles/run.invoker",
  ])
}
 
resource "google_project_iam_member" "pipeline_roles" {
  for_each = local.pipeline_roles
  project  = var.project_id
  role     = each.value
  member   = "serviceAccount:${google_service_account.pipeline_runner.email}"
}

This reduces repetition and makes role additions a single-line change in the locals block.

Secret Manager

Secret Manager uses a two-level structure: the secret (a named container) and one or more versions (the actual values). This is why there are two Terraform resources per secret — one for the container, one for the value. The container defines the name and replication policy; the version holds the actual sensitive data. You can have multiple versions (e.g., after rotating a password) and Cloud Run references version = "latest" to always get the newest one. For the operational side of working with secrets — rotation, access auditing, and application integration patterns — see secrets-management.

Secret data in Terraform state

The secret_data argument value is stored in plaintext in the Terraform state file. Anyone with read access to the state can read all secret values.

Encrypt state at rest

Always use a remote backend (GCS) with encryption enabled and restrict access to the state bucket. See gcs-buckets-and-lifecycle for bucket configuration and providers-and-backend for backend setup.

Provider v5.0+ replication syntax

The replication { auto {} } block syntax replaced the older replication { automatic = true } in Google provider v5.0. If upgrading from an older provider version, update the replication block to avoid deprecation warnings.

google_secret_manager_secret

The google_secret_manager_secret resource creates a secret container in GCP. Changing the secret_id forces Terraform to destroy and recreate the secret, which destroys all versions — use lifecycle { prevent_destroy = true } on production secrets.

google_secret_manager_secret | Auto-replicated secret container

Creates a secret with automatic replication — GCP replicates the secret data across multiple regions for durability.

Create an auto-replicated secret container for the database password.

resource "google_secret_manager_secret" "db_password" {
  secret_id = "data-pipeline-db-password"
 
  replication {
    auto {}
  }
}
ArgumentRequiredDescription
secret_idYesName of the secret in GCP. Used by Cloud Run to reference it: secret = "data-pipeline-db-password".
replication.autoYesAutomatic replication — GCP manages region placement. The alternative user_managed lets you specify exact regions for data residency requirements.

Missing prevent_destroy on production secrets

Without lifecycle { prevent_destroy = true }, a terraform destroy or accidental removal from config will permanently delete the secret and all its versions.

Protect stateful resources

Add a lifecycle block to secrets holding production credentials:

resource "google_secret_manager_secret" "db_password" {
  secret_id = "data-pipeline-db-password"
  replication { auto {} }
 
  lifecycle {
    prevent_destroy = true
  }
}

google_secret_manager_secret_version

The google_secret_manager_secret_version resource creates a version within a secret container. Each version is immutable — creating a new version does not modify or delete previous versions. Terraform creates a new version on every apply where secret_data has changed.

google_secret_manager_secret_version | Database password

Stores the database password as a secret version. The value comes from a sensitive variable — never hardcode secrets in HCL files.

Store the database password as an immutable secret version.

resource "google_secret_manager_secret_version" "db_password" {
  secret      = google_secret_manager_secret.db_password.id
  secret_data = var.db_password
}
ArgumentRequiredDescription
secretYesThe parent secret this version belongs to.
secret_dataYesThe actual secret value. Terraform stores this in state (encrypted in the GCS backend). Cloud Run reads it at container startup via value_source.secret_key_ref.

Conditional Datadog Resources

The Datadog integration uses a conditional creation pattern — resources are only created when the var.dd_api_key variable is non-empty. This uses Terraform’s count meta-argument as a conditional: count = 0 skips the resource entirely, count = 1 creates it. See conditional-resources for the full pattern.

google_service_account | Conditional Datadog account

The Datadog service account is only created if a Datadog API key is provided.

Create the Datadog service account only when an API key is provided.

resource "google_service_account" "datadog" {
  count        = var.dd_api_key != "" ? 1 : 0
  account_id   = "data-pipeline-datadog"
  display_name = "Datadog GCP Integration"
}
ArgumentRequiredDescription
countNoConditional resource creation. When dd_api_key is empty, count = 0 and this resource (plus all dependent Datadog resources) is not created. When set, count = 1 creates it.
account_idYesThe service account identifier.
display_nameNoHuman-readable name in the GCP console.

google_project_iam_member | Datadog read-only roles

The Datadog service account receives three read-only roles for observability:

RoleWhat it allows
roles/monitoring.viewerRead Cloud Monitoring metrics (CPU, memory, disk).
roles/compute.viewerRead Compute Engine metadata (VM names, zones, machine types).
roles/cloudasset.viewerRead Cloud Asset Inventory (resource discovery across the project).

No write or admin roles

The Datadog integration can only observe — it cannot modify any resource. This is the correct least-privilege posture for a monitoring integration.

google_secret_manager_secret_version | Conditional Datadog API key

The secret container (dd_api_key) is always created, but the version (the actual key value) is only created when dd_api_key is provided. This means the secret exists as a placeholder even when Datadog is disabled — avoiding a Terraform error if you later enable it.

Store the Datadog API key as a secret version only when provided.

resource "google_secret_manager_secret_version" "dd_api_key" {
  count       = var.dd_api_key != "" ? 1 : 0
  secret      = google_secret_manager_secret.dd_api_key.id
  secret_data = var.dd_api_key
}

CI/CD Service Account

A dedicated service account for GitHub Actions CI/CD pipelines. For the full GitHub Actions workflow that uses this account, see github-actions-ci-cd.

SA key export for CI

The traditional approach stores a JSON key as a GitHub Actions secret (GCP_SA_KEY). SA keys are long-lived credentials that can be exfiltrated and are difficult to audit.

Prefer Workload Identity Federation (WIF)

Workload Identity Federation (WIF) lets GitHub Actions authenticate directly without exporting a key. The GitHub OIDC token is exchanged for short-lived GCP credentials, eliminating the need for stored secrets. See service-accounts-and-iam for WIF configuration.

google_service_account | CI/CD account

Creates the CI/CD service account used by GitHub Actions.

Create the CI/CD service account for GitHub Actions deployments.

resource "google_service_account" "ci" {
  account_id   = "data-pipeline-ci"
  display_name = "the data pipeline project CI/CD (GitHub Actions)"
}

google_project_iam_member | CI registry and deployment roles

The CI account needs two project-level roles: push images to Artifact Registry and deploy to Cloud Run.

Grant the CI SA Artifact Registry writer access at project level.

resource "google_project_iam_member" "ci_registry" {
  project = var.project_id
  role    = "roles/artifactregistry.writer"
  member  = "serviceAccount:${google_service_account.ci.email}"
}

Grant the CI SA Cloud Run developer access at project level.

resource "google_project_iam_member" "ci_run" {
  project = var.project_id
  role    = "roles/run.developer"
  member  = "serviceAccount:${google_service_account.ci.email}"
}
RoleWhat it allows
roles/artifactregistry.writerPush (write) Docker images to Artifact Registry. Cannot delete images or modify repository settings.
roles/run.developerDeploy new revisions to Cloud Run services and jobs. Cannot modify IAM or networking.

google_service_account_iam_member | CI act-as binding

When GitHub Actions deploys a Cloud Run job, it must specify which service account the job runs as. The roles/iam.serviceAccountUser role allows the CI account to assign the pipeline identity without being able to use the pipeline account’s permissions directly.

Grant the CI SA act-as (serviceAccountUser) permission on the pipeline SA.

resource "google_service_account_iam_member" "ci_act_as_pipeline" {
  service_account_id = google_service_account.pipeline.name
  role               = "roles/iam.serviceAccountUser"
  member             = "serviceAccount:${google_service_account.ci.email}"
}
ArgumentRequiredDescription
service_account_idYesThe target service account that the CI account will be allowed to act as.
roleYesroles/iam.serviceAccountUser grants the “act as” permission. Visible in GCP under the target SA → Permissions → “Principals with access to this service account.”
memberYesThe principal receiving the act-as permission.

Least privilege chain

The CI account can push images and update deployments, but it cannot access the database, read secrets, or trigger pipeline runs. It can only assign existing service accounts to Cloud Run workloads.

Verification Commands

These gcloud commands verify the resources created by the Terraform configuration above.

gcloud | Service account inspection

List project service accounts

Lists all service accounts matching the project prefix.

List all service accounts with the data-pipeline prefix.

gcloud iam service-accounts list --filter="email~data-pipeline"

View act-as permissions on a service account

Shows which principals can impersonate (act as) the pipeline service account.

Verify which principals hold act-as permission on the pipeline SA.

gcloud iam service-accounts get-iam-policy data-pipeline-pipeline@data-platform-prod.iam.gserviceaccount.com

View project-level roles for a service account

Shows the project-level IAM roles bound to the Airflow service account.

Verify the project-level roles assigned to the Airflow SA.

gcloud projects get-iam-policy data-platform-prod \
  --flatten="bindings[].members" \
  --filter="bindings.members:data-pipeline-airflow@data-platform-prod.iam.gserviceaccount.com" \
  --format="table(bindings.role)"

gcloud | Secret inspection

List project secrets

Lists all secrets matching the project prefix.

List all secrets with the data-pipeline prefix.

gcloud secrets list --filter="name~data-pipeline"

View secret versions

List all versions of the database password secret.

gcloud secrets versions list data-pipeline-db-password

List all versions of the Datadog API key secret.

gcloud secrets versions list data-pipeline-dd-api-key

View secret access policy

Shows which principals have access to the database password secret.

Verify which principals hold secretAccessor on the db-password secret.

gcloud secrets get-iam-policy data-pipeline-db-password

Access the latest secret value

Prints secret to terminal

This command outputs the secret value in plaintext. Only use in secure environments — never in shared terminals, CI logs, or screen recordings.

Use secret references instead

In application code and Cloud Run, reference secrets via value_source.secret_key_ref or environment variable injection rather than accessing the value directly.

Access the latest version of the database password secret (outputs plaintext — secure environments only).

gcloud secrets versions access latest --secret=data-pipeline-db-password

GCP service references:

Terraform references:

CI/CD integration:

Terraform IAM and Secrets References