GCP Lesson 34 of 98

VPC Service Controls and Access Context Manager: Preventing Data Exfiltration on GCP

In a nutshell

Picture a secure government building. Your IAM badge says who you are and which floors your clearance opens — that is regular GCP access control, and it is essential. But a valid badge does nothing to stop a cleared employee (or someone who stole a badge) from walking classified files out the front door to the parking lot. Stopping that needs a second thing: a customs checkpoint at the building’s perimeter that inspects not only who you are, but where you came from and what you are carrying out. VPC Service Controls (VPC-SC) is that perimeter checkpoint for GCP’s managed data APIs — Cloud Storage, BigQuery, Secret Manager, and roughly a hundred more.

The rest of the building’s security is just the different rules the guards enforce. Access levels are the visitor policy: “only arrivals on the approved corporate shuttle (an allow-listed IP range), only with a company-issued and encrypted laptop (a managed device), only people on today’s list (an approved identity).” Ingress and egress rules are the specific, logged loading-dock doors: the one courier allowed to drop off a package, the one crate allowed to leave to a named address. And crucially, before you lock the whole building down, you run a dry-run — a dress rehearsal where the guards write down everyone they would have turned away, without actually stopping anyone, so you fix the visitor list before the real lockdown strands your own night shift.

Why should a beginner care? Because the single most common cloud data breach is not someone breaking the lock — it is a valid credential (a leaked key, an over-scoped token, a compromised laptop) being used from the wrong place to copy data somewhere it should never go. IAM cheerfully authorizes that, because the credential is genuine. VPC-SC is the layer that says “valid or not, this data does not leave the perimeter.” By the end of this lesson you will be able to build one, open precise holes in it for the traffic that is legitimate, and roll it out without paging yourself at 2 a.m.

VPC Service Controls request decision flow: a caller with valid credentials passes IAM (layer 1), then the service perimeter (layer 2) checks whether the caller is inside the perimeter, reaching restricted APIs over the restricted VIP, or is admitted by an access level or ingress/egress rule — ending in allow or a logged violation

The diagram traces one request left to right: the caller authenticates, IAM confirms the role grants the permission (necessary but not sufficient), and then the perimeter decides — in-perimeter callers reach restricted services through the restricted VIP, an outside caller is bounced unless an access level or ingress/egress rule admits it, and every block is written to an audit log as a dry-run violation before enforcement and a 403 after.

Level: Expert · Time: ~40 min (longer if you wire the restricted VIP and bake a real dry-run window)

Prerequisites & what you’ll be able to do. You will get the most from this if you are comfortable with GCP’s resource hierarchy (org → folder → project), IAM allow policies and service accounts, and the basics of VPC networking and Private Google Access. If IAM is shaky, the companion piece is Advanced GCP IAM: deny policies, conditional bindings, and impersonation chains — VPC-SC is the network-and-identity half of the same defense-in-depth story. It also pays to keep three neighbours straight from this one: hierarchical firewall policies and Cloud NAT egress control (the packet-level perimeter), resource hierarchy and org-policy guardrails (the configuration guardrails), and Shared VPC multi-project network architecture (where the restricted VIP and Private Google Access get wired). After working through this lesson you will be able to:

IAM answers “is this identity allowed to call this API?” It does not answer “even with valid credentials, is this request allowed to leave with the data?” That gap is the entire exfiltration story on GCP: a leaked service account key, an over-scoped OAuth token, or a compromised laptop can read a Cloud Storage bucket or run a BigQuery extract from anywhere on the internet, and IAM will happily authorize it because the credential is valid. VPC Service Controls (VPC-SC) closes that gap by drawing a network-and-identity perimeter around the managed-API control plane itself. This guide builds a perimeter from the threat model up, layers Access Context Manager on top, opens controlled holes with ingress/egress rules, and — most importantly — rolls the whole thing out in dry-run so you find out what breaks before your users do.

1. The threat model VPC-SC actually addresses

Be precise about scope, because VPC-SC is frequently mis-sold. It protects against data exfiltration over the Google API surfacestorage.googleapis.com, bigquery.googleapis.com, secretmanager.googleapis.com, and roughly 100 other managed services. The concrete attacks it defeats:

What VPC-SC does not do: it is not a firewall (that is hierarchical firewall policies and Cloud NAT), it is not IAM (you still need least-privilege bindings), and it does nothing for data leaving via a GCE VM’s own NIC to the public internet. It is one layer. Treat it as the control-plane perimeter that sits alongside network controls, not instead of them.

