Cloud Infrastructure · Identity and Access
Attribute Conditions: Constraining Access by Tag Instead of by Name
How principal tags and resource tags become condition keys, why the two-sided check needs both halves, and what happens when a tag is missing or renamed.
A policy that enumerates resource names is correct on the day it is written and wrong on the day a resource is added. The alternative is to name the property that makes a resource eligible rather than the resource itself, and let the request decide. That property is usually a tag, and the machinery that turns a tag into a decision has more moving parts than the syntax suggests.
Two sides, one comparison
Tag-based conditions compare a value attached to the principal against a value attached to the resource. The principal side is supplied by the session: tags attached to the role that the session assumed are visible as aws:PrincipalTag/<key>. The resource side is supplied by the service handling the request: tags attached to the resource are visible as aws:ResourceTag/<key> for services that support them, and as aws:RequestTag/<key> for tags being written in the request itself.
The standard pattern is a condition with two clauses: the resource’s tag must equal a value, and the principal’s tag must equal the same value.
{
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject"],
"Resource": "arn:aws:s3:::shared-data/*",
"Condition": {
"StringEquals": {
"aws:ResourceTag/project": "${aws:PrincipalTag/project}"
}
}
}
The condition compares two request-context values rather than a context value against a literal. Whether that is permitted depends on the service and on the operator: the policy variable substitution happens first, so the clause reduces to a comparison between the resource’s tag value and the substituted principal tag value. If the principal has no project tag, the variable resolves to nothing, the comparison has no left-hand value, and the clause does not match.
That last point is the first failure mode worth internalizing: a missing principal tag denies, and it denies silently. There is no error that says the role lacks a tag. The request simply fails as unauthorized, and the identity’s policies look correct. Multi-tenant systems built on this pattern fail onboarding whenever a new role is created without the tag, and the failure is a support ticket rather than a policy bug.
What the comparison cannot see
Not every resource carries tags in the request context. The condition keys are populated by the service, and services differ in whether they populate them at all, at what point in the request they are available, and which tag operations they support. A service that does not populate aws:ResourceTag will never match a condition on it, so a policy written against that key denies everything for that service. This is a per-service property, not a per-policy property, and it is documented per service rather than centrally.
The second invisibility is temporal. A condition on a resource tag evaluates the tag as it exists when the request is handled. A resource that is untagged at the moment of the request does not match, even if it is tagged a second later by a lifecycle rule. Automated tagging that runs on a schedule therefore produces windows during which newly created resources are inaccessible to the very principals that are supposed to manage them.
The third is that tag keys and values are strings, and comparison is by the operator chosen. StringEquals is exact and case-sensitive. StringLike performs wildcard matching with the same star semantics as resource ARNs, which means a value of prod-* matches prod-eu and prod-us, and a value of * matches every non-empty value including ones the author did not intend. For an access-control tag, exact equality is almost always the correct operator; StringLike belongs in the tag-policy enforcement document, not in the grant.
Tag policies enforce the vocabulary, not the access
An organization can publish a tag policy that defines the permitted keys and, for each key, the permitted values or a case treatment. The policy does not grant access and does not deny access. It standardizes the vocabulary so that a grant written against project is not silently unmatched because half the resources are tagged Project.
This separation is the reason tag-based access works at all at scale. The grant document and the vocabulary document are maintained by different people with different change cadences. The grant says “same project”. The vocabulary says what a project value may look like. Without the second, the first degrades: a resource tagged proj-42 and a role tagged project-42 are different strings, and the comparison that was supposed to be structural becomes accidental.
Tag policies can also require a key to be present. A policy that requires every resource of a type to carry an owner key does not itself deny anything, but it makes the absence of the key an auditable finding rather than an invisible one. Combined with a deny statement that rejects untagged resources, the two documents produce a closed system: resources must be tagged to be created, and access is decided by the tags.
The deny that closes the loop
A grant conditioned on tags is only half a control. The other half is a deny that rejects requests where the relevant tag is absent, because a missing tag on the principal and a missing tag on the resource both evaluate as non-match, and a non-match in a grant means the request falls through to the default deny. That is safe for the grant but it is not safe for operations that create resources: a caller with s3:CreateBucket and no tag condition can create an untagged bucket, and the untagged bucket is then invisible to the tag-based grants.
The usual structure pairs the grant with a deny on creation actions where the required tag key is absent from the request:
{
"Effect": "Deny",
"Action": "s3:CreateBucket",
"Resource": "*",
"Condition": {
"Null": { "aws:RequestTag/project": "true" }
}
}
Null with the value "true" matches when the key is absent from the request context. This is the one operator whose semantics invert the usual reading: the string "true" in the value position means “the key is not present”. Writing "false" means “the key is present”, which is rarely what an author wants.
The deny uses aws:RequestTag rather than aws:ResourceTag because at creation time the resource does not yet exist and has no tags. The two keys are populated at different points in the request lifecycle, and using the wrong one produces a deny that either never fires or fires on every request.
Renaming, retagging, and the audit trail
Tags are mutable. A principal with tag:TagResources can change a resource’s tags, and a principal with iam:TagRole can change a role’s tags. If the access decision depends on a tag, then the permission to edit that tag is a permission to change access. That is a genuine escalation path and it is easy to overlook, because the tag-editing permission looks like metadata administration.
The mitigation is to restrict tag modification on the specific keys that drive access, and to treat those keys as security-relevant configuration rather than as inventory metadata. A deny on tag:TagResources conditioned on the access-control keys, for all principals except a small number of automation roles, converts the escalation into an auditable event.
Auditing tag-driven access is harder than auditing name-driven access, because the policy document no longer names the resources it protects. A review of the document says “same project tag”; it does not say which resources those are, and the answer changes as tags change. The compensating control is to record tag changes in the same trail as policy changes, and to review the two together. A tag change on a resource holding regulated data is a policy change in effect, and treating it as inventory bookkeeping is how the trail goes cold.
The other audit angle is drift between the principal’s tags and the resource’s tags. A role tagged project: atlas can reach resources tagged project: atlas. When the project is renamed, some resources are retagged and some are not, and the ones that are not become unreachable to the team that owns them while remaining reachable to anyone still carrying the old tag. The asymmetry is what makes this expensive: access that should have been removed persists, and access that should persist is removed.
Where the pattern fits and where it does not
Tag-based conditions are the right instrument when the set of resources is large, changes frequently, and is created by the same teams that consume it. They are the wrong instrument when the set is small and stable, because a short enumeration is easier to review and has no runtime dependency on tag correctness. They are also the wrong instrument when the service does not populate the condition keys, which rules them out for parts of the surface regardless of intent.
The middle case, where a resource set is large but access must be tightly bounded, is usually handled by combining the tag condition with a naming constraint: the resource ARN limits the namespace, and the tag condition decides within it. That layering is the same shape as the wildcard discipline described in resource ARN wildcard matching, applied to a second dimension.
The evaluation order that decides how these conditions interact with denies and ceilings is in the request evaluation order. The property that matters there is unchanged: a tag condition that does not match contributes nothing, and the default deny is what the request receives.
References
- Controlling access to AWS resources using tags — AWS Identity and Access Management User Guide
- IAM JSON policy elements: Condition operators — AWS Identity and Access Management User Guide
- Guide to Attribute Based Access Control (ABAC) Definition and Considerations — NIST Special Publication 800-162