CoursesAdvanced cloud securityPolicy evaluation, boundaries & escalation paths

Policy evaluation, boundaries & escalation paths

Deny/allow order, permission boundaries, and access analyzers.

Expert35 min · lesson 3 of 15

A cargo container moving through a port passes a line of inspection booths before it reaches the ship. Any booth can stamp REJECTED, and the box stops there. No booth can un-reject what an earlier booth already refused. And a booth waving you on means only that you cleared that one booth, never that the next will agree. Cloud authorization runs the same line. The request is the container, each policy is a booth, one rejection anywhere ends the trip, and clearing a booth is not the same as clearing the port.

Amazon Web Services (AWS), Google Cloud Platform (GCP) and Microsoft Azure build this line from different parts, and the logic underneath is one shape. Learn the shape once and you can read all three.

Three families feed one decision

Three kinds of policy vote on every request. Grants are the yes votes. They come from identity policies, attached to a user, a role, or a service account (a service account is the login a piece of software uses instead of a person), and they say what that principal (any identity that can make a request, human or machine) is allowed to do. Grants also come from resource policies, attached to the thing being touched: an Amazon Simple Storage Service (S3) bucket policy, an Identity and Access Management (IAM) binding on a Google Cloud project, an Azure role assignment at a resource scope. Grants stack. If any grant says yes, that part is satisfied.

Ceilings sit above the grants and cap them, the way a spending limit caps what one manager can approve no matter how many times they sign their own request. On AWS the ceilings are permission boundaries and Service Control Policies (SCPs), with resource control policies (RCPs) as the newer resource-side companion. On GCP they are IAM deny policies and organization policy constraints. On Azure they are deny assignments plus Azure Policy rules set to deny, which flow downhill from a management group (an Azure container that sits above your subscriptions) to everything under it. A ceiling never hands out a permission. It only trims what the grants already gave, and every ceiling on the path has to agree or the request dies.

Conditions are the fine print that ties a grant to context: AWS IAM condition keys, GCP IAM conditions, Azure attribute-based access control (ABAC, rules that read tags and attributes at request time). One sentence holds the whole model together. Grants are additive (a union, any yes counts), ceilings are conjunctive (every one must agree), and an explicit Deny beats all of it.

That last clause is the whole security model, so look at how each cloud writes a ceiling down. Here is an AWS permission boundary, the sandbox wall you put around a role that a team lead is allowed to create. Notice what is missing. The action kms:CreateKey (KMS is AWS Key Management Service, which mints and guards encryption keys) is neither allowed nor denied here, so the moment this boundary is attached, an identity-policy grant for that same action collapses into a silent implicit deny.

team-lead-boundary.json
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AppServicesOnly",
"Effect": "Allow",
"Action": ["s3:*", "dynamodb:*", "logs:*", "cloudwatch:*"],
"Resource": "*"
},
{
"Sid": "NeverTouchIdentity",
"Effect": "Deny",
"Action": ["iam:*", "organizations:*", "account:*"],
"Resource": "*"
}
]
}

GCP has no boundary object. Its hard ceiling is a deny policy, attached to a project, a folder, or the whole organization, that strips a permission from a set of principals and names the exceptions. This one blocks the power to impersonate any service account for everyone except a break-glass account (the emergency identity you reach for only when normal access is gone, like the little hammer behind glass next to a fire exit).

deny-actas.json
{
"displayName": "No actAs except break-glass",
"rules": [
{
"denyRule": {
"deniedPrincipals": ["principalSet://goog/public:all"],
"exceptionPrincipals": [
"principal://iam.googleapis.com/projects/-/serviceAccounts/[email protected]"
],
"deniedPermissions": ["iam.googleapis.com/serviceAccounts.actAs"]
}
}
]
}

Azure writes delegation as a custom role: the Actions it carries minus the NotActions it does not. Give a delegated admin only this role and they can deploy application resources without ever being able to write a role assignment.

