aws2-2 Exercise 2: Conditions, Policy Variables and Missing Keys: Run the Documentation's Tables, Then the Traps ================================================================================================================ Run with Python 3.14.0 on Windows 11 in a scratch venv: boto3/botocore 1.43.112 (used offline, through botocore's Stubber), parliament 1.6.4 and setuptools 80.10.2 (parliament imports pkg_resources, which newer setuptools removed). Setup: py -m venv .venv ; .venv/Scripts/python.exe -m pip install boto3 parliament "setuptools<81". VERIFICATION LEVEL: 1 (run here; no AWS account, no AWS CLI, no network call to AWS). The evaluator is MY MODEL of the IAM User Guide and STS API pages read on 2026-10-12 (through a page-fetch tool that summarises pages): it is not AWS, and AWS may change its rules. It raises NotModelled for anything it does not implement. iam_eval.py is the file listed in Exercise 1; this exercise runs the condition part of it. --- t_conditions.py --- """t_conditions.py - run the condition tables printed on AWS's IAM pages through iam_eval.cond_ok. 'docs' is the Match / No match column of the table on the page named in the section header. The expected value is written down first; the model's answer is then compared. Rows I believe are inconsistent with the page's own text are NOT counted: they are printed separately, labelled, so you can judge them. """ import sys from iam_eval import cond_ok, clause, NotModelled rows, excluded = [], [] def case(label, docs, cond, ctx): rows.append((label, docs, cond_ok(cond, ctx))) # ---- String operators page C = {"StringEquals": {"aws:PrincipalTag/job-category": "iamuser-admin"}} case("StringEquals PrincipalTag: iamuser-admin", True, C, {"aws:PrincipalTag/job-category": "iamuser-admin"}) case("StringEquals PrincipalTag: dev-ops", False, C, {"aws:PrincipalTag/job-category": "dev-ops"}) case("StringEquals PrincipalTag: key absent", False, C, {}) V = {"StringLike": {"s3:prefix": ["", "home/", "home/${aws:username}/"]}} case("variable ${aws:username}: martha_rivera lists home/martha_rivera/", True, V, {"s3:prefix": "home/martha_rivera/", "aws:username": "martha_rivera"}) case("variable ${aws:username}: nikki_wolf lists home/martha_rivera/", False, V, {"s3:prefix": "home/martha_rivera/", "aws:username": "nikki_wolf"}) case("variable ${aws:username}: no username in the request", False, V, {"s3:prefix": "home/martha_rivera/"}) TAGS = {"StringEqualsIgnoreCase": {"aws:RequestTag/environment": ["preprod", "storage"]}, "ForAnyValue:StringEquals": {"aws:TagKeys": "environment"}} case("tags: TagKeys=[environment], environment=preprod", True, TAGS, {"aws:TagKeys": ["environment"], "aws:RequestTag/environment": "preprod"}) case("tags: TagKeys=[environment,costcenter], environment=PreProd", True, TAGS, {"aws:TagKeys": ["environment", "costcenter"], "aws:RequestTag/environment": "PreProd"}) case("tags: TagKeys=[Environment] (capital E)", False, TAGS, {"aws:TagKeys": ["Environment"], "aws:RequestTag/Environment": "preprod"}) case("tags: TagKeys=[costcenter]", False, TAGS, {"aws:TagKeys": ["costcenter"], "aws:RequestTag/environment": "preprod"}) case("tags: no TagKeys in the request", False, TAGS, {"aws:RequestTag/environment": "storage"}) case("tags: no RequestTag/environment", False, TAGS, {"aws:TagKeys": ["environment"]}) case("tags: neither key present", False, TAGS, {}) # ---- the aws:ResourceOrgPaths example: my reading says the page's Match / No match column is inverted ORG = {"ForAllValues:StringNotLike": {"aws:ResourceOrgPaths": ["o-acorg/r-acroot/ou-acroot-mediaou/", "o-acorg/r-acroot/ou-acroot-sportsou/*"]}} for label, docs, ctx in (("orgpaths: .../sportsou/costcenter/", True, {"aws:ResourceOrgPaths": ["o-acorg/r-acroot/ou-acroot-sportsou/costcenter/"]}), ("orgpaths: .../mediaou/costcenter/", False, {"aws:ResourceOrgPaths": ["o-acorg/r-acroot/ou-acroot-mediaou/costcenter/"]}), ("orgpaths: key absent", False, {})): excluded.append((label, docs, cond_ok(ORG, ctx))) # ---- Numeric, Date, Boolean, IP, ARN N = {"NumericLessThanEquals": {"aws:MultiFactorAuthAge": "3600"}} case("Numeric: MFA age 2800 <= 3600", True, N, {"aws:MultiFactorAuthAge": 2800}) case("Numeric: MFA age 4000", False, N, {"aws:MultiFactorAuthAge": 4000}) case("Numeric: key absent (access keys, no MFA)", False, N, {}) D = {"DateGreaterThan": {"aws:TokenIssueTime": "2020-01-01T00:00:01Z"}} case("Date: token issued 2026 > 2020", True, D, {"aws:TokenIssueTime": "2026-10-12T10:00:00Z"}) case("Date: key absent (long-term access keys)", False, D, {}) BL = {"Bool": {"aws:SecureTransport": "false"}} case("Bool: SecureTransport=false", True, BL, {"aws:SecureTransport": "false"}) case("Bool: SecureTransport=true", False, BL, {"aws:SecureTransport": "true"}) case("Bool: SecureTransport absent", False, BL, {}) IP = {"IpAddress": {"aws:SourceIp": "203.0.113.0/24"}} case("IpAddress: 203.0.113.1 in 203.0.113.0/24", True, IP, {"aws:SourceIp": "203.0.113.1"}) case("IpAddress: 198.51.100.1", False, IP, {"aws:SourceIp": "198.51.100.1"}) AR = {"ArnEquals": {"aws:SourceArn": "arn:aws:sns:us-west-2:123456789012:TOPIC-ID"}} case("ArnEquals: same topic", True, AR, {"aws:SourceArn": "arn:aws:sns:us-west-2:123456789012:TOPIC-ID"}) case("ArnEquals: topic in another account", False, AR, {"aws:SourceArn": "arn:aws:sns:us-west-2:777788889999:TOPIC-ID"}) case("ArnEquals: key absent (a person called, not a service)", False, AR, {}) FA = {"ForAllValues:ArnLike": {"logs:LogGeneratingResourceArns": ["arn:aws:cloudfront::123456789012:distribution/*", "arn:aws:cloudfront::123456789012:distribution/support*"]}} case("ForAllValues:ArnLike: one ARN in the account", True, FA, {"logs:LogGeneratingResourceArns": ["arn:aws:cloudfront::123456789012:distribution/costcenter"]}) case("ForAllValues:ArnLike: two ARNs in the account", True, FA, {"logs:LogGeneratingResourceArns": ["arn:aws:cloudfront::123456789012:distribution/costcenter", "arn:aws:cloudfront::123456789012:distribution/support2025"]}) case("ForAllValues:ArnLike: ARN in another account", False, FA, {"logs:LogGeneratingResourceArns": ["arn:aws:cloudfront::777788889999:distribution/costcenter"]}) case("ForAllValues:ArnLike: key absent -> TRUE (the page says so)", True, FA, {}) # ---- IfExists and Null IFX = {"StringLikeIfExists": {"ec2:InstanceType": ["t1.*", "t2.*", "m3.*"]}} case("IfExists: t1.micro", True, IFX, {"ec2:InstanceType": "t1.micro"}) case("IfExists: m2.micro", False, IFX, {"ec2:InstanceType": "m2.micro"}) case("IfExists: key absent -> true", True, IFX, {}) NU = {"Null": {"aws:TokenIssueTime": "false"}} case("Null false: temporary credentials (key present)", True, NU, {"aws:TokenIssueTime": "2026-10-12T10:00:00Z"}) case("Null false: long-term credentials (key absent)", False, NU, {}) # ---- set operators page: ForAllValues needs the Null guard FAV = {"ForAllValues:StringEquals": {"aws:TagKeys": ["environment", "cost-center"]}, "Null": {"aws:TagKeys": "false"}} case("ForAllValues+Null: [environment]", True, FAV, {"aws:TagKeys": ["environment"]}) case("ForAllValues+Null: [cost-center]", True, FAV, {"aws:TagKeys": ["cost-center"]}) case("ForAllValues+Null: [environment, cost-center]", True, FAV, {"aws:TagKeys": ["environment", "cost-center"]}) case("ForAllValues+Null: [environment, dept]", False, FAV, {"aws:TagKeys": ["environment", "dept"]}) case("ForAllValues+Null: no TagKeys", False, FAV, {}) FAY = {"ForAnyValue:StringEquals": {"aws:TagKeys": ["environment", "cost-center"]}} case("ForAnyValue: [environment]", True, FAY, {"aws:TagKeys": ["environment"]}) case("ForAnyValue: [cost-center]", True, FAY, {"aws:TagKeys": ["cost-center"]}) case("ForAnyValue: [environment, cost-center]", True, FAY, {"aws:TagKeys": ["environment", "cost-center"]}) case("ForAnyValue: [environment, dept]", True, FAY, {"aws:TagKeys": ["environment", "dept"]}) case("ForAnyValue: [dept]", False, FAY, {"aws:TagKeys": ["dept"]}) case("ForAnyValue: no TagKeys", False, FAY, {}) # ---- the missing-key rule for negated operators (Condition operators page, 'Important' box) case("StringNotLike with the key absent -> true", True, {"StringNotLike": {"aws:username": "admin*"}}, {}) case("ArnNotLike with the key absent -> true", True, {"ArnNotLike": {"aws:SourceArn": "arn:aws:sns:*:*:t"}}, {}) case("StringNotEquals, key present and equal -> false", False, {"StringNotEquals": {"aws:RequestedRegion": "eu-west-1"}}, {"aws:RequestedRegion": "eu-west-1"}) if __name__ == "__main__": flip = "--flip" in sys.argv bad = 0 w = max(len(r[0]) for r in rows) print(f"{'case':<{w}} docs model result") for i, (label, docs, got) in enumerate(rows): if flip and i == 3: docs = not docs res = "match" if docs == got else "MISMATCH" bad += res != "match" print(f"{label:<{w}} {'Match' if docs else 'No match':<9} {'Match' if got else 'No match':<9} {res}") print(f"\n{len(rows)} cases, mismatches: {bad}") if not flip: print("\nNOT counted: the aws:ResourceOrgPaths example. The page's table says Match for sportsou/costcenter and No match for mediaou/costcenter and for a missing key.") print("The operator ForAllValues:StringNotLike is true only when NO request value matches ANY pattern, and is true on a missing key. By that rule:") for label, docs, got in excluded: print(f" {label:<40} page table: {'Match' if docs else 'No match':<9} model: {'Match' if got else 'No match'}") --- py -X utf8 t_conditions.py --- case docs model result StringEquals PrincipalTag: iamuser-admin Match Match match StringEquals PrincipalTag: dev-ops No match No match match StringEquals PrincipalTag: key absent No match No match match variable ${aws:username}: martha_rivera lists home/martha_rivera/ Match Match match variable ${aws:username}: nikki_wolf lists home/martha_rivera/ No match No match match variable ${aws:username}: no username in the request No match No match match tags: TagKeys=[environment], environment=preprod Match Match match tags: TagKeys=[environment,costcenter], environment=PreProd Match Match match tags: TagKeys=[Environment] (capital E) No match No match match tags: TagKeys=[costcenter] No match No match match tags: no TagKeys in the request No match No match match tags: no RequestTag/environment No match No match match tags: neither key present No match No match match Numeric: MFA age 2800 <= 3600 Match Match match Numeric: MFA age 4000 No match No match match Numeric: key absent (access keys, no MFA) No match No match match Date: token issued 2026 > 2020 Match Match match Date: key absent (long-term access keys) No match No match match Bool: SecureTransport=false Match Match match Bool: SecureTransport=true No match No match match Bool: SecureTransport absent No match No match match IpAddress: 203.0.113.1 in 203.0.113.0/24 Match Match match IpAddress: 198.51.100.1 No match No match match ArnEquals: same topic Match Match match ArnEquals: topic in another account No match No match match ArnEquals: key absent (a person called, not a service) No match No match match ForAllValues:ArnLike: one ARN in the account Match Match match ForAllValues:ArnLike: two ARNs in the account Match Match match ForAllValues:ArnLike: ARN in another account No match No match match ForAllValues:ArnLike: key absent -> TRUE (the page says so) Match Match match IfExists: t1.micro Match Match match IfExists: m2.micro No match No match match IfExists: key absent -> true Match Match match Null false: temporary credentials (key present) Match Match match Null false: long-term credentials (key absent) No match No match match ForAllValues+Null: [environment] Match Match match ForAllValues+Null: [cost-center] Match Match match ForAllValues+Null: [environment, cost-center] Match Match match ForAllValues+Null: [environment, dept] No match No match match ForAllValues+Null: no TagKeys No match No match match ForAnyValue: [environment] Match Match match ForAnyValue: [cost-center] Match Match match ForAnyValue: [environment, cost-center] Match Match match ForAnyValue: [environment, dept] Match Match match ForAnyValue: [dept] No match No match match ForAnyValue: no TagKeys No match No match match StringNotLike with the key absent -> true Match Match match ArnNotLike with the key absent -> true Match Match match StringNotEquals, key present and equal -> false No match No match match 49 cases, mismatches: 0 NOT counted: the aws:ResourceOrgPaths example. The page's table says Match for sportsou/costcenter and No match for mediaou/costcenter and for a missing key. The operator ForAllValues:StringNotLike is true only when NO request value matches ANY pattern, and is true on a missing key. By that rule: orgpaths: .../sportsou/costcenter/ page table: Match model: No match orgpaths: .../mediaou/costcenter/ page table: No match model: Match orgpaths: key absent page table: No match model: Match --- py -X utf8 t_conditions.py --flip (control) --- case docs model result StringEquals PrincipalTag: iamuser-admin Match Match match StringEquals PrincipalTag: dev-ops No match No match match StringEquals PrincipalTag: key absent No match No match match variable ${aws:username}: martha_rivera lists home/martha_rivera/ No match Match MISMATCH variable ${aws:username}: nikki_wolf lists home/martha_rivera/ No match No match match variable ${aws:username}: no username in the request No match No match match tags: TagKeys=[environment], environment=preprod Match Match match tags: TagKeys=[environment,costcenter], environment=PreProd Match Match match tags: TagKeys=[Environment] (capital E) No match No match match tags: TagKeys=[costcenter] No match No match match tags: no TagKeys in the request No match No match match tags: no RequestTag/environment No match No match match tags: neither key present No match No match match Numeric: MFA age 2800 <= 3600 Match Match match Numeric: MFA age 4000 No match No match match Numeric: key absent (access keys, no MFA) No match No match match Date: token issued 2026 > 2020 Match Match match Date: key absent (long-term access keys) No match No match match Bool: SecureTransport=false Match Match match Bool: SecureTransport=true No match No match match Bool: SecureTransport absent No match No match match IpAddress: 203.0.113.1 in 203.0.113.0/24 Match Match match IpAddress: 198.51.100.1 No match No match match ArnEquals: same topic Match Match match ArnEquals: topic in another account No match No match match ArnEquals: key absent (a person called, not a service) No match No match match ForAllValues:ArnLike: one ARN in the account Match Match match ForAllValues:ArnLike: two ARNs in the account Match Match match ForAllValues:ArnLike: ARN in another account No match No match match ForAllValues:ArnLike: key absent -> TRUE (the page says so) Match Match match IfExists: t1.micro Match Match match IfExists: m2.micro No match No match match IfExists: key absent -> true Match Match match Null false: temporary credentials (key present) Match Match match Null false: long-term credentials (key absent) No match No match match ForAllValues+Null: [environment] Match Match match ForAllValues+Null: [cost-center] Match Match match ForAllValues+Null: [environment, cost-center] Match Match match ForAllValues+Null: [environment, dept] No match No match match ForAllValues+Null: no TagKeys No match No match match ForAnyValue: [environment] Match Match match ForAnyValue: [cost-center] Match Match match ForAnyValue: [environment, cost-center] Match Match match ForAnyValue: [environment, dept] Match Match match ForAnyValue: [dept] No match No match match ForAnyValue: no TagKeys No match No match match StringNotLike with the key absent -> true Match Match match ArnNotLike with the key absent -> true Match Match match StringNotEquals, key present and equal -> false No match No match match 49 cases, mismatches: 1 --- t_traps.py --- """t_traps.py - condition mistakes that the AWS pages warn about, shown with the model (a model of the documented rules, not AWS).""" from iam_eval import cond_ok, decide, NotModelled def show(label, value): print(f"{label:<92} {value}") def yn(b): return "statement APPLIES" if b else "statement does not apply" print("1. ForAllValues on a request with no tags (the page: true 'if there are no context keys in the request')") unsafe = {"ForAllValues:StringEquals": {"aws:TagKeys": ["environment", "cost-center"]}} guard = dict(unsafe, Null={"aws:TagKeys": "false"}) show(" an Allow statement, no Null guard, request carries NO tag keys", yn(cond_ok(unsafe, {}))) show(" the same with \"Null\": {\"aws:TagKeys\": \"false\"}, request carries NO tag keys", yn(cond_ok(guard, {}))) show(" with the guard, a request tagging only 'environment'", yn(cond_ok(guard, {"aws:TagKeys": ["environment"]}))) print("\n2. One statement is checked against EVERY resource the action touches (the page's ec2:RunInstances example)") bad = [dict(Effect="Allow", Action="ec2:RunInstances", Resource="*", Condition={"StringLike": {"ec2:InstanceType": ["t1.*", "t2.*", "m3.*"]}})] good = [dict(Effect="Allow", Action="ec2:RunInstances", Resource="*", Condition={"StringLikeIfExists": {"ec2:InstanceType": ["t1.*", "t2.*", "m3.*"]}})] who = {"type": "user", "arn": "arn:aws:iam::111111111111:user/dev", "account": "111111111111"} def launch(policy, itype): resources = [("arn:aws:ec2:eu-west-1:111111111111:instance/i-1", {"ec2:InstanceType": itype}), ("arn:aws:ec2:eu-west-1::image/ami-1", {}), ("arn:aws:ec2:eu-west-1:111111111111:key-pair/k", {}), ("arn:aws:ec2:eu-west-1:111111111111:subnet/s-1", {})] res = [decide({"who": who, "action": "ec2:RunInstances", "resource": r, "ctx": c}, {"identity": [policy]})[0] for r, c in resources] return all(res), res for name, pol in (("StringLike (the page's 'does not work' policy)", bad), ("StringLikeIfExists", good)): for t in ("t2.micro", "m2.micro"): ok, per = launch(pol, t) show(f" {name}, instance type {t}", f"{'LAUNCH ALLOWED' if ok else 'launch denied'} per resource (instance, image, key pair, subnet): {per}") print("\n3. Negated operators and a missing key (the page: negated operators are true when the key is absent)") deny_c = {"StringNotEquals": {"aws:RequestedRegion": ["eu-west-1", "eu-central-1"]}} show(" Deny + StringNotEquals region, request has NO aws:RequestedRegion", yn(cond_ok(deny_c, {}))) show(" Deny + StringNotEqualsIfExists region, request has NO aws:RequestedRegion (the page: still denied)", yn(cond_ok({"StringNotEqualsIfExists": deny_c["StringNotEquals"]}, {}))) show(" Deny + StringNotEquals region, request region eu-west-1", yn(cond_ok(deny_c, {"aws:RequestedRegion": "eu-west-1"}))) print("\n4. Things the model refuses to guess") for label, c, ctx in ((" policy variable inside a Numeric operator (the page: not supported)", {"NumericLessThanEquals": {"s3:max-keys": "${aws:username}"}}, {"s3:max-keys": 5, "aws:username": "a"}), (" multivalued key without ForAllValues / ForAnyValue", {"StringEquals": {"aws:TagKeys": "environment"}}, {"aws:TagKeys": ["environment", "dept"]}), (" an operator it does not implement (BinaryEquals)", {"BinaryEquals": {"k": "QmluYXJ5"}}, {"k": "QmluYXJ5"})): try: cond_ok(c, ctx); show(label, "evaluated") except NotModelled as e: show(label, "NotModelled: " + str(e)) print("\n5. A malformed policy is an error, not an empty policy") try: decide({"who": who, "action": "s3:GetObject", "resource": "*", "ctx": {}}, {"identity": [[{"Action": "s3:*"}]]}) except ValueError as e: show(" a statement with no Effect", "ValueError: " + str(e)) --- py -X utf8 t_traps.py --- 1. ForAllValues on a request with no tags (the page: true 'if there are no context keys in the request') an Allow statement, no Null guard, request carries NO tag keys statement APPLIES the same with "Null": {"aws:TagKeys": "false"}, request carries NO tag keys statement does not apply with the guard, a request tagging only 'environment' statement APPLIES 2. One statement is checked against EVERY resource the action touches (the page's ec2:RunInstances example) StringLike (the page's 'does not work' policy), instance type t2.micro launch denied per resource (instance, image, key pair, subnet): [True, False, False, False] StringLike (the page's 'does not work' policy), instance type m2.micro launch denied per resource (instance, image, key pair, subnet): [False, False, False, False] StringLikeIfExists, instance type t2.micro LAUNCH ALLOWED per resource (instance, image, key pair, subnet): [True, True, True, True] StringLikeIfExists, instance type m2.micro launch denied per resource (instance, image, key pair, subnet): [False, True, True, True] 3. Negated operators and a missing key (the page: negated operators are true when the key is absent) Deny + StringNotEquals region, request has NO aws:RequestedRegion statement APPLIES Deny + StringNotEqualsIfExists region, request has NO aws:RequestedRegion (the page: still denied) statement APPLIES Deny + StringNotEquals region, request region eu-west-1 statement does not apply 4. Things the model refuses to guess policy variable inside a Numeric operator (the page: not supported) NotModelled: policy variable in NumericLessThanEquals multivalued key without ForAllValues / ForAnyValue NotModelled: multivalued key aws:TagKeys needs ForAllValues or ForAnyValue an operator it does not implement (BinaryEquals) NotModelled: operator BinaryEquals 5. A malformed policy is an error, not an empty policy a statement with no Effect ValueError: not a policy or a statement: ['Action'] WHY THIS WORKS AS AN ANSWER --------------------------- Every row of t_conditions.py is a row of a table on the IAM 'Condition operators' or 'Single-valued vs multivalued context keys' pages (or a sentence from them), with the page's Match / No match written first. All 49 counted rows agree. The control flips one and reports one mismatch. Three rows are deliberately NOT counted: the aws:ResourceOrgPaths example, whose table says Match for a path inside sportsou/ and No match for mediaou/ and for a missing key. ForAllValues:StringNotLike is true only if no request value matches any pattern and is true on a missing key (the page says so), so by the page's own rules those results come out reversed; it looks like an error in that table, but I cannot prove it, so the rows are printed separately for you to judge. I also used well-formed ARNs in the ArnLike rows because that table prints 'arn:aws::cloudfront:...' (an extra colon). The traps follow from the rules: ForAllValues is true on a missing key (so an Allow needs a Null guard), one statement is checked against every resource an action touches (so a key only the instance carries needs IfExists), and a negated operator is true on a missing key (so a Deny written that way also catches requests without the key). They are the model's answers, derived from the documented rules, not measurements of AWS. Unverified: how a PLAIN negated operator combines several policy values (the sentence was past the end of the page copy I could read); the model treats it as 'none may match'. Not run: Binary operators (the model refuses), any real request.