aws2-2 Exercise 1: Model the Documented Evaluation Order and Test It Against the Documentation's Worked Examples =============================================================================================================== 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 (the model; Exercise 2 and 3 import it) --- """iam_eval.py - a MODEL of AWS's documented IAM policy evaluation (not AWS itself). Sources (AWS IAM User Guide pages read on 2026-10-12): "Policy evaluation logic" and its "How AWS enforcement code logic evaluates requests" page, "Cross-account policy evaluation logic", "Permissions boundaries", "Condition operators", "Single-valued vs multivalued context keys", the AssumeRole API page, and the Organizations SCP pages (Chapter 1). What is modelled (each step is a sentence on those pages): 1 an explicit Deny in ANY applicable policy gives Deny 2 SCPs: an Allow at every level from the root to the account (levels given as a list of lists of statements) 3 resource-based policy, same account: depending on the principal it names it can grant on its own user ARN, role SESSION ARN, federated-user session ARN, service principal -> final Allow role ARN, ARN of the user who federated -> still limited by boundary and session policy 4 identity-based policy must allow (unless 3 granted directly or the role-ARN case) 5 permissions boundary must allow, if one is set 6 session policy must allow, if one was passed 7 cross-account: BOTH accounts' evaluations must Allow 8 role trust policies and KMS key policies must explicitly allow the principal (an account principal delegates to identity policies) Conditions: String/Arn/Ip/Bool/Numeric/Date/Null operators, IfExists, ForAllValues/ForAnyValue, ${variables}, missing-key rules. NOT modelled (the model raises NotModelled rather than guess): resource control policies, wildcard Principal in an Allow, NotPrincipal, account-root principals in ordinary resource policies, VPC endpoint policies, RAM, ABAC tag sources, service-specific evaluation rules, role chaining. One combining rule is NOT verified from the pages I could read: a PLAIN negated operator with several policy values is treated as 'none of them may match' (the page I fetched ended before it). """ import ipaddress, re from datetime import datetime, timezone class NotModelled(Exception): pass class Missing(Exception): pass def L(x): return x if isinstance(x, list) else [x] # ---------------------------------------------------------------- glob and variables def glob_re(p, ci=False): r = "".join(".*" if c == "*" else "." if c == "?" else re.escape(c) for c in p) return re.compile("^" + r + "$", re.I if ci else 0) def glob(p, s, ci=False): return bool(glob_re(p, ci).match(s)) def subst(v, ctx): def rep(m): k = m.group(1).lower() if k not in ctx: raise Missing(k) vals = L(ctx[k]) if len(vals) != 1: raise NotModelled("multivalued key used as variable") return str(vals[0]) return re.sub(r"\$\{([^}]+)\}", rep, v) if isinstance(v, str) else v # ---------------------------------------------------------------- condition operators def arn_like(p, s): pp, ss = p.split(":", 5), s.split(":", 5) return len(pp) == len(ss) and all(glob(a, b) for a, b in zip(pp, ss)) def to_dt(v): v = str(v) if re.fullmatch(r"-?[0-9]+(\.[0-9]+)?", v): return datetime.fromtimestamp(float(v), timezone.utc) return datetime.fromisoformat(v.replace("Z", "+00:00")) POS = { "StringEquals": lambda r, p: str(r) == str(p), "StringEqualsIgnoreCase": lambda r, p: str(r).lower() == str(p).lower(), "StringLike": lambda r, p: glob(str(p), str(r)), "ArnEquals": lambda r, p: arn_like(str(p), str(r)), "ArnLike": lambda r, p: arn_like(str(p), str(r)), "IpAddress": lambda r, p: ipaddress.ip_address(str(r)) in ipaddress.ip_network(str(p), strict=False), "Bool": lambda r, p: str(r).lower() == str(p).lower(), "NumericEquals": lambda r, p: float(r) == float(p), "NumericLessThan": lambda r, p: float(r) < float(p), "NumericLessThanEquals": lambda r, p: float(r) <= float(p), "NumericGreaterThan": lambda r, p: float(r) > float(p), "NumericGreaterThanEquals": lambda r, p: float(r) >= float(p), "DateEquals": lambda r, p: to_dt(r) == to_dt(p), "DateLessThan": lambda r, p: to_dt(r) < to_dt(p), "DateLessThanEquals": lambda r, p: to_dt(r) <= to_dt(p), "DateGreaterThan": lambda r, p: to_dt(r) > to_dt(p), "DateGreaterThanEquals": lambda r, p: to_dt(r) >= to_dt(p), } NEG = {"StringNotEquals": "StringEquals", "StringNotEqualsIgnoreCase": "StringEqualsIgnoreCase", "StringNotLike": "StringLike", "ArnNotEquals": "ArnEquals", "ArnNotLike": "ArnLike", "NotIpAddress": "IpAddress", "NumericNotEquals": "NumericEquals", "DateNotEquals": "DateEquals"} VAR_OK = ("String", "Arn", "Bool") # operators that support policy variables def clause(op, key, polvals, ctx): """one operator/key pair -> True/False""" set_op = None if op.startswith("ForAllValues:"): set_op, op = "all", op[len("ForAllValues:"):] elif op.startswith("ForAnyValue:"): set_op, op = "any", op[len("ForAnyValue:"):] ifex = op.endswith("IfExists") if ifex: op = op[:-len("IfExists")] k = key.lower() if op == "Null": present = k in ctx and ctx[k] not in (None, "", []) return (not present) == (str(L(polvals)[0]).lower() == "true") neg = op in NEG base = NEG.get(op, op) if base not in POS: raise NotModelled("operator " + op) if k not in ctx: if ifex or set_op == "all": return True # "IfExists": missing key is true; ForAllValues is true on a missing key if set_op == "any": return False return neg # missing key: positive operators false, negated operators true reqs = L(ctx[k]) try: pol = [] for v in L(polvals): if isinstance(v, str) and "${" in v and not base.startswith(VAR_OK): raise NotModelled("policy variable in " + op) pol.append(subst(v, ctx)) except Missing: return False # a variable with no value: documented table shows 'No match' m = POS[base] if set_op == "all": return all((not any(m(r, p) for p in pol)) if neg else any(m(r, p) for p in pol) for r in reqs) if set_op == "any": return any(all(not m(r, p) for p in pol) if neg else any(m(r, p) for p in pol) for r in reqs) if len(reqs) != 1: raise NotModelled("multivalued key %s needs ForAllValues or ForAnyValue" % key) r = reqs[0] return (not any(m(r, p) for p in pol)) if neg else any(m(r, p) for p in pol) def cond_ok(cond, ctx): """every operator block and every key must hold (AND); the several values of one key are OR (NOR for plain negated)""" ctx = {k.lower(): v for k, v in ctx.items()} return all(clause(op, key, vals, ctx) for op, block in (cond or {}).items() for key, vals in block.items()) # ---------------------------------------------------------------- statements def act_hit(s, action): if "Action" in s: return any(glob(a, action, ci=True) for a in L(s["Action"])) return not any(glob(a, action, ci=True) for a in L(s["NotAction"])) def res_hit(s, resource, ctx): lc = {k.lower(): v for k, v in ctx.items()} try: if "Resource" in s: return any(glob(subst(r, lc), resource) for r in L(s["Resource"])) if "NotResource" in s: return not any(glob(subst(r, lc), resource) for r in L(s["NotResource"])) except Missing: return False return True def stmts(policy): """flatten a policy into statements. A policy may be a statement, a dict with a Statement key, or a list of either. Anything else is an error: silently reading a malformed policy as 'no statements' would hide mistakes.""" if isinstance(policy, dict): if "Effect" in policy: return [policy] if "Statement" in policy: return stmts(L(policy["Statement"])) raise ValueError("not a policy or a statement: %r" % list(policy)[:4]) if isinstance(policy, list): return [s for x in policy for s in stmts(x)] raise ValueError("not a policy: %r" % (policy,)) def applies(s, req): return act_hit(s, req["action"]) and res_hit(s, req["resource"], req["ctx"]) and cond_ok(s.get("Condition"), req["ctx"]) def hits(policies, req, effect): return [s for p in policies for s in stmts(p) if s.get("Effect") == effect and applies(s, req)] # ---------------------------------------------------------------- principals def principal_kind(arn): if ":assumed-role/" in arn: return "role-session" if ":federated-user/" in arn: return "federated-session" if ":user/" in arn: return "user" if ":role/" in arn: return "role" raise NotModelled("principal " + arn) def role_of_session(arn): # arn:aws:sts::A:assumed-role/R/S -> arn:aws:iam::A:role/R m = re.match(r"arn:aws:sts::([0-9]+):assumed-role/([^/]+)/", arn) return "arn:aws:iam::%s:role/%s" % (m.group(1), m.group(2)) def grant_kind(stmt, who): """how a resource-policy statement's Principal matches the requester: None, or the category used by the evaluation page""" p = stmt.get("Principal") if "NotPrincipal" in stmt: raise NotModelled("NotPrincipal") if p == "*" or p == {"AWS": "*"}: return "wildcard" if not isinstance(p, dict): raise NotModelled("Principal form") if "Service" in p: return "service" if who["type"] == "service" and who["name"] in L(p["Service"]) else None out = None for a in L(p.get("AWS", [])): acct = a if re.fullmatch(r"[0-9]{12}", a) else a.split(":")[4] if re.fullmatch(r"arn:aws:iam::[0-9]{12}:root", a) else None if acct: if acct == who["account"]: out = out or "account" continue arn = who["arn"] if a == arn: out = principal_kind(arn) elif who["type"] == "role-session" and a == role_of_session(arn): out = out or "role" elif who["type"] == "federated-session" and a == who.get("federator_arn"): out = out or "federator-user" return out DIRECT = ("user", "role-session", "federated-session", "service") LIMITED = ("role", "federator-user") # ---------------------------------------------------------------- one account's evaluation def side_deny(req, S): pols = [p for lv in (S.get("scp") or []) for p in lv] + S.get("identity", []) + ([S["boundary"]] if S.get("boundary") else []) \ + ([S["session"]] if S.get("session") else []) + S.get("resource", []) return hits(pols, req, "Deny") def identity_side(req, S): """requester account: SCP, identity, boundary, session (no resource-policy shortcut). returns (allowed, why)""" d = side_deny(req, {k: v for k, v in S.items() if k != "resource"}) if d: return False, "explicit Deny" for i, lv in enumerate(S.get("scp") or []): if not hits(lv, req, "Allow"): return False, "SCP level %d has no Allow" % (i + 1) if not hits(S.get("identity", []), req, "Allow"): return False, "no identity-based Allow (implicit deny)" if S.get("boundary") and not hits([S["boundary"]], req, "Allow"): return False, "permissions boundary does not allow (implicit deny)" if req["who"]["type"] in ("role-session", "federated-session") and S.get("session") and not hits([S["session"]], req, "Allow"): return False, "session policy does not allow (implicit deny)" return True, "identity side allows" def decide(req, S, explicit_resource_allow=False): """same-account evaluation. req: who, action, resource, ctx. S: scp (list of levels), identity, boundary, session, resource. explicit_resource_allow: a role trust policy or KMS key policy, which must itself allow the principal.""" who = req["who"] if side_deny(req, S): return False, "explicit Deny" for i, lv in enumerate(S.get("scp") or []): if not hits(lv, req, "Allow"): return False, "SCP level %d has no Allow" % (i + 1) kinds = [grant_kind(s, who) for p in S.get("resource", []) for s in stmts(p) if s.get("Effect") == "Allow" and applies(s, req)] if any(k == "wildcard" for k in kinds): raise NotModelled("wildcard Principal in an Allow") kinds = [k for k in kinds if k] if explicit_resource_allow and not kinds: return False, "this resource policy must explicitly allow the principal (it does not)" for k in kinds: if k in DIRECT: return True, "resource-based policy grants the %s directly" % k limited = any(k in LIMITED for k in kinds) if not limited: if not hits(S.get("identity", []), req, "Allow"): return False, "no identity-based Allow (implicit deny)" if S.get("boundary") and not hits([S["boundary"]], req, "Allow"): return False, "permissions boundary does not allow (implicit deny)" if who["type"] in ("role-session", "federated-session") and S.get("session") and not hits([S["session"]], req, "Allow"): return False, "session policy does not allow (implicit deny)" return True, "allowed (via the %s grant)" % kinds[0] if limited else "allowed by identity-based policy" def resource_side(req, R, explicit_resource_allow=False): """resource account of a cross-account request: needs a matching Allow in its resource policy and no Deny""" if hits(R.get("resource", []), req, "Deny"): return False, "explicit Deny in the resource policy" kinds = [grant_kind(s, req["who"]) for p in R.get("resource", []) for s in stmts(p) if s.get("Effect") == "Allow" and applies(s, req)] if any(k == "wildcard" for k in kinds): raise NotModelled("wildcard Principal in an Allow") if not any(kinds): return False, "resource policy does not allow the requester (implicit deny)" return True, "resource policy allows the requester" def decide_cross(req, requester_S, resource_R): """cross-account: both evaluations must Allow""" a, wa = identity_side(req, requester_S) b, wb = resource_side(req, resource_R) return (a and b), "requester account: %s; resource account: %s" % (wa, wb) def decide_trust(req, S, trust_policy, cross=False, requester_S=None): """sts:AssumeRole. The trust policy is the role's resource policy and must explicitly allow the principal. An account principal delegates to identity policies; a principal named directly is enough on its own in the same account.""" who = req["who"] if hits([trust_policy], req, "Deny"): return False, "explicit Deny in the trust policy" kinds = [grant_kind(s, who) for s in stmts(trust_policy) if s.get("Effect") == "Allow" and applies(s, req)] if not any(kinds): return False, "trust policy does not allow the principal" req_side_S = requester_S if cross else S if cross or "account" in kinds and not any(k in DIRECT for k in kinds): ok, why = identity_side(req, req_side_S) return ok, "trust policy allows; requester identity side: " + why return decide(req, dict(S, resource=[trust_policy]), explicit_resource_allow=True) --- t_principals.py --- """t_principals.py - run the documented policy-evaluation examples through iam_eval.py. Every 'docs say' value comes from a sentence or worked example on an AWS IAM page (named in the label), written down BEFORE running the model. A MISMATCH would mean the model disagrees with the documentation. """ import sys from iam_eval import decide, decide_cross, decide_trust, NotModelled A, B = "111111111111", "222222222222" def user(acct, name): return {"type": "user", "arn": f"arn:aws:iam::{acct}:user/{name}", "account": acct} def rsess(acct, role, s): return {"type": "role-session", "arn": f"arn:aws:sts::{acct}:assumed-role/{role}/{s}", "account": acct} def fsess(acct, name): return {"type": "federated-session", "arn": f"arn:aws:sts::{acct}:federated-user/{name}", "account": acct, "federator_arn": f"arn:aws:iam::{acct}:user/{name}"} def req(who, action, resource="*", **ctx): return {"who": who, "action": action, "resource": resource, "ctx": ctx} def allow(actions, res="*", **kw): return dict(Effect="Allow", Action=actions, Resource=res, **kw) def deny(actions, res="*", **kw): return dict(Effect="Deny", Action=actions, Resource=res, **kw) rows = [] def check(label, expected, fn): try: ok, why = fn() except NotModelled as e: ok, why = None, "NotModelled: " + str(e) rows.append((label, expected, ok, why)) # ---- 1. permissions boundary (Permissions boundaries page, user Shirley) bound = [allow(["s3:*", "cloudwatch:*", "ec2:*"])] shirley = user(A, "Shirley") check("boundary: identity allows iam:CreateUser, boundary does not -> iam:CreateUser", False, lambda: decide(req(shirley, "iam:CreateUser"), {"identity": [[allow("iam:CreateUser")]], "boundary": bound})) check("boundary: identity allows s3:*, boundary allows s3:* -> s3:GetObject", True, lambda: decide(req(shirley, "s3:GetObject"), {"identity": [[allow("s3:*")]], "boundary": bound})) check("boundary: a boundary alone grants nothing -> s3:GetObject with no identity policy", False, lambda: decide(req(shirley, "s3:GetObject"), {"identity": [], "boundary": bound})) check("boundary updated to include iam:CreateUser -> iam:CreateUser", True, lambda: decide(req(shirley, "iam:CreateUser"), {"identity": [[allow("iam:CreateUser")]], "boundary": bound + [allow("iam:CreateUser")]})) # ---- 2. cross-account (Cross-account policy evaluation logic page, Carlos and the Demo role) demo = rsess(A, "Demo", "carlos") demo_pol = [allow("s3:ListAllMyBuckets"), allow("s3:*Object*", "arn:aws:s3:::amzn-s3-demo-bucket-production/*"), deny("s3:*", ["arn:aws:s3:::*log*", "arn:aws:s3:::*log*/*"])] bucket_pol = [dict(Effect="Allow", Action=["s3:GetObject*", "s3:PutObject*", "s3:ReplicateObject", "s3:RestoreObject"], Principal={"AWS": f"arn:aws:iam::{A}:role/Demo"}, Resource="arn:aws:s3:::amzn-s3-demo-bucket-production/*")] RA, RB = {"identity": [demo_pol]}, {"resource": [bucket_pol]} check("cross-account: PutObject to the production-logs bucket -> Deny (DenyS3Logs in the requester account)", False, lambda: decide_cross(req(demo, "s3:PutObject", "arn:aws:s3:::amzn-s3-demo-bucket-production-logs/f.txt"), RA, {"resource": []})) check("cross-account: PutObject to the production bucket -> Allow (both accounts allow)", True, lambda: decide_cross(req(demo, "s3:PutObject", "arn:aws:s3:::amzn-s3-demo-bucket-production/f.txt"), RA, RB)) check("cross-account: DeleteObject on the production bucket -> Deny ('can create and edit, but not delete')", False, lambda: decide_cross(req(demo, "s3:DeleteObject", "arn:aws:s3:::amzn-s3-demo-bucket-production/f.txt"), RA, RB)) check("cross-account: bucket policy allows the role but the role has no identity policy -> Deny", False, lambda: decide_cross(req(demo, "s3:PutObject", "arn:aws:s3:::amzn-s3-demo-bucket-production/f.txt"), {"identity": []}, RB)) check("cross-account: identity policy allows but the bucket policy is empty -> Deny", False, lambda: decide_cross(req(demo, "s3:PutObject", "arn:aws:s3:::amzn-s3-demo-bucket-production/f.txt"), RA, {"resource": []})) # ---- 3. Nikhil (Permissions boundaries page, delegation example) nik = user(A, "Nikhil") xbound = [allow(["s3:*", "cloudwatch:*", "ec2:*", "dynamodb:*"]), deny("s3:*", ["arn:aws:s3:::logs", "arn:aws:s3:::logs/*"]), deny("ec2:*", "arn:aws:ec2:*:*:instance/i-1234567890abcdef0")] nid = [[allow("iam:*"), allow(["s3:Get*", "s3:List*"])]] logs_pol = [dict(Effect="Allow", Action="s3:PutObject", Principal={"AWS": nik["arn"]}, Resource="arn:aws:s3:::logs/*")] secret_pol = [dict(Effect="Allow", Action="secretsmanager:GetSecretValue", Principal={"AWS": nik["arn"]}, Resource="*")] check("Nikhil: bucket policy allows PutObject on 'logs', boundary has an explicit Deny on it -> Deny", False, lambda: decide(req(nik, "s3:PutObject", "arn:aws:s3:::logs/x"), {"identity": nid, "boundary": xbound, "resource": [logs_pol]})) check("Nikhil: secret's resource policy names his user ARN; boundary does not mention Secrets Manager -> Allow", True, lambda: decide(req(nik, "secretsmanager:GetSecretValue", "arn:aws:secretsmanager:eu-west-1:%s:secret:s" % A), {"identity": nid, "boundary": xbound, "resource": [secret_pol]})) # ---- 4. which implicit denies limit a resource-based grant (Policy evaluation logic page, principal types) def principal_case(named, who, with_session): pol = [dict(Effect="Allow", Action="secretsmanager:GetSecretValue", Principal={"AWS": named}, Resource="*")] S = {"identity": [], "boundary": xbound, "resource": [pol]} if with_session: S["session"] = [allow("s3:GetObject")] # a session policy that does not allow the action return decide(req(who, "secretsmanager:GetSecretValue"), S) role_s = rsess(A, "examplerole", "sess"); fed = fsess(A, "exampleuser") check("resource policy names an IAM USER ARN; identity none, boundary no -> Allow", True, lambda: principal_case(nik["arn"], nik, False)) check("resource policy names an IAM ROLE ARN; boundary and session policy do not allow -> Deny", False, lambda: principal_case(f"arn:aws:iam::{A}:role/examplerole", role_s, True)) check("resource policy names the ROLE SESSION ARN; boundary and session policy do not allow -> Allow", True, lambda: principal_case(role_s["arn"], role_s, True)) check("resource policy names the FEDERATED USER session ARN -> Allow", True, lambda: principal_case(fed["arn"], fed, True)) check("resource policy names the IAM user who federated, not the session -> Deny", False, lambda: principal_case(fed["federator_arn"], fed, True)) # ---- 5. SCP, boundary, identity: all three must allow; a Deny anywhere wins (Permissions boundaries page) FULL = [allow("*")]; NONE = [] def three(scp_ok, b_ok, i_ok, deny_in=None): sc = [FULL if scp_ok else [allow("ec2:*")]] bd = [allow("s3:*")] if b_ok else [allow("ec2:*")] idp = [[allow("s3:*")]] if i_ok else [[allow("ec2:*")]] if deny_in == "scp": sc[0] = sc[0] + [deny("s3:GetObject")] if deny_in == "boundary": bd = bd + [deny("s3:GetObject")] if deny_in == "identity": idp = [idp[0] + [deny("s3:GetObject")]] return decide(req(nik, "s3:GetObject", "arn:aws:s3:::b/k"), {"scp": sc, "boundary": bd, "identity": idp}) for s_ in (True, False): for b_ in (True, False): for i_ in (True, False): check(f"SCP {'allows' if s_ else 'omits'} / boundary {'allows' if b_ else 'omits'} / identity {'allows' if i_ else 'omits'} -> s3:GetObject", s_ and b_ and i_, lambda s_=s_, b_=b_, i_=i_: three(s_, b_, i_)) for where in ("scp", "boundary", "identity"): check(f"all three allow, but an explicit Deny in the {where} -> Deny", False, lambda w=where: three(True, True, True, w)) # ---- 6. delegating user creation with a boundary condition (Permissions boundaries page, Maria and Zhang) zhang = user(A, "Zhang") XB = f"arn:aws:iam::{A}:policy/XCompanyBoundaries" zb = [allow(["iam:AttachUserPolicy", "iam:CreateUser", "iam:PutUserPermissionsBoundary", "iam:PutUserPolicy"], Condition={"StringEquals": {"iam:PermissionsBoundary": XB}}), deny("iam:DeleteUserPermissionsBoundary")] zp = [[allow("iam:*")]] check("Zhang: iam:CreateUser WITHOUT setting a boundary -> Deny", False, lambda: decide(req(zhang, "iam:CreateUser"), {"identity": zp, "boundary": zb})) check("Zhang: iam:CreateUser with the XCompanyBoundaries boundary -> Allow", True, lambda: decide(req(zhang, "iam:CreateUser", **{"iam:PermissionsBoundary": XB}), {"identity": zp, "boundary": zb})) check("Zhang: iam:CreateUser with a different boundary policy -> Deny", False, lambda: decide(req(zhang, "iam:CreateUser", **{"iam:PermissionsBoundary": f"arn:aws:iam::{A}:policy/Other"}), {"identity": zp, "boundary": zb})) check("Zhang: iam:DeleteUserPermissionsBoundary -> Deny (explicit)", False, lambda: decide(req(zhang, "iam:DeleteUserPermissionsBoundary"), {"identity": zp, "boundary": zb})) if __name__ == "__main__": flip = len(sys.argv) > 1 and sys.argv[1] == "--flip" bad = 0 w = max(len(r[0]) for r in rows) print(f"{'case (source: AWS IAM page named in the script)':<{w}} docs model result") for i, (label, exp, ok, why) in enumerate(rows): if flip and i == 1: exp = not exp # control: one expectation flipped on purpose res = "match" if ok == exp else "MISMATCH" bad += res != "match" print(f"{label:<{w}} {'Allow' if exp else 'Deny ':<5} {'Allow' if ok else 'Deny ' if ok is False else '? ':<6} {res} {why if res != 'match' or '--why' in sys.argv else ''}") print(f"\n{len(rows)} cases, mismatches: {bad}") --- py -X utf8 t_principals.py --why (the last column explains each decision) --- case (source: AWS IAM page named in the script) docs model result boundary: identity allows iam:CreateUser, boundary does not -> iam:CreateUser Deny Deny match permissions boundary does not allow (implicit deny) boundary: identity allows s3:*, boundary allows s3:* -> s3:GetObject Allow Allow match allowed by identity-based policy boundary: a boundary alone grants nothing -> s3:GetObject with no identity policy Deny Deny match no identity-based Allow (implicit deny) boundary updated to include iam:CreateUser -> iam:CreateUser Allow Allow match allowed by identity-based policy cross-account: PutObject to the production-logs bucket -> Deny (DenyS3Logs in the requester account) Deny Deny match requester account: explicit Deny; resource account: resource policy does not allow the requester (implicit deny) cross-account: PutObject to the production bucket -> Allow (both accounts allow) Allow Allow match requester account: identity side allows; resource account: resource policy allows the requester cross-account: DeleteObject on the production bucket -> Deny ('can create and edit, but not delete') Deny Deny match requester account: identity side allows; resource account: resource policy does not allow the requester (implicit deny) cross-account: bucket policy allows the role but the role has no identity policy -> Deny Deny Deny match requester account: no identity-based Allow (implicit deny); resource account: resource policy allows the requester cross-account: identity policy allows but the bucket policy is empty -> Deny Deny Deny match requester account: identity side allows; resource account: resource policy does not allow the requester (implicit deny) Nikhil: bucket policy allows PutObject on 'logs', boundary has an explicit Deny on it -> Deny Deny Deny match explicit Deny Nikhil: secret's resource policy names his user ARN; boundary does not mention Secrets Manager -> Allow Allow Allow match resource-based policy grants the user directly resource policy names an IAM USER ARN; identity none, boundary no -> Allow Allow Allow match resource-based policy grants the user directly resource policy names an IAM ROLE ARN; boundary and session policy do not allow -> Deny Deny Deny match permissions boundary does not allow (implicit deny) resource policy names the ROLE SESSION ARN; boundary and session policy do not allow -> Allow Allow Allow match resource-based policy grants the role-session directly resource policy names the FEDERATED USER session ARN -> Allow Allow Allow match resource-based policy grants the federated-session directly resource policy names the IAM user who federated, not the session -> Deny Deny Deny match permissions boundary does not allow (implicit deny) SCP allows / boundary allows / identity allows -> s3:GetObject Allow Allow match allowed by identity-based policy SCP allows / boundary allows / identity omits -> s3:GetObject Deny Deny match no identity-based Allow (implicit deny) SCP allows / boundary omits / identity allows -> s3:GetObject Deny Deny match permissions boundary does not allow (implicit deny) SCP allows / boundary omits / identity omits -> s3:GetObject Deny Deny match no identity-based Allow (implicit deny) SCP omits / boundary allows / identity allows -> s3:GetObject Deny Deny match SCP level 1 has no Allow SCP omits / boundary allows / identity omits -> s3:GetObject Deny Deny match SCP level 1 has no Allow SCP omits / boundary omits / identity allows -> s3:GetObject Deny Deny match SCP level 1 has no Allow SCP omits / boundary omits / identity omits -> s3:GetObject Deny Deny match SCP level 1 has no Allow all three allow, but an explicit Deny in the scp -> Deny Deny Deny match explicit Deny all three allow, but an explicit Deny in the boundary -> Deny Deny Deny match explicit Deny all three allow, but an explicit Deny in the identity -> Deny Deny Deny match explicit Deny Zhang: iam:CreateUser WITHOUT setting a boundary -> Deny Deny Deny match permissions boundary does not allow (implicit deny) Zhang: iam:CreateUser with the XCompanyBoundaries boundary -> Allow Allow Allow match allowed by identity-based policy Zhang: iam:CreateUser with a different boundary policy -> Deny Deny Deny match permissions boundary does not allow (implicit deny) Zhang: iam:DeleteUserPermissionsBoundary -> Deny (explicit) Deny Deny match explicit Deny 31 cases, mismatches: 0 --- py -X utf8 t_principals.py --flip (control: one expectation flipped on purpose) --- case (source: AWS IAM page named in the script) docs model result boundary: identity allows iam:CreateUser, boundary does not -> iam:CreateUser Deny Deny match boundary: identity allows s3:*, boundary allows s3:* -> s3:GetObject Deny Allow MISMATCH allowed by identity-based policy boundary: a boundary alone grants nothing -> s3:GetObject with no identity policy Deny Deny match boundary updated to include iam:CreateUser -> iam:CreateUser Allow Allow match cross-account: PutObject to the production-logs bucket -> Deny (DenyS3Logs in the requester account) Deny Deny match cross-account: PutObject to the production bucket -> Allow (both accounts allow) Allow Allow match cross-account: DeleteObject on the production bucket -> Deny ('can create and edit, but not delete') Deny Deny match cross-account: bucket policy allows the role but the role has no identity policy -> Deny Deny Deny match cross-account: identity policy allows but the bucket policy is empty -> Deny Deny Deny match Nikhil: bucket policy allows PutObject on 'logs', boundary has an explicit Deny on it -> Deny Deny Deny match Nikhil: secret's resource policy names his user ARN; boundary does not mention Secrets Manager -> Allow Allow Allow match resource policy names an IAM USER ARN; identity none, boundary no -> Allow Allow Allow match resource policy names an IAM ROLE ARN; boundary and session policy do not allow -> Deny Deny Deny match resource policy names the ROLE SESSION ARN; boundary and session policy do not allow -> Allow Allow Allow match resource policy names the FEDERATED USER session ARN -> Allow Allow Allow match resource policy names the IAM user who federated, not the session -> Deny Deny Deny match SCP allows / boundary allows / identity allows -> s3:GetObject Allow Allow match SCP allows / boundary allows / identity omits -> s3:GetObject Deny Deny match SCP allows / boundary omits / identity allows -> s3:GetObject Deny Deny match SCP allows / boundary omits / identity omits -> s3:GetObject Deny Deny match SCP omits / boundary allows / identity allows -> s3:GetObject Deny Deny match SCP omits / boundary allows / identity omits -> s3:GetObject Deny Deny match SCP omits / boundary omits / identity allows -> s3:GetObject Deny Deny match SCP omits / boundary omits / identity omits -> s3:GetObject Deny Deny match all three allow, but an explicit Deny in the scp -> Deny Deny Deny match all three allow, but an explicit Deny in the boundary -> Deny Deny Deny match all three allow, but an explicit Deny in the identity -> Deny Deny Deny match Zhang: iam:CreateUser WITHOUT setting a boundary -> Deny Deny Deny match Zhang: iam:CreateUser with the XCompanyBoundaries boundary -> Allow Allow Allow match Zhang: iam:CreateUser with a different boundary policy -> Deny Deny Deny match Zhang: iam:DeleteUserPermissionsBoundary -> Deny (explicit) Deny Deny match 31 cases, mismatches: 1 WHY THIS WORKS AS AN ANSWER --------------------------- The expected result of every row comes from a sentence or worked example on an AWS IAM page (Permissions boundaries, Cross-account policy evaluation logic, Policy evaluation logic), written before the model ran, so a match is evidence that the model agrees with the documentation and a MISMATCH would have been a bug in one or the other. All 31 match; the control, with one expectation flipped, reports exactly one mismatch, so the comparison can fail. The principal-type rows are the surprising ones: a resource policy naming an IAM user, a role session or a federated session grants on its own, while one naming a role (or the user who federated) is still limited by a boundary and session policy. Reasons for the order of the checks: a Deny anywhere wins first, SCPs come next, then the resource policy's direct grants, then identity policy, boundary and session policy. One honest note about the first run: it reported one mismatch, caused by my test data (an SCP level written as a list of statements where a list of policies was expected). The model had read the malformed level as empty and said 'SCP level 1 has no Allow'. That fail-silent behaviour is the real defect, so stmts() now raises ValueError for anything that is neither a policy nor a statement, and the output above is from the fixed version. Not modelled (it raises NotModelled): resource control policies, wildcard principals in an Allow, NotPrincipal, account principals in ordinary resource policies, VPC endpoint policies, RAM, role chaining. Not run: any real AWS request.