deployer-role.json
{
"Name": "App Deployer",
"IsCustom": true,
"Description": "Deploy app resources; never grant roles.",
"Actions": [
"Microsoft.Compute/*",
"Microsoft.Storage/*",
"Microsoft.Web/*"
],
"NotActions": [
"Microsoft.Authorization/*/write",
"Microsoft.Authorization/*/delete"
],
"AssignableScopes": [
"/subscriptions/00000000-0000-0000-0000-000000000000"
]
}

Permission boundaries exist for safe delegation. You let a team lead create roles for their own apps, then attach a boundary so nothing they mint can exceed it. They build freely inside the sandbox and cannot climb the wall. SCPs do the same job one level up, over every principal in the account including the root user. Because an SCP lives at the organization level, an account administrator cannot delete their way out of it, and that is what lets a single control restrain even the person who runs the account.

There is an honest asymmetry across the three. An AWS permission boundary and a GCP deny policy are hard ceilings that override other grants. Azure NotActions only subtracts from the one role it sits in, so a second role assignment could hand the same permission right back. Azure's true override, the thing that beats every grant, is a deny assignment, and you cannot hand-write one. Only the platform creates them, through managed applications or a deployment stack's deny settings. Drift between these encodings, when one cloud's ceiling quietly ends up looser than the others, is where an attacker starts looking.

The order, and the silence when a ceiling voids you

Walk an AWS request down the line. It starts as an implicit deny, the default No. An explicit Deny in any policy (identity, resource, SCP, RCP, permission boundary, or session policy, the extra policy you can attach when assuming a role) ends it at once: DENIED, evaluation stops. If nothing denies, the request is allowed only when a matching Allow exists in an identity or resource policy, and the permission boundary lists the action if one is attached, and every SCP and RCP on the account's path lists it, and any session policy lists it. Boundaries, SCPs and RCPs never grant. They filter.

How one AWS request is decided
1Implicit deny
every request starts as No
2Explicit Deny anywhere?
identity, resource, SCP, RCP, boundary, session: a yes here ends it, DENIED
3SCPs and RCPs
every org-path policy must allow, or the action is void
4Permission boundary
if attached, must list the action; it never grants
5Session policy
if one was passed, it must allow too
6Identity or resource Allow
the only booth that can actually say yes
7ALLOWED
only when every booth above agreed
One explicit Deny at any booth ends the trip; boundaries and org policies only filter, they never grant.

That filtering has a quiet failure mode. If your identity policy grants kms:CreateKey but the boundary on your role does not list kms, the grant is voided. No error. No log line reading 'boundary blocked you.' It resolves to an implicit deny as if the grant were never written. Teams that do not know about the silent void tend to respond by widening the grant again and again, which is how a boundary meant to contain a role ends up buried under a pile of over-broad policies. The cure is to stop guessing and replay the decision. Every cloud ships a replayer. On AWS it is the policy simulator.

terminal
aws iam simulate-principal-policy \
--policy-source-arn arn:aws:iam::123456789012:role/team-lead \
--action-names kms:CreateKey \
--query 'EvaluationResults[].{Action:EvalActionName,Decision:EvalDecision,ByBoundary:PermissionsBoundaryDecisionDetail.AllowedByPermissionsBoundary}'
output
[
{
"Action": "kms:CreateKey",
"Decision": "implicitDeny",
"ByBoundary": false
}
]

implicitDeny with ByBoundary false is the silent void made visible: the identity policy grants the action, and the boundary is the reason it still fails. GCP asks the same question through the Policy Troubleshooter, which replays the allow and deny policies up the whole hierarchy for one principal and one permission.

terminal
gcloud policy-troubleshoot iam \
//cloudresourcemanager.googleapis.com/projects/prod-app \
--permission=iam.serviceAccounts.actAs
output
access: NOT_GRANTED
explainedPolicies:
- access: NOT_GRANTED
fullResourceName: //cloudresourcemanager.googleapis.com/projects/prod-app
bindingExplanations:
- access: NOT_GRANTED
role: roles/iam.serviceAccountUser
rolePermission: ROLE_PERMISSION_INCLUDED
memberships:
serviceAccount:[email protected]:
membership: MEMBERSHIP_NOT_INCLUDED