The mental model: IAM is “who you are and what role you hold.” VPC-SC is “from where, and to where, the request and its data are allowed to flow.” A request must satisfy both. They are AND-ed, never OR-ed.

2. Perimeters, restricted services, and the restricted VIP

A service perimeter is a boundary around a set of projects (more precisely, project numbers; perimeters can also protect VPC networks directly) within which a chosen list of restricted services can only be reached by callers inside the perimeter. Everything lives under an access policy bound to your organization.

First, find or create the org-scoped access policy. There is normally exactly one.

ORG_ID=123456789012

# Is there already an org access policy?
gcloud access-context-manager policies list --organization="$ORG_ID"

# If empty, create one (scopeless == org-wide)
gcloud access-context-manager policies create \
  --organization="$ORG_ID" \
  --title="org-access-policy"

POLICY=$(gcloud access-context-manager policies list \
  --organization="$ORG_ID" --format="value(name)")

Now create a perimeter protecting two projects, restricting Storage, BigQuery, and Secret Manager:

gcloud access-context-manager perimeters create prod_data_perimeter \
  --policy="$POLICY" \
  --title="prod-data-perimeter" \
  --resources="projects/111111111111,projects/222222222222" \
  --restricted-services="storage.googleapis.com,bigquery.googleapis.com,secretmanager.googleapis.com" \
  --perimeter-type=regular

Two non-obvious but critical points:

Resources are project numbers, not IDs. projects/my-prod-app will be silently wrong in some tooling; always resolve with gcloud projects describe my-prod-app --format='value(projectNumber)'.

The restricted VIP is mandatory for it to mean anything. When a service is restricted, in-perimeter callers must reach it through the restricted Virtual IP, restricted.googleapis.com (199.36.153.4/30), not the public endpoint. This requires Private Google Access plus a DNS override and a route. The DNS response CNAMEs the public hostnames to the restricted VIP:

# Private DNS zone so *.googleapis.com resolves to the restricted VIP
gcloud dns managed-zones create restricted-googleapis \
  --project=my-host-project \
  --visibility=private \
  --networks=projects/my-host-project/global/networks/prod-vpc \
  --dns-name=googleapis.com. \
  --description="Force Google APIs onto the restricted VIP"

gcloud dns record-sets create googleapis.com. \
  --project=my-host-project --zone=restricted-googleapis \
  --type=A --ttl=300 --rrdatas="199.36.153.4,199.36.153.5,199.36.153.6,199.36.153.7"

gcloud dns record-sets create '*.googleapis.com.' \
  --project=my-host-project --zone=restricted-googleapis \
  --type=CNAME --ttl=300 --rrdatas="googleapis.com."

Then add a route to 199.36.153.4/30 via the default internet gateway, and ensure subnets have --enable-private-ip-google-access. Without the VIP wiring, requests still hit the public endpoint and the perimeter looks like it “isn’t working” — when in fact the traffic never entered it.

3. Access Context Manager: access levels by IP, identity, and device

Restricting services to in-perimeter callers is binary. Access levels add nuance: they are reusable predicates — “the corporate egress IPs,” “a managed device,” “a specific identity” — that you reference from ingress rules and from the perimeter’s --access-levels to grant conditional entry to human and external callers.

The cleanest way to author non-trivial levels is a YAML spec applied with replace-all. A basic level pinning to corporate egress CIDRs:

# corp-network.yaml
- ipSubnetworks:
    - 203.0.113.0/24
    - 198.51.100.0/24
gcloud access-context-manager levels create corp_network \
  --policy="$POLICY" \
  --title="corp-network" \
  --basic-level-spec=corp-network.yaml \
  --combine-function=AND

Conditions within a single block are AND-ed; multiple blocks (list entries) are OR-ed when --combine-function=OR. To require the request come from corp IPs and be made by a member of an approved group, combine an IP condition with members:

# corp-and-trusted-identity.yaml
- ipSubnetworks:
    - 203.0.113.0/24
  members:
    - user:breakglass@example.com
    - serviceAccount:etl-runner@my-prod-app.iam.gserviceaccount.com

For device trust (managed, encrypted, screen-locked, or — with Endpoint Verification — corporate-owned), use devicePolicy:

