8.1.7 The GCP platform: resource hierarchy, IAM, networking, Terraform and CI/CD¶
How to use this page. Read it once from top to bottom. It teaches from the fundamentals. For a short revision sheet, use the platform, IAM and Terraform deep dive.
Three key ideas:
- Start every platform design with the layout (folders, projects, datasets).
- Use the correct IAM words: a principal gets a role on a resource. That is a binding.
- A CI service account never gets "all privileges". The safe pattern is one deployer per environment, Workload Identity Federation, plan on PR, apply on merge, CODEOWNERS.
1. What it is and why it exists¶
A data platform is more than pipelines. Somebody must decide:
- Where things live (which project, which region).
- Who can do what to them (IAM).
- What is never allowed, even for admins (organization policies, VPC Service Controls).
- How things are created and changed in a safe, repeatable way (Terraform and CI/CD).
On GCP these answers are built from a few building blocks: the resource hierarchy (organization → folders → projects → resources), IAM (allow policies with bindings), guardrails (org policies, VPC-SC), networking, and infrastructure as code.
An analogy: an office building¶
| Building | GCP |
|---|---|
| The company that owns the building | Organization (your domain, e.g. example.com) |
| Floors (Sales floor, Finance floor) | Folders |
| Rooms, each with its own electricity meter and lock | Projects (own billing, quotas, APIs, IAM) |
| Cabinets and drawers in a room | Resources (datasets, buckets, topics) |
| Key cards: "Anna may open cabinet X" | IAM bindings |
| A key card that works on the whole floor | A binding on a folder (inherited by every room) |
| Building rules: "no open flames, ever" | Organization policies |
| Guards at the doors who stop boxes leaving | VPC Service Controls |
| The architect's drawings, used to build every new floor the same way | Terraform modules |
| A change process: drawings are reviewed before builders act | CI/CD with plan on PR, apply on merge |
The important idea: rules flow downward. A key card for the floor opens every room on the floor. That is powerful and dangerous, so you give out floor-level cards very rarely.
2. Where it fits in a modern GCP data platform¶
A typical enterprise data warehouse (EDW) modernization on GCP moves the load layer into shared ingestion projects, with reusable ingestion patterns, a clear project structure, IAM and Terraform modules. Terraform, CI/CD and git are the everyday tools.
That means, in practice:
- Design the project structure for a shared ingestion platform that many domain teams use (sales, finance, supply chain).
- Design identities and permissions: which service account runs which pipeline, which group can read which dataset, with least privilege.
- Write Terraform modules so that "onboard a new source" is one small pull request, not two weeks of clicking.
- Run Terraform through CI/CD in a safe way (no keys, reviewed plans, separate environments).
- Hand it over: other teams must be able to use the modules without the platform team.
Existing platforms are often built by hand, project by project. So expect brownfield work: importing existing resources into Terraform, changing IAM without breaking dashboards, and moving ingestion without breaking consumers (the migration side is in Migration and modernization).
3. Architecture: how it works under the hood¶
3.1 The resource hierarchy¶
Organization: example.com (org ID 123456789012)
│ org policies: EU-only locations, no SA keys, domain-restricted sharing
│
├── Folder: platform
│ ├── Project: prj-net-host-prod (Shared VPC host: networks, firewalls, VPN)
│ ├── Project: prj-logging (central log buckets, billing export)
│ └── Project: prj-iac (Terraform state buckets, deployer SAs, WIF pool)
│
├── Folder: prod
│ ├── Project: prj-ingest-prod ← shared ingestion (Datastream, Pub/Sub, Dataflow,
│ │ landing buckets, raw_* datasets)
│ ├── Project: prj-orch-prod ← Cloud Composer
│ ├── Project: prj-dwh-sales-prod ← stg_sales, mart_sales
│ ├── Project: prj-dwh-finance-prod
│ └── Project: prj-bi-prod ← Looker connection, BI reservation assignment
│
├── Folder: nonprod
│ ├── Folder: test (same projects, suffix -test)
│ └── Folder: dev (same projects, suffix -dev)
│
└── Folder: sandbox (personal experiments, budgets, auto-cleanup)
The billing account is not in this tree. It is a separate object, with its own IAM, that is linked to projects. One billing account can pay for many projects.
3.2 What happens when a pipeline makes an API call¶
Every action in GCP is an API call. When a Dataflow job writes to BigQuery, this happens:
(1) Who are you? (2) Are you allowed? (3) Is it allowed at all?
┌──────────────┐ OAuth ┌──────────────────────────────────┐ ┌───────────────────────────┐
│ Dataflow │ access │ IAM check on the target resource │ │ Org policy (at create │
│ worker runs │ token │ - deny policies first │ │ time: e.g. location) │
│ as SA ├────────► │ - allow policy of the resource ├─►│ VPC-SC perimeter │
│ sa-ingest-pos│ │ + all its ancestors (union) │ │ (is the call crossing │
└──────────────┘ │ - conditions evaluated │ │ the perimeter?) │
└──────────────────────────────────┘ └─────────────┬─────────────┘
▼
bigquery.googleapis.com writes the rows
→ Cloud Audit Logs record the call
Three independent layers:
- Authentication: the caller proves who it is with a short-lived OAuth 2.0 access token.
- Authorization (IAM): does any binding give this principal the needed permission on this resource or one of its parents? Is there a deny policy that blocks it?
- Guardrails: org policies restrict what can be configured (for example "datasets only in the EU"). VPC-SC restricts where data can flow (for example "no copy to a project outside the perimeter"). They apply even to Owners.
3.3 How Terraform fits in¶
Git repo (HCL) CI runner (GitHub Actions / Cloud Build)
┌─────────────┐ PR ┌─────────────────────────────────────────────┐
│ modules/ │ ───────► │ fmt, validate, tflint, checkov, conftest │
│ envs/prod/ │ │ terraform plan ──► comment on the PR │
└─────────────┘ │ (merge) │
│ terraform apply ──► GCP APIs │
└───────┬───────────────────────┬─────────────┘
OIDC token │ │ reads/writes state + lock
→ WIF → SA ▼ ▼
┌─────────────────────┐ ┌─────────────────────────┐
│ sa-tf-prod │ │ gs://example-tfstate-prod│
│ (deployer, prod │ │ versioned, locked down │
│ folder only) │ └─────────────────────────┘
└─────────────────────┘
4. Core concepts, one by one¶
Each concept follows the same pattern: what → how it works → why it matters → example → common mistake.
4.1 Organization and folders¶
- What. The organization is the root node. It is tied to one Google Workspace or Cloud Identity domain. Folders group projects and other folders. Folders can nest (up to 10 levels).
- How it works. IAM allow policies and org policies can be attached to the org, to any folder, or to a project. Everything below inherits them. A project has exactly one parent.
- Why it matters. Folders are the place for environment-wide and team-wide rules: "the platform team is admin on the
prodfolder", "dev may use any region in the EU". You set a rule once and every new project gets it. - Example. Folder
prodgets the bindinggroup:platform-admins → roles/resourcemanager.folderViewer, and an org policy that blocks public IPs. - Common mistake. Granting roles on the organization "because it's easier". An org-level
roles/bigquery.dataViewermeans that group can read every dataset in the company, now and in the future.
4.2 What a project really is¶
- What. A project is the basic container for resources. It has a project ID (globally unique, chosen by you, immutable, e.g.
prj-ingest-prod), a project number (assigned by Google), and a display name. -
How it works. A project is at the same time:
- an API boundary: each service (BigQuery, Dataflow, Datastream) must be enabled in the project before you can use it;
- a billing boundary: the project is linked to one billing account, and costs are reported per project;
- a quota boundary: most quotas are per project (and often per region);
- an IAM boundary: a project-level binding applies to all resources in the project;
- a home for service agents (Google-managed identities, see 4.9) and default service accounts.
When you delete a project, it is first shut down for about 30 days and can be restored in that period. - Why it matters. Separate projects per layer or domain give you blast-radius control (a broken pipeline can't burn the finance quota), clean cost attribution, and simple IAM ("sales owns
prj-dwh-sales-*"). - Example. The shared ingestion project enablesdatastream.googleapis.com,pubsub.googleapis.com,dataflow.googleapis.com,bigquery.googleapis.com,storage.googleapis.com,secretmanager.googleapis.com. A mart project needs onlybigquery.googleapis.com. - Common mistake. One giant project for everything. Then IAM becomes dataset-by-dataset exceptions, costs can't be split, and one team's runaway query uses everyone's quota. The opposite mistake is a project per table: too many projects to manage.
4.3 Regions, zones, multi-regions and BigQuery locations¶
- What. A region is a geographic area (e.g.
europe-north1, which is in Hamina, Finland). A zone is an isolated location inside a region (europe-north1-a). A multi-region is a large area made of several regions (BigQuery and GCS haveEUandUS). - How it works. Compute resources (Dataflow workers, Composer) run in a region and its zones. A BigQuery dataset has a location that you pick at creation, and it can't be changed later. A query runs in the location of the data it reads. In general, one query can't join tables from two different locations (for example
EUandeurope-north1). Check current docs, because Google has been adding cross-region features. - Why it matters.
- Data residency. European companies usually want data in the EU, sometimes in one specific country. You enforce this with the org policy
gcp.resourceLocationsand by choosing dataset locations on purpose. - Consistency. If raw data is in
EUand marts are ineurope-north1, transformations can't join them. Pick one location for the whole warehouse and put it in the Terraform module default. - Cost and latency. Moving data between regions costs network egress.
- Data residency. European companies usually want data in the EU, sometimes in one specific country. You enforce this with the org policy
- Example. Standard: all BigQuery datasets in
EUmulti-region (or all ineurope-north1), all GCS buckets in the same location, Dataflow and Composer ineurope-north1oreurope-west1. - Common mistake. Creating a dataset with the default location (historically
USin some tools) by accident. Then you must copy the data to a new dataset to fix it.
4.4 Principals: who can act¶
| Principal type | Example | Used for |
|---|---|---|
| Google account (user) | user:anna@example.com |
A human. Don't bind users directly. |
| Google group | group:sales-engineers@example.com |
Humans, managed in the identity provider (often synced from Entra ID / Okta) |
| Service account | serviceAccount:sa-ingest-pos@prj-ingest-prod.iam.gserviceaccount.com |
A workload (pipeline, Composer, Dataflow, CI) |
| Workload identity (federated) | principalSet://iam.googleapis.com/projects/NUM/locations/global/workloadIdentityPools/github/attribute.repository/example/infra |
An external workload (GitHub Actions) without a key |
| Workforce identity (federated) | users from an external IdP without Google accounts | Humans signing in with the company IdP |
| Domain / special | domain:example.com, allUsers, allAuthenticatedUsers |
Avoid. allUsers = public. |
- Why groups. When Anna leaves, IT removes her from the group in the IdP and all her GCP access disappears. Nobody needs to search through hundreds of bindings. Groups also keep policies small (there are limits on policy size).
- Common mistake. Letting people log in as a shared service account to "share access". Then audit logs show the service account, not the person. Humans use groups; workloads use service accounts.
4.5 Roles and permissions¶
- What. A permission is one allowed action, written
service.resource.verb, e.g.bigquery.tables.getData,bigquery.jobs.create,storage.objects.create. You never grant a permission directly. You grant a role, which is a named set of permissions. - Three kinds of roles.
| Kind | Examples | Use |
|---|---|---|
| Basic | roles/owner, roles/editor, roles/viewer |
Too broad. Editor can change almost everything in a project. Not in prod. |
| Predefined | roles/bigquery.dataViewer, roles/pubsub.publisher, roles/dataflow.worker |
Default choice. Google maintains them and adds new permissions when services change. |
| Custom | organizations/123/roles/ingestionOperator |
When a predefined role is too broad. Defined at org or project level (not folder). You must maintain it. |
- Why it matters. Least privilege means: the smallest role on the lowest resource. A predefined role on a dataset is usually better than a custom role on a project.
- Example. A pipeline that only publishes events needs
roles/pubsub.publisheron one topic, notroles/pubsub.editoron the project. - Common mistake. Making a custom role for every small case. Custom roles don't get new permissions automatically, so things break silently when a service adds a new permission. Use them only where it clearly matters (for example, a support role that can see job metadata but no data).
4.6 Allow policies, bindings, inheritance and the effective policy¶
- What. Every resource that supports IAM has one allow policy. A policy is a list of bindings. A binding says: these principals have this role (optionally, under this condition).
{
"bindings": [
{
"role": "roles/bigquery.dataViewer",
"members": [
"group:sales-engineers@example.com",
"serviceAccount:sa-dataform-sales@prj-dwh-sales-prod.iam.gserviceaccount.com"
]
}
],
"etag": "BwX...",
"version": 1
}
- How it works.
- The effective policy on a resource is the union of its own policy and the policies of all its ancestors (project, folders, organization).
- Allow policies are additive only. A child can't remove a role granted at the parent. If the org grants
viewerto a group, no project can "un-grant" it with an allow policy. (Only a deny policy can block it.) - The
etagis for optimistic concurrency: you read the policy, change it, and write it back with the etag. If someone else changed it in between, the write fails. This is why "read-modify-write" tools exist, and why Terraform's authoritative resources are dangerous (see 4.19). version: 3is needed when a policy contains conditions.- IAM changes are usually effective within a minute or two, but they are eventually consistent, so a pipeline right after a grant may fail once. Add retries.
- Why it matters. When someone asks "why can this group see the finance dataset?", the answer is often an inherited binding two folders up. Tools: Policy Analyzer ("who has access to what") and Policy Troubleshooter ("why was this request allowed or denied?").
- Common mistake. Thinking a dataset-level policy can restrict a project-level grant. It can't. If
sales-analystshasbigquery.dataVieweron the project, they can read every dataset in it, whatever the dataset policy says.
4.7 IAM conditions¶
- What. A binding can have a condition written in CEL (Common Expression Language). The role applies only when the condition is true.
- How it works. Common attributes:
request.time(temporary access),resource.nameandresource.type(only some resources), and resource tags (resource.matchTag(...)). Not every service supports every attribute, so check the docs for the service. - Example: temporary access for an incident.
resource "google_project_iam_member" "oncall_temp" {
project = "prj-ingest-prod"
role = "roles/dataflow.developer"
member = "group:ingest-oncall@example.com"
condition {
title = "expires-2026-11-01"
description = "Temporary access for incident INC-1234"
expression = "request.time < timestamp(\"2026-11-01T00:00:00Z\")"
}
}
- Why it matters. It gives you time-bound and attribute-based access without separate projects. Conditions are also used to limit what a deployer can grant (for example "this SA may only grant these roles"; check the docs for "limit role grants with conditions").
- Common mistake. Using conditions as the main access model. They are harder to read and audit. Use a clean project/dataset structure first, conditions as an extra.
4.8 Deny policies (brief)¶
- What. A deny policy says "these principals can never use these permissions, even if an allow policy grants them". It can be attached to the org, a folder or a project. It can list exception principals.
- How it works. IAM checks deny policies before allow policies. Permissions in deny policies use a different format, e.g.
bigquery.googleapis.com/tables.delete. Not all permissions are supported yet; check the docs. - Example. "Nobody except
group:break-glass-adminsmay delete BigQuery datasets in theprodfolder." - Common mistake. Using deny policies to fix a messy allow model. Fix the bindings first. Deny is for a few hard rules. (There are also Principal Access Boundary policies that limit which resources a principal can access at all; good to know the name.)
4.9 Service accounts in depth¶
A service account (SA) is an identity for a workload. It is two things at once:
- A principal: it can receive roles ("
sa-ingest-poscan write toraw_pos"). - A resource: other principals can get roles on it ("Composer may impersonate
sa-ingest-pos").
This double nature is the key to understanding SA security.
Kinds of service accounts¶
| Kind | Email format | Who manages it | Notes |
|---|---|---|---|
| User-managed | NAME@PROJECT_ID.iam.gserviceaccount.com |
You | One per pipeline. This is what you design. |
| Default SAs | PROJECT_NUMBER-compute@developer.gserviceaccount.com (Compute Engine default) |
Created by Google when an API is enabled, then yours | Historically got Editor on the project automatically. Dataflow workers use it if you don't set a worker SA. Don't use it; the org policy iam.automaticIamGrantsForDefaultServiceAccounts stops the automatic Editor grant. |
| Service agents | service-PROJECT_NUMBER@gcp-sa-SERVICE.iam.gserviceaccount.com |
The identity a Google service uses to act in your project (e.g. the Dataflow or Composer service agent). They hold roles like roles/dataflow.serviceAgent. Never remove those bindings. |
Keys vs. impersonation¶
- Service account keys are long-lived JSON private keys. Whoever has the file is the SA until the key is deleted. They leak into git repos, laptops and CI variables. Avoid them. Block them with the org policy
iam.disableServiceAccountKeyCreation(newer organizations get this enforced by default; check docs). - Attached SAs: a VM, Dataflow worker, Cloud Run service or Composer environment runs as an SA. The code gets tokens from the metadata server automatically. No key exists.
- Impersonation: a principal with
roles/iam.serviceAccountTokenCreatoron the SA can ask the IAM Credentials API for a short-lived access token of that SA (default lifetime 1 hour; up to 12 hours only if an org policy allows it). Audit logs show both the caller and the SA.
# A human engineer runs a command as the pipeline SA (for debugging in dev)
gcloud storage ls gs://prj-ingest-dev-landing-pos \
--impersonate-service-account=sa-ingest-pos@prj-ingest-dev.iam.gserviceaccount.com
# Terraform can also impersonate (in the provider block):
# provider "google" { impersonate_service_account = "sa-tf-dev@prj-iac.iam.gserviceaccount.com" }
The two roles people always confuse¶
| Role on the SA | Key permission | Meaning | Example |
|---|---|---|---|
roles/iam.serviceAccountUser |
iam.serviceAccounts.actAs |
"I may attach this SA to a resource I create" | The SA that launches a Dataflow job needs serviceAccountUser on the worker SA, so the job can run as it. Same for creating a Composer environment or a Cloud Run service with a custom SA. |
roles/iam.serviceAccountTokenCreator |
iam.serviceAccounts.getAccessToken |
"I may get tokens and act as this SA directly" | Composer's SA impersonates sa-ingest-pos (Airflow operators support impersonation_chain). A CI identity impersonates the deployer SA. |
Why this matters (privilege escalation). If someone can create a Dataflow job and has actAs on a powerful SA, they can run any code with that SA's permissions. So actAs on a powerful SA is as powerful as the SA itself. Grant both roles on the specific SA, never on the whole project (a project-level serviceAccountUser gives actAs on every SA in the project).
Design rule¶
- One SA per pipeline or source (
sa-ingest-pos,sa-ingest-ecom,sa-dataform-sales,sa-looker). Small blast radius, readable audit logs, easy to revoke. - Name SAs consistently; put purpose in the description; label owners.
- Common mistake. One
sa-data-pipelineswithbigquery.adminthat every DAG uses. One bug or leak = full access to everything.
4.10 BigQuery IAM¶
BigQuery has two different kinds of permission that people mix up:
- Data permissions: can I read or write this table? Granted on the dataset (or table/view), with roles like
dataViewer. - Job permissions: can I run a query job? A query is a job, and the job runs in a project. That needs
bigquery.jobs.create, fromroles/bigquery.jobUser, on the project where the job runs. That project also pays for the query (on-demand bytes, or the reservation's slots).
The rule: to read a table in another project, a principal needs dataViewer on the dataset (where the data lives) plus jobUser on some project (where the query runs).
group:sales-analysts
│ jobUser on prj-bi-prod ───────► the query job runs and is billed HERE
│
└─ dataViewer on dataset prj-dwh-sales-prod.mart_sales ──► data is read from HERE
(storage is billed to prj-dwh-sales-prod)
| Role | What it gives | Typical binding |
|---|---|---|
roles/bigquery.dataViewer |
Read table data and metadata | Group or SA → dataset |
roles/bigquery.dataEditor |
+ create, update, delete tables, insert data | Pipeline SA → its own dataset |
roles/bigquery.dataOwner |
+ manage the dataset and its access | Platform team / Terraform only |
roles/bigquery.metadataViewer |
See schemas, no data | Catalog / support users |
roles/bigquery.jobUser |
Run jobs (queries, loads, copies) | Project where the work runs |
roles/bigquery.user |
jobUser + list and create datasets | Dev/sandbox only |
roles/bigquery.readSessionUser |
Use the Storage Read API (Spark, fast reads) | Dataproc / Dataflow SA |
roles/bigquery.connectionUser |
Use an external connection (BigLake, remote functions) | SA that queries BigLake |
roles/bigquery.admin |
Everything | Break-glass only |
Finer-grained controls:
- Authorized views / authorized datasets: a view in dataset B may read dataset A, and users only get access to B. The view runs with its own authorization, not the user's.
- Column-level security: policy tags in a taxonomy; readers need
Fine-Grained Readeron the tag. Plus dynamic data masking. - Row-level security:
CREATE ROW ACCESS POLICY ... GRANT TO (...) FILTER USING (...).
Common mistake. Giving analysts roles/bigquery.user on the prod data project so they "can query". They can now create datasets there. Give jobUser on a BI or analytics project and dataViewer on the marts.
4.11 Cloud Audit Logs¶
| Log type | What it records | On by default? |
|---|---|---|
| Admin Activity | Changes to configuration and metadata (create dataset, set IAM policy) | Always on, can't be turned off, no charge |
| Data Access | Reads of config/metadata and reads/writes of user data | Off by default for most services (can be expensive). BigQuery is the important exception: its data access logs are on by default |
| System Event | Google's own actions (e.g. live migration) | Always on |
| Policy Denied | Requests denied by VPC-SC or org policies | On |
- How it works. Logs go into log buckets per project:
_Required(admin activity, about 400 days) and_Default(30 days by default). Log sinks route logs elsewhere. An aggregated sink at the org or folder level sends all projects' logs to a central logging project or a BigQuery dataset. - Why it matters. "Who changed this IAM policy?" and "who read the PII table?" are answered here. For BigQuery you also have
INFORMATION_SCHEMA.JOBS(who ran which query, bytes, slots, referenced tables). - Common mistake. Enabling Data Access logs for all services at org level: you can get a big logging bill. Enable them where they matter (GCS buckets with sensitive data, Secret Manager).
4.12 Organization policies¶
- What. An org policy sets a constraint on a resource node (org, folder, project). It restricts how resources can be configured, for everybody, including Owners. It is the GCP equivalent of an AWS SCP or Azure Policy (deny effect).
- How it works. There are list constraints (allowed/denied values) and boolean constraints (enforce on/off). Policies are inherited; a child can merge with or replace the parent's policy only if the person has the org policy admin role (normally only at org level). You can also write custom constraints with CEL on resource fields (for supported services). A dry-run mode exists for some constraints; check docs.
- Examples a data platform should know:
| Constraint | Effect |
|---|---|
gcp.resourceLocations = in:eu-locations |
Resources only in EU locations (data residency). Use in:europe-north1-locations for Finland only. |
iam.allowedPolicyMemberDomains |
Only principals from your Workspace/Cloud Identity customer can be granted roles. Stops sharing with gmail.com and allUsers. |
iam.disableServiceAccountKeyCreation |
No SA keys |
iam.automaticIamGrantsForDefaultServiceAccounts |
Default SAs don't get Editor |
storage.publicAccessPrevention |
No public buckets |
storage.uniformBucketLevelAccess |
Buckets use IAM only, no object ACLs |
compute.vmExternalIpAccess |
No public IPs on VMs (Dataflow workers must be private) |
compute.skipDefaultNetworkCreation |
New projects don't get the wide-open default VPC |
sql.restrictPublicIp |
Cloud SQL without public IP |
- Why it matters. IAM says "who can". Org policies say "what is never OK". Even if a deployer SA is compromised, it can't create a US dataset or a public bucket.
- Common mistake. Rolling out
allowedPolicyMemberDomainswithout checking existing bindings. Existing external grants keep working, but new ones fail, and some Google services that use special principals may need exceptions. Test in a nonprod folder first.
4.13 VPC Service Controls¶
- What. A service perimeter is a virtual fence around a set of projects and a set of Google APIs (e.g. BigQuery, GCS, Pub/Sub). API calls that would move data across the fence are blocked, even with valid IAM.
- How it works. Every call to a protected API is checked: is the caller (network origin, identity, device) inside or outside the perimeter, and is the resource inside or outside? Calls that cross are denied unless an ingress rule, egress rule or access level (from Access Context Manager, e.g. corporate IP ranges) allows them. You can run the perimeter in dry-run mode first and read the "would be denied" logs.
- What exfiltration it prevents.
- An insider with read access runs
bq cp prj-ingest-prod:raw_pos.orders my-personal-project:x.orders. IAM allows it (they can read and they own the other project). The perimeter blocks it, because the destination is outside. - A stolen token used from the public internet: blocked if the call doesn't come from an allowed network or device.
- A query that writes results to a bucket outside the perimeter.
- An insider with read access runs
- Why it matters. IAM controls who. VPC-SC controls where data can go. For sales data with customer PII, this extra layer is important.
- Common mistakes. Turning it on in enforced mode without dry-run, and breaking: CI runners outside the perimeter, Looker or a SaaS tool calling BigQuery, cross-project jobs where one project is outside. Plan ingress/egress rules first.
4.14 Networking basics for data platforms¶
┌──────────────── prj-net-host-prod (Shared VPC host) ─────────────────┐
On-prem DCs │ VPC "vpc-prod" (global) │
(ERP, POS DB) ───┼──HA VPN / Interconnect──► Cloud Router │
│ subnet europe-north1 10.10.0.0/20 Private Google Access = ON │
│ Cloud NAT (outbound internet without public IPs) │
│ PSC endpoint for Google APIs / Datastream private connectivity │
└───────────▲───────────────────────────────▲──────────────────────────┘
│ uses subnets │ uses subnets
prj-ingest-prod (service project) prj-orch-prod (service project)
Dataflow workers (no public IPs) Composer (private IP)
| Piece | What it is | Why a data engineer cares |
|---|---|---|
| VPC | A global private network; subnets are regional | Dataflow workers and Composer get IPs from subnets |
| Shared VPC | The network lives in a host project; service projects use its subnets | The network team controls firewalls and on-prem routes centrally; data teams just deploy into it |
| Private Google Access | A subnet setting: VMs without external IPs can reach Google APIs | Private Dataflow workers can still call BigQuery and GCS |
| Private Service Connect (PSC) | A private IP endpoint in your VPC for Google APIs or a producer's service | Reach APIs or managed services privately; one of the private connectivity options for some services |
| Cloud NAT | Managed outbound NAT, per region | Private workers that must reach the internet (package downloads, external APIs) |
| Cloud VPN / Interconnect | Encrypted tunnels (HA VPN) or dedicated physical links to on-prem | Reaching on-prem source databases for Datastream or batch extracts |
| Firewall rules | Allow/deny traffic inside the VPC | Dataflow workers need to talk to each other on specific ports |
For Datastream specifically, the source DB can be reached by IP allowlisting, a forward SSH tunnel, or private connectivity (a private connection into your VPC). Check current docs for the options per source.
4.15 Secret Manager¶
- What. A managed store for secrets (DB passwords, API keys). A secret has versions; you add a new version to rotate.
- How it works. Access is IAM on the secret:
roles/secretmanager.secretAccessorlets a principal read the value. Replication can be automatic or user-managed (pick EU regions for residency). Access is logged in audit logs. Composer can use Secret Manager as its secrets backend for Airflow connections. Some services (check Datastream's docs) can read connection passwords from Secret Manager directly. - Why it matters. No passwords in DAG code, Terraform variables or state.
- Common mistake. Creating the secret and its value in Terraform. Then the password is in the Terraform state file in plain text. Better: Terraform creates the empty secret and the IAM; a human or a rotation job adds the version.
4.16 Monitoring, logging, labels and billing export¶
- Cloud Monitoring collects metrics (Pub/Sub backlog, Dataflow system lag, Datastream freshness, BigQuery slot usage) and runs alert policies. Cloud Logging holds logs; log-based metrics turn log lines into metrics (e.g. "count of failed load jobs").
- Labels are key-value pairs on resources (
team=sales,source=pos,env=prod,cost_center=cc123). Lowercase, limited length. They appear in the billing export. BigQuery jobs can have labels too, so you can attribute query cost to a pipeline. - Tags (Resource Manager tags) are different: they are used for policy (IAM conditions, org policy conditions, firewall), not for billing reports.
- Billing export to BigQuery sends detailed cost rows (per project, service, SKU, labels) to a dataset. Data starts flowing when you turn it on, so turn it on early. Budgets send alerts; they don't stop spending by themselves.
- Why it matters. A modernized platform should track metrics for load duration, throughput, stability and cost. Cost per source = billing export + labels +
INFORMATION_SCHEMA.JOBSwith job labels.
4.17 Terraform: how it works¶
The pieces¶
| Piece | What it is | Example |
|---|---|---|
| Provider | A plugin that knows one API | hashicorp/google, hashicorp/google-beta |
| Resource | Something Terraform creates and manages | google_bigquery_dataset.raw |
| Data source | Something Terraform only reads | data "google_project" "this" (to get the project number) |
| Variable / output / local | Inputs, results, computed helper values | var.source_name, output "dataset_id" |
| Module | A folder of .tf files called with inputs |
module "src_pos" { source = ... } |
| State | Terraform's record of what it manages | default.tfstate in a GCS bucket |
The dependency graph¶
Terraform reads all .tf files and builds a graph. If resource B uses an attribute of A (google_service_account.ingest.email), B depends on A. Terraform creates independent resources in parallel and dependent ones in order. Use depends_on only for hidden dependencies (for example "the API must be enabled first").
Plan and apply¶
terraform init download providers and modules, configure the backend
terraform plan 1. refresh: read the real state of each resource from the APIs
2. compare: config (desired) vs. state (+ real values)
3. output: + create, ~ update in place, -/+ replace, - destroy
terraform apply execute the plan (ideally a saved plan file: plan -out=tf.plan)
The -/+ (replace) lines are the dangerous ones. Some attribute changes force replacement: for example, changing a dataset's location or a bucket's name means destroy and recreate, which for a dataset means losing data.
Why the state file exists¶
Terraform needs to know which real object belongs to which resource address. The state maps google_bigquery_dataset.raw → projects/prj-ingest-prod/datasets/raw_pos, stores attribute values, and records dependencies. Without state, Terraform couldn't tell "I created this and must update it" from "someone else's resource with the same name". State can contain secrets in plain text (any sensitive attribute), so treat it as sensitive data.
Remote state on GCS and locking¶
terraform {
required_version = ">= 1.6"
required_providers {
google = {
source = "hashicorp/google"
version = "~> 6.0"
}
}
backend "gcs" {
bucket = "example-tfstate-prod"
prefix = "ingestion"
}
}
- The GCS backend supports locking natively (it writes a lock object), so two applies can't run at the same time. No DynamoDB-like table is needed (unlike the classic S3 backend).
- Turn on object versioning on the state bucket so you can restore an old state after corruption.
- One state bucket (or prefix) per environment, and small states per component (network, ingestion, each domain). A huge single state is slow and has a big blast radius.
Workspaces vs. directory per environment¶
| CLI workspaces | Directory per environment (envs/dev, envs/prod) |
|
|---|---|---|
| How | Same code and backend, terraform workspace select prod |
Separate root modules, each with its own backend and tfvars |
| Pros | Less duplication | Clear separation: different backends, different credentials, different module versions per env |
| Cons | Same credentials and backend for all envs; easy to apply to the wrong one | Some duplication (kept small by modules) |
| Recommendation | Fine for short-lived feature stacks | Prod data platforms: dev can be on module v1.5.0 while prod stays on v1.4.0 |
4.18 Modules, loops, lifecycle, import, moved and drift¶
Modules¶
A module is a folder with variables.tf (inputs), main.tf (resources), outputs.tf (results), and versions.tf (required providers). You version modules with git tags (?ref=v1.4.0) or a private registry. Use semantic versioning: a breaking input change is a major version.
count vs. for_each¶
# BAD for a list of things: count uses positions
resource "google_bigquery_dataset" "raw" {
count = length(var.sources) # ["pos", "ecom", "erp"]
dataset_id = "raw_${var.sources[count.index]}"
}
# Remove "pos" → "ecom" moves from index 1 to 0 → Terraform wants to
# REPLACE raw_ecom and raw_erp. That means deleting datasets.
# GOOD: for_each uses stable keys
resource "google_bigquery_dataset" "raw" {
for_each = toset(var.sources)
dataset_id = "raw_${each.key}"
}
# Remove "pos" → only google_bigquery_dataset.raw["pos"] is destroyed.
Use count only for "0 or 1" (count = var.enable_cdc ? 1 : 0). Keys of for_each must be known at plan time (not computed from resources that don't exist yet).
lifecycle¶
resource "google_bigquery_dataset" "raw" {
dataset_id = "raw_pos"
location = "EU"
lifecycle {
prevent_destroy = true # plan fails if anything would destroy it
ignore_changes = [labels["last_modified_by"]] # don't fight an external tool
}
}
prevent_destroy: protect datasets and buckets with data. Note: it can't take a variable, and it doesn't help if someone removes the whole resource block (use aremovedblock or review for that).ignore_changes: when something outside Terraform legitimately changes an attribute.create_before_destroy: for replacements without downtime.- The Google provider also has its own protections:
deletion_protectionon BigQuery tables, anddeletion_policyon projects in recent provider versions (check docs).
Import and moved (brownfield work)¶
# Bring an existing hand-made dataset under Terraform (Terraform 1.5+)
import {
to = google_bigquery_dataset.raw["pos"]
id = "projects/prj-ingest-prod/datasets/raw_pos"
}
# Refactor: the resource moved into a module. Without this, Terraform
# would destroy the old address and create a new one.
moved {
from = google_bigquery_dataset.raw_pos
to = module.src_pos.google_bigquery_dataset.raw
}
The safe import loop: write the config → plan → adjust config until the plan shows no changes except the import → apply → enforce through CI. terraform plan -generate-config-out=generated.tf can draft the config for you.
Drift¶
Drift = the real world differs from state/config (someone clicked in the console). Detect it with a scheduled terraform plan (e.g. nightly) that alerts when the plan isn't empty, or terraform plan -refresh-only. Then either fix the config or let apply revert the manual change. Long-term fix: humans don't have write roles in prod; only the deployer SA has.
4.19 Google provider specifics¶
IAM resources: _iam_member vs. _iam_binding vs. _iam_policy¶
Most GCP resources have three Terraform IAM resource types:
| Resource | Authoritative for | Effect |
|---|---|---|
google_X_iam_member |
Nothing. Additive: one principal + one role | Adds one member; leaves everything else alone. Default choice. |
google_X_iam_binding |
One role on the resource | Sets the complete member list of that role. Removes anyone else who has that role. |
google_X_iam_policy |
The whole policy on the resource | Replaces all bindings. Anything not in your config is removed. |
Danger example 1 (policy). An engineer writes:
data "google_iam_policy" "ingest" {
binding {
role = "roles/owner"
members = ["group:platform-admins@example.com"]
}
}
resource "google_project_iam_policy" "ingest" {
project = "prj-ingest-prod"
policy_data = data.google_iam_policy.ingest.policy_data
}
On apply, every other binding on the project is deleted: the pipeline SAs' roles, Composer's roles, and the service agents (roles/dataflow.serviceAgent, the Datastream and Pub/Sub agents). Dataflow jobs fail, Datastream stops, Pub/Sub push breaks. If the group binding is wrong, you can even lock yourself out. Recovery needs someone with rights from the folder or org.
Danger example 2 (binding). Team A's module sets:
resource "google_bigquery_dataset_iam_binding" "readers" {
project = "prj-dwh-sales-prod"
dataset_id = "mart_sales"
role = "roles/bigquery.dataViewer"
members = ["group:sales-analysts@example.com"]
}
Team B's config adds the Looker SA with an _iam_member for the same role. Every time team A applies, the Looker SA is removed and dashboards break. Then team B applies and adds it back. The two configs fight forever.
Rule: use _iam_member by default. Use _iam_binding only when this config is the single owner of that role on that resource. Avoid _iam_policy on projects, folders and the org.
BigQuery-specific gotcha. A dataset's access can also be managed with access {} blocks inside google_bigquery_dataset or with google_bigquery_dataset_access. The access blocks are authoritative for the dataset's access list. Don't mix them with google_bigquery_dataset_iam_* resources on the same dataset. The provider docs also warn that the authoritative dataset IAM resources can remove authorized view entries; for authorized views use google_bigquery_dataset_access (check current provider docs).
API enablement: google_project_service¶
resource "google_project_service" "apis" {
for_each = toset([
"bigquery.googleapis.com",
"datastream.googleapis.com",
"dataflow.googleapis.com",
"pubsub.googleapis.com",
"secretmanager.googleapis.com",
])
project = "prj-ingest-prod"
service = each.key
disable_on_destroy = false # never switch off an API just because this resource is removed
}
- Resources in a project fail with "API not enabled" if this hasn't happened yet. Add
depends_on = [google_project_service.apis]where Terraform can't see the dependency. disable_on_destroy = falsematters: disabling an API on destroy can break running services that someone else depends on.- Enabling an API can also create service agents; they may appear a little later (eventual consistency).
Other useful provider features¶
default_labelsin the provider block adds labels to every supported resource.impersonate_service_accountin the provider block: run Terraform as a deployer SA without keys.googlevs.google-beta: beta features need the beta provider.
4.20 CI/CD for infrastructure and Workload Identity Federation¶
The pipeline¶
feature branch ──► Pull Request
├─ terraform fmt -check (style)
├─ terraform validate (syntax, types)
├─ tflint (provider-aware lint: bad machine types, unused vars)
├─ checkov / trivy (security: public buckets, no CMEK, basic roles)
├─ conftest (OPA) on plan JSON (our rules: EU only, labels required, no _iam_policy)
├─ terraform plan per env → posted as a PR comment
└─ review: CODEOWNERS (platform team for IAM/network/prod), 1–2 approvals
merge to main
├─ apply dev (automatic)
├─ apply test (automatic, then smoke tests)
└─ apply prod (manual approval in a protected environment)
Policy as code reads the plan, not only the code, so it sees final values:
terraform plan -out=tf.plan
terraform show -json tf.plan > plan.json
conftest test plan.json --policy policies/
The deployer service account per environment¶
sa-tf-dev,sa-tf-test,sa-tf-prod. Each has roles only on its environment's folder (and its state bucket). The dev deployer can't touch prod.- Optionally split plan and apply identities: a plan SA with read-only roles (plus access to the state bucket for the lock) that any PR can use, and an apply SA that only the
mainbranch in a protected environment can use. - The deployer SA is the most powerful identity in the platform. Protect it: no keys, only CI can impersonate it, audit logs alerts on its use outside CI, and limit which roles it can grant (IAM conditions on role grants).
Workload Identity Federation (WIF), step by step¶
The problem: GitHub Actions runs outside GCP. The old way was a JSON key in a GitHub secret. WIF removes the key.
GitHub Actions job Google STS IAM Credentials API
────────────────── ────────── ───────────────────
(1) Job asks GitHub for an
OIDC ID token (JWT):
iss = token.actions.githubusercontent.com
sub = repo:example/infra:ref:refs/heads/main
repository = example/infra, ref, environment, ...
│
│ (2) send JWT
└──────────────────────► (3) STS checks: signature against
GitHub's public keys; issuer and
audience match the WIF provider;
attribute mapping + attribute
CONDITION (e.g. repository ==
'example/infra') is true
(4) returns a short-lived
FEDERATED token for the principal
principalSet://.../attribute.repository/example/infra
┌────────────────────────────────┘
│ (5) use federated token to call generateAccessToken
└──────────────────────────────────────────────────────► (6) IAM checks: does this
principal have
roles/iam.workloadIdentityUser
ON sa-tf-prod?
(7) returns an OAuth access
token for sa-tf-prod (~1 h)
(8) terraform uses the token to call GCP APIs. Nothing long-lived was ever stored.
Terraform for the WIF setup (in the prj-iac project):
resource "google_iam_workload_identity_pool" "github" {
project = "prj-iac"
workload_identity_pool_id = "github"
display_name = "GitHub Actions"
}
resource "google_iam_workload_identity_pool_provider" "github" {
project = "prj-iac"
workload_identity_pool_id = google_iam_workload_identity_pool.github.workload_identity_pool_id
workload_identity_pool_provider_id = "github-oidc"
attribute_mapping = {
"google.subject" = "assertion.sub"
"attribute.repository" = "assertion.repository"
"attribute.ref" = "assertion.ref"
"attribute.environment" = "assertion.environment"
}
# Only tokens from our organization's infra repo are accepted at all
attribute_condition = "assertion.repository == \"example/infra\""
oidc {
issuer_uri = "https://token.actions.githubusercontent.com"
}
}
# Only jobs running in the protected GitHub environment "prod" may become sa-tf-prod
resource "google_service_account_iam_member" "prod_deployer_wif" {
service_account_id = "projects/prj-iac/serviceAccounts/sa-tf-prod@prj-iac.iam.gserviceaccount.com"
role = "roles/iam.workloadIdentityUser"
member = "principalSet://iam.googleapis.com/${google_iam_workload_identity_pool.github.name}/attribute.environment/prod"
}
Why the attribute condition matters. GitHub signs tokens for every repository on GitHub. Without a condition on repository (or repository_owner), any repo in the world with the same issuer could try to use your pool. The condition is your main control.
The GitHub workflow:
name: terraform-prod
on:
pull_request:
paths: ["envs/prod/**", "modules/**"]
push:
branches: [main]
paths: ["envs/prod/**", "modules/**"]
permissions:
id-token: write # allow the job to request the OIDC token
contents: read
pull-requests: write # post the plan as a comment
jobs:
plan:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: google-github-actions/auth@v2
with:
workload_identity_provider: projects/123456789/locations/global/workloadIdentityPools/github/providers/github-oidc
service_account: sa-tf-plan-prod@prj-iac.iam.gserviceaccount.com
- uses: hashicorp/setup-terraform@v3
- run: terraform -chdir=envs/prod init -input=false
- run: terraform -chdir=envs/prod plan -input=false -out=tf.plan
# (post plan output as PR comment, run conftest on the plan JSON)
apply:
if: github.event_name == 'push'
runs-on: ubuntu-latest
environment: prod # protected environment: required reviewers
steps:
- uses: actions/checkout@v4
- uses: google-github-actions/auth@v2
with:
workload_identity_provider: projects/123456789/locations/global/workloadIdentityPools/github/providers/github-oidc
service_account: sa-tf-prod@prj-iac.iam.gserviceaccount.com
- uses: hashicorp/setup-terraform@v3
- run: terraform -chdir=envs/prod init -input=false
- run: terraform -chdir=envs/prod apply -input=false -auto-approve
(In a stricter setup the apply job applies the exact plan file that was reviewed, stored as a build artifact.)
5. What matters most in practice¶
5.1 The shared ingestion platform layout (start here)¶
Folder prod
├── prj-ingest-prod (shared, owned by the platform team)
│ ├── Datastream streams, Pub/Sub topics, Dataflow jobs
│ ├── GCS: prj-ingest-prod-landing-<source> (one bucket per source)
│ └── BigQuery datasets (location EU)
│ ├── raw_pos ← one dataset PER SOURCE
│ ├── raw_ecom
│ ├── raw_erp_files
│ └── ops_ingestion ← load audit, freshness, reconciliation tables
├── prj-orch-prod (Composer; DAG factory reads source YAMLs)
├── prj-dwh-sales-prod (domain: stg_sales, mart_sales)
├── prj-dwh-finance-prod
└── prj-bi-prod (Looker SA runs jobs here; BI reservation)
The reasoning:
- Shared ingestion project: one place for connectivity to sources (VPN, private connectivity), one set of standard patterns, one monitoring view, and no duplicated ingestion of the same source by two teams.
- One dataset per source: dataset is the BigQuery IAM unit, so "sales can read
raw_pos" is one binding. Ownership and labels are clean. Deleting a source is deleting one dataset. - Domain projects for transformations: domain teams own their logic and cost; they read raw data cross-project.
- Separate orchestration and BI projects: Composer has its own lifecycle and quotas; BI query cost is visible separately.
- Same layout in dev and test, created from the same modules.
- Ingestion per environment, not one ingestion for all: dev pipelines must not write to prod raw.
When might you split the ingestion project? If one source is very sensitive (HR, payments) and needs its own VPC-SC perimeter, or if quotas (e.g. Dataflow per-project limits) become a problem. It is a trade-off between isolation and simplicity.
5.2 The identity design¶
| Identity | Type | Purpose |
|---|---|---|
sa-ingest-<source> |
SA in prj-ingest-* |
Runs that source's load: Dataflow worker, load jobs, reads its landing bucket |
sa-composer |
SA in prj-orch-* |
The Composer environment; impersonates the per-source SAs for each task |
sa-dataform-<domain> |
SA in the domain project | Runs transformations for that domain |
sa-looker |
SA in prj-bi-* |
Looker's BigQuery connection |
sa-tf-<env>, sa-tf-plan-<env> |
SA in prj-iac |
CI deployers |
group:platform-admins |
Group | Admin on platform folder, break-glass |
group:<domain>-engineers |
Group | Read raw + write in dev of their domain |
group:<domain>-analysts |
Group | Read marts only |
group:ingest-oncall |
Group | Operate pipelines (Dataflow developer, view logs), time-bound escalation |
Why Composer impersonates instead of having all the rights itself: Composer is shared by many DAGs. If the Composer SA had write access to all raw datasets, any DAG (or any bug) could write anywhere. With impersonation, each task runs as its source's SA, and the logs show which source did what.
5.3 Least privilege, concretely¶
| Principal | Role | Resource |
|---|---|---|
sa-ingest-pos |
roles/bigquery.dataEditor |
dataset prj-ingest-prod.raw_pos only |
sa-ingest-pos |
roles/bigquery.jobUser |
project prj-ingest-prod (load jobs run here) |
sa-ingest-pos |
roles/storage.objectAdmin |
bucket prj-ingest-prod-landing-pos only |
sa-ingest-pos |
roles/dataflow.worker |
project prj-ingest-prod (if it is a Dataflow worker SA) |
sa-ingest-pos |
roles/secretmanager.secretAccessor |
secret src-pos-credentials only |
sa-composer |
roles/iam.serviceAccountTokenCreator |
on sa-ingest-pos (the SA as a resource) |
sa-dataform-sales |
roles/bigquery.dataViewer |
datasets raw_pos, raw_ecom |
sa-dataform-sales |
roles/bigquery.dataEditor |
datasets stg_sales, mart_sales |
sa-dataform-sales |
roles/bigquery.jobUser |
project prj-dwh-sales-prod |
group:sales-analysts |
roles/bigquery.dataViewer |
dataset mart_sales |
group:sales-analysts |
roles/bigquery.jobUser |
project prj-bi-prod |
sa-looker |
roles/bigquery.dataViewer + jobUser |
marts + prj-bi-prod |
Note what is missing: no project-level dataViewer, no basic roles, no bigquery.admin, no serviceAccountUser on the project.
5.4 Walkthrough: the "onboard a new source" module¶
The goal: onboarding a source = one module block in a PR. Everything a source needs, created the same way every time.
modules/ingestion_source/versions.tf¶
terraform {
required_version = ">= 1.6"
required_providers {
google = {
source = "hashicorp/google"
version = ">= 6.0, < 7.0"
}
}
}
A module declares which provider versions it supports, but does not configure the provider or the backend. The root module (envs/prod) does that. This keeps the module reusable for every environment.
modules/ingestion_source/variables.tf¶
variable "project_id" {
description = "The shared ingestion project, e.g. prj-ingest-prod."
type = string
}
variable "env" {
description = "Environment name: dev, test or prod."
type = string
validation {
condition = contains(["dev", "test", "prod"], var.env)
error_message = "env must be dev, test or prod."
}
}
variable "source_name" {
description = "Short source name, lowercase, e.g. pos."
type = string
validation {
condition = can(regex("^[a-z][a-z0-9_]{1,20}$", var.source_name))
error_message = "source_name must be lowercase letters, digits or _, max 21 chars."
}
}
variable "source_type" {
description = "postgres, mysql, gcs_files or pubsub_events."
type = string
}
variable "owner_domain" {
description = "Owning domain team, e.g. sales. Used for labels and cost attribution."
type = string
}
variable "location" {
description = "BigQuery and GCS location. Must match the rest of the warehouse."
type = string
default = "EU"
}
variable "reader_groups" {
description = "Groups that may read the raw dataset."
type = list(string)
default = []
}
variable "reader_service_accounts" {
description = "Service accounts (e.g. Dataform SAs) that may read the raw dataset."
type = list(string)
default = []
}
variable "orchestrator_sa" {
description = "Composer SA email that will impersonate this source's SA."
type = string
}
variable "composer_bucket" {
description = "Composer environment bucket, where the DAG factory reads configs."
type = string
}
variable "freshness_sla_minutes" {
description = "Maximum allowed data age before alerting."
type = number
default = 60
}
variable "contains_pii" {
description = "True if the source has personal data. Drives labels and policy checks."
type = bool
default = false
}
variable "needs_credentials" {
description = "True if the source needs a DB password or API key."
type = bool
default = false
}
variable "secret_region" {
description = "Region for user-managed secret replication."
type = string
default = "europe-north1"
}
variable "landing_retention_days" {
description = "Days before landing files move to a colder storage class."
type = number
default = 30
}
Line by line, the important points:
validationblocks catch mistakes atplantime, before anything reaches GCP. A wrongenvor an uppercase source name fails in CI, not in production.locationhas a default of the warehouse's location, so teams can't easily create a dataset in the wrong region. (The org policy is the second line of defence.)reader_groupsandreader_service_accountsare separate lists so the module can build the correct principal prefix (group:vsserviceAccount:).contains_piiis not just documentation: it becomes a label, and policy-as-code can require extra controls (e.g. policy tags, no wide readers) when it istrue.orchestrator_sais an input, not hard-coded, so the same module works with the dev and prod Composer.
modules/ingestion_source/main.tf¶
locals {
labels = {
source = var.source_name
source_type = var.source_type
owner_domain = var.owner_domain
env = var.env
pii = var.contains_pii ? "true" : "false"
managed_by = "terraform"
}
reader_members = concat(
[for g in var.reader_groups : "group:${g}"],
[for s in var.reader_service_accounts : "serviceAccount:${s}"],
)
}
# 1. One identity per source
resource "google_service_account" "ingest" {
project = var.project_id
account_id = "sa-ingest-${replace(var.source_name, "_", "-")}"
display_name = "Ingestion SA for source ${var.source_name}"
description = "Owned by ${var.owner_domain}. Managed by Terraform."
}
# 2. Landing bucket for files (or Dataflow temp/staging)
resource "google_storage_bucket" "landing" {
project = var.project_id
name = "${var.project_id}-landing-${replace(var.source_name, "_", "-")}"
location = var.location
uniform_bucket_level_access = true
public_access_prevention = "enforced"
force_destroy = false
labels = local.labels
versioning {
enabled = true
}
lifecycle_rule {
condition {
age = var.landing_retention_days
}
action {
type = "SetStorageClass"
storage_class = "COLDLINE"
}
}
}
# 3. Raw dataset: one per source
resource "google_bigquery_dataset" "raw" {
project = var.project_id
dataset_id = "raw_${var.source_name}"
location = var.location
description = "Raw data from ${var.source_name}. Owner: ${var.owner_domain}."
labels = local.labels
lifecycle {
prevent_destroy = true
}
}
# 4. The source SA can write only to its own dataset
resource "google_bigquery_dataset_iam_member" "writer" {
project = var.project_id
dataset_id = google_bigquery_dataset.raw.dataset_id
role = "roles/bigquery.dataEditor"
member = "serviceAccount:${google_service_account.ingest.email}"
}
# 5. ...and run load/query jobs in the ingestion project
resource "google_project_iam_member" "job_user" {
project = var.project_id
role = "roles/bigquery.jobUser"
member = "serviceAccount:${google_service_account.ingest.email}"
}
# 6. ...and manage objects in its own landing bucket only
resource "google_storage_bucket_iam_member" "landing_rw" {
bucket = google_storage_bucket.landing.name
role = "roles/storage.objectAdmin"
member = "serviceAccount:${google_service_account.ingest.email}"
}
# 7. Readers (domain groups and transformation SAs)
resource "google_bigquery_dataset_iam_member" "readers" {
for_each = toset(local.reader_members)
project = var.project_id
dataset_id = google_bigquery_dataset.raw.dataset_id
role = "roles/bigquery.dataViewer"
member = each.value
}
# 8. Composer may impersonate this SA (per-task identity)
resource "google_service_account_iam_member" "orchestrator_impersonation" {
service_account_id = google_service_account.ingest.name
role = "roles/iam.serviceAccountTokenCreator"
member = "serviceAccount:${var.orchestrator_sa}"
}
# 9. A secret container for credentials (the VALUE is added outside Terraform)
resource "google_secret_manager_secret" "credentials" {
count = var.needs_credentials ? 1 : 0
project = var.project_id
secret_id = "src-${replace(var.source_name, "_", "-")}-credentials"
labels = local.labels
replication {
user_managed {
replicas {
location = var.secret_region
}
}
}
}
resource "google_secret_manager_secret_iam_member" "credentials_access" {
count = var.needs_credentials ? 1 : 0
project = var.project_id
secret_id = google_secret_manager_secret.credentials[0].secret_id
role = "roles/secretmanager.secretAccessor"
member = "serviceAccount:${google_service_account.ingest.email}"
}
# 10. Config for the Composer DAG factory, generated from the same inputs
resource "google_storage_bucket_object" "dag_config" {
bucket = var.composer_bucket
name = "dags/configs/sources/${var.source_name}.yaml"
content = yamlencode({
source = var.source_name
source_type = var.source_type
owner_domain = var.owner_domain
target_project = var.project_id
target_dataset = google_bigquery_dataset.raw.dataset_id
landing_bucket = google_storage_bucket.landing.name
service_account = google_service_account.ingest.email
freshness_sla_minutes = var.freshness_sla_minutes
})
}
# (Optional, by source_type: Datastream connection profile + stream for
# postgres/mysql, a Pub/Sub topic + BigQuery subscription for events,
# and a Cloud Monitoring alert policy on freshness. Left out to keep the
# example readable; they follow the same count/for_each pattern.)
Line by line:
locals.labels: one label map used everywhere. Billing export andINFORMATION_SCHEMAcan then group cost bysourceandowner_domain.managed_by = terraformtells people not to click-edit it.reader_members: builds full principal strings from simple inputs, so callers can't make typos like forgettinggroup:.- (1)
google_service_account:account_idmust be 6–30 characters, lowercase letters, digits and hyphens, so we replace_with-. (A long source name could exceed the limit; a validation could check that too.) - (2) bucket: bucket names are global across all of GCS, so we prefix with the project ID.
uniform_bucket_level_access= IAM only.public_access_prevention = "enforced"= can never be public.force_destroy = falsemeans Terraform won't delete a bucket that still has files.versioningprotects against accidental overwrite. Thelifecycle_rulemoves old files to Coldline to save money. - (3) dataset:
locationcomes from the variable (default EU).prevent_destroystops any plan that would delete raw data, for example if someone changes the location (which forces replacement). - (4) writer:
dataEditoron this dataset only, with_iam_member(additive, safe). - (5) job user: load jobs are jobs, so the SA needs
jobUseron the project where they run. This is project-level because jobs are project-level; it gives no data access by itself. - (6) bucket IAM: bucket-level, not project-level, so
sa-ingest-poscan't read the ecom landing files. - (7) readers:
for_eachover a set, so adding or removing one reader changes only that binding. - (8) impersonation: the binding is on the SA resource (
service_account_id), not on the project. Composer can impersonate this SA and no others. - (9) secret:
countmakes it optional. Replication is user-managed in one EU region (hereeurope-north1) for residency. We create the container and the access, not the value, so no password ends up in state. - (10) DAG config: Terraform writes a YAML file that the Composer DAG factory reads. So one PR creates the infrastructure and the pipeline. There is no second manual step to forget.
modules/ingestion_source/outputs.tf¶
output "dataset_id" {
description = "Raw dataset ID."
value = google_bigquery_dataset.raw.dataset_id
}
output "service_account_email" {
description = "The source's ingestion SA."
value = google_service_account.ingest.email
}
output "landing_bucket" {
description = "Landing bucket name."
value = google_storage_bucket.landing.name
}
output "secret_id" {
description = "Secret ID for credentials, or null."
value = var.needs_credentials ? google_secret_manager_secret.credentials[0].secret_id : null
}
Outputs let other configs use the results (e.g. the sales domain config can reference the raw dataset ID) without hard-coding names.
Usage: one block per source, or a YAML list¶
# envs/prod/sources.tf
module "src_pos" {
source = "git::https://github.com/example/tf-modules.git//ingestion_source?ref=v1.4.0"
project_id = "prj-ingest-prod"
env = "prod"
source_name = "pos"
source_type = "postgres"
owner_domain = "sales"
reader_groups = ["sales-engineers@example.com"]
reader_service_accounts = ["sa-dataform-sales@prj-dwh-sales-prod.iam.gserviceaccount.com"]
orchestrator_sa = "sa-composer@prj-orch-prod.iam.gserviceaccount.com"
composer_bucket = "europe-north1-composer-prod-bucket"
freshness_sla_minutes = 15
contains_pii = true
needs_credentials = true
}
At scale, teams don't even write HCL. They add a YAML file, and the root module loops over all of them:
locals {
source_files = fileset("${path.module}/sources", "*.yaml")
sources = {
for f in local.source_files :
trimsuffix(f, ".yaml") => yamldecode(file("${path.module}/sources/${f}"))
}
}
module "sources" {
source = "git::https://github.com/example/tf-modules.git//ingestion_source?ref=v1.4.0"
for_each = local.sources
project_id = "prj-ingest-prod"
env = "prod"
source_name = each.key
source_type = each.value.source_type
owner_domain = each.value.owner_domain
reader_groups = try(each.value.reader_groups, [])
orchestrator_sa = "sa-composer@prj-orch-prod.iam.gserviceaccount.com"
composer_bucket = "europe-north1-composer-prod-bucket"
freshness_sla_minutes = try(each.value.freshness_sla_minutes, 60)
contains_pii = try(each.value.contains_pii, false)
needs_credentials = try(each.value.needs_credentials, false)
}
5.5 Keyless CI/CD with Workload Identity Federation¶
See 4.20. In short: "GitHub issues an OIDC token, Google STS validates it against the WIF provider with an attribute condition on our repository, returns a federated token, and that identity impersonates the per-environment deployer SA through workloadIdentityUser. Tokens live about an hour. No key exists anywhere."
5.6 Protecting the state and the deployer¶
- State bucket in a dedicated
prj-iacproject, versioning on, uniform access, public access prevention, optional CMEK. - Only the deployer SAs (and a break-glass group) have
storage.objectAdminon it. Engineers don't need to read prod state. - No secrets as Terraform inputs or outputs (state stores them in plain text). Mark sensitive variables
sensitive = true(this hides them in output, but not in state). - The deployer SA: roles only on its env folder; WIF only from the protected branch/environment; alert on its use from anywhere except CI; limit the roles it can grant.
5.7 Brownfield: bringing a hand-made platform under Terraform¶
- Inventory existing resources (Cloud Asset Inventory can export them).
- Write module calls for them and add
importblocks. planuntil it shows only imports and no changes. Differences mean either the config is wrong or the real resource has drift that you decide on.- Apply. Remove humans' write access in prod. From now on, every change goes through PRs.
- Do IAM carefully: import existing bindings as
_iam_member, never replace a whole policy.
5.8 Cost attribution¶
- Projects per domain → cost per domain without any work.
- Labels on every resource (
source,owner_domain,env) via the module → cost per source in the billing export. - Job labels on BigQuery jobs (set by the DAG factory:
dag_id,task_id,source) → query cost per pipeline fromINFORMATION_SCHEMA.JOBS(total_bytes_billed,total_slot_ms). - Budgets with alerts per project.
- A small dashboard: cost per source per day, next to load duration and freshness (the core platform metrics).
5.9 Adoption and self-service (the "without you" part)¶
- Golden path: the easy way is the correct way. The module gives naming, labels, IAM, alerts and DAG config automatically.
- Template repo with a working example source. Copy, rename, open a PR.
- Docs: README per module (inputs, outputs, examples, "how to onboard a source in 30 minutes"), generated with
terraform-docs. - Guardrails in CI: mistakes are caught by tools, not by tired reviewers.
- Versioned modules + changelog: teams upgrade on purpose. Deprecate old versions with a date.
- Handover: pair with the internal team on the first sources, then they do the next ones while the platform team reviews, then they own it.
6. Mapping from AWS and Azure¶
| Concept | AWS | Azure / Fabric | GCP |
|---|---|---|---|
| Root of everything | AWS Organization | Entra tenant + root management group | Organization |
| Grouping | Organizational Unit (OU) | Management group | Folder |
| Isolation + billing unit | Account | Subscription (+ resource group) | Project |
| Workspace for data | (S3 bucket / Glue database) | Fabric workspace | BigQuery dataset (IAM unit) |
| Human identity | IAM Identity Center user/group | Entra ID user/group | Google account / Google Group (often synced from Entra) |
| Workload identity | IAM role assumed by a service (instance profile) | Managed identity | Service account (attached or impersonated) |
| Assume a role | sts:AssumeRole |
Use a managed identity / role assignment | Impersonation (serviceAccountTokenCreator) |
| "Pass" a role to a service | iam:PassRole |
(assigning a managed identity to a resource) | iam.serviceAccounts.actAs (serviceAccountUser) |
| Permissions document | IAM policy JSON (Allow/Deny statements, attached to a principal) | Role definition + role assignment at a scope | Role + binding in the resource's allow policy |
| Hard guardrails | SCP | Azure Policy (deny) | Organization policy (+ deny policies) |
| Data exfiltration fence | VPC endpoint policies, S3 bucket policies with conditions | Private endpoints, network rules | VPC Service Controls |
| Private access to services | VPC endpoints / PrivateLink | Private endpoints | Private Google Access / Private Service Connect |
| Central network | Transit Gateway, shared VPC via RAM | Hub-spoke VNets | Shared VPC |
| Secrets | Secrets Manager | Key Vault | Secret Manager |
| Audit | CloudTrail | Activity log | Cloud Audit Logs |
| Cost tags | Cost allocation tags | Tags | Labels (+ billing export to BigQuery) |
| CI without keys | GitHub OIDC → IAM role | Federated credentials on an app/managed identity | Workload Identity Federation |
| Terraform remote state | S3 backend + DynamoDB lock | azurerm backend (blob lease) | GCS backend (lock built in) |
The big mental shift from AWS. In AWS, you usually attach a policy to the principal ("this role can read bucket X"). In GCP, the policy lives on the resource ("dataset X lets these principals read it"). To answer "what can this SA do?", you search across resources (Policy Analyzer). To answer "who can read this dataset?", you look at the dataset and its ancestors.
7. Hands-on exercise¶
Part A: a design exercise (paper, 45 minutes)¶
Scenario. ShopFlow, the course's e-commerce company, has 3 sources: a POS Postgres database (on-prem), an e-commerce MySQL (Cloud SQL), and daily ERP CSV files from SFTP. Domains: sales and finance. Finance must not see e-commerce customer emails. Everything must stay in the EU.
Write down:
- The folder and project tree for dev and prod.
- The datasets in the ingestion project.
- A table of every SA and group with its roles and resources.
- Which org policies you set and where.
- How finance reads e-commerce orders without emails. (Answer: an authorized view in a finance-facing dataset that excludes the column, or policy tags with masking on the email column.)
- How a new source is onboarded in one PR.
Compare your answer with section 5.
Part B: real Terraform in a free/trial project (60–90 minutes)¶
You need one GCP project (free trial). BigQuery sandbox alone is not enough for IAM.
# 1. Authenticate and set the project
gcloud auth application-default login
gcloud config set project YOUR_PROJECT_ID
# 2. Create a state bucket (versioned)
gcloud storage buckets create gs://YOUR_PROJECT_ID-tfstate --location=EU --uniform-bucket-level-access
gcloud storage buckets update gs://YOUR_PROJECT_ID-tfstate --versioning
Then:
- Copy the module from 5.4 into
modules/ingestion_source/. Createenvs/dev/main.tfwith the backend, the provider (project,region = "europe-north1"), and one module call. Use your own email as a reader withreader_groups = []and add a separategoogle_bigquery_dataset_iam_memberforuser:you@...(only for this lab). - Run
terraform init,plan,apply. Read the plan: count the resources and check the dependency order. - See the
countvsfor_eachdifference: add two readers, remove the first one, and look at the plan. - See
prevent_destroy: changelocationtoUSand runplan. It should fail. - See drift: add a label to the dataset in the console, run
plan. Terraform wants to remove it. - See impersonation: create a second SA, give yourself
serviceAccountTokenCreatoron it, and rungcloud storage ls --impersonate-service-account=.... Then look at the audit log entry: both identities are there. - Import: create a dataset by hand with
bq mk, then write animportblock and plan until no changes. terraform destroyat the end (removeprevent_destroyfirst). Notice that it refuses to delete a non-empty bucket because offorce_destroy = false.
8. Glossary¶
| Term | Meaning |
|---|---|
| Access level | A condition (IP range, device, identity) from Access Context Manager used by VPC-SC |
| actAs | The permission iam.serviceAccounts.actAs: attach a service account to a resource |
| Allow policy | The list of bindings on one resource |
| Attribute condition (WIF) | A CEL rule on the external token's claims that must be true to accept the token |
| Audit logs | Admin Activity, Data Access, System Event and Policy Denied logs |
| Authorized view | A view allowed to read a dataset that its users can't read directly |
| Basic role | Owner, Editor, Viewer. Too broad for prod |
| Billing account | Pays for linked projects; outside the resource hierarchy |
| Billing export | Detailed cost data exported to a BigQuery dataset |
| Binding | Role + principals (+ optional condition) on a resource |
| CEL | Common Expression Language, used for IAM conditions and custom constraints |
| CODEOWNERS | A git file that makes reviews from certain teams mandatory for certain paths |
| Custom role | A role you define with a chosen list of permissions (org or project level) |
| Deny policy | A policy that blocks permissions for principals, evaluated before allow |
| Drift | Differences between real resources and Terraform state/config |
| Effective policy | Union of a resource's allow policy and all its ancestors' |
| etag | A version tag on a policy used to avoid overwriting concurrent changes |
| Folder | A grouping node between the organization and projects |
| Golden path | The supported, easy, standard way to do a task |
| Impersonation | Getting short-lived tokens for an SA via serviceAccountTokenCreator |
| Labels | Key-value metadata on resources, used for cost reporting |
| Location (BigQuery) | Region or multi-region where a dataset lives; fixed at creation |
| Module (Terraform) | A reusable folder of Terraform code with inputs and outputs |
| Org policy | A constraint on resource configuration, inherited down the hierarchy |
| Permission | One allowed action, e.g. bigquery.jobs.create |
| Plan / apply | Terraform's "show the changes" and "make the changes" steps |
| Predefined role | A role maintained by Google |
| Principal | An identity that can be granted a role (user, group, SA, federated identity) |
| Private Google Access | Lets VMs without public IPs reach Google APIs |
| Private Service Connect | Private endpoints in your VPC for Google APIs or managed services |
| Project | Container for resources; boundary for APIs, billing, quotas, IAM |
| Resource hierarchy | Organization → folders → projects → resources |
| Secret Manager | Managed storage for secrets with versions and IAM |
| Service account | An identity for workloads; also a resource that others can be granted roles on |
| Service agent | A Google-managed SA that a Google service uses in your project |
| Service perimeter | The VPC-SC fence around projects and APIs |
| Shared VPC | A network in a host project used by service projects |
| State (Terraform) | The record mapping code addresses to real resources |
| STS | Security Token Service; exchanges external tokens for Google federated tokens |
| Tags (Resource Manager) | Key-value tags used in policies (not for billing) |
| Workload Identity Federation | Lets external workloads use GCP without SA keys, via token exchange |
| Workspace (Terraform) | Multiple named states for the same configuration |