The situation
A platform team runs a multi-account AWS estate for a mid-size product organisation. Each product team has its own account, and every account already carries a small pipeline role that CI uses to deploy CDK stacks. Those stacks increasingly need IAM roles of their own: a role for a Lambda function, a role for an ECS task, an instance profile for a batch worker. Today every one of those roles goes through a ticket to the platform team, and the queue is a week deep.
So the platform team is going to delegate. Each product team’s pipeline role will create and manage IAM roles directly, in its own account, so a new Lambda role ships in the same deploy as the Lambda. That means granting iam:CreateRole, iam:PutRolePolicy, iam:AttachRolePolicy, and the delete counterparts to the pipeline role.
A principal that can create a role and attach a policy to it can attach AdministratorAccess, assume that role, and be an administrator. The same principal can create a role that trusts an attacker’s account. iam:CreateRole plus iam:AttachRolePolicy, handed out plainly, is a privilege-escalation grant. The platform team needs product teams managing their own roles without any of those roles, or the pipeline role itself, being able to climb to administrator.
What actually matters
When the pipeline role calls iam:CreateRole and then iam:AttachRolePolicy with AdministratorAccess, neither call is individually suspicious. Each is a legitimate IAM action the pipeline role was granted. The escalation lives in the composition. So the control cannot be a one-off deny on attaching AdministratorAccess: dozens of managed policies grant broad power, and an inline policy can grant the same thing under any name at all. What has to be capped is the effective permissions of every role the pipeline creates, whatever gets attached to those roles afterwards.
A permissions boundary does that. It is a managed policy, AWS managed or customer managed, attached to a user or a role in a slot of its own, and it sets the maximum permissions an identity-based policy can grant that entity. Effective permissions become the intersection of the identity policies and the boundary. Attach AdministratorAccess to a role boundaried to S3 and DynamoDB, and the role still reaches only S3 and DynamoDB. The boundary grants nothing by itself, so a role with a boundary and no identity policy can do nothing at all.
Capping the created roles is not enough on its own, and the second requirement is the one that gets left out. The delegation policy has to force every created role to carry the boundary. If the pipeline role can create a role with no boundary, or with a boundary of its own choosing, the cap is optional. So the grant of iam:CreateRole is conditional, allowed only where the request sets one specific, named boundary policy. IAM exposes that through the iam:PermissionsBoundary condition key, which checks the policy attached as the permissions boundary on the principal the request creates or changes. No boundary, or the wrong one, and CreateRole fails.
Third, the boundary has to sit out of reach of the hand it constrains. A delegated principal that can call iam:CreatePolicyVersion on the boundary policy rewrites the ceiling as *. One that can call iam:DeleteRolePermissionsBoundary strips the ceiling off a role it created. The delegation policy itself is in the same position: a principal that can edit the policy constraining it is not constrained. So the delegation denies all three explicitly, and an explicit deny in any applicable policy overrides every allow.
Last, who can undo it. A permissions boundary is a per-principal control that the account’s own administrators manage, so it does not survive a careless or compromised account administrator: whoever can attach the boundary can also replace it. Service control policies sit above that. An SCP is set in the management account, caps every IAM user and role in the member accounts it covers including their root users, and cannot be altered by any principal in those accounts. Two holes are worth knowing before leaning on it. SCPs have no effect on users and roles in the management account, and neither instrument reaches a service-linked role, because PutRolePermissionsBoundary rejects a service-linked role outright and Organizations exempts service-linked roles from SCPs.
Both of those are prevention, and prevention is half the control. The detection half runs on CloudTrail, which logs every authenticated IAM API request: CreateRole, PutRolePermissionsBoundary and DeleteRolePermissionsBoundary arrive as management events, recorded in us-east-1 because IAM is a global service, so a single-Region trail elsewhere never sees them. For current state rather than history, GetRole returns the role’s PermissionsBoundary as the ARN of the policy setting it, which is what an auditor can be shown for a role today.
What we’ll filter on
- Caps effective permissions. Does the control limit a principal to an intersection, whatever identity policies get attached to it later?
- Enforceable on creation. Can it be required at role-creation time, so a delegated principal cannot mint an uncapped role?
- Self-protecting. Can the delegated principal edit, detach or re-version the control that constrains it?
- Right scope. Does it apply where the problem lives: one principal, one account, or the whole organisation?
- Readable afterwards. Can an auditor read the control back off the role months later, without the session that used it?
The landscape
Four policy types cap what a principal is allowed to do. They look alike from a distance and behave differently up close.
1. Identity policies. The everyday grant: attach a policy to a role and the role can do what the policy allows. Here they are the problem rather than the solution. Grant iam:CreateRole and iam:AttachRolePolicy through an identity policy and you have granted escalation. A role carries up to 20 attached managed policies by default and 25 on request, and none of them stops the principal composing its grants into administrator. Identity policies grant; they never cap.
2. Permissions boundaries. A managed policy in the boundary slot, separate from the entity’s identity policies, one per entity. The effective permissions of the entity are the intersection of what its identity policies allow and what the boundary allows, so an action has to appear in both. A boundary attaches to a user or a role and not to a group, and PutRolePermissionsBoundary cannot set one on a service-linked role. A customer managed policy used as a boundary carries the 6,144-character limit on managed policies, which is not adjustable, so a boundary written as a long list of individual actions runs out of room where one written in service-level wildcards does not. This is the instrument that makes iam:CreateRole safe to hand out, because the iam:PermissionsBoundary condition key can require that every role the delegated principal creates carries the same boundary.
3. Session policies. A policy passed as a parameter at sts:AssumeRole or GetFederationToken time. Like a boundary it caps rather than grants: the session’s effective permissions are the intersection of the role’s identity policies and the session policy. It lives only for the session, it is chosen by whoever assumes the role, and the passed JSON document plus any passed managed policy ARNs come to at most 2,048 characters combined, with ten ARNs the ceiling. Nothing is stored, so once the session expires there is no artefact to read back. Right tool for scoping one assumed session down, such as a federation broker minting narrow sessions; wrong tool for a standing delegation.
4. Service control policies. An Organizations control attached to the organization root, an OU or an account, available only where the organisation has all features enabled. It caps every IAM user and role in the member accounts below it, their root users included, and no principal in those accounts can alter it. It grants nothing. It denies rather than requires, it is coarse because one policy covers whole accounts, and it has no effect in the management account. It is also the only layer a product account cannot edit its way around.
For delegating role creation to a per-account pipeline role, the permissions boundary does the work: it is the only per-principal cap that can be required on created roles through a condition key. The SCP sits above it as the layer the account cannot rewrite. Session policies and plain identity policies do not fit this shape.
Evaluation
Side by side
Filtering the four policy types against the attributes:
| Option | Caps effective perms | Enforceable on creation | Self-protecting | Scope | Readable afterwards |
|---|---|---|---|---|---|
| Identity policy | ✗ | n/a | ✗ | per-principal | ✓ ListAttachedRolePolicies |
| Permissions boundary | ✓ | ✓ | ✓ (if self-edit denied) | per-principal | ✓ GetRole returns the ARN |
| Session policy | ✓ | ✗ | ✗ | per-session | ✗ nothing is stored |
| Service control policy | ✓ | ✗ (denies, doesn’t require) | ✓ | root / OU / account | ✓ from the management account |
Identity policies grant and never cap, so they cannot contain an escalation on their own. Session policies cap but expire, and the assumer chooses them, so they cannot be required of roles a delegated principal creates. SCPs cap and cannot be rewritten from inside a member account, but they deny rather than require and they cover whole accounts, which makes them a strong backstop and a clumsy delegation tool. The permissions boundary is the only instrument that caps a named principal, can be forced onto every role that principal creates through the iam:PermissionsBoundary condition, and cannot be widened from below once the principal is denied the right to edit it. The pick is a permissions boundary as the delegation cap, an SCP as the backstop, and a delegation policy written so the pipeline role creates roles without escaping the boundary.
Effective permissions are the intersection
The solution
Three pieces have to fit together: the boundary policy, the delegation policy that lets the pipeline role create roles safely, and the deny statements that stop the pipeline role escaping.
The boundary policy. An ordinary customer-managed policy, distinguished only by where it gets attached. It describes the maximum any delegated role, and the pipeline role itself, may ever do. Make it as wide as the product teams legitimately need and no wider. If product roles touch S3, DynamoDB, SQS and CloudWatch Logs, the boundary allows those four and stops. Because the boundary only intersects, widening it grants nothing the identity policies have not already granted, while a hole in it is a hole in the cap, so err tight. Write it in service-level wildcards rather than enumerated actions, or the 6,144 characters a managed policy gets will run out. Give it a stable name and treat it as a governed artefact, because everything downstream references it by ARN.
The delegation policy, conditional on the boundary. The pipeline role’s identity policy grants the IAM management actions, but the grant of iam:CreateRole, iam:PutRolePolicy and iam:AttachRolePolicy is gated on iam:PermissionsBoundary. That key works with ARN operators, so ArnEquals against the boundary ARN reads: you may create or change this role only where this policy is its boundary. Create a role with no boundary and the key is absent from the request, the condition is false, and the allow does not match. Create one with a weaker boundary of your choosing and the ARN does not match, so the allow does not match either. The pipeline role ends up able to mint any number of roles, every one of them carrying the same ceiling it has. The iam:PolicyARN key does the parallel job for iam:AttachRolePolicy and iam:AttachUserPolicy, constraining which managed policies may be attached when an approved list is what you need.
The deny statements that make it self-protecting. Deny iam:CreatePolicyVersion, iam:DeletePolicyVersion, iam:SetDefaultPolicyVersion and iam:DeletePolicy on the boundary policy ARN, so the pipeline role cannot rewrite the boundary as *. Deny iam:DeleteRolePermissionsBoundary outright, and deny iam:PutRolePermissionsBoundary unless the boundary it sets is the mandated one. Deny the same four policy-version actions on the delegation policy itself, so the pipeline role cannot loosen its own grant. Deny iam:CreateServiceLinkedRole, because a service-linked role is the one role in the account no boundary can be set on. An explicit deny overrides any allow, so these hold even if a later identity policy grants something broad by accident.
Three mechanics trip people up here. First, a permissions boundary caps permissions and has no effect on a role’s trust policy. A delegated principal that can call iam:UpdateAssumeRolePolicy can make a role assumable by an external account, and the boundary limits only what that role does once assumed. Scoping the grant to the product role path keeps the exposure to roles that already carry the boundary, which bounds the damage without closing the path. Two controls close it. An RCP in the organisation can require aws:PrincipalOrgID on sts:AssumeRole, since RCPs cover AWS STS resources and apply to principals inside and outside the organisation alike, with the same two exemptions as SCPs: no effect in the management account, no effect on service-linked roles. That is the prevention. IAM Access Analyzer’s external access analyzer is the detection, because IAM roles are among the resource types it analyses and it generates a finding for a role whose trust policy grants access to a principal outside the zone of trust.
Second, resource-based policies are evaluated alongside the boundary, and the result depends on what the resource policy names. A resource policy that grants permissions to a role’s ARN is limited by an implicit deny in that role’s permissions boundary. A resource policy that grants permissions to an assumed-role session ARN in the same account grants the session directly, and no implicit deny in the boundary limits it; the same holds for a policy naming an IAM user’s ARN in the same account. An explicit deny in the boundary applies in every one of those cases, which is the argument for writing the narrow denies into the boundary rather than relying on it to allow nothing.
Third, the service-linked role is outside both nets. PutRolePermissionsBoundary rejects one and SCPs do not restrict the permissions attached to one, so “every role in this account carries the boundary” holds only while nobody in the account can call iam:CreateServiceLinkedRole.
The escalation paths a boundary closes
Attach an admin policy to yourself or a role you control. Without a boundary, iam:AttachRolePolicy plus AdministratorAccess on any role you can assume is enough to reach administrator. With the boundary on both the pipeline role and every role it creates, attaching AdministratorAccess changes the paper permissions and not the effective ones: the intersection with the boundary still allows only the four services. The attach succeeds and grants nothing.
Create a fresh role with admin and assume it. The pipeline role creates EscalationRole, attaches admin, writes a trust policy naming itself, assumes it. Every step is a granted action. The iam:PermissionsBoundary condition breaks the first step: CreateRole without the mandated boundary is denied, and with it the new role is capped exactly like its creator. There is no uncapped role to escape into.
Pass a privileged existing role to a new service. Where a powerful role already exists, iam:PassRole lets a principal hand it to a Lambda function or an EC2 instance and act through it. A boundary does not constrain iam:PassRole at all, so this one closes by scoping the grant: a Condition on iam:PassedToService for the services the pipeline legitimately deploys to, and a resource constraint to the product role path, where every role carries the boundary. Worth calling out because the boundary alone leaves this open; the delegation policy has to shut it.
Edit your own boundary. The scheme rests on the delegated principal being unable to raise its own ceiling. The explicit denies on CreatePolicyVersion and PutRolePermissionsBoundary close it inside the account, and the SCP closes it again above the account, so an account administrator cannot lift the boundary without tripping an organisation control.
Worked example
The platform team needs the pipeline-deployer role in each product account to create and manage IAM roles for that account’s workloads, capped so nothing it creates, and nothing it does, exceeds a fixed set of services.
First, the boundary policy, product-workload-boundary, attached to pipeline-deployer and required on every role it creates. It allows the services product workloads legitimately use:
{
"Version": "2012-10-17",
"Statement": [{
"Sid": "WorkloadCeiling",
"Effect": "Allow",
"Action": ["s3:*", "dynamodb:*", "sqs:*", "logs:*"],
"Resource": "*"
}]
}
Then the delegation policy on pipeline-deployer. It grants IAM management, gated on the boundary, and denies the escape routes. Every ARN in it names the product account the policy is deployed into, 123456789012 here, because a customer managed policy is account-local and each product account holds its own copy of the boundary under the same name:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "CreateRolesOnlyWithBoundary",
"Effect": "Allow",
"Action": ["iam:CreateRole", "iam:PutRolePolicy", "iam:AttachRolePolicy"],
"Resource": "arn:aws:iam::123456789012:role/product/*",
"Condition": {
"ArnEquals": {
"iam:PermissionsBoundary":
"arn:aws:iam::123456789012:policy/product-workload-boundary"
}
}
},
{
"Sid": "ManageAndDeleteProductRoles",
"Effect": "Allow",
"Action": [
"iam:DeleteRole", "iam:DeleteRolePolicy",
"iam:DetachRolePolicy", "iam:TagRole", "iam:GetRole",
"iam:UpdateAssumeRolePolicy"
],
"Resource": "arn:aws:iam::123456789012:role/product/*"
},
{
"Sid": "DenyPolicyTampering",
"Effect": "Deny",
"Action": [
"iam:CreatePolicyVersion", "iam:DeletePolicyVersion",
"iam:SetDefaultPolicyVersion", "iam:DeletePolicy"
],
"Resource": [
"arn:aws:iam::123456789012:policy/product-workload-boundary",
"arn:aws:iam::123456789012:policy/pipeline-deployer-delegation"
]
},
{
"Sid": "DenyRemovingBoundaryFromRoles",
"Effect": "Deny",
"Action": "iam:DeleteRolePermissionsBoundary",
"Resource": "*"
},
{
"Sid": "DenySwappingTheBoundary",
"Effect": "Deny",
"Action": "iam:PutRolePermissionsBoundary",
"Resource": "*",
"Condition": {
"ArnNotEquals": {
"iam:PermissionsBoundary":
"arn:aws:iam::123456789012:policy/product-workload-boundary"
}
}
},
{
"Sid": "DenyServiceLinkedRoles",
"Effect": "Deny",
"Action": "iam:CreateServiceLinkedRole",
"Resource": "*"
},
{
"Sid": "PassOnlyBoundariedProductRoles",
"Effect": "Allow",
"Action": "iam:PassRole",
"Resource": "arn:aws:iam::123456789012:role/product/*",
"Condition": {
"StringEquals": {
"iam:PassedToService": [
"lambda.amazonaws.com",
"ecs-tasks.amazonaws.com",
"ec2.amazonaws.com"
]
}
}
},
{
"Sid": "DenyPassingRolesOutsideTheProductPath",
"Effect": "Deny",
"Action": "iam:PassRole",
"NotResource": "arn:aws:iam::123456789012:role/product/*"
}
]
}
The product/ path prefix keeps the delegation inside a namespace: pipeline-deployer manages roles under role/product/ and cannot touch the platform team’s own roles elsewhere in the account. DenySwappingTheBoundary relies on a documented quirk of negated operators. Where a request names no boundary at all, iam:PermissionsBoundary is absent from the request context, and a negated operator such as ArnNotEquals evaluates to true on a missing key, so the deny catches the no-boundary case as well as the wrong-boundary case.
Finally, the SCP on the OU that holds every product account, so the account cannot edit its way out even with a compromised administrator. The account field of the boundary ARN is a wildcard because one SCP covers many accounts, each with its own copy of the policy under that name:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "RequireBoundaryOnCreate",
"Effect": "Deny",
"Action": ["iam:CreateRole", "iam:CreateUser"],
"Resource": "*",
"Condition": {
"ArnNotLike": {
"iam:PermissionsBoundary":
"arn:aws:iam::*:policy/product-workload-boundary"
}
}
},
{
"Sid": "DenyDetachingTheBoundary",
"Effect": "Deny",
"Action": [
"iam:DeleteRolePermissionsBoundary",
"iam:DeleteUserPermissionsBoundary"
],
"Resource": "*"
},
{
"Sid": "ProtectTheBoundaryPolicy",
"Effect": "Deny",
"Action": [
"iam:CreatePolicyVersion", "iam:DeletePolicyVersion",
"iam:DeletePolicy", "iam:SetDefaultPolicyVersion"
],
"Resource": "arn:aws:iam::*:policy/product-workload-boundary"
}
]
}
The boundary is now required at two layers. The delegation policy requires it of pipeline-deployer, and the SCP requires it of every principal in the account, so a second principal that somehow gained iam:CreateRole still cannot mint an uncapped role. The SCP also protects the boundary policy from edits and from being detached, closing the rewrite-the-boundary path against an account administrator.
That breadth has a consequence to plan for. RequireBoundaryOnCreate names "Resource": "*", so it binds every role created in the account, the platform team’s own roles included, and a platform admin assuming a role in the product account is a principal in that account like any other. Either platform roles carry the same boundary or the statement needs a NotResource carve-out for a platform path. The delegation itself holds: product teams ship their own roles in their own deploys, and no path runs from iam:CreateRole to administrator.
What’s worth remembering
- A boundary caps; it grants nothing. Effective permissions are the intersection of identity policies and boundary, so an action must appear in both.
- Require the boundary on CreateRole. Gate
iam:CreateRoleon theiam:PermissionsBoundarykey, an ARN-operator key, so created roles must carry the mandated boundary. - Deny edits to the boundary. Deny the policy-version actions on the boundary policy, and
iam:DeleteRolePermissionsBoundaryon roles, or the ceiling lifts from below. - Boundary delegates; SCP backstops. An SCP covers whole member accounts, root users included, and cannot be rewritten from inside, but it denies rather than requires.
- Boundaries miss trust and PassRole. A boundary caps permissions, not trust policies, and does not constrain
iam:PassRole; scope both in the delegation policy. - Service-linked roles escape both. No boundary can be set on one and SCPs exempt them, so deny
iam:CreateServiceLinkedRoleto keep the invariant.