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.
- Run here (level 1): Python 3.14.0 on Windows 11, in a scratch folder outside this site, with
boto3/botocore1.43.112 (used offline, through its test Stubber) and the open-source linterparliament1.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.
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):
| # | Step | Outcome if it fails |
|---|---|---|
| 1 | Explicit deny. Look through every applicable policy (SCPs, RCPs, resource-based, identity-based, permissions boundaries, session policies) for a Deny that matches | One matching Deny anywhere gives a final Deny |
| 2 | Resource control policies (RCPs). An Organizations policy type attached to resources of the accounts it covers; RCPFullAWSAccess is attached automatically when RCPs are enabled | No applicable Allow: Deny |
| 3 | Service control policies (SCPs). Apply to the principals of the accounts they cover (AWS Intermediate/Advanced 1) | No applicable Allow: Deny |
| 4 | Resource-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 |
| 5 | Identity-based policy. Some statement must allow the action | None: implicit Deny |
| 6 | Permissions boundary, if the user or role has one | It does not allow the action: implicit Deny |
| 7 | Session policy, if the principal is a session and one was passed | It 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 names | Limited by the principal's identity policy? | Limited by a boundary or session policy? | Documented example ARN |
|---|---|---|---|
| An IAM user | No | No (a user has no session policy) | arn:aws:iam::111122223333:user/exampleuser |
An IAM role (by ARN, or with aws:PrincipalArn) | No | Yes: limited by an implicit deny in a boundary or session policy | arn:aws:iam::111122223333:role/examplerole |
| A role session | No | No: "Permissions granted directly to a session are not limited" | arn:aws:sts::111122223333:assumed-role/examplerole/examplerolesessionname |
A federated user session (GetFederationToken) | No | No | arn:aws:sts::111122223333:federated-user/exampleuser |
| The IAM user who federated | No | Yes | arn: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):
Run here (level 1). Each row's "docs" value was written down before the model ran:
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:
| Situation | Documented 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 IfExists | Key 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 operator | Tests only whether the key exists ("false" means "must exist") |
ForAllValues: qualifier | True if every request value matches, and also true if the key is missing or empty |
ForAnyValue: qualifier | True if at least one request value matches; false if the key is missing |
Several keys or operators in one Condition | All must hold (the page's tables show a statement failing when any one part fails) |
| Several values for one key | Any 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:
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.
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:
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.
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:
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:
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.
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:AssumeRoleon 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
AssumeRoleon the role's ARN.
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.
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:
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:
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:
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":
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):
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.
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):
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.
Practice
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 solutionAdd 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.
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.
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,
IfExiststrue,ForAllValuestrue,ForAnyValuefalse. GuardForAllValueswithNull - 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
IfExistsfor 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+Denywith 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:ExternalIdin the trust policy, a value the third party generates. AWS service in your resource policy:aws:SourceArnoraws: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