MEMBERSHIP_NOT_INCLUDED is the readout. The role that would grant iam.serviceAccounts.actAs exists, but this service account is in no binding for it, so actAs is unbound and the account cannot impersonate anyone. Azure has no single-command simulator in its command-line interface (CLI). You enumerate what actually reaches a principal and reason about deny assignments on top.

terminal
az role assignment list \
--assignee [email protected] \
--all --include-inherited --include-groups \
--query "[].{Principal:principalName, Role:roleDefinitionName, Scope:scope}" \
-o table
output
Principal Role Scope
------------------ ------------ --------------------------------------------------------------------------
[email protected] App Deployer /subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/prod-app

That table is every role assignment reaching the user, including ones inherited from a higher scope and ones granted through a group. What it does not show is deny assignments, which override every row in it. On Azure the list command and the real decision are two different things, so you check deny assignments separately before you trust the answer.

Where escalation hides

Least privilege (the rule that every identity gets only the permissions it truly needs and nothing spare) fails quietly when a handful of reasonable permissions add up to 'become admin.' The classic AWS pair is iam:PassRole with a compute action like lambda:CreateFunction (AWS Lambda runs your code on demand with no server to manage), ec2:RunInstances (Elastic Compute Cloud, a rented virtual server) or ecs:RunTask (Elastic Container Service, which runs containers). PassRole does nothing on its own. Hand a powerful role to a function you control, run the function, and you have become that role. GCP's twin is iam.serviceAccounts.actAs plus a deploy permission: start a Cloud Function or a Compute Engine virtual machine (VM) that runs as a privileged service account, and you inherit everything it can do. Azure's is Microsoft.Authorization/roleAssignments/write. Hold it and you grant yourself Owner, with no compute step at all.

A second family rewrites the grant itself. On AWS that is iam:CreatePolicyVersion (quietly ship a new default version of a policy that is already attached), iam:AttachUserPolicy, or an edit to a role's trust policy (the rule that decides who is allowed to assume the role). On GCP it is setIamPolicy or serviceAccountKeys.create (mint a fresh key and walk off wearing the identity). On Azure it is an edit to a custom role definition. Each one reads as ordinary in a code review. Put the right pair together and each one is a skeleton key.

PassRole and actAs slip through code review
iam:PassRole and iam.serviceAccounts.actAs do nothing by themselves, so reviewers wave them through as harmless plumbing. Paired with any run-this-workload-as-that-role action they become a full privilege-escalation primitive. Always bind them with a Condition to specific role or service-account resource names (ARNs, Amazon Resource Names), never Resource: "*", and alert on any change that widens them.

You can hunt the most direct Azure primitive with one query. Which built-in roles list the role-assignment write action in full?

terminal
az role definition list \
--query "[?permissions[0].actions[?contains(@, 'Microsoft.Authorization/roleAssignments/write')]].roleName" \
-o tsv
output
Role Based Access Control Administrator
Key Vault Data Access Administrator
Substring audits miss the most dangerous roles
The contains() query only matches roles that spell Microsoft.Authorization/roleAssignments/write out in full. Owner ("*") and User Access Administrator ("Microsoft.Authorization/*") grant it through wildcards the match cannot see, so the two roles you most want to catch never appear. Contributor holds "*" too, but its NotActions block Authorization writes, so it genuinely cannot assign roles. Resolve wildcards before you trust any action-string audit.

Find every path before an attacker does

Replaying one decision confirms a single grant. Finding all of them means enumerating reachability across the whole organization. Each cloud has a tool for 'who can reach this sensitive permission.' On AWS, IAM Access Analyzer answers it two ways: unused-access findings show which granted permissions nobody has touched, and custom policy checks fail a policy at review time. This check is built for a continuous-integration (CI) gate. Given a policy document, it confirms the policy does not grant a listed action.

