Business Challenge
#35 ended on the observation that the deny policy permissions are among the handful you cannot put in a custom role. This post is about what that restriction protects.
Then the rule does not do what you think. Deny permissions use the IAM v2 permission format, which uses fully qualified domain names — iam.googleapis.com/roles.delete, not iam.roles.delete. One product, two permission syntaxes, depending on which policy you are writing.
Translate every permission into SERVICE_FQDN/RESOURCE.ACTION when moving from an allow policy to a deny rule, and never copy a permission name between the two unchanged.
The opposite. If the condition evaluates to true or cannot be evaluated, the deny rule applies. An unevaluable condition is not an inert rule — it is an active denial.
Correct approachDesign conditions so the unevaluable case is the one you want. Deny fails closed, which is correct for a guardrail and dangerous if you assumed otherwise.
You cannot. Denial conditions have the same structure as IAM Conditions, but denial conditions only recognize resource tag functions. The rich attribute set from #37 is not available here.
Correct approachExpress the carve-out as a tag on the resources, and condition on that. If it cannot be described by a tag, it cannot be a denial condition.
There is no such attachment. Each deny policy is attached to an organization, folder, or project — the resource-level attachment that allow policies support does not exist for deny.
Correct approachAttach at the lowest of the three that contains what you mean, and use a tag-based denial condition to narrow further within it.
Architecture
A deny policy is metadata plus deny rules, and a deny rule is five fields. The fields are simple; the asymmetries with allow policies are the substance.
The five fields of a deny rule
| Field | What it does |
|---|---|
deniedPrincipals |
Who loses the permission. Individuals or sets, up to and including all users on the internet. |
exceptionPrincipals |
Who is exempt. These principals are not denied the specified permissions even if they are listed in deniedPrincipals — or are in a group that is. |
deniedPermissions |
What is denied, in v2 FQDN format. Only some permissions can be denied. |
exceptionPermissions |
Permissions carved back out, typically to punch a hole in a permission group. |
denialCondition |
When the rule applies. Tag functions only, and it fails closed. |
The two exception* fields are what make deny usable in practice. The idiomatic guardrail is not a narrow denial of a few people — it is a total denial with a named exemption: deny principalSet://goog/public:all, then list the one group that should still be able to act. That reads as "only this team may do this", expressed as a deny.
Evaluation order
One sentence governs everything: IAM always checks relevant deny policies before checking relevant allow policies. There is no combination of roles that overcomes a matching deny rule, which is precisely what #33 could not offer. It also means a deny policy cannot be debugged by reading allow policies, because the allow side is never consulted once a deny matches.
Deny policies inherit like allow policies do — attach one and the policy is also effective for all resources inside that project, folder, or organization. And each deny policy is evaluated independently of all other deny policies, so a resource may carry up to 500 deny policies, and together these deny policies can contain a total of 500 deny rules.
Every other optional field in IAM narrows a rule when it is absent or unsatisfied. The denial condition does the reverse: if it evaluates to true or cannot be evaluated, the rule applies, and only an explicit false spares the principal. That is the right default for a guardrail — a control that switches itself off when confused is not a control. But combine an unevaluable condition with public:all in deniedPrincipals and the result is everyone denied that permission across the attachment point and everything under it, from a rule that was supposed to apply narrowly. Test denial conditions against resources that lack the tag, not only against ones that carry it.
Why This Architecture Holds Up
It would be reasonable to expect deny to be allow with the sign flipped. It is not, in four specific ways, and each one is a decision rather than an oversight.
| Allow policy | Deny policy | |
|---|---|---|
| Permission format | iam.roles.delete |
iam.googleapis.com/roles.delete — v2, FQDN. |
| Wildcards | Never, in any role. | Yes, in supported permission groups. |
| Attachment | Organization, folder, project, or resource. | Organization, folder, project only. |
| Conditions | The full IAM Conditions attribute set. | Resource tag functions only, failing closed. |
The wildcard asymmetry is the interesting one
#35 established that a custom role cannot use wildcards, so every permission is listed explicitly and the list ages: when a service gains a permission, the custom role does not. Deny policies invert this exactly. Permission groups include all current and future permissions that match the specified pattern — deny example.googleapis.com/exampleResource.* today and tomorrow's new permission on that resource type is denied automatically, with no edit.
Put those two facts together and the shape of the design appears. Grants do not grow on their own; prohibitions do. A permission added to a service next quarter arrives ungranted by your custom roles and already covered by your deny rules. Whether that was the intent or a happy consequence of two teams making locally sensible choices, it is the right asymmetry, and it is the strongest argument for expressing a guardrail as a deny policy rather than as the absence of a grant.
The restriction on that power is narrow and explicit: you can only use wildcards in supported permission groups, and using wildcards in other permission names is not supported. So the future-proofing applies where Google has defined a group, not wherever you would like it.
What to do with this
- Write guardrails as deny-all-with-exceptions.
public:allindeniedPrincipals, the responsible group inexceptionPrincipals. It survives new people, new roles and new projects. - Prefer permission groups to permission lists where a group exists, because the group covers permissions that do not exist yet.
- Test every denial condition against an untagged resource. That is the case that fails closed, and it is the one nobody tries.
- Do not reach for deny to fix a bad grant. A deny rule masking an over-broad role leaves the role in place for #34 to trip over later; fix the grant and use deny for policy you want to hold regardless.
- Budget the 500. Rules are the scarcer resource — 500 across all policies on a resource, however you distribute them.
- Remember it is eventually consistent, exactly as in #33. A new deny is not instantaneous protection.
Key Architecture Decisions
| Decision | Choose this | Because |
|---|---|---|
| Making a restriction unconditional | A deny policy | IAM always checks deny policies before allow policies. |
| Writing the permission | SERVICE_FQDN/RESOURCE.ACTION |
Deny uses the IAM v2 permission format with fully qualified domain names. |
| Covering future permissions | A permission group | Groups include all current and future permissions matching the pattern. |
| Wildcards outside a group | Not available | You can only use wildcards in supported permission groups. |
| Restricting an admin action to one team | Deny all, except that group | Exception principals are not denied even when listed in deniedPrincipals. |
| Narrowing below project level | A tag-based denial condition | Deny policies attach only to an organization, folder, or project. |
| Conditioning on request attributes | Not possible in deny | Denial conditions only recognize resource tag functions. |
| Handling an unevaluable condition | Assume it denies | If it cannot be evaluated, the deny rule applies. |
| Organising many rules | Several policies, one concern each | Each deny policy is evaluated independently of all others. |
| Assuming a permission is deniable | Check first | Only some permissions can be denied. |
Closing Thought
Three posts ago the union rule looked like a limitation: access accumulates, nothing subtracts, and a grant made at the organization cannot be taken back lower down. Deny policies resolve that, and the way they resolve it is more interesting than the fact that they do. They are not a negative binding inside the allow policy, which would have been the obvious design. They are a separate policy type, evaluated first, with a different permission syntax, a different attachment model and a deliberately impoverished condition language.
That separation is the feature. An allow policy is a statement about what someone may do, and it is edited constantly by many people across the hierarchy. A deny policy is a statement about what nobody may do, it lives at three levels only, it fails closed, and — per #35 — it cannot be administered through a custom role you built yourself. Every one of those choices makes it harder to change casually. For a mechanism whose whole purpose is to hold when everything else has been granted away, harder to change is the point.
#37 opens up the condition language that deny policies deliberately do without: IAM Conditions — attribute-based access control, what you can actually test on a request, and why a conditional binding is still only ever an addition.
Comments