Get the Zoom link
Cloud Security Office Hours Banner

IAM policy languages and Cedar, hands-on

A policy that says Allow may grant nothing at all. Learn the evaluation model that decides, the IAM elements that do the opposite of what they look like, and Cedar, which catches at write time what IAM lets fail silently.

Jump to the walk-through All how-to guides

· · Vendor-neutral

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:

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

  1. The evaluation model, which is the whole game
  2. Walk-through: AWS IAM
  3. Walk-through: Cedar
  4. Hands-on exercises
  5. Common mistakes
  6. Where next

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:

  1. An explicit Deny anywhere ends it. In any policy, of any type, at any level. Nothing overrides it.
  2. 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.
  3. 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.
  4. 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.

  1. Effect: Allow or Deny? Deny changes everything that follows.
  2. Principal: who? Absent means the policy is identity-based and the principal is whoever it is attached to.
  3. Action: which operations, and is it Action or NotAction?
  4. Resource: which objects, and is it Resource or NotResource?
  5. Condition: under what circumstances, and what happens when a key is missing?
  6. 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.

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

Exercise 1

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.

Exercise 2

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.

Exercise 3

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.

Exercise 4

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

Where next