terminal
aws accessanalyzer check-access-not-granted \
--policy-document file://ci-deploy-policy.json \
--access actions=iam:PassRole,iam:CreatePolicyVersion \
--policy-type IDENTITY_POLICY
output
{
"result": "FAIL",
"message": "The policy document grants access to perform one or more of the listed actions.",
"reasons": [
{
"description": "One or more of the listed actions in the statement with index: 1 is allowed by the following statement.",
"statementIndex": 1,
"statementId": "AllowPassRole"
}
]
}

result FAIL, statement AllowPassRole: the policy grants iam:PassRole and the pipeline stops before that policy ever ships. GCP's Policy Analyzer, part of Cloud Asset Inventory, sweeps an entire organization for one permission and lists every identity that holds it.

terminal
gcloud asset analyze-iam-policy \
--organization=456789012345 \
--permissions="iam.serviceAccounts.actAs"
output
mainAnalysis:
analysisResults:
- attachedResourceFullName: //cloudresourcemanager.googleapis.com/projects/prod-app
iamBinding:
role: roles/iam.serviceAccountUser
members:
- serviceAccount:[email protected]
accessControlLists:
- accesses:
- permission: iam.serviceAccounts.actAs
fullyExplored: true

There is legacy-ci, still holding iam.serviceAccounts.actAs through roles/iam.serviceAccountUser, the very impersonation your troubleshooter run confirmed ci-deployer does not have. One org sweep finds the account a per-principal check would have missed. Azure's equivalent is the role hunt above, backed by Privileged Identity Management (PIM, which hands out privileged roles only for a fixed window and logs every activation) and access reviews that expire standing grants on a schedule.

The parity problem is the real work at multi-cloud scale. 'No workload may escalate itself' has to be written three times in three encodings, and drift between them is where breaches live. Put the ceilings (SCPs, RCPs, deny policies, deny assignments, boundaries) in Terraform (cloud setup written as code, so the same file rebuilds the same guardrail every time) or a landing-zone module (a prebuilt secure-account foundation) so they deploy the same way everywhere and nobody edits one out of band. Then run the analyzers on a schedule, not once. Access Analyzer and Policy Analyzer both emit findings you can wire into CI, so a newly granted PassRole or actAs shows up as a failed pull request (a proposed code change under review) this week, instead of a line in next quarter's incident review.

Quick check
01A team lead's IAM identity policy grants kms:CreateKey, but the permission boundary on their role lists only s3, dynamodb, logs and cloudwatch actions. They call kms:CreateKey. What happens?
Incorrect — a boundary is a filter, not an advisory, and it removes grants it does not list without any warning.
Correct — this is the silent void, an implicit deny with no error and no 'boundary blocked you' line.
Incorrect — omission is an implicit deny; an explicit Deny is a written Deny statement and can never be overridden.
Incorrect — boundaries restrain the principal they are attached to; SCPs are the control that reaches the root user.
02You run az role definition list with a contains() filter for Microsoft.Authorization/roleAssignments/write and Owner does not appear in the results. What does that tell you?
Incorrect — Wrong and dangerous: Owner holds "*", which includes the action; it is merely not spelled out in full.
Incorrect — contains() is valid JMESPath; the flaw here is semantic, not syntax.
Correct — resolve wildcards before trusting the list, or the worst roles stay hidden.
Incorrect — that describes Contributor; Owner carries no such NotActions.
03In CI, aws accessanalyzer check-access-not-granted returns "result": "FAIL" for a deploy role, pointing at a statement AllowPassRole that uses Resource: "*". The deploy legitimately needs to pass one specific role. What is the right fix?
Incorrect — unbounded PassRole plus a compute action is a full escalation path, and muting the check hides it.
Incorrect — that breaks the deploy, which has a legitimate need to pass one specific role.
Incorrect — that changes what is evaluated and hides the finding instead of fixing the grant.
Correct — scoping the resource keeps the deploy working and removes the wildcard the check flagged.

Try this

Work through “Find every path before an attacker does” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.

Takeaway

The trap worth remembering here: passRole and actAs slip through code review. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related