IAM Deep Dive

AWS Intermediate/Advanced

Chapter 2 ยท IAM Policy Evaluation and Cross-Account Access

AWS Fundamentals 2 taught you to write an IAM policy that allows something. This chapter is about the harder question: given all the policies that could apply to a request, will it be allowed? Those policies include the service control policies from AWS Intermediate/Advanced 1, permissions boundaries, session policies, identity-based policies and resource-based policies, in two accounts when the request crosses an account. You will learn the evaluation order, the rules for conditions and policy variables, how roles and STS (the Security Token Service) hand out temporary access, how to let another account in without opening a door for strangers, and a workflow for shrinking a broad policy to what a workload really uses.

How every example in this chapter was verified
There is no AWS account and no AWS CLI on the machine that wrote this course, so nothing here was run against AWS.
  • Run here (level 1): Python 3.14.0 on Windows 11, in a scratch folder outside this site, with boto3/botocore 1.43.112 (used offline, through its test Stubber) and the open-source linter parliament 1.6.4 (offline, with the setuptools pin that it needs on this Python). Output is pasted from the real runs.
  • Needs an AWS account (level 2): none in this chapter. Nothing here creates, changes or costs anything.
  • From the documentation, not run (level 3): everything about how AWS itself behaves. I read the IAM, STS and Access Analyzer pages on 2026-10-12 through a page-fetch tool that summarises them, so quotes are as that tool returned them. The copy I could read of one page ended early; the chapter says where that matters.
The central tool is a model I wrote of the documented evaluation rules (iam_eval.py), not AWS. I tested it against the documentation's own worked examples and tables. When it says "Allow", that means "allowed by my reading of the documentation". It refuses (raises an error) for anything it does not model, instead of guessing.

The Order AWS Evaluates a Request In

