IAM, conditions & deny policies
Additive allows, conditions, and enforceable denies.
Someone on the platform team asks for roles/editor on a folder so a launch can ship on Friday. It ships. The grant stays. Six months later that person can still touch every project under that folder, including the one holding your audit logs. Most cloud access trouble looks like this. Nobody picked a lock. Old yeses piled up, and no single screen in the console shows you the total. This lesson walks through the three things Google Cloud gives you to pull that access back in: smaller role grants, conditions that fence a grant in, and deny policies that say no in a way no yes can beat.
Start with the mental model. IAM (Identity and Access Management) is the service that decides who is allowed to do what. A single grant behaves like a keycard you hand someone: the door reads the card, the door opens. In Google Cloud (often shortened to GCP, for Google Cloud Platform) that card is called a binding, and it ties three things together. The principal is who holds the card, meaning a person, a Google Group, or a service account (a robot login that software uses instead of a human). The role is a bundle of permissions, say read objects in Cloud Storage. The resource is the door the card opens: a project, a bucket, a whole folder. One habit pays for itself forever. Grant roles to groups, never to individuals. Then joiners and leavers are handled by whoever runs your identity provider, and you never open IAM for it again.
Leave the basic roles alone. Owner, Editor and Viewer date from a smaller, simpler Google Cloud, and Editor on its own can change almost everything in a project. Reach for a predefined role instead, like roles/storage.objectViewer. Google maintains those and keeps them current as APIs (the interfaces software calls to get work done) grow new permissions. Write a custom role only when nothing predefined fits, and go in knowing you now own it forever, every time the API changes underneath you.
Read the policy before you change it
Before you grant anything, find out what is already granted. IAM allow policies are additive: access accumulates, and nothing adds it up for you. get-iam-policy prints the current bindings on a resource so you can see who already holds a card. Look at the etag line in the output. That is a version stamp on the policy document. If two people edit the same policy at once, the second save gets rejected instead of quietly wiping out the first.
gcloud projects get-iam-policy payments-prod --format=yamlbindings:- members:- group:[email protected]role: roles/editor- members:- group:[email protected]role: roles/owner- members:- group:[email protected]role: roles/storage.objectVieweretag: BwYf3kQvD2c=version: 1
There it is in black and white. The analytics group can read every object in the project when all it needs is one reports bucket, and platform-admins hold Editor. Neither grant is scoped to anything. Tighten the analytics one first with a condition. Then build a guardrail that stops even an Owner from deleting the audit logs.
A keycard that only works nine to five
Office keycards are often time-boxed. Yours opens the side door on weekdays between nine and five and is a dead lump of plastic at midnight. IAM conditions do that to a grant. A condition is a small yes/no test written in CEL (Common Expression Language, a tiny expression language Google uses for exactly this kind of check). Attach it to a binding and the grant counts only while the test passes. Two attributes carry most of the weight: resource.name, so the grant applies only to resources whose name matches a pattern, and request.time, so it applies only inside a window. The command below gives analytics read access on buckets whose name starts with reports-, and only during business hours.
gcloud projects add-iam-policy-binding payments-prod \--member="group:[email protected]" \--role="roles/storage.objectViewer" \--condition='title=reports-hours,description=reports bucket during business hours,expression=resource.name.startsWith("projects/_/buckets/reports-") && request.time.getHours("UTC") >= 9 && request.time.getHours("UTC") < 17'Updated IAM policy for project [payments-prod].bindings:- condition:description: reports bucket during business hoursexpression: resource.name.startsWith("projects/_/buckets/reports-") && request.time.getHours("UTC")>= 9 && request.time.getHours("UTC") < 17title: reports-hoursmembers:- group:[email protected]role: roles/storage.objectVieweretag: BwYf3kZ1p9A=version: 3
Two things to notice. The policy moved to version 3, because conditions exist only in version 3 policies and gcloud bumped it for you. Second, the one that catches people over and over: a condition restricts the binding it is attached to and nothing else. If analytics also picks up an unconditional role somewhere higher up the tree, that looser grant still stands, and your business-hours fence buys you nothing. Which makes how grants combine the thing to understand next.
Yeses add up, which is the whole problem
Google Cloud resources sit in a tree. The organization at the top, then folders, then projects, then the buckets and machines inside them. A grant made high up flows downhill to everything beneath it. Your real access at any moment is the union of every binding from the org down to the thing you are touching. Allow policies have no way to say stop here. They only add. One forgotten Editor on a folder therefore reaches every project below it, and no amount of careful, tightly scoped work at the project level takes that back. The blast radius comes from the level you forgot to look at, not the one on your screen.
Deny policies are the missing no. Every building has a sign bolted to the fire exit: this door is never propped open, not for the CEO, not for the cleaners, not for anyone. The number of keycards in your pocket makes no difference. Google Cloud checks deny policies first, before it reads a single allow. If a deny matches your identity and the action you asked for, the request stops right there. No grant outranks it.
Writing a rule that beats every grant
A deny policy is a separate document you attach to a resource. Inside it, a deny rule names three things. deniedPrincipals is who the rule hits, and principalSet://goog/public:all means everybody. exceptionPrincipals is who still gets through, which is where your break-glass group belongs: the small, high-trust group you touch only in a real emergency. deniedPermissions lists the actions to block, written as the service plus the permission, like logging.googleapis.com/buckets.delete. Add a denialCondition and the rule bites only on the resources you name. The policy below stops everyone except break-glass from deleting the audit log bucket or the log sinks that feed it (sinks are the rules that route logs into storage), whatever roles they happen to hold.
rules:- denyRule:deniedPrincipals:- principalSet://goog/public:allexceptionPrincipals:- principalSet://goog/group/[email protected]deniedPermissions:- logging.googleapis.com/buckets.delete- logging.googleapis.com/sinks.deletedenialCondition:title: only-the-audit-log-bucketexpression: resource.name.endsWith("locations/global/buckets/audit-logs")displayName: Protect the audit log pipeline from deletion
gcloud iam policies create deny-audit-log-deletion \--attachment-point="cloudresourcemanager.googleapis.com/projects/payments-prod" \--kind=denypolicies \--policy-file=deny-audit-logs.yamlWaiting for operation [cloudresourcemanager.googleapis.com/projects/payments-prod/denypolicies/deny-audit-log-deletion@operations/a1b9...c3] to complete...done.Created policy [deny-audit-log-deletion].
gcloud logging buckets delete audit-logs \--location=global --project=payments-prodERROR: (gcloud.logging.buckets.delete) PERMISSION_DENIED: Permission 'logging.buckets.delete' denied on resource 'projects/payments-prod/locations/global/buckets/audit-logs'. Access was denied by IAM deny policy 'deny-audit-log-deletion'.
The account running that delete holds roles/owner, the strongest allow the project has, and it still bounces. That is what deny is for. It changes the answer in a way no allow can argue with. Deny policies attach at an organization, a folder or a project, and they flow downhill the same way allows do, so one deny at the org protects every project you will ever create under it. Running them costs nothing. The price is discipline: keep the break-glass exception tiny, require a second login factor beyond the password (multi-factor authentication) for everyone in it, and raise an alert every single time it is used.
None of this survives without a cleanup habit. Put a date on every grant that exists to unblock something, and treat a grant nobody can explain as an expired one. A quarterly pass over the org and folder levels turns up more real risk than a month of tightening project bindings, because the wide grants live up there and nobody looks. Two questions per binding are enough. Who asked for this, and what breaks if it goes away? If the answer to either one is a shrug, remove it and see who shouts.
Conditions do not replace least privilege. They shrink a grant you cannot avoid making, down to weekdays only, or resources tagged env=dev, or calls coming from one VPC (virtual private cloud, your own private network inside Google Cloud). Deny policies sit above all of it. Deny service account key creation at the folder, the power that roles/iam.serviceAccountKeyAdmin carries, and no allow binding anywhere beneath that folder can mint a key, Owners included. That is a guardrail you can point at in an audit and move on.
Try this
Take one production project. Dump its IAM policy, then ask Policy Analyzer who is able to change that policy. What you want back is a short, boring list with no Owner you did not expect.
gcloud projects get-iam-policy payments-prod --format=json > policy.jsongcloud policy-intelligence query-activity \--activity-type=permission-usage \--project=payments-prod \--permission=resourcemanager.projects.setIamPolicy 2>/dev/null || truegcloud asset search-all-iam-policies \--scope=projects/payments-prod \--query="policy:roles/owner" \--format="table(resource,policy.bindings.members)"
bindings:- members:- group:[email protected]role: roles/owner- members:- serviceAccount:[email protected]role: roles/editoretag: BwYh3n2K9pE=version: 1# asset search (trimmed)RESOURCE MEMBERS//cloudresourcemanager.googleapis.com/projects/802451296328group:[email protected]
Takeaway
Every grant is a keycard, and keycards stack. Hand them to groups, fence the ones you cannot avoid with a condition on time or resource name, and write a deny policy when a yes must never be allowed to win.
The next lesson moves from the people holding cards to the machines that hold them: service accounts, and the JSON key files that leak them.
Every card in this lesson was held by a person or a group. The harder half of Google Cloud identity is the service account, the robot login your software runs as, along with impersonation, where one identity is allowed to become another for a while and borrow its permissions. Conditions and deny policies are what keep that borrowing from turning into a quiet path to everything. That is where you go next.