Policy evaluation, boundaries & escalation paths
Deny/allow order, permission boundaries, and access analyzers.
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.
{"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).
{"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.
{"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.
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.
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}'
[{"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.
gcloud policy-troubleshoot iam \//cloudresourcemanager.googleapis.com/projects/prod-app \--permission=iam.serviceAccounts.actAs
access: NOT_GRANTEDexplainedPolicies:- access: NOT_GRANTEDfullResourceName: //cloudresourcemanager.googleapis.com/projects/prod-appbindingExplanations:- access: NOT_GRANTEDrole: roles/iam.serviceAccountUserrolePermission: ROLE_PERMISSION_INCLUDEDmemberships: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.
az role assignment list \--assignee [email protected] \--all --include-inherited --include-groups \--query "[].{Principal:principalName, Role:roleDefinitionName, Scope:scope}" \-o table
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.
You can hunt the most direct Azure primitive with one query. Which built-in roles list the role-assignment write action in full?
az role definition list \--query "[?permissions[0].actions[?contains(@, 'Microsoft.Authorization/roleAssignments/write')]].roleName" \-o tsv
Role Based Access Control AdministratorKey Vault Data Access Administrator
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.
aws accessanalyzer check-access-not-granted \--policy-document file://ci-deploy-policy.json \--access actions=iam:PassRole,iam:CreatePolicyVersion \--policy-type IDENTITY_POLICY
{"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.
gcloud asset analyze-iam-policy \--organization=456789012345 \--permissions="iam.serviceAccounts.actAs"
mainAnalysis:analysisResults:- attachedResourceFullName: //cloudresourcemanager.googleapis.com/projects/prod-appiamBinding:role: roles/iam.serviceAccountUsermembers:- serviceAccount:[email protected]accessControlLists:- accesses:- permission: iam.serviceAccounts.actAsfullyExplored: 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.
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.