Every request starts as an implicit deny. The documentation says "By default, all requests are implicitly denied with the exception of the AWS account root user, which has full access", and "An explicit deny overrides an explicit allow". The enforcement code then works through the policy types in this order (the page's own list, in my words):

#StepOutcome if it fails
1Explicit deny. Look through every applicable policy (SCPs, RCPs, resource-based, identity-based, permissions boundaries, session policies) for a Deny that matchesOne matching Deny anywhere gives a final Deny
2Resource control policies (RCPs). An Organizations policy type attached to resources of the accounts it covers; RCPFullAWSAccess is attached automatically when RCPs are enabledNo applicable Allow: Deny
3Service control policies (SCPs). Apply to the principals of the accounts they cover (AWS Intermediate/Advanced 1)No applicable Allow: Deny
4Resource-based policy. Depending on the principal it names, an Allow here can be enough on its own (next section)Continues to step 5, or ends in Allow
5Identity-based policy. Some statement must allow the actionNone: implicit Deny
6Permissions boundary, if the user or role has oneIt does not allow the action: implicit Deny
7Session policy, if the principal is a session and one was passedIt does not allow the action: implicit Deny; otherwise Allow

Three rules follow from the page, and they are the whole of the chapter in miniature. Inside one account, identity-based and resource-based policies are a union: "If an action is allowed by an identity-based policy, a resource-based policy, or both, then AWS allows the action." Identity policies and permissions boundaries are an intersection: "the resulting permissions are the intersection of the two categories". And SCPs, boundaries and identity policies are all ceilings and grants together: "an action must be allowed by all three policy types". Across accounts, there are two evaluations, one in each account, and "the request is allowed only if both evaluations return a decision of Allow".

Who the resource policy names changes what it can do

This is the part most people have never heard, and it is stated plainly on the evaluation page. Within the same account, a resource-based policy that names the requester can grant access even when an identity policy, boundary or session policy would not, depending on which kind of principal it names:

The resource policy namesLimited by the principal's identity policy?Limited by a boundary or session policy?Documented example ARN
An IAM userNoNo (a user has no session policy)arn:aws:iam::111122223333:user/exampleuser
An IAM role (by ARN, or with aws:PrincipalArn)NoYes: limited by an implicit deny in a boundary or session policyarn:aws:iam::111122223333:role/examplerole
A role sessionNoNo: "Permissions granted directly to a session are not limited"arn:aws:sts::111122223333:assumed-role/examplerole/examplerolesessionname
A federated user session (GetFederationToken)NoNoarn:aws:sts::111122223333:federated-user/exampleuser
The IAM user who federatedNoYesarn:aws:iam::111122223333:user/exampleuser

The page explains the role-versus-session split: "When you assume a role and make a request, the principal making the request is the IAM role session ARN and not the ARN of the role itself." Two exceptions are also stated. Role trust policies and KMS key policies "must explicitly allow access for principals": an identity policy alone is never enough for them. And "Resource-based policies for services other than IAM and AWS KMS may also require an explicit Allow statement within the same account", so for any given service you check its own documentation (AWS Intermediate/Advanced 3 returns to key policies).

A model of those rules, tested against the documentation

I turned the steps into code and fed it the documentation's own worked examples: the boundary on user Shirley, the cross-account request from Carlos's Demo role to an S3 bucket, the delegated user-creation example (Maria, Zhang and Nikhil), the principal types in the table above, and the three-way SCP, boundary and identity combinations. The core of it (the full file is in Exercise 1):

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"

Run here (level 1). Each row's "docs" value was written down before the model ran:

py -X utf8 t_principals.py
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 Allow Allow match 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
py -X utf8 t_principals.py (continued)
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: 0

All 31 results match the documentation. The control, running the script with one expectation flipped on purpose, reports 31 cases, mismatches: 1, so the comparison can fail. One thing to know about how I got here: the first run reported a mismatch, and it was my test data, not the model: I had written an SCP level as a plain list of statements where the model expected a list of policies, and the model silently read it as "no statements". That is the dangerous failure for any policy tool (a malformed policy quietly treated as empty), so the model now raises an error on anything that is not a policy or a statement, and the table above is from the fixed version. The model does not handle resource control policies, wildcard principals in an Allow, account principals in ordinary resource policies, or role chaining; it raises an error for those.

Conditions and Policy Variables

A Condition compares keys in the request context with values in the policy. The rules below are on the Condition and Condition-operators pages. The ones that cause real incidents are about keys that are missing:

SituationDocumented result
Key missing, ordinary operator (StringEquals, Bool, NumericLessThan, IpAddress, ...)"The values do not match and the condition is false"
Key missing, negated operator (StringNotEquals, StringNotLike, ArnNotLike, ...)The condition is true
Operator ends in IfExistsKey missing: true. Key present: evaluated normally. "If you are using an "Effect": "Deny" element with a negated condition operator like StringNotEqualsIfExists, the request is still denied even if the condition key is not present"
Null operatorTests only whether the key exists ("false" means "must exist")
ForAllValues: qualifierTrue if every request value matches, and also true if the key is missing or empty
ForAnyValue: qualifierTrue if at least one request value matches; false if the key is missing
Several keys or operators in one ConditionAll must hold (the page's tables show a statement failing when any one part fails)
Several values for one keyAny one may match (the page's own example: "as long as the s3:prefix matches any one of the specified patterns")
Policy variables such as ${aws:username}Supported in String, ARN and Boolean operators; not supported in Numeric, Date, Binary and IP address operators or in Null

One rule I could not verify. How a plain negated operator combines several policy values (does StringNotEquals with two values mean "equals neither"?) is a sentence on the Condition page that was past the end of the copy I could read. The set-operator forms are documented ("None of the values of the context key in the request can match any of the context key values in your policy"), and my model implements the plain form the same way, but treat that one as unverified. Also note the page's advice on key types: "Multivalued context keys require a condition set operator", and ForAllValues or ForAnyValue must not be used on single-valued keys.

The part of the model that implements the missing-key and set-operator rules:

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)

Run here (level 1). I ran every table I could find on those pages through it: the tag-key examples, the ${aws:username} home-directory example, the numeric, date, Boolean, IP and ARN examples, IfExists, Null and both set operators. Again the documentation's answer was written first.

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

49 of 49 match. Three rows I did not count, and why: for the aws:ResourceOrgPaths example the page's table says Match for a path inside sportsou/ and No match for a path under mediaou/ and for a missing key. ForAllValues:StringNotLike is true only when no request value matches any pattern, and the page itself says ForAllValues is true on a missing key, so by the page's own rules those three results come out the other way round (below). It reads to me as an error in that table, and in a Deny statement the page's intent (deny access outside the listed OUs) agrees with the model. I cannot be certain, so I show it rather than bury it:

py -X utf8 t_conditions.py (continued)
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

The ARN rows use well-formed ARNs: the page's ForAllValues:ArnLike table prints arn:aws::cloudfront:... with an extra colon on both the policy and the request side, which matches itself but is not an ARN; I used the form in the policy JSON above it.

Mistakes the documentation warns about, run

These are consequences of the rules above. They are the model's answers, derived from the documented rules (not measurements from AWS). The ec2:RunInstances case is the page's own example: a statement is "evaluated against every resource that is required to launch the instance", and an instance-type key exists only on the instance.

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']

The practical rules: guard every ForAllValues Allow with Null; use IfExists when one statement spans resource types that do not all carry the key; remember that a Deny with a negated operator also catches requests that lack the key; and know that aws:SourceIp is the caller's IP address, so a service acting on your behalf (the page's example is CloudFormation calling EC2) is seen with the service's address. The documentation says there is "currently no way" to pass the original through.

Permissions Boundaries and Session Policies

Both are ceilings, not grants. A permissions boundary is a managed policy attached to a user or role "to set the maximum permissions that an identity-based policy can grant". The documentation's example gives user Shirley this boundary:

{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": [ "s3:*", "cloudwatch:*", "ec2:*" ], "Resource": "*" } ] }

Shirley "can never perform operations in any other service, including IAM, even if she has a permissions policy that allows it", and the boundary alone gives her nothing. Both statements ran above (the first four rows of the table). A session policy is passed when you create temporary credentials (AssumeRole's Policy and PolicyArns parameters). "The resulting session's permissions are the intersection of the role's identity-based policy and the session policies. ... You cannot use session policies to grant more permissions than those allowed by the identity-based policy of the role". It is how a tool that holds a powerful role hands a narrower one to a job:

{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": "s3:GetObject", "Resource": "arn:aws:s3:::reports/*" } ] }

Delegating without losing control is the use that justifies boundaries. In the documentation's example the administrator lets Zhang create users, but only if every new user carries the boundary XCompanyBoundaries. The trick is a condition on the request itself (iam:PermissionsBoundary) in Zhang's own boundary, plus a Deny on removing boundaries. These statements (modelled on the page's) are the last four rows of the Exercise 1 table: creating a user without a boundary is denied, with the right boundary allowed, with a different boundary denied, and removing a boundary denied.

{ "Version": "2012-10-17", "Statement": [ { "Sid": "CreateOrChangeOnlyWithBoundary", "Effect": "Allow", "Action": [ "iam:AttachUserPolicy", "iam:CreateUser", "iam:PutUserPermissionsBoundary", "iam:PutUserPolicy" ], "Resource": "*", "Condition": { "StringEquals": { "iam:PermissionsBoundary": "arn:aws:iam::111111111111:policy/XCompanyBoundaries" } } }, { "Sid": "NoBoundaryUserDelete", "Effect": "Deny", "Action": "iam:DeleteUserPermissionsBoundary", "Resource": "*" } ] }

Two warnings from the page. First, never use NotPrincipal with Deny in a resource-based policy for principals that have boundaries: "The NotPrincipal element with a Deny effect will always deny any IAM principal that has a permissions boundary policy attached"; use ArnNotEquals on aws:PrincipalArn instead. Second, a boundary's implicit deny does not block a resource policy that names the user: in the page's example, a Secrets Manager secret whose policy names Nikhil lets him read it even though his boundary never mentions Secrets Manager (a row in the table above). A boundary is not a way to stop access that resource policies grant directly: use an explicit Deny for that.

Roles and STS

A role has two policies, and the AssumeRole page says so: "a role trust policy that specifies who can assume the role, and a permissions policy that specifies what can be done with the role". The trust policy is a resource-based policy. Calling AssumeRole returns temporary credentials (an access key ID, a secret key and a session token).

Who may assume a role (from the same page):

  • Same account: either the trust policy names the user directly, or the trust policy trusts the account and the user has an identity policy that allows sts:AssumeRole on the role. "When a resource-based policy grants access to a principal in the same account, no additional identity-based policy is required."
  • Another account: the role's trust policy must trust that account and "A user who wants to access a role in a different account must also have permissions that are delegated from the account administrator": an identity policy in their own account allowing AssumeRole on the role's ARN.
{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Principal": { "AWS": "arn:aws:iam::111111111111:user/dev" }, "Action": "sts:AssumeRole" } ] }

Run here (level 1). The assumption rules, the external ID, the service-principal case and the session-policy rule from the pages, through the model. The last three rows are marked derived: they combine two documented rules and report whether the Deny statement applies.

py -X utf8 t_trust.py
case basis expect model result (derived rows: does the Deny statement apply?) same account: trust names the USER directly, user has no identity policy docs Allow Allow match same account: trust names the ACCOUNT, user has no identity policy docs Deny Deny match same account: trust names the ACCOUNT, user's policy allows sts:AssumeRole on this role docs Allow Allow match same account: trust names the account, user's policy allows a DIFFERENT role docs Deny Deny match same account: the trust policy does not name the user or account at all docs Deny Deny match same account: trust allows, but the user has an explicit Deny on sts:AssumeRole docs Deny Deny match cross account: trust in B names account A, user in A may AssumeRole on the role docs Allow Allow match cross account: trust in B names account A, but the user has no policy in A docs Deny Deny match cross account: user may AssumeRole in A, but B's trust policy does not name A docs Deny Deny match cross account: even if B's trust names the user ARN, the user still needs a policy in A docs Deny Deny match external ID: AssumeRole sent with ExternalId 12345 (the trust policy requires it) docs Allow Allow match external ID: AssumeRole sent with the other customer's ExternalId 67890 docs Deny Deny match external ID: AssumeRole sent with no ExternalId docs Deny Deny match no external ID condition: the SAME caller with ExternalId 67890 is let in (the confused deputy gap) docs Allow Allow match service principal, no condition: CloudTrail acting for the owner's account 111122223333 docs Allow Allow match service principal, no condition: CloudTrail acting for an UNKNOWN account 999999999999 docs Allow Allow match with aws:SourceAccount=111122223333: CloudTrail acting for 111122223333 docs Allow Allow match with aws:SourceAccount=111122223333: CloudTrail acting for 999999999999 docs Deny Deny match with aws:SourceAccount=111122223333: a request that carries no SourceAccount at all docs Deny Deny match session policy allows s3:GetObject only; request s3:GetObject docs Allow Allow match session policy allows s3:GetObject only; request s3:PutObject (role allows it) docs Deny Deny match session policy allows ec2:*, the role does not -> a session policy cannot add permissions docs Deny Deny match no session policy passed -> the role's own permissions apply docs Allow Allow match Deny if MFA age > 3600: caller used access keys (the key is absent) -> the Deny does NOT apply derived Skipped Skipped match same Deny written with NumericGreaterThanIfExists -> the Deny applies to the keys-only caller derived Applies Applies match same Deny written with NumericGreaterThan: caller with a 5000-second-old MFA session -> applies derived Applies Applies match 26 cases, mismatches: 0

All 26 match (and a flipped control reports one mismatch). Read the derived rows twice. aws:MultiFactorAuthAge is, per the Condition page, absent when the caller uses long-term access keys; a numeric operator on a missing key is false; so a Deny that says "deny if the MFA is older than an hour" simply does not apply to the caller with no MFA at all. Adding IfExists makes the missing key count as true and closes the gap. (Whether you want that Deny at all is a separate decision: it would also catch every role session that has no MFA.)

Session length and what the SDK checks

The DurationSeconds rules, as written on the page, as a function:

py -X utf8 duration.py
DurationSeconds role max chained result (not given) 3600 False ok: 3600 s (1.00 h) 3600 3600 False ok: 3600 s (1.00 h) 7200 3600 False fails: above the role's maximum session duration (3600 s) 7200 14400 False ok: 7200 s (2.00 h) 43200 43200 False ok: 43200 s (12.00 h) 43200 14400 False fails: above the role's maximum session duration (14400 s) 899 3600 False fails: below the 900-second minimum 900 3600 False ok: 900 s (0.25 h) 3600 43200 True ok: 3600 s (1.00 h) 7200 43200 True fails: role chaining limits the session to one hour (not given) 43200 True ok: 3600 s (1.00 h) (not given) 43200 False ok: 3600 s (1.00 h)

The other documented limits: a session policy is plain text of at most 2,048 characters (inline and managed together), up to 10 managed policy ARNs; RoleSessionName is 2 to 64 characters from [\w+=,.@-]; ExternalId is 2 to 1,224 characters from [\w+=,.@:/-]; SourceIdentity persists across chained sessions and may not start with aws:. Also "In cross-account scenarios, the role session name is visible to, and can be logged by the account that owns the role": do not put anything sensitive in it.

Run here (level 1). How much of that does the AWS SDK check before sending anything? I called assume_role through botocore's test Stubber (no network) with values that break each documented rule:

py -X utf8 sts_params.py
botocore 1.43.112, boto3 1.43.112; no network (Stubber) case botocore (client side) documented rule a valid call accepted ok RoleSessionName of 1 character REJECTED (valid min length: 2) RoleSessionName length 2-64 RoleSessionName of 65 characters accepted RoleSessionName length 2-64 RoleSessionName with a space accepted RoleSessionName pattern [\w+=,.@-]* DurationSeconds 899 REJECTED (valid min value: 900) DurationSeconds 900-43200 DurationSeconds 43201 accepted DurationSeconds 900-43200 DurationSeconds 3600 accepted ok ExternalId of 1 character REJECTED (valid min length: 2) ExternalId length 2-1224 ExternalId with a space accepted ExternalId pattern [\w+=,.@:/-]* session Policy of 2,049 characters accepted session policy at most 2,048 characters 11 managed session policy ARNs accepted at most 10 managed session policy ARNs TokenCode of 5 digits REJECTED (valid min length: 6) TokenCode exactly 6 digits TokenCode of 6 letters accepted TokenCode exactly 6 digits RoleArn of 19 characters REJECTED (valid min length: 20) RoleArn length at least 20 cases the documentation forbids but botocore accepted without complaint: 7

botocore enforces the minimum lengths and the minimum duration and nothing else. A 65-character session name, a 43,201-second duration, a space in a session name or external ID, a 2,049-character session policy and 11 managed policy ARNs all went through; AWS would reject them (according to the page), but you would find out only from the service's error. This is a client-side check in the installed SDK version, not a statement about AWS: I could not send a request to see AWS's response.

Cross-Account Access, and the Confused Deputy

The Carlos example from the Cross-account page is worth restating, because it is the cleanest picture of the two-evaluation rule. A Demo role in account 111111111111 may use S3, but its policy denies any bucket with log in its name. A bucket policy in account 222222222222 allows the Demo role to get and put (not delete) objects. Putting to the production-logs bucket fails in his own account (explicit deny). Putting to the production bucket works because both accounts allow it. Deleting an object fails in the bucket's account because the bucket policy never allowed deletes, even though his own policy would. All three are rows in the Exercise 1 table, with two more: a bucket policy that allows the role but a role that has no identity policy (denied), and the reverse (denied).

The external ID

Giving a third party a role in your account has a trap. The role ARN "isn't a secret", so another customer of the same vendor can tell the vendor to use your role ARN, and the vendor, trusted by your role, acts on your account for them: the vendor is a confused deputy. The fix is an sts:ExternalId condition in the trust policy:

{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Principal": { "AWS": "arn:aws:iam::111111111111:root" }, "Action": "sts:AssumeRole", "Condition": { "StringEquals": { "sts:ExternalId": "12345" } } } ] }

The documentation is specific about who chooses the value: it "must be unique among Example Corp's customers and controlled by Example Corp, not its customers. This is why you get it from Example Corp and you don't come up with it on your own." The model's rows above show the effect: with the condition, only a caller that sends 12345 gets in; without it, the same caller sending the other customer's ID is let in.

Services as principals

The same problem exists between AWS services. If a bucket policy trusts cloudtrail.amazonaws.com with no condition, a CloudTrail trail in any account that knows the bucket name could write there. The documentation recommends aws:SourceArn, aws:SourceAccount, aws:SourceOrgID or aws:SourceOrgPaths "whenever access to one of your resources is granted to an AWS service principal":

{ "Version": "2012-10-17", "Statement": [ { "Sid": "AWSCloudTrailWrite", "Effect": "Allow", "Principal": { "Service": "cloudtrail.amazonaws.com" }, "Action": "s3:PutObject", "Resource": "arn:aws:s3:::amzn-s3-demo-bucket1/*", "Condition": { "StringEquals": { "aws:SourceAccount": "111111111111" } } } ] }

In the table above, with this condition the owner's account is let in and an unknown account (or a request carrying no SourceAccount at all) is not. The page adds that some services have their own protection: for a KMS grant given to an AWS service, "AWS KMS uses the encryption context associated with the resource, and the key grant". Resource control policies can enforce the same check across an organization (an example RCP is on the page; I did not run it, and my model does not handle RCPs).

Resource-Based Policies in Practice

Which resources take a resource-based policy? The documentation's Access Analyzer page lists the resource types it checks for external access, which is a good list of the places a resource policy can open a door: S3 buckets (and directory buckets), IAM roles (trust policies), KMS keys, Lambda functions and layers, SQS queues, Secrets Manager secrets, SNS topics, EBS and RDS snapshots, ECR repositories, EFS file systems, and DynamoDB tables and streams. The commonest design question is the one in the table at the start of this chapter: do I put the grant in the identity policy, the resource policy, or both? Same account, for most resources, either is enough; for another account you need both; for a role trust policy or a KMS key policy the resource side is mandatory.

The Condition page's SNS-to-SQS example shows the usual shape for a service writing to a queue: the queue policy names sns.amazonaws.com and an ArnEquals on aws:SourceArn pins it to one topic. That is the confused-deputy guard from the previous section in its most common form. (From the documentation, not run; AWS Intermediate/Advanced 9 builds it.)

Access Analyzer and the Policy Simulator

Two AWS tools check policies. Neither could be used here (level 3), so this is the documentation, with its caveats.

IAM Access Analyzer does six things (all from its overview page):

  • External access analyzers find resources shared outside your zone of trust (your account or organization) by reasoning about resource policies, and generate a finding for each. They look only at the Region where the analyzer is enabled, so you need one per Region. A new or changed policy is analysed "within about 30 minutes".
  • Internal access analyzers show which principals inside your organization can reach selected critical resources (S3 buckets, DynamoDB tables, RDS snapshots among them).
  • Unused access analyzers report unused roles, access keys and passwords and, for active roles, unused services and actions.
  • Policy validation checks a policy against IAM grammar and best practices.
  • Custom policy checks test a policy against standards you set, for example "this update grants no new access" or "these critical actions are not allowed".
  • Policy generation builds a policy from the actions a user or role used in your CloudTrail logs over a date range.

On cost, the page says charges apply to unused-access analysis (per role and user analyzed, per analyzer, per month), internal-access analysis (per resource monitored) and custom policy checks (per API request); it points to the pricing page for the rest, which I did not read.

The IAM policy simulator evaluates "identity-based policies, IAM permissions boundaries, service control policies (SCPs), and resource-based policies that you provide" without sending a real request. Its documented limits matter: "The policy simulator results can differ from your live AWS environment"; it "does not support resource control policies (RCPs)" and can differ for VPC endpoint policies, role chaining or multiple resource-based policies on one resource; for SCPs it gives the decision but "does not surface the matched statements". You supply the context key values that your conditions use, or the condition is not satisfied and the action is denied. It is a good check, not a final one: the page itself recommends confirming against the live environment.

Offline Linting with parliament

You can catch a class of mistakes before any AWS call. parliament is an open-source linter that ships its own copy of AWS's action and condition-key lists. Run here (level 1):

py -X utf8 lint_policies.py
parliament 1.6.4 -- core checks correct (no findings) misspelt action (GetObjekt) UNKNOWN_ACTION: Unknown action s3:GetObjekt bucket ARN where an object ARN is needed RESOURCE_MISMATCH: [{'action': 's3:GetObject', 'required_format': 'arn:*:s3:::*... Effect misspelt (Allowed) MALFORMED: Unknown Effect used. Effect must be either Allow or Deny no Resource MALFORMED: Statement contains neither Resource nor NotResource condition operator misspelt RESOURCE_STAR: ['s3:GetObject'] UNKNOWN_OPERATOR: StringEqualz EC2 condition key on an S3 action RESOURCE_STAR: ['s3:GetObject'] UNKNOWN_CONDITION_FOR_ACTION: Unknown condition ec2:InstanceType for action s3:GetObject iam:* on every resource RESOURCE_STAR: ['iam:AddClientIDToOpenIDConnectProvider', 'iam:AddRoleToIns... Action * on Resource * (no findings) Chapter 1's default-controls SCP (actions added to AWS in recent years) (no findings) -- community auditors (off by default), on a policy that can escalate privileges core only: ["RESOURCE_STAR: ['iam:PassRole', 'lambda:CreateFunction', 'lambda:InvokeFunc..."] with community on: ['PERMISSIONS_MANAGEMENT_ACTIONS', 'PRIVILEGE_ESCALATION', "RESOURCE_STAR: ['iam:PassRole', 'lambda:CreateFunction', 'lambda:InvokeFunc..."] -- the limit: a role trust policy is not an identity policy ['MALFORMED: Statement contains neither Resource nor NotResource']

What it caught: a misspelt action, a bucket ARN where an object action needs an object ARN, a misspelt effect, a missing Resource, a misspelt condition operator, an EC2 condition key on an S3 action, a Resource: "*" on iam:*, and (only with the community auditors switched on) a combination of actions that can escalate privileges. What it did not do: it reported nothing for Action: * on Resource: *; it reported nothing for the SCP from AWS Intermediate/Advanced 1 (it knows the account:CloseAccount action, but SCP-specific rules are outside what it checks); and because it treats every input as an identity policy it called a role trust policy "malformed". It also called the documentation's own iam:PermissionsBoundary condition "mismatched type but usable". Treat its output as a first filter. The data inside it is as current as the installed package, which I did not date.

The Least-Privilege Workflow

"Least privilege" is a process, not a policy you write once: start with a policy that works, observe what the workload really does, cut the policy to that, and keep checking. The AWS tools that fit each step are on the documentation page above: generate a policy from CloudTrail activity, validate it, check it against your standards, and watch unused access findings afterwards. I cannot run those, so here is the method on invented data: twelve events in the general shape of CloudTrail records (the log is made up; I have no CloudTrail to confirm that real events carry exactly these names), turned into a policy, linted, replayed and attacked.

py -X utf8 least_priv.py (steps 1 and 2)
12 invented events written to events.json 1. naive actions (service:eventName): ['dynamodb:GetItem', 'dynamodb:PutItem', 'dynamodb:Query', 's3:GetObject', 's3:ListObjects', 's3:ListObjectsV2', 's3:PutObject', 'sqs:DeleteMessage', 'sqs:ReceiveMessage'] 2. parliament on the naive policy: ('UNKNOWN_ACTION', 'Unknown action s3:ListObjects') ('UNKNOWN_ACTION', 'Unknown action s3:ListObjectsV2')

The first lesson is in step 2. An event's name is not always an IAM action: the log records ListObjects and ListObjectsV2, and parliament says s3:ListObjects and s3:ListObjectsV2 are not actions (the IAM action for listing a bucket is s3:ListBucket). I fixed those two by hand, from my own knowledge, accepting the fix only because parliament then recognised the action. Next, resources are taken from the request parameters so the policy is scoped (a bucket for ListBucket, bucket/* for object actions):

{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": [ "dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:Query" ], "Resource": [ "arn:aws:dynamodb:eu-west-1:111111111111:table/orders" ] }, { "Effect": "Allow", "Action": [ "s3:ListBucket" ], "Resource": [ "arn:aws:s3:::reports" ] }, { "Effect": "Allow", "Action": [ "s3:PutObject" ], "Resource": [ "arn:aws:s3:::reports-out/*" ] }, { "Effect": "Allow", "Action": [ "s3:GetObject" ], "Resource": [ "arn:aws:s3:::reports/*" ] }, { "Effect": "Allow", "Action": [ "sqs:DeleteMessage", "sqs:ReceiveMessage" ], "Resource": [ "arn:aws:sqs:eu-west-1:111111111111:jobs" ] } ] }
py -X utf8 least_priv.py (steps 5 to 8)
5. parliament on the derived policy: (no findings) | community auditors: (no findings) 6. replay: 12 of 12 logged requests are allowed by the derived policy (model) 7. requests that were NOT in the log: s3:DeleteObject arn:aws:s3:::reports/a.csv denied s3:GetObject arn:aws:s3:::other-bucket/a.csv denied s3:PutObject arn:aws:s3:::reports/new.csv denied dynamodb:DeleteTable arn:aws:dynamodb:eu-west-1:111111111111:table/orders denied dynamodb:GetItem arn:aws:dynamodb:eu-west-1:111111111111:table/users denied sqs:PurgeQueue arn:aws:sqs:eu-west-1:111111111111:jobs denied iam:CreateUser * denied 8. the action lists inside parliament: s3:* = 163 actions, dynamodb:* = 71 actions, sqs:* = 20 actions a broad policy (s3:*, dynamodb:*, sqs:* on Resource *) allows 254 actions on every resource; the derived policy allows 8 actions on 5 resources 3.1% of the actions, and each limited to the resources actually used

Every logged request is allowed (a policy that breaks the workload is no good), and the requests that were not in the log are denied, including the neighbouring ones that a broad policy would have allowed: another bucket, another table, a destructive action on the right table. The last step counts what a "broad" version would permit using parliament's own action lists: s3:*, dynamodb:* and sqs:* on every resource is 254 actions; the derived policy is 8 actions on 5 resources. The caveats are the point: a log shows what happened in the window you looked at, so a monthly job or an error-handling path that did not run is missing and will fail when it first runs; read access can be wider than the log shows (the log lists each object read, the policy allows the prefix); and the derived policy contains no conditions, which is where the next gains (a source VPC, an MFA requirement, a required tag) come from. I believe object-level S3 and DynamoDB data events must be switched on in CloudTrail before they are logged at all, but I did not check that today.

Cost and Clean-Up

Nothing in this chapter creates anything in AWS, so there is nothing to delete and nothing to pay. The Access Analyzer features that charge are listed above; check the pricing pages before enabling them. When you do try these in a real account, do it in the sandbox account from AWS Intermediate/Advanced 1, with a budget alert (AWS Fundamentals 9) in place first.

What was and was not checked
Run here: the evaluation model against 31 documented outcomes, 49 documented condition results, 26 role and cross-account cases and the derived MFA cases; botocore's parameter checks (14 cases, offline); the documented session-duration rules; parliament on 12 policies; and the log-to-policy method on invented events. From the documentation, not run: every statement about how AWS behaves, including Access Analyzer, the policy simulator and RCPs. Unverified: how a plain negated operator combines several values; that real CloudTrail events have the names I invented; the dating of parliament's data. Not run at all: any real request, any role assumption, Access Analyzer, the simulator, other SDK versions or other languages' SDKs.

Practice

Exercise 1

Write an evaluator for the documented evaluation order (explicit deny first, SCP levels, resource-based policy with the principal-type rules, identity policy, permissions boundary, session policy; two evaluations for cross-account requests) that raises an error for anything it does not model. Run it against the documentation's worked examples (Shirley, Carlos, Nikhil, the principal-type table, the SCP, boundary and identity combinations, Maria and Zhang), writing the expected result first, then flip one expectation as a control.

๐Ÿ“„ View solution
Exercise 2

Add condition evaluation (string, ARN, IP, Boolean, numeric and date operators, IfExists, Null, ForAllValues and ForAnyValue, policy variables, the missing-key rules). Run every table from the AWS condition pages as a test and report any row you believe the page has wrong. Then show five traps: ForAllValues without a Null guard, ec2:RunInstances across several resources, a Deny with a negated operator on a missing key, and the cases your evaluator refuses.

๐Ÿ“„ View solution
Exercise 3

Model role assumption (same account, cross-account, external ID, service principals with aws:SourceAccount, session policies, the MFA-age trap), check which AssumeRole limits the AWS SDK enforces offline, apply the documented session-duration rules, lint policies with parliament, and derive a least-privilege policy from an event log, then replay the log and try requests that were never logged.

๐Ÿ“„ View solution

Chapter 2 Quick Reference

  • Default is deny (except the account root user). Order: any explicit Deny, RCPs, SCPs, resource-based policy, identity-based policy, permissions boundary, session policy. A Deny anywhere wins
  • Same account: identity and resource policies are a union; identity policy, boundary and session policy are an intersection; SCP and RCP allows are needed too. Another account: both accounts must allow
  • A resource policy naming a user, a role session or a federated session grants on its own; one naming a role is still limited by the boundary and session policy. Trust policies and KMS key policies must name the principal
  • Missing key: ordinary operators are false, negated operators true, IfExists true, ForAllValues true, ForAnyValue false. Guard ForAllValues with Null
  • Policy variables work in String, ARN and Boolean operators, not in Numeric, Date, Binary, IP or Null. Multivalued keys need a set operator
  • One statement is checked against every resource the action touches: use IfExists for keys only some resources carry
  • Boundaries and session policies only limit. A boundary's implicit deny does not stop a resource policy that names the user; use an explicit Deny. Never NotPrincipal + Deny with boundaries
  • AssumeRole: session 900 s up to the role's maximum (1 to 12 h), default 3,600 s, role chaining 1 h; session policy at most 2,048 characters; botocore checked only minimum lengths offline
  • Third party in your account: sts:ExternalId in the trust policy, a value the third party generates. AWS service in your resource policy: aws:SourceArn or aws:SourceAccount
  • Access Analyzer: external, internal and unused-access analyzers, policy validation, custom checks, generation from CloudTrail. The simulator can differ from live and does not support RCPs
  • Least privilege is a loop: start broad, observe, derive, lint, replay, restrict resources, add conditions, watch unused access. Event names are not always action names
Coming next
Chapter 3 is encryption and secrets: KMS keys and key policies (the other policy that must name its principals), envelope encryption, Secrets Manager versus Parameter Store, rotation and certificates. Start with AWS Intermediate/Advanced 3.