GCP Security & Compliance
IAM Deny policies, Conditions, Org Policy constraints, VPC Service Controls, Cloud KMS/CMEK, Secret Manager, Binary Authorization, and Security Command Center — the mechanisms that sit on top of the additive IAM model covered in from-aws.md and services-overview.md, for when "grant only what's needed" isn't enough of a guardrail on its own.
1. IAM Deny Policies
from-aws.md and services-overview.md both describe standard GCP IAM bindings as purely additive — no explicit deny, union of every Allow, can't revoke what a higher level granted. That was true of GCP IAM for years, and it's why "design least-privilege from the start, there's no deny-all-and-punch-holes escape hatch" was the standing advice. It's no longer the whole picture. IAM Deny policies shipped to general availability in 2023, and they add exactly the guardrail that was missing: a way to block access that no Allow binding, anywhere in the hierarchy, can override.
A Deny policy attaches to a node in the resource hierarchy — an Organization, a Folder, or a Project — and lists one or more deny rules, each with:
deniedPrincipals— who this rule blocks (a specific principal, a group, or a principal set likeprincipalSet://goog/public:allfor "anyone outside the org")deniedPermissions— which permissions are blocked (e.g.resourcemanager.projects.setIamPolicy)exceptionPrincipals(optional) — a carve-out list exempted from this specific ruledenialCondition(optional) — a CEL expression narrowing when the rule applies (e.g. only outside business hours, or only for resources without a specific tag)
The mechanism that actually matters: deny policies are evaluated before, and completely independently of, allow policies. When a principal calls an API, GCP first walks the resource hierarchy checking every applicable deny policy. If any deny rule matches — the principal is in deniedPrincipals (and not in exceptionPrincipals), the permission is in deniedPermissions, and any denialCondition evaluates true — the call is denied immediately. Allow bindings are never consulted at all. Only if no deny rule matches does evaluation fall through to the familiar additive-Allow union.
graph TD
classDef gcp fill:#4285f4,stroke:#2a56c6,color:#fff
classDef green fill:#2ecc71,stroke:#27ae60,color:#fff
classDef red fill:#e74c3c,stroke:#c0392b,color:#fff
classDef orange fill:#e67e22,stroke:#d35400,color:#fff
subgraph ORG["Organization node"]
DENYP["Deny policy: block-external-editors<br/>deniedPrincipals: principalSet://goog/public:all<br/>deniedPermissions: resourcemanager.projects.setIamPolicy, compute.instances.delete"]:::red
end
subgraph PROJ["Project: prod-backend, three levels below"]
ALLOWB["Allow binding: roles/editor<br/>granted to user:contractor@gmail.com"]:::green
end
CALL["contractor@gmail.com calls<br/>compute.instances.delete on prod-backend"]:::orange
CALL -->|"1. every deny policy up the hierarchy checked FIRST"| DENYP
DENYP -->|"principal matches deniedPrincipals,<br/>permission matches deniedPermissions,<br/>no exception listed"| BLOCKED["DENIED — request stops here"]:::red
ALLOWB -.->|"2. never reached — a matching deny<br/>short-circuits the whole call"| CALL
contractor@gmail.com holds roles/editor on prod-backend, granted directly on that project. Taken alone, this Allow binding is entirely valid and would let the contractor delete VMs.
compute.instances.delete — attached three levels above the project, at the Organization node.
compute.instances.delete. GCP's authorization check starts by walking every deny policy attached anywhere from the resource up to the Organization root — not by looking at Allow bindings first.
deniedPrincipals), and the permission being called is in deniedPermissions. No exceptionPrincipals entry covers this contractor.
roles/editor was validly granted at the project level, or that Editor is a broad role that would otherwise cover this action. A matching deny rule wins unconditionally and independently of every allow policy anywhere in the hierarchy.
# Create a deny policy on an org — attachment point is a URL-encoded resource path
gcloud iam policies create block-external-editors \
--attachment-point='cloudresourcemanager.googleapis.com/organizations/123456789' \
--kind=denypolicies \
--policy-file=deny-policy.yaml
# deny-policy.yaml
displayName: block-external-editors
rules:
- denyRule:
deniedPrincipals:
- principalSet://goog/public:all
exceptionPrincipals:
- principalSet://goog/group/trusted-vendors@example.com
deniedPermissions:
- resourcemanager.googleapis.com/projects.setIamPolicy
- compute.googleapis.com/instances.delete
Does an Allow binding at a lower level (say, directly on the resource) override an org-level Deny policy, the way a more-specific Allow can sometimes matter in other systems?
exceptionPrincipals entry on that same deny rule, or removing/narrowing the deny rule itself.2. IAM Conditions
Conditions attach directly to a single role binding and add a CEL (Common Expression Language) expression that must evaluate to true at request time for that specific binding to apply. Unlike a Deny policy, a Condition doesn't block anything on its own — it narrows one particular Allow grant.
Two common patterns:
Time-bound access — grant a role that automatically stops applying after a date:
gcloud projects add-iam-policy-binding my-project \
--member="user:contractor@example.com" \
--role="roles/storage.objectViewer" \
--condition='expression=request.time < timestamp("2026-12-31T00:00:00Z"),title=expires-eoy,description=Temporary contractor access'
Resource-name-based access — grant a role scoped to a path or prefix instead of the whole bucket:
gcloud storage buckets add-iam-policy-binding gs://my-bucket \
--member="serviceAccount:etl@my-project.iam.gserviceaccount.com" \
--role="roles/storage.objectAdmin" \
--condition='expression=resource.name.startsWith("projects/_/buckets/my-bucket/objects/staging/"),title=staging-prefix-only'
The gotcha worth internalizing: a Condition only restricts the binding it's attached to. It cannot reach out and restrict a different binding that grants the same role without a condition. If a service account holds roles/storage.objectViewer twice — once unconditionally, once with a condition scoping it to a prefix — the unconditional grant already covers everything, and the conditional one adds nothing. This falls straight out of the additive model: Conditions narrow one grant, they don't create a deny.
A service account has two IAM bindings for the same role on the same bucket: one unconditional, one with a Condition restricting it to objects under /staging/. Does the account's overall access get scoped down to just /staging/?
3. Org Policy Constraints
Org Policy constraints are different from both IAM Deny and Conditions — they don't govern who can do what, they govern what configurations are allowed to exist at all, regardless of who's making the API call or what IAM role they hold. A constraint set at the Organization binds every Folder and Project beneath it by default.
Three constraints worth knowing by name:
| Constraint | Type | What it restricts |
|---|---|---|
constraints/iam.allowedPolicyMemberDomains |
List | IAM bindings can only reference principals from specified Cloud Identity/Workspace customer IDs — blocks adding @gmail.com or another org's domain as an IAM member anywhere underneath |
constraints/compute.vmExternalIpAccess |
List | Which Compute Engine VMs (by resource name) are allowed an external IP — allValues: DENY blocks all of them org-wide |
constraints/sql.restrictPublicIp |
Boolean | Disables assigning a public IP to any new Cloud SQL instance created underneath this node |
# domain-restriction.yaml — only allow IAM members from this Workspace customer ID
constraint: constraints/iam.allowedPolicyMemberDomains
listPolicy:
allowedValues:
- "C0xxxxxxx"
# block-external-ips.yaml — no VM anywhere under this node may have a public IP
constraint: constraints/compute.vmExternalIpAccess
listPolicy:
allValues: DENY
gcloud resource-manager org-policies set-policy domain-restriction.yaml --organization=123456789
gcloud resource-manager org-policies set-policy block-external-ips.yaml --folder=456789012
# restrictPublicIp is boolean, not list — enforce it directly
gcloud resource-manager org-policies enable-enforce constraints/sql.restrictPublicIp --project=my-project
# see the resolved policy after inheritance, not just what's set locally
gcloud resource-manager org-policies describe constraints/compute.vmExternalIpAccess \
--project=my-project --effective
Inheritance runs Org → Folder → Project, and it only gets stricter going down — not looser. Changing an org policy requires the orgpolicy.policyAdmin role, which is deliberately not bundled into Owner or Editor. That means a project's own Owner or Editor — no matter how broad their project-level IAM is — has no path to relax a constraint set above them; they simply lack the permission to touch org policy at all. The one sanctioned way for someone who does hold orgpolicy.policyAdmin at a lower node to carve out a narrow exception, without blanket-replacing the parent's policy for every resource underneath, is a conditional rule scoped by resource tag — a rule whose condition checks something like resource.matchTag('123456789/env', 'sandbox'), so the exception applies only to resources carrying that exact tag, and every other resource in the same project stays fully covered by the inherited restriction.
constraints/compute.vmExternalIpAccess is set to allValues: DENY at the Organization node — no VM anywhere in the org may have a public IP, by default.
eng-sandbox reachable from the public internet. The project's Owner cannot grant this themselves — they don't hold orgpolicy.policyAdmin anywhere in the hierarchy.
orgpolicy.policyAdmin — typically a platform/security team — adds a rule at the project scoped by condition: resource.matchTag(...), allowing external IPs only for VMs carrying a specific demo-external-ip tag.
eng-sandbox, and every other project in the org, is still fully denied. The exception is narrow, explicit, auditable, and never required loosening the constraint anywhere but exactly where it was needed.
graph TD
classDef gcp fill:#4285f4,stroke:#2a56c6,color:#fff
classDef red fill:#e74c3c,stroke:#c0392b,color:#fff
classDef orange fill:#e67e22,stroke:#d35400,color:#fff
classDef green fill:#2ecc71,stroke:#27ae60,color:#fff
ORGN["Organization<br/>compute.vmExternalIpAccess = DENY all"]:::red
FOLDERN["Folder: Engineering<br/>(no policy of its own — inherits)"]:::orange
PROJN["Project: eng-sandbox<br/>tag-scoped exception: demo-external-ip=true → ALLOW"]:::green
VM1["VM: demo-vm (tagged)<br/>public IP allowed"]:::green
VM2["VM: everything-else<br/>public IP still denied"]:::red
ORGN -->|"inherits down"| FOLDERN
FOLDERN -->|"inherits down, unchanged"| PROJN
PROJN -->|"matches tag condition"| VM1
PROJN -->|"no tag match — falls back to inherited DENY"| VM2
A project's Owner wants to allow public IPs on VMs in their project, but the org has set compute.vmExternalIpAccess to DENY at the Organization node. Can the project Owner just set their own policy at the project level to override it?
orgpolicy.policyAdmin role, which isn't part of the Owner or Editor role bundles — so broad project-level IAM doesn't grant the ability to touch org policy at all. Someone who does hold orgpolicy.policyAdmin can carve out a narrow, tag-conditioned exception at that project, but there's no way for ordinary project permissions to relax a constraint set above them.4. VPC Service Controls
VPC-SC operates at a different layer entirely from firewall rules and IAM. Firewall rules control network reachability; IAM controls who can call which API method. VPC-SC controls where API responses are allowed to flow — it's a defense against a valid, permitted identity being used to move data somewhere it shouldn't go, whether that's a compromised service account key, a careless bq cp to the wrong project, or a malicious insider with legitimate access.
Core pieces:
- Service perimeter — a boundary drawn around a set of projects (or a whole org) that restricts calls to VPC-SC-supported APIs (Cloud Storage, BigQuery, Bigtable, Pub/Sub, and more) crossing that boundary — inbound from an untrusted network, or outbound to a project outside the perimeter — even when the caller's IAM permissions would otherwise allow the call.
- Access levels — trust rules (based on source IP range, device attributes, or identity) that let specific callers cross the boundary from outside — e.g. the corporate office network, or a specific CI service account.
- Bridge perimeters — a connector between two separate regular perimeters, letting resources inside both talk to each other without merging them into one giant perimeter or exposing either one to the public internet. A bridge has no restricted-services list of its own; it exists purely to open a controlled two-way path between two perimeters that would otherwise be fully isolated from each other.
- Dry-run mode — apply a perimeter configuration in audit-only mode: every call that would be blocked gets logged, but nothing is actually denied. This is how a perimeter gets validated against real traffic before it goes live, to find legitimate flows that would otherwise break silently.
graph TD
classDef trusted fill:#2ecc71,stroke:#27ae60,color:#fff
classDef perimeter fill:#4285f4,stroke:#2a56c6,color:#fff
classDef attacker fill:#e74c3c,stroke:#c0392b,color:#fff
classDef outside fill:#7f8c8d,stroke:#616a6b,color:#fff
subgraph PERIM["Service Perimeter: finance-data"]
BQ["BigQuery dataset:<br/>customer_transactions"]:::perimeter
SA["ETL service account<br/>has valid roles/bigquery.dataViewer IAM"]:::trusted
SA -->|"read query — inside the perimeter"| BQ
end
subgraph OUTSIDE["Outside the perimeter"]
PERSONAL["Attacker's personal GCP project<br/>(not in finance-data's perimeter)"]:::outside
end
ATTACKER["Attacker, using SA's stolen key<br/>same valid IAM permissions"]:::attacker
ATTACKER -->|"copy job: BQ dataset → personal project"| BQ
BQ -.->|"BLOCKED by VPC-SC —<br/>destination is outside the perimeter,<br/>IAM permission is irrelevant here"| PERSONAL
A service account has valid, correctly-scoped roles/bigquery.dataViewer IAM permission on a dataset. Its key is stolen and used to copy that dataset into an attacker's personal GCP project outside the organization. Does IAM alone stop this?
5. Cloud KMS / CMEK
Cloud KMS uses the same envelope encryption pattern this repo's kubernetes/storage.md describes for encrypting etcd Secrets: a small Data Encryption Key (DEK) does the actual bulk encryption of your data, and a Key Encryption Key (KEK) — held entirely inside KMS — only ever encrypts and decrypts that DEK, never the data directly. The KEK never leaves KMS's boundary; if the encrypted data and its wrapped DEK are both stolen, the attacker still needs KMS access to unwrap the DEK before any of it becomes readable.
Three ways a GCP resource (GCS bucket, BigQuery table, Persistent Disk, Secret Manager secret) can get its KEK:
gcloud kms keyrings create my-keyring --location=us-central1
gcloud kms keys create my-key \
--keyring=my-keyring --location=us-central1 \
--purpose=encryption --rotation-period=90d --next-rotation-time=2026-12-01T00:00:00Z
# Point a GCS bucket at the CMEK key
gcloud storage buckets update gs://my-bucket --default-encryption-key=projects/my-project/locations/us-central1/keyRings/my-keyring/cryptoKeys/my-key
Rotation creates a new primary CryptoKeyVersion used for all future encrypt calls, but older versions stay around — data encrypted under a prior version still needs that specific version to decrypt, so rotation on its own doesn't retire anything. Destroying a CryptoKeyVersion is the only way to cryptographically retire it, and that's irreversible: unlike revoking an IAM binding (which can always be re-granted), once a CryptoKeyVersion is destroyed, no one — including Google — can ever unwrap the DEKs it protected again.
You destroy the CMEK CryptoKeyVersion protecting a set of GCS objects. Is that the same kind of "access revoked" as removing an IAM binding — reversible if you change your mind?
6. Secret Manager
Secret Manager and Cloud KMS get confused constantly because both live under "encryption/keys" in most people's mental model — but they do genuinely different jobs.
Secret Manager stores and versions the actual secret value — a database password, an API token — as a named resource. Each write creates a new immutable version (projects/P/secrets/S/versions/3, or .../versions/latest), and IAM binds per-secret, not just per-project: roles/secretmanager.secretAccessor on one specific secret lets a service account read exactly that credential, not everything else in the project. Rotation is schedule-driven: Secret Manager fires a Pub/Sub notification on the configured period, and something downstream (a Cloud Function, a Cloud Run job) is responsible for actually generating the new credential and writing it as a new version — Secret Manager doesn't rotate the underlying credential itself, only the trigger.
gcloud secrets create db-password --replication-policy=automatic
gcloud secrets versions add db-password --data-file=password.txt
gcloud secrets add-iam-policy-binding db-password \
--member="serviceAccount:my-app@my-project.iam.gserviceaccount.com" \
--role="roles/secretmanager.secretAccessor"
gcloud secrets versions access latest --secret=db-password
Cloud KMS never does this. It doesn't store or return your secret's plaintext to you at all — it only ever performs encrypt/decrypt/sign operations on data you send it, and the key material itself never leaves KMS. The two do compose: Secret Manager can optionally use a CMEK from Cloud KMS to encrypt its own storage at rest with a key you control, instead of Google's default — but that's a layer underneath Secret Manager, not a substitute for it.
Your app needs the actual plaintext database password at startup. Does it call Secret Manager or Cloud KMS to get it?
7. Binary Authorization
Binary Authorization is an admission-time gate that sits at the end of the build→deploy pipeline described in cicd.md — it doesn't replace the vulnerability-scan-then-release flow that file already covers, it's the backstop that enforces it can't be bypassed.
The mechanism: an Attestor (backed by a Cloud KMS or PGP key) produces a cryptographically signed attestation — a statement that a specific image digest (never a mutable tag like :latest) passed a specific check, such as a vulnerability scan or QA sign-off. A Binary Authorization policy attached to a GKE cluster or Cloud Run service requires one or more named attestations to exist for an image's digest before the admission controller will let it run.
...@sha256:abc123... — not just a tag.
cicd.md's pipeline. On success, CI calls the Attestor to create a signed attestation for that exact digest — not for the tag, which could later be repointed at a different image.
gcloud container binauthz attestors create ci-scan-attestor \
--attestation-authority-note=ci-scan-note \
--attestation-authority-note-project=my-project
gcloud container binauthz policy import policy.yaml
# policy.yaml — GKE cluster requires this attestor's signature to admit any image
defaultAdmissionRule:
evaluationMode: REQUIRE_ATTESTATION
enforcementMode: ENFORCED_BLOCK_AND_AUDIT_LOG
requireAttestationsBy:
- projects/my-project/attestors/ci-scan-attestor
A developer with direct GKE cluster access pushes an unscanned image straight to Artifact Registry and runs kubectl apply against it, skipping the CI pipeline entirely. Does Binary Authorization still stop it?
8. Security Command Center
The rule of thumb: reach for Standard as the always-on baseline that catches configuration drift before it becomes a finding in an audit; reach for Premium the moment "did anyone actually exploit this" becomes a question that matters — a regulated workload, a security team that owns incident response, or any environment where posture scanning alone isn't enough to sleep at night.
Security Command Center Standard flags a publicly readable GCS bucket. A week later, an external actor is actively scraping that bucket. Does Standard tier catch the active scraping as it happens?
Summary
| Mechanism | Layer it operates at | Defends against |
|---|---|---|
| IAM Deny policies | Identity + permission, hierarchy-wide | Any Allow grant, anywhere, being sufficient on its own |
| IAM Conditions | A single role binding | That one grant applying outside a time/resource scope |
| Org Policy constraints | Resource configuration | A disallowed configuration existing at all, regardless of who's asking |
| VPC Service Controls | API-level data flow | A valid credential exfiltrating data to an unauthorized destination |
| Cloud KMS / CMEK | Encryption key lifecycle | Stolen ciphertext being decryptable without KMS access |
| Secret Manager | Secret value storage/versioning | Credentials embedded in code or config, unscoped IAM to a shared password |
| Binary Authorization | Deploy-time admission | An unattested/unscanned image reaching GKE or Cloud Run at all |
| Security Command Center | Detection (posture + threats) | Misconfiguration going unnoticed, or active exploitation going unnoticed |
None of these substitute for the others — they stack. A resource can be correctly IAM-scoped, sit inside a VPC-SC perimeter, be CMEK-encrypted, and still need an org-level Deny policy to guarantee that no future Allow binding — however broad, however deep in the hierarchy — can quietly undo the rest.