# managed-device.yaml
- devicePolicy:
    requireScreenlock: true
    requireCorpOwned: true
    allowedEncryptionStatuses:
      - ENCRYPTED
    osConstraints:
      - osType: DESKTOP_MAC
        minimumVersion: "14.0.0"
      - osType: DESKTOP_CHROME_OS

Access levels are an allow-grant, not a restriction. They never tighten a perimeter; they open it to callers who would otherwise be blocked. A perimeter with no access levels and no ingress rules is maximally closed — which is exactly where you want enforcement to start.

4. Ingress and egress rules for controlled cross-perimeter access

Real systems are not hermetic. Your CI in a tooling project deploys to prod; a partner service reads one bucket; an analytics job in another perimeter queries BigQuery. Ingress/egress rules are the surgical holes — far better than the legacy “perimeter bridge” because they are directional, scoped to identity and service and method, and self-documenting.

Each rule has a from (source: identities and/or sources like access levels or other projects) and a to (destination: resources and the operations/services permitted). Author them as YAML and apply with --set-policy/update.

Allow a CI service account living outside the perimeter to push objects into a perimeter bucket:

# ingress-ci.yaml
- ingressFrom:
    identities:
      - serviceAccount:deployer@tooling-project.iam.gserviceaccount.com
    sources:
      - accessLevel: "*"
  ingressTo:
    operations:
      - serviceName: storage.googleapis.com
        methodSelectors:
          - method: google.storage.objects.create
          - method: google.storage.objects.get
    resources:
      - "*"
gcloud access-context-manager perimeters update prod_data_perimeter \
  --policy="$POLICY" \
  --set-ingress-policies=ingress-ci.yaml

Allow an in-perimeter ETL job to read from a specific bucket in a partner project (egress):

# egress-partner-read.yaml
- egressFrom:
    identities:
      - serviceAccount:etl-runner@my-prod-app.iam.gserviceaccount.com
  egressTo:
    operations:
      - serviceName: storage.googleapis.com
        methodSelectors:
          - method: google.storage.objects.get
          - method: google.storage.objects.list
    resources:
      - projects/333333333333
gcloud access-context-manager perimeters update prod_data_perimeter \
  --policy="$POLICY" \
  --set-egress-policies=egress-partner-read.yaml

Two rules of thumb. Scope resources to specific project numbers, not "*", on egress — an egress rule with resources: ["*"] and a wildcard service is functionally a hole the size of the entire managed-API surface. And prefer methodSelectors over * for method: objects.create + objects.get is a deploy path; * includes setIamPolicy and bucket deletion.

5. Dry-run mode: rolling out without breaking workloads

This is the step that separates a successful VPC-SC rollout from a Sev-1. Never enforce a perimeter blind. A perimeter (and every individual ingress/egress rule) has a dry-run configuration that evaluates exactly as enforcement would and logs every violation it would have caused — without blocking anything.

Create the perimeter in dry-run by attaching the spec to the spec config and marking it dry-run:

# Promote an existing enforced perimeter's config into dry-run for editing,
# or author dry-run directly:
gcloud access-context-manager perimeters dry-run create prod_data_perimeter \
  --policy="$POLICY" \
  --resources="projects/111111111111,projects/222222222222" \
  --restricted-services="storage.googleapis.com,bigquery.googleapis.com,secretmanager.googleapis.com"

You can iterate on the dry-run spec (add restricted services, tweak ingress/egress) independently of what is enforced:

gcloud access-context-manager perimeters dry-run update prod_data_perimeter \
  --policy="$POLICY" \
  --add-restricted-services="aiplatform.googleapis.com"

Let it bake for at least one full business cycle — a week covers weekly batch jobs, monthly close jobs will surprise you. Mine the dry-run violations (Step 8), fix each one with a rule or by moving a resource inside the perimeter, and only when violations go quiet do you promote:

# Apply the dry-run config as the enforced config
gcloud access-context-manager perimeters dry-run enforce prod_data_perimeter \
  --policy="$POLICY"

Dry-run logs appear under method *DryRun* in audit logs with vpcServiceControlsUniqueIdentifier. A request that would be blocked succeeds and emits a violation entry; a request that succeeds under both specs emits nothing. Zero dry-run violations over a representative window is your green light — not “it looked fine for an hour.”

6. Perimeter bridges and multi-project, multi-perimeter designs

For most cross-perimeter access, reach for ingress/egress rules first. Perimeter bridges remain useful for one pattern: a set of projects that must freely share a restricted service among themselves while each also belonging to its own regular perimeter. A bridge is a perimeter of type bridge whose member projects can call each other’s restricted services without rules.

gcloud access-context-manager perimeters create shared_analytics_bridge \
  --policy="$POLICY" \
  --title="shared-analytics-bridge" \
  --perimeter-type=bridge \
  --resources="projects/111111111111,projects/444444444444"

A project can sit in exactly one regular perimeter but in multiple bridges. Architecturally, the patterns that scale:

Pattern When Trade-off
One large perimeter per environment (all of prod) Few teams, uniform trust Coarse blast radius; one bad rule is org-wide
Perimeter per data domain (payments, PII, analytics) Strong domain isolation needs More rules; clearer ownership and audit
Regular perimeters + bridges for shared assets Teams isolated but share a data lake Bridges are coarse — they share all restricted services

Lean toward domain perimeters with explicit egress rules over bridges. Bridges are blunt; an ingress/egress rule says exactly “this identity, this method, this destination,” which is what an auditor (and future-you) wants to read.

7. Common breakages: BigQuery, Cloud Build, and service agents

These are the ones that will actually page you.

# ingress for Cloud Build + Artifact Registry from a tooling perimeter/project
- ingressFrom:
    identities:
      - serviceAccount:service-111111111111@gcp-sa-cloudbuild.iam.gserviceaccount.com
    sources:
      - accessLevel: "*"
  ingressTo:
    operations:
      - serviceName: artifactregistry.googleapis.com
        methodSelectors:
          - method: "*"
      - serviceName: storage.googleapis.com
        methodSelectors:
          - method: google.storage.objects.get
    resources:
      - "*"

A perimeter does not exempt Google’s own managed services. If a first-party service agent legitimately needs in, it goes in the rules like any other identity. There is no implicit allowlist for “Google services.”

8. Reading VPC-SC violation logs and iterating toward enforcement

Every block (real or dry-run) writes a Cloud Audit Log entry with a metadata block of type VpcServiceControlAuditMetadata. This is your single source of truth — read it, do not guess. Query in Logs Explorer with this filter:

logName=~"cloudaudit.googleapis.com%2Fpolicy"
protoPayload.metadata.@type="type.googleapis.com/google.cloud.audit.VpcServiceControlAuditMetadata"

For just the violations a dry-run config would have caused (so you can clear them before enforcing):

logName=~"cloudaudit.googleapis.com%2Fpolicy"
protoPayload.metadata.violationReason!=""
protoPayload.metadata.dryRun=true

The fields that tell you exactly what to do:

Field What it tells you
protoPayload.metadata.violationReason e.g. NO_MATCHING_ACCESS_LEVEL, RESOURCES_NOT_IN_SAME_SERVICE_PERIMETER, SERVICE_NOT_ALLOWED_FROM_VPC
protoPayload.authenticationInfo.principalEmail which identity to put in an ingress/egress rule
protoPayload.metadata.ingressViolations / egressViolations the source/target project and service — your rule’s from/to
protoPayload.requestMetadata.callerIp source IP — feed it into an access level if it is a corp egress
protoPayload.serviceName / methodName the exact serviceName + method for methodSelectors

The loop is mechanical and should be boring: read a violation, decide whether it is legitimate (write a tightly-scoped rule) or an attack (you just caught one), update the dry-run config, wait, repeat until the dry-run violation count for legitimate traffic is zero. Then dry-run enforce. Wire a log-based metric on violationReason!="" with dryRun=false so that post-enforcement violations page you — those are either a new legitimate workload that needs a rule, or someone trying to walk out with your data.

9. VPC accessible services: capping the APIs reachable from inside

Everything so far controls who may reach your restricted services from outside. There is a second, orthogonal dimension: which Google APIs may be called from inside the perimeter at all. That is VPC accessible services (vpcAccessibleServices), and it is the lever that shrinks lateral-movement and pivot risk. Without it, a compromised in-perimeter VM can still call any Google API the restricted VIP routes — including services you never restricted — and use one of them as a staging ground. With it, in-perimeter clients may call only the services on an explicit allow-list; everything else is refused inside the boundary.

Turn it on for a perimeter and pin the allow-list:

gcloud access-context-manager perimeters update prod_data_perimeter \
  --policy="$POLICY" \
  --enable-vpc-accessible-services \
  --add-vpc-allowed-services="storage.googleapis.com,bigquery.googleapis.com,logging.googleapis.com,monitoring.googleapis.com"

The special token RESTRICTED-SERVICES expands to exactly the perimeter’s restricted-services list, so a common tight baseline is “allow the restricted set plus logging/monitoring and nothing else”:

gcloud access-context-manager perimeters update prod_data_perimeter \
  --policy="$POLICY" \
  --enable-vpc-accessible-services \
  --set-vpc-allowed-services="RESTRICTED-SERVICES,logging.googleapis.com,monitoring.googleapis.com"

In config/Terraform form the same intent reads as:

# fragment of the perimeter spec
vpcAccessibleServices:
  enableRestriction: true
  allowedServices:
    - storage.googleapis.com
    - bigquery.googleapis.com
    - logging.googleapis.com

restricted-services and vpcAccessibleServices answer different questions. restricted-services = “which of my services are protected from outside callers.” vpcAccessibleServices = “which services may be called from inside my VPC.” Set both: the first stops exfiltration inward-to-outward across the boundary; the second stops a foothold inside from pivoting to an unexpected API. A SERVICE_NOT_ALLOWED_FROM_VPC violation in the logs is this control firing — add the service to the allow-list only if the call is legitimate.

Going deeper

Everything above is the working toolkit. This section is the layer underneath it — the internals, limits, and sharp edges that separate a perimeter that looks right from one that holds up under audit, at scale, and under attack.

VPC-SC is one of four distinct controls people constantly conflate. Get the taxonomy crisp and most confusion evaporates:

Control Question it answers Enforced by A block surfaces as
IAM (allow / deny) Which identity may call which API permission? Per-request authorization check PERMISSION_DENIED (IAM)
VPC Service Controls From where / to where may the request and its data flow across the managed-API surface? Service perimeter PERMISSION_DENIED carrying a VPC-SC violationReason
VPC / hierarchical firewall Which packets (L3/L4) may reach a VM’s NIC? Data-plane packet filter connection timeout / refused — no API-level error
Org-policy constraint What resources may exist / how may they be configured? Resource create/update validation constraint-violation error at create time

Read the table twice. A firewall never produces an API error — it drops packets, so the symptom is a hang. An org-policy constraint fires at create time on configuration (e.g. gcp.restrictVpcPeering, iam.disableServiceAccountKeyCreation), never on a data read. IAM and VPC-SC both surface as PERMISSION_DENIED, which is exactly why teams re-grant IAM in a panic when the real culprit is the perimeter — the tell is a violationReason field in the error and a VpcServiceControlAuditMetadata log entry.

Restricted VIP vs private VIP — the silent-failure trap. There are two Private-Google-Access virtual IPs and they are not interchangeable. restricted.googleapis.com (199.36.153.4/30) routes only the services that VPC-SC supports and is the one that makes the perimeter enforce. private.googleapis.com (199.36.153.8/30) routes all Google APIs — including ones VPC-SC cannot protect — but provides no VPC-SC enforcement. Point your *.googleapis.com DNS at the private VIP by mistake and your perimeter quietly does nothing for that traffic. If an in-perimeter workload genuinely needs an API the restricted VIP does not route, that is a real design tension — split DNS or a separate egress path — not something to paper over by switching everything to the private VIP.

Access policies can be org-scoped or scoped to a folder/project. The default access policy is org-wide and there is exactly one, but you can also create scoped policies bound to a specific folder or project (--scopes=folders/FOLDER_ID). Scoped policies let a delegated team manage their own perimeters and access levels without touching org-wide policy — useful in large estates where a central team should not be the bottleneck for every rule change. The trade-off is more moving parts and the need to reason about which policy owns a given perimeter. Perimeters and levels always live under exactly one access policy; you reference a level by its full name, accessPolicies/POLICY_ID/accessLevels/LEVEL_NAME.

Ingress/egress rules have more knobs than identities and methods. Two that matter at scale. identityType lets a rule match a class of caller rather than an enumerated list — ANY_IDENTITY, ANY_USER_ACCOUNT, ANY_SERVICE_ACCOUNT — so you can say “any identity, but only from this access level,” which is how you admit a fleet of unknown-in-advance callers gated purely by network/device posture:

# ingress-any-identity-from-corp.yaml
- ingressFrom:
    identityType: ANY_IDENTITY
    sources:
      - accessLevel: accessPolicies/1122334455/accessLevels/corp_network
  ingressTo:
    operations:
      - serviceName: bigquery.googleapis.com
        methodSelectors:
          - method: "*"
    resources:
      - "*"

And on egress, sourceRestriction: SOURCE_RESTRICTION_ENABLED lets you constrain which in-perimeter sources may use the egress rule (by access level), rather than every in-perimeter caller:

# egress-scoped-source.yaml
- egressFrom:
    identities:
      - serviceAccount:etl-runner@my-prod-app.iam.gserviceaccount.com
    sourceRestriction: SOURCE_RESTRICTION_ENABLED
    sources:
      - accessLevel: accessPolicies/1122334455/accessLevels/corp_network
  egressTo:
    operations:
      - serviceName: bigquery.googleapis.com
        methodSelectors:
          - method: "*"
    resources:
      - projects/555555555555

Manage perimeters as code, and mind the spec vs status split. In Terraform the resource is google_access_context_manager_service_perimeter; the enforced config lives in the status {} block and the dry-run config in the spec {} block, gated by use_explicit_dry_run_spec = true. That mirrors the gcloud dry-run surface exactly — spec is the rehearsal, status is live.

resource "google_access_context_manager_service_perimeter" "prod" {
  parent = "accessPolicies/${var.policy_id}"
  name   = "accessPolicies/${var.policy_id}/servicePerimeters/prod_data_perimeter"
  title  = "prod-data-perimeter"

  # spec{} = dry-run rehearsal; status{} = enforced. Flip this to promote.
  use_explicit_dry_run_spec = true

  spec {
    resources           = ["projects/111111111111", "projects/222222222222"]
    restricted_services = ["storage.googleapis.com", "bigquery.googleapis.com"]

    vpc_accessible_services {
      enable_restriction = true
      allowed_services   = ["storage.googleapis.com", "bigquery.googleapis.com"]
    }
  }
}

A subtle IaC trap: managing ingress/egress rules both inline in the perimeter resource and via the separate google_access_context_manager_service_perimeter_ingress_policy / _egress_policy resources fights itself — pick one representation per perimeter or Terraform will thrash on every plan.

Changes are eventually consistent — do not test-then-declare-victory. VPC-SC configuration changes propagate on the order of minutes, not instantly. A freshly attached rule or a just-promoted enforcement will not necessarily be live on the very next call. Never fire the action immediately, see it still succeed, and conclude your rule “doesn’t work” — give it time, and confirm through the logs rather than a single probe. The same caution applies in reverse when tightening: a request that starts failing right after a change may simply be a stale-cache artifact for a few minutes.

Perimeters have real quotas. There are limits on projects per perimeter, perimeters per access policy, access levels per policy, and rules per perimeter (all in the low hundreds to low thousands depending on the object — check the current VPC-SC quota page before you design a per-team fan-out). This is why the scalable design is few, well-chosen perimeters with tag- and access-level-driven rules, not one perimeter per project or one rule per resource.

The console has a first-class troubleshooter, and every denial carries a correlation ID. When a request is blocked, the error and its log entry carry a vpcServiceControlsUniqueIdentifier. Paste it into the VPC Service Controls troubleshooter in the console (or feed the identifier to support) and it walks the exact policy evaluation — which perimeter, which config, which missing rule caused the block. It turns “opaque 403” into “add this identity to this ingress rule.” For automated triage, the same identifier lets you correlate a user-reported failure with its precise VpcServiceControlAuditMetadata entry.

VPC-SC composes with, but is not, Access Approval or Assured Workloads. For regulated estates, VPC-SC is one control in a stack: Access Approval gates Google support access to your data behind your explicit sign-off, and Assured Workloads pins location/personnel constraints. They are complementary — VPC-SC stops your credentials from exfiltrating data across the boundary; the others constrain Google’s access and residency. Do not expect any one of them to do another’s job.

Enterprise scenario

A fintech platform team ran a regulated data lake: customer PII in BigQuery and Cloud Storage across two prod projects, with a strict control that “production data must never be readable from outside the corporate network or copied to a non-prod project.” IAM was already least-privilege, but an internal red-team exercise lifted a Dataflow worker’s service account token from a debug log and demonstrated that, with that token, they could bq extract a PII table to a personal GCS bucket from a coffee-shop laptop. IAM authorized it end to end. That was the finding that funded VPC-SC.

The constraint that made rollout hard: a nightly Cloud Composer (Airflow) DAG ran ~40 BigQuery jobs, several of which read a reference dataset hosted in a separate “shared-ref” project, and the reporting team’s Looker instance queried BigQuery from a fixed NAT egress IP block. A naive enforced perimeter would have black-holed the entire nightly close and every dashboard the next morning.

They ran the perimeter in dry-run for three weeks — long enough to capture the monthly close. Mining VpcServiceControlAuditMetadata dry-run violations surfaced exactly three legitimate gaps: the Composer service agent reading cross-project, the BigQuery copy into the reference project, and Looker’s IP block. Each became a tightly-scoped rule rather than a broad opening. The Looker access level pinned to the NAT CIDRs:

# looker-egress-ip.yaml
- ipSubnetworks:
    - 203.0.113.16/28

The Composer egress to the reference project, scoped to read methods only:

# composer-ref-read.yaml
- egressFrom:
    identities:
      - serviceAccount:service-111111111111@cloudcomposer-accounts.iam.gserviceaccount.com
  egressTo:
    operations:
      - serviceName: bigquery.googleapis.com
        methodSelectors:
          - method: "google.cloud.bigquery.v2.JobService.InsertJob"
    resources:
      - projects/555555555555

After two consecutive nights of zero dry-run violations across the full DAG, they ran dry-run enforce. The red-team’s stolen-token attack now failed with NO_MATCHING_ACCESS_LEVEL from the external IP — the credential was still valid, but the data could not leave. Total user-facing impact at enforcement: zero. The cost was three weeks of patience and a habit of reading violation logs instead of guessing.

Verify

Confirm the perimeter behaves before and after enforcement:

# 1. Perimeter config and its enforced vs dry-run specs
gcloud access-context-manager perimeters describe prod_data_perimeter \
  --policy="$POLICY"

# 2. From an in-perimeter VM, restricted API must resolve to the VIP
dig storage.googleapis.com +short        # expect 199.36.153.x
gsutil ls gs://prod-bucket               # succeeds in-perimeter

# 3. From OUTSIDE the perimeter, with valid creds, it must be denied
#    (run on a non-perimeter host using the same SA)
gsutil ls gs://prod-bucket               # expect: 403, request violates VPC-SC

# 4. List active access levels and rules
gcloud access-context-manager levels list --policy="$POLICY"
gcloud access-context-manager perimeters describe prod_data_perimeter \
  --policy="$POLICY" --format="yaml(status.ingressPolicies,status.egressPolicies)"

In Logs Explorer, after enforcement, confirm legitimate workloads emit no violationReason while a deliberate out-of-perimeter call does. That asymmetry is proof the perimeter is real and correctly scoped.

Practice challenges

Work these against a sandbox org or project where you can safely attach policies — never production as written. Replace every placeholder (123456789012, project numbers, SA and group emails, $POLICY) with your own. Each solution notes the one idea it proves.

1. (Beginner) Get the identifier VPC-SC actually wants. Your teammate put projects/my-prod-app in a perimeter’s --resources and it behaves oddly. Produce the value the perimeter really needs, and confirm an in-perimeter VM resolves Storage to the restricted VIP.

<details> <summary>Solution</summary>

# Perimeters key on the project NUMBER, not the ID:
gcloud projects describe my-prod-app --format='value(projectNumber)'
# -> e.g. 111111111111  ->  use projects/111111111111 in --resources

# On an in-perimeter VM, the restricted VIP must answer:
dig storage.googleapis.com +short        # expect 199.36.153.x (restricted VIP)

Why: --resources takes projects/<NUMBER>; a project ID is silently wrong in parts of the tooling. And if dig does not return 199.36.153.x, the restricted VIP is not wired, so the perimeter never sees the traffic. </details>

2. (Beginner) Stand up a perimeter the safe way — in dry-run. Create the org access policy (if absent) and a dry-run perimeter protecting project 111111111111, restricting Cloud Storage and BigQuery. Do not enforce.

<details> <summary>Solution</summary>

ORG_ID=123456789012
gcloud access-context-manager policies create \
  --organization="$ORG_ID" --title="org-access-policy"   # skip if one exists
POLICY=$(gcloud access-context-manager policies list \
  --organization="$ORG_ID" --format="value(name)")

gcloud access-context-manager perimeters dry-run create prod_data_perimeter \
  --policy="$POLICY" \
  --resources="projects/111111111111" \
  --restricted-services="storage.googleapis.com,bigquery.googleapis.com"

Why: dry-run create builds the spec config that evaluates and logs violations without blocking anything — the only safe way to discover what a perimeter would break before it breaks it. </details>

3. (Intermediate) An access level that needs corp network AND an approved group. Author a level corp_and_ops that admits a request only when it comes from 203.0.113.0/24 and the caller is in group:prod-operators@example.com, then attach it to the perimeter.

<details> <summary>Solution</summary>

# corp-and-ops.yaml
- ipSubnetworks:
    - 203.0.113.0/24
  members:
    - group:prod-operators@example.com
gcloud access-context-manager levels create corp_and_ops \
  --policy="$POLICY" --title="corp-and-ops" \
  --basic-level-spec=corp-and-ops.yaml --combine-function=AND

gcloud access-context-manager perimeters dry-run update prod_data_perimeter \
  --policy="$POLICY" --add-access-levels=corp_and_ops

Why: conditions within one block are AND-ed, so IP and members must both hold. Two separate list entries would OR instead — a much weaker control. </details>

4. (Intermediate) Let outside CI push objects in, and nothing else. Write an ingress rule so serviceAccount:deployer@tooling-project.iam.gserviceaccount.com (outside the perimeter) may objects.create and objects.get on Storage inside it — but not delete or change IAM.

<details> <summary>Solution</summary>

# ingress-ci.yaml
- ingressFrom:
    identities:
      - serviceAccount:deployer@tooling-project.iam.gserviceaccount.com
    sources:
      - accessLevel: "*"
  ingressTo:
    operations:
      - serviceName: storage.googleapis.com
        methodSelectors:
          - method: google.storage.objects.create
          - method: google.storage.objects.get
    resources:
      - "*"
gcloud access-context-manager perimeters dry-run update prod_data_perimeter \
  --policy="$POLICY" --set-ingress-policies=ingress-ci.yaml

Why: methodSelectors pins the hole to exactly the deploy path; a method: "*" would silently include setIamPolicy and buckets.delete. sources.accessLevel: "*" means “from anywhere network-wise” — tighten it to a real level if the CI has a fixed egress. </details>

5. (Advanced) Egress to one external project, read-only, no wildcards. Let the in-perimeter etl-runner@my-prod-app.iam.gserviceaccount.com read (but not write) Storage objects in partner project 333333333333 — and nowhere else.

<details> <summary>Solution</summary>

# egress-partner-read.yaml
- egressFrom:
    identities:
      - serviceAccount:etl-runner@my-prod-app.iam.gserviceaccount.com
  egressTo:
    operations:
      - serviceName: storage.googleapis.com
        methodSelectors:
          - method: google.storage.objects.get
          - method: google.storage.objects.list
    resources:
      - projects/333333333333
gcloud access-context-manager perimeters dry-run update prod_data_perimeter \
  --policy="$POLICY" --set-egress-policies=egress-partner-read.yaml

Why: resources is scoped to one project number, and methods to read-only. resources: ["*"] with a wildcard method is a hole the size of the whole managed-API surface — the exact exfiltration path the perimeter exists to close. </details>

6. (Advanced) Mine the logs, fix one gap, then go live. A nightly Composer DAG shows dry-run violations. Find them, turn the one legitimate egressViolation into a rule, confirm a clean window, and enforce.

<details> <summary>Solution</summary>

# Logs Explorer — dry-run violations only:
logName=~"cloudaudit.googleapis.com%2Fpolicy"
protoPayload.metadata.violationReason!=""
protoPayload.metadata.dryRun=true

Read principalEmail (the Composer service agent), egressViolations (target project + service), and methodName — those become the rule’s egressFrom/egressTo/methodSelectors (as in challenge 5). Apply with dry-run update, wait for the DAG to run clean, then:

# Only after a representative clean window (include the monthly close):
gcloud access-context-manager perimeters dry-run enforce prod_data_perimeter \
  --policy="$POLICY"

# Then alert on POST-enforcement violations:
#   filter: violationReason!="" AND protoPayload.metadata.dryRun=false

Why: the log fields map one-to-one onto rule fields, so you author from evidence, not guesswork. Enforcing before violations go quiet is exactly how a rollout becomes a Sev-1; the post-enforcement alert catches both new legitimate workloads and real attacks. </details>

Common beginner mistakes

Glossary

Rollout checklist

gcpvpc-service-controlsaccess-context-managersecuritydata-protection
Need this built for real?

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

Work with me

Comments