Conditions: building and checking them¶
A Condition narrows a statement to the requests where some fact holds: the caller came over TLS, the source is this
topic, the object has this tag, the time is before the end of the month. IAM writes it as three nested levels, an
operator, a key and the values, and a mistake in any of them is usually not an error. It is a statement that
quietly never matches (an Allow that grants nothing), or a Deny that never applies. This chapter is about building
conditions so that cannot happen by accident. The builders live in cfn_typed.policy; the
policy chapter covers the statements they go into.
One condition¶
condition(operator, key, values) builds one, and every operator has a shorthand that fixes the operator and the type of
the values:
from cfn_typed import allow
from cfn_typed.policy import string_like
allow("s3:ListBucket", bucket_arn, condition=string_like("s3:prefix", "home/${aws:username}/*"))
{"Effect": "Allow", "Action": "s3:ListBucket", "Resource": "arn:aws:s3:::b",
"Condition": {"StringLike": {"s3:prefix": "home/${aws:username}/*"}}}
| Kind | Builders | Values |
|---|---|---|
| String | string_equals, string_not_equals, string_like, string_not_like, string_equals_ignore_case, string_not_equals_ignore_case |
strings |
| ARN | arn_equals, arn_not_equals, arn_like, arn_not_like |
ARNs, or intrinsics that give one |
| Number | numeric_equals, numeric_not_equals, numeric_less_than, numeric_less_than_equals, numeric_greater_than, numeric_greater_than_equals |
int |
| Date | date_equals, date_not_equals, date_less_than, date_less_than_equals, date_greater_than, date_greater_than_equals |
a timezone-aware datetime, or a string such as an epoch |
| Boolean | bool_ |
bool |
| Network | ip_address, not_ip_address |
CIDR strings |
| Binary | binary_equals |
base64 strings |
| Presence | null |
whether the key is absent |
What the builders do for you:
- The operator is a
Literal. A misspelled operator incondition(...)is an error in the type checker, before anything runs. - Numbers, booleans and dates become the strings IAM compares:
bool_("aws:SecureTransport", False)is"false",numeric_less_than("s3:signatureAge", 600000)is"600000". A float is aTypeError. - Dates are written in UTC.
datetime(2026, 1, 1, 12, tzinfo=timezone(timedelta(hours=2)))becomes"2026-01-01T10:00:00Z". Adatetimewith no timezone is aValueError: guessing one in a policy is worse than failing. - One value is a scalar and several are a list, as in the IAM documentation. Values may be intrinsics (
ref(...),sub(...),get_att(...)).
from cfn_typed.policy import arn_equals, date_greater_than, ip_address, null
arn_equals("aws:SourceArn", ref("Topic")) # {"ArnEquals": {"aws:SourceArn": {"Ref": "Topic"}}}
ip_address("aws:SourceIp", ["10.0.0.0/8", "192.0.2.0/24"])
date_greater_than("aws:CurrentTime", datetime(2026, 1, 1, tzinfo=UTC)) # "2026-01-01T00:00:00Z"
null("aws:TokenIssueTime") # the key is absent: {"Null": {...: "true"}}
null("aws:TokenIssueTime", False) # the key is present
Several keys: merge_conditions¶
A condition block with several entries needs all of them to hold. Build them one at a time and merge:
from cfn_typed.policy import GlobalKeys, arn_equals, merge_conditions, string_equals
allow("sqs:SendMessage", queue_arn, principal=service("sns.amazonaws.com"),
condition=merge_conditions(
arn_equals(GlobalKeys.SourceArn, ref("Topic")),
string_equals(GlobalKeys.SourceAccount, ref("AWS::AccountId")),
))
"Condition": {"ArnEquals": {"aws:SourceArn": {"Ref": "Topic"}},
"StringEquals": {"aws:SourceAccount": {"Ref": "AWS::AccountId"}}}
Two conditions with the same operator and key are an error, not a silent overwrite:
merge_conditions(string_equals("a", "1"), string_equals("a", "2")) raises
condition StringEquals on a is given twice. Dict | would keep the second and drop the first; in a policy
that is a missing restriction, so it is refused. (If you want either value, pass a list to one builder.)
Several values: the qualifiers¶
Every builder except null takes three keyword flags:
| Flag | Writes | Use it when |
|---|---|---|
for_any_value=True |
ForAnyValue:StringEquals |
the key holds a list (a set of tags) and one match is enough |
for_all_values=True |
ForAllValues:StringEquals |
the key holds a list and every element must be in your values |
if_exists=True |
StringEqualsIfExists |
the condition should hold only when the key is in the request |
allow("ec2:RunInstances", "*",
condition=string_equals("aws:RequestTag/team", ["a", "b"], for_all_values=True, if_exists=True))
# {"ForAllValues:StringEqualsIfExists": {"aws:RequestTag/team": ["a", "b"]}}
for_any_value with for_all_values, and if_exists with null, are errors, because IAM has no such operators.
Two behaviours cause most surprises, and if_exists is the fix for the first:
- A key that is not in the request makes most operators false. A
Denyguarded bystring_equals("aws:RequestTag/x", ...)does not apply to a request with no tag at all. Useif_exists=Truewhere an absent key should count as a match, or add anull(...)condition for the absent case. ForAllValuesis true for an empty list. A request with no tags passesfor_all_values=True. Pair it withnull(key, False)when the key must be present.
Keys¶
A key is a string. Global keys start with aws:, a service's keys with its prefix (s3:prefix,
sqs:MessageRetentionPeriod), and some end with a name you choose (s3:ExistingObjectTag/env, aws:RequestTag/team).
GlobalKeys names the few that most policies use, and any other key is just a string:
from cfn_typed.policy import GlobalKeys
GlobalKeys.SourceArn # "aws:SourceArn"
GlobalKeys.SourceAccount # "aws:SourceAccount"
GlobalKeys.PrincipalArn # "aws:PrincipalArn"
GlobalKeys.PrincipalOrgID # "aws:PrincipalOrgID"
GlobalKeys.SecureTransport # "aws:SecureTransport"
The key is not checked by the type checker, since there are thousands and a wrong one is valid Python. That check happens when the template is built, below.
Recipes¶
Each is a statement you can paste, with the condition that makes it the right size.
Refuse plain HTTP to a bucket
deny("s3:*", [bucket_arn, sub("${Bucket.Arn}/*")], principal=ANY, condition=bool_(GlobalKeys.SecureTransport, False))
Let one service in, for one source. A service principal alone lets that service act for anyone's resource. Pin it
with aws:SourceArn and aws:SourceAccount (the merge_conditions example above).
Let a whole organization in
allow("s3:GetObject", objects_arn, principal=ANY, condition=string_equals(GlobalKeys.PrincipalOrgID, ref("OrgId")))
A folder per user
allow("s3:ListBucket", bucket_arn, condition=string_like("s3:prefix", "home/${aws:username}/*"))
allow(["s3:GetObject", "s3:PutObject"], sub("${Bucket.Arn}/home/${!aws:username}/*"))
${aws:username} is an IAM policy variable, and it clashes with Fn::Sub's ${...}. Write ${!aws:username}
inside a sub(...), and plain ${aws:username} in a string that is not.
Tag-based access
allow("s3:GetObject", objects_arn, condition=string_equals("s3:ExistingObjectTag/env", "prod"))
allow("ec2:StartInstances", "*", condition=string_equals("aws:ResourceTag/team", ref("Team")))
Require MFA for everything but IAM (if_exists, because a request without MFA has no key at all)
deny(not_actions=["iam:*"], resource="*", condition=bool_("aws:MultiFactorAuthPresent", False, if_exists=True))
A time window
allow("s3:GetObject", objects_arn, condition=merge_conditions(
date_greater_than("aws:CurrentTime", datetime(2026, 1, 1, tzinfo=UTC)),
date_less_than("aws:CurrentTime", datetime(2026, 2, 1, tzinfo=UTC)),
))
The two use different operators, so merge_conditions accepts them; two date_less_than on one key would be refused.
Only from the corporate network
deny("*", "*", condition=merge_conditions(
not_ip_address("aws:SourceIp", ["203.0.113.0/24"]),
bool_("aws:ViaAWSService", False),
))
An external id on a trust policy
assume_role_policy(aws(partner_account_root), condition=string_equals("sts:ExternalId", ref("ExternalId")))
What is checked¶
The builders check the shape (operator, value type, qualifiers). The template check, assert_iam_valid in your tests or
find_iam_problems(template) anywhere, checks the content of every statement in the finished template, including ones
you wrote as dict literals:
| Mistake | Message |
|---|---|
| a misspelled operator | 'StringLik' is not a condition operator (did you mean 'StringLike'?) |
| a misspelled key of a known service | 's3:prefx' is not a known s3 condition key (did you mean 's3:prefix'?) |
| a key that none of the statement's actions of that service takes | 's3:prefix' is not a condition key of any s3 action in the statement; it is for s3:ListBucket, s3:ListBucketVersions |
an Effect that is not Allow or Deny |
'Alow' is not 'Allow' or 'Deny' |
The last-but-one is the one that finds real bugs: s3:prefix is a ListBucket key, so on a GetObject statement the
condition is never true and the statement grants nothing.
The keys come from AWS's Service Authorization Reference, for the services in permissions, and are
refreshed with scripts/sync-condition-keys.py. What is not checked:
- global
aws:keys, and the keys of services outside that list (they are left alone, not flagged); - a key that is itself an intrinsic, and a statement with
NotAction, whose actions cannot be listed; - the values of a condition, and whether a key suits its operator (
numeric_less_thanon a string key); - whether the condition is the right one for what you meant.
A statement listing several actions passes when any of them takes the key, because a wildcard such as s3:Get* covers
actions that do not. If you list actions explicitly and some do not take the key, give them their own statement: the
condition would be false for those actions, so they would silently get no access (or, in a Deny, no restriction).