Time: ~2 hours · Difficulty: Intermediate · You need: a terminal; a Rust toolchain for the Cedar half; an AWS account only for the optional simulator step
Reading a policy correctly is its own skill
Almost everyone in cloud security reads IAM policies, and most people are worse at it than they believe. Not because the JSON is hard, but because the JSON is only half the input. Whether a request succeeds depends on several policy types evaluated together in a fixed order, and a document that plainly says "Effect": "Allow" may grant nothing at all.
Two languages are worth real study:
- The AWS IAM policy grammar. Unavoidable, thirty years of accumulated design decisions, and full of elements that do the opposite of what they appear to.
- Cedar. AWS's open-source authorization language, released under Apache 2.0 in 2023, the engine behind Amazon Verified Permissions, and increasingly the way application-level authorization gets written. It is what IAM might look like designed once, deliberately, with a type checker.
Learning them together is worth more than learning either alone, because Cedar makes explicit several things IAM leaves implicit, and once you have seen the explicit version the implicit one stops surprising you.
On this page
The evaluation model, which is the whole game
AWS does not ask "does a policy allow this". It runs a fixed evaluation across every policy type that applies:
- An explicit
Denyanywhere ends it. In any policy, of any type, at any level. Nothing overrides it. - Every applicable guardrail must allow. Service control policies, resource control policies, permissions boundaries, and session policies each act as a ceiling. They do not grant anything; they cap what can be granted.
- Something must actually grant it. Within one account, an allow in either the identity policy or the resource policy is generally enough. Across accounts, both sides must allow independently.
- Otherwise, implicit deny. Nothing is permitted by default.
Read that as an intersection, not a union. The effective permission is what survives every layer, and adding a policy can only ever remove access or leave it unchanged, never add access past a ceiling above it.
This is why "the policy says Allow" is not an answer to "can they do it". An AdministratorAccess policy attached to a role inside an organizational unit whose SCP denies everything outside eu-west-1 grants exactly nothing outside eu-west-1. Reading one document tells you an upper bound on that document's contribution, and nothing about the result.
The six questions
For any statement, answer these in order. It sounds mechanical because it is, and doing it mechanically is how you stop being fooled.
- Effect: Allow or Deny? Deny changes everything that follows.
- Principal: who? Absent means the policy is identity-based and the principal is whoever it is attached to.
- Action: which operations, and is it
ActionorNotAction? - Resource: which objects, and is it
ResourceorNotResource? - Condition: under what circumstances, and what happens when a key is missing?
- What type of policy is this, and therefore is it a grant or a ceiling?
Question six is the one people skip, and it is the one that most often makes the other five irrelevant.
Walk-through: AWS IAM
1. The grammar, and the parts that invert
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReadOwnBucketPrefix",
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject"],
"Resource": "arn:aws:s3:::acme-data/${aws:PrincipalTag/team}/*",
"Condition": {
"Bool": { "aws:SecureTransport": "true" },
"StringEquals": { "aws:PrincipalOrgID": "o-abc123" }
}
}
]
}Three things in that small document are worth naming.
${aws:PrincipalTag/team} is a policy variable, substituted at evaluation time. One statement covering every team is far better than fifty near-identical policies, and it is the standard way to do attribute-based access control on AWS. Note that only single-valued context keys can be used as variables.
aws:PrincipalOrgID is the modern answer to the confused-deputy problem: the caller must belong to your organization, whoever they claim to be. Its siblings aws:SourceArn and aws:SourceAccount do the same job for service principals, and their absence is exactly what made a long list of cross-tenant vulnerabilities possible.
Wildcards are not regular expressions. Only * (any sequence) and ? (any single character) exist. s3:Get* is fine; s3:Get.* matches nothing, and [a-z] is a literal bracket expression that will never match an action name.
2. NotAction, and why it is almost always wrong
{
"Effect": "Allow",
"NotAction": "iam:*",
"Resource": "*"
}Read that carefully. It does not deny IAM. It allows every action in AWS except IAM, on every resource. That includes services launched after the policy was written, which is the part that makes it unmanageable: the blast radius grows every time AWS ships something.
NotAction has one defensible use, in a Deny statement expressing "deny everything except this narrow set", which is how the standard region-restriction SCP is written:
{
"Effect": "Deny",
"NotAction": ["iam:*", "organizations:*", "sts:*", "cloudfront:*",
"route53:*", "support:*", "waf:*"],
"Resource": "*",
"Condition": {
"StringNotEquals": { "aws:RequestedRegion": ["eu-west-1", "eu-central-1"] }
}
}The exception list exists because those services are global and live in us-east-1; deny them by region and you lock yourself out of IAM. That list is also the thing people copy without updating, so treat any inherited version of this SCP as needing review rather than as settled.
NotPrincipal is worse still and the AWS documentation itself steers you away from it. If you find one, treat it as a finding.
3. Conditions, and the two set operators that fail open
Multi-valued condition keys need a set operator, and the two available behave differently in the case that matters: the key not being present at all.
ForAllValuesis true when every value in the request matches something in the policy. It is also true when there are no values in the request at all.ForAnyValueis true when at least one value matches. It is false when the key is absent.
So ForAllValues in an Allow fails open. The AWS documentation carries an explicit warning on this and prescribes the fix: pair it with a Null check so the statement requires the key to be present.
{
"Effect": "Allow",
"Action": "ec2:DeleteTags",
"Resource": "arn:aws:ec2:us-east-1:111122223333:instance/*",
"Condition": {
"ForAllValues:StringEquals": {
"aws:TagKeys": ["environment", "cost-center"]
},
"Null": { "aws:TagKeys": "false" }
}
}Without that Null block, a request carrying no tag keys satisfies the condition and is allowed. With it, the same request is a no-match. One line, and it is the difference between a working restriction and a decorative one.
The related trap is the ...IfExists suffix: StringEqualsIfExists means "if the key is present it must match, and if it is absent that is fine". Deliberately permissive, occasionally correct, and frequently pasted in to make a policy stop blocking something without anyone noticing what it now permits.
4. Test it rather than reasoning about it
Given how many layers combine, reading is not verification. AWS has three tools that answer different questions, and all three are underused.
# 1. Grammar, best practice, and security warnings on a single document. # Runs offline against the policy text. Start here. aws accessanalyzer validate-policy \ --policy-type IDENTITY_POLICY \ --policy-document file://policy.json # 2. Would this specific principal be allowed to do this specific thing? # Evaluates the real combination of attached policies and boundaries. aws iam simulate-principal-policy \ --policy-source-arn arn:aws:iam::111122223333:role/app \ --action-names s3:GetObject \ --resource-arns arn:aws:s3:::acme-data/reports/q3.csv # 3. Does this new version of a policy grant anything the old one did not? # A genuine CI gate: fail the pipeline if a change broadens access. aws accessanalyzer check-no-new-access \ --existing-policy-document file://old.json \ --new-policy-document file://new.json \ --policy-type IDENTITY_POLICY
check-no-new-access is the one worth building a habit around. It answers a comparative question that no amount of reading reliably answers, and it turns "this refactor is equivalent, trust me" into something a pipeline can confirm. Its siblings check-access-not-granted and check-no-public-access cover "this policy must never grant these actions" and "this resource policy must not be public".
Walk-through: Cedar
5. Install it and write a policy set
cargo install cedar-policy-cli cedar --version mkdir -p ~/cedar-lab && cd ~/cedar-lab
Cedar's shape will look familiar, and the differences are the interesting part:
// policies.cedar
// Members of the security group may read any bucket, but only with MFA.
permit (
principal in Group::"security",
action == Action::"read",
resource
)
when { context.mfa == true };
// A forbid always wins, whatever any permit says. Same rule as an
// explicit Deny in IAM, and stated in the language rather than in a
// separate document describing how the language is evaluated.
forbid (
principal,
action,
resource
)
when { resource.public == true };Three structural choices to notice. The head is always principal, action, resource, so every policy is scoped the same way and can be indexed. in walks an entity hierarchy, so group membership is a first-class relation rather than something you fake with tags. And forbid beating permit is a property of the language, not of the service evaluating it.
6. The part IAM cannot do: a schema
// store.cedarschema
entity Group;
entity User in [Group];
entity Bucket = {
"public": Bool,
};
action read, write appliesTo {
principal: [User],
resource: [Bucket],
context: { "mfa": Bool }
};cedar validate --schema store.cedarschema --policies policies.cedar # policy set validation passed # no errors or warnings
Now introduce a typo. Change resource.public to resource.publik and validate again:
ร policy set validation failed
โฐโโถ for policy `policy0`, attribute `publik` on entity type `Bucket` not found
โญโ[2:8]
2 โ when { resource.publik == true };
ยท โโโโโโโโโโโโโโโ
โฐโโโโ
help: did you mean `public`?This is the single most important difference between the two languages. The same typo in an IAM condition key produces a perfectly valid policy that simply never matches. No error at write time, no error at evaluation time, no signal of any kind. A Deny statement with a misspelled condition key is a security control that does not exist, and it looks exactly like one that does.
7. Evaluate real requests
# entities.json describes the world: who exists, what they belong to,
# and what attributes each resource has.
cat > entities.json <<'EOF'
[
{"uid": {"type":"Group","id":"security"}, "attrs": {}, "parents": []},
{"uid": {"type":"User","id":"alice"}, "attrs": {},
"parents": [{"type":"Group","id":"security"}]},
{"uid": {"type":"User","id":"bob"}, "attrs": {}, "parents": []},
{"uid": {"type":"Bucket","id":"private-logs"}, "attrs": {"public": false}, "parents": []},
{"uid": {"type":"Bucket","id":"public-site"}, "attrs": {"public": true}, "parents": []}
]
EOF
echo '{"mfa": true}' > ctx_mfa.json
echo '{"mfa": false}' > ctx_nomfa.json# In the security group, with MFA, on a private bucket -> ALLOW cedar authorize --schema store.cedarschema --policies policies.cedar \ --entities entities.json -c ctx_mfa.json \ -l 'User::"alice"' -a 'Action::"read"' -r 'Bucket::"private-logs"' # Same principal, no MFA -> DENY # Same principal, with MFA, but a public bucket -> DENY (forbid wins) # Not in the group at all -> DENY
Run all four. cedar authorize exits 0 on ALLOW and non-zero on DENY, so a shell script can assert an expected decision, which means your authorization rules can have a test suite. Writing those four cases takes two minutes and is the closest thing to a unit test that authorization logic normally gets.
Amazon Verified Permissions is this engine as a managed service; it currently supports Cedar 4.5, which added the is operator for matching on entity type. You do not need the service to use the language: the engine is a library, and running it in-process is a perfectly normal deployment.
Hands-on exercises
A role has this identity policy attached. The account is in an OU whose SCP denies all actions outside eu-west-1. The role also has a permissions boundary allowing only s3:*. What can this role actually do?
{ "Effect": "Allow", "Action": "*", "Resource": "*" }Show the answer
Every S3 action, in eu-west-1 only. Nothing else.
Work the intersection rather than the documents. The identity policy grants everything, so it constrains nothing. The permissions boundary caps the role at s3:*. The SCP caps the whole account at one region. The result is the overlap: s3:* in eu-west-1.
Two things worth carrying away. An administrator-level identity policy told you nothing useful, which is exactly why "the policy says Allow" is not an answer. And the boundary and the SCP are doing all the real work while being attached somewhere else entirely, so anyone reading only the role's inline policy would get this completely wrong.
One caveat to keep honest: global services such as IAM and CloudFront do not sit in eu-west-1, so a region-restricting SCP written without an exception list breaks them. That is why the SCP in step 2 carries one.
This statement was written to force MFA. Explain precisely how a request without MFA gets through, and fix it.
{
"Effect": "Deny",
"Action": "*",
"Resource": "*",
"Condition": {
"BoolIfExists": { "aws:MultiFactorAuthPresent": "false" }
}
}Show the answer
This one is genuinely subtle, and it is a case where the naive fix is wrong.
aws:MultiFactorAuthPresent is not present at all for requests made with long-term IAM user access keys. It is only populated for temporary credentials. So with a plain Bool, the condition is a no-match for exactly the credential type you were most worried about, and the deny never fires.
BoolIfExists is therefore correct here, not the bug: it makes the deny apply when the key is absent as well as when it is false. That is the standard AWS-documented pattern for an MFA-enforcement deny.
The real problem is "Action": "*". Denying everything without MFA also denies the calls a user needs to set up and use MFA, locking them out of self-service. The working version carves those out:
{
"Effect": "Deny",
"NotAction": [
"iam:CreateVirtualMFADevice", "iam:EnableMFADevice",
"iam:ResyncMFADevice", "iam:ListMFADevices",
"iam:ListVirtualMFADevices", "iam:GetUser",
"sts:GetSessionToken", "iam:ChangePassword"
],
"Resource": "*",
"Condition": {
"BoolIfExists": { "aws:MultiFactorAuthPresent": "false" }
}
}Note that this is the defensible use of NotAction from step 2: inside a Deny, expressing "everything except this narrow list".
The general lesson is the one worth keeping: a condition key that is absent is not the same as a condition key that is false, and which of those you get depends on how the request was authenticated rather than on anything in the policy.
Add a Cedar policy letting any user read a bucket they own, then extend the schema so it validates. What did the schema force you to decide that IAM would have let you leave vague?
Show the answer
// store.cedarschema - Bucket gains an owner
entity Bucket = {
"public": Bool,
"owner": User,
};
// policies.cedar
permit (principal, action == Action::"read", resource)
when { resource.owner == principal };The schema forced you to declare that owner is of type User, and that every Bucket has one. Both are real decisions with real consequences, and both are the kind of thing an IAM policy leaves implicit until an edge case surfaces them in production.
Make it optional with "owner"?: User and Cedar will then refuse to let you write resource.owner == principal without a presence check first, in the same way CEL demands has(). The type checker is not being fussy; unowned buckets are a case that exists, and it is asking which way you want them to fall.
Do not forget the entities file. Adding a required attribute to the schema means every existing Bucket entity needs an owner, and cedar authorize will tell you so.
You are refactoring a 400-line IAM policy into three smaller ones and want to prove you have not widened access. How do you get evidence rather than an opinion?
Show the answer
aws accessanalyzer check-no-new-access \ --existing-policy-document file://old.json \ --new-policy-document file://new.json \ --policy-type IDENTITY_POLICY
This compares the two documents semantically rather than textually, and reports whether the new one permits anything the old one did not. Wire it into CI and a pull request that broadens access fails, whatever the diff looks like.
Two things to be careful about. It compares documents, so splitting one policy into three means checking the union, not each part. And it answers only "is anything new allowed" - a refactor that accidentally removes needed access passes cleanly, which is an outage rather than a breach but is still your problem. Pair it with simulate-principal-policy over the actions that must keep working.
This is the same shape as the deny-direction-only testing trap in the Rego page: a check that can only fail one way is half a check.
Common mistakes
- Reading one policy and concluding what a principal can do. The answer is the intersection of every applicable layer, minus every explicit deny.
NotActionwithAllow. It grants every future AWS service too. Defensible only inside aDeny.ForAllValuesin anAllowwithout aNullcheck. Documented by AWS as overly permissive, because a request with no values satisfies it.- Assuming a missing condition key evaluates to false. It usually means no-match, and which one you get is often the difference between enforcement and decoration.
- Treating wildcards as regex. Only
*and?. Anything else is a literal that will never match. - Typos in condition keys. Silently never match. This is the failure Cedar's schema exists to eliminate, and the reason to run
validate-policyon anything hand-written. - Forgetting cross-account needs both sides. A resource policy alone is not enough when the principal lives in another account.
- Omitting
aws:SourceArnoraws:SourceAccounton service-principal trust. This is the confused-deputy shape behind a long list of cross-tenant findings. - Shipping a Cedar policy set without a schema. You give up the one advantage the language has over what you were using before.
Where next
- IAM and identity architecture for how these documents fit an account and organization design.
- OPA and Rego for gating policy documents in CI before they reach an account.
- jq and JMESPath, since auditing policies at scale means querying thousands of JSON documents.
- CEL for the Google Cloud equivalent, where IAM conditions are CEL expressions.
- AWS security and AWS vs Azure vs GCP for how the three providers' models differ, which is more than terminology.
- cedarpolicy.com has a browser playground, and the AWS policy evaluation logic reference is the authoritative version of the ordering above, including the flowcharts.
