IAM administration
Delegated IAM administration with a permissions boundary
Let a team create, change and delete /app/ roles only with the AppWorkloadBoundary boundary, never remove it, and pass those roles only to Lambda.
Published · Updated .
The problem
An application team in 111122223333 ships Lambda functions weekly, each needing an execution role. Routing every role through the security team is slow. Granting iam:CreateRole and iam:AttachRolePolicy on everything makes the team an administrator: it could create a role with AdministratorAccess, pass it to a function and act through the function.
A permissions boundary makes the delegation safe. The team creates and edits roles only under /app/, only with the managed policy AppWorkloadBoundary as their boundary, and can never remove or rewrite it. Whatever it attaches, a role's effective permissions are the intersection with the boundary. A third policy limits iam:PassRole to /app/ roles and Lambda.
An earlier version never showed the boundary and said 'create and manage' without granting tagging, deletion or trust changes. Here every grant and omission is explained.
The policies
AppWorkloadBoundary: the permissions boundary
IAM identity-based policy. Attach to: Customer managed policy AppWorkloadBoundary in 111122223333, set as the boundary of every /app/ role (never attached as a permissions policy)
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowApplicationRuntimeActions",
"Effect": "Allow",
"Action": [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents",
"xray:PutTraceSegments",
"xray:PutTelemetryRecords",
"cloudwatch:PutMetricData",
"s3:GetObject",
"s3:PutObject",
"s3:ListBucket",
"dynamodb:GetItem",
"dynamodb:PutItem",
"dynamodb:UpdateItem",
"dynamodb:DeleteItem",
"dynamodb:Query",
"dynamodb:BatchGetItem",
"dynamodb:BatchWriteItem",
"sqs:SendMessage",
"sqs:ReceiveMessage",
"sqs:DeleteMessage",
"sqs:ChangeMessageVisibility",
"sqs:GetQueueAttributes",
"sns:Publish",
"secretsmanager:GetSecretValue",
"ssm:GetParameter",
"ssm:GetParameters",
"kms:Decrypt",
"kms:GenerateDataKey"
],
"Resource": "*"
},
{
"Sid": "DenyIdentityAndAccountChanges",
"Effect": "Deny",
"Action": [
"iam:*",
"sts:AssumeRole",
"organizations:*",
"account:*"
],
"Resource": "*"
}
]
}Statement by statement
- AllowApplicationRuntimeActions is the ceiling: runtime calls a typical Lambda workload makes, without service-wide wildcards or control-plane actions such as deleting tables or changing bucket policies. Resource "*" is fine because a boundary grants nothing; each role's own policy names the real resources. Anything left out fails with a message that no permissions boundary allows it.
- DenyIdentityAndAccountChanges is explicit because an implicit deny in a boundary does not limit a same-account resource-based policy that grants to a role session ARN; an explicit Deny always applies. Denying sts:AssumeRole stops /app/ roles hopping into roles that trust the whole account. If a workload must assume a role, drop it here and allow that ARN in the role's own policy.
AppTeamIamAdmin: the delegated administrator's policy
IAM identity-based policy. Attach to: Customer managed policy AppTeamIamAdmin on the role AppTeamIamAdmin, which lives at /platform/, outside /app/
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "CreateAppRolesWithBoundary",
"Effect": "Allow",
"Action": [
"iam:CreateRole",
"iam:PutRolePermissionsBoundary"
],
"Resource": "arn:aws:iam::111122223333:role/app/*",
"Condition": {
"StringEquals": {
"iam:PermissionsBoundary": "arn:aws:iam::111122223333:policy/AppWorkloadBoundary"
}
}
},
{
"Sid": "ManageBoundedAppRoles",
"Effect": "Allow",
"Action": [
"iam:AttachRolePolicy",
"iam:DetachRolePolicy",
"iam:PutRolePolicy",
"iam:DeleteRolePolicy",
"iam:UpdateAssumeRolePolicy",
"iam:UpdateRole",
"iam:UpdateRoleDescription",
"iam:DeleteRole"
],
"Resource": "arn:aws:iam::111122223333:role/app/*",
"Condition": {
"StringEquals": {
"iam:PermissionsBoundary": "arn:aws:iam::111122223333:policy/AppWorkloadBoundary"
}
}
},
{
"Sid": "ReadIamForTroubleshooting",
"Effect": "Allow",
"Action": [
"iam:GetRole",
"iam:GetRolePolicy",
"iam:ListRoles",
"iam:ListRolePolicies",
"iam:ListAttachedRolePolicies",
"iam:GetPolicy",
"iam:GetPolicyVersion",
"iam:ListPolicies",
"iam:ListPolicyVersions",
"iam:SimulatePrincipalPolicy",
"iam:SimulateCustomPolicy"
],
"Resource": "*"
},
{
"Sid": "DenyRoleCreationWithoutBoundary",
"Effect": "Deny",
"Action": [
"iam:CreateRole",
"iam:PutRolePermissionsBoundary"
],
"Resource": "*",
"Condition": {
"StringNotEquals": {
"iam:PermissionsBoundary": "arn:aws:iam::111122223333:policy/AppWorkloadBoundary"
}
}
},
{
"Sid": "DenyBoundaryRemoval",
"Effect": "Deny",
"Action": "iam:DeleteRolePermissionsBoundary",
"Resource": "*"
},
{
"Sid": "DenyEditsToGuardrailPolicies",
"Effect": "Deny",
"Action": [
"iam:CreatePolicyVersion",
"iam:DeletePolicy",
"iam:DeletePolicyVersion",
"iam:SetDefaultPolicyVersion"
],
"Resource": [
"arn:aws:iam::111122223333:policy/AppWorkloadBoundary",
"arn:aws:iam::111122223333:policy/AppTeamIamAdmin"
]
}
]
}Statement by statement
- CreateAppRolesWithBoundary: for CreateRole the key is the boundary named in the request; without --permissions-boundary it has no value, so StringEquals cannot match. PutRolePermissionsBoundary can add the boundary to an existing /app/ role, but only AppWorkloadBoundary.
- ManageBoundedAppRoles: here the key is the boundary already on the role, so only bounded /app/ roles can be changed. The Service Authorization Reference lists iam:PermissionsBoundary for each action here. UpdateAssumeRolePolicy is granted because CreateRole already allows any trust policy; DeleteRole lets the team clean up.
- ReadIamForTroubleshooting: read-only calls and the simulator, so the team can debug and run the checks below.
- DenyRoleCreationWithoutBoundary repeats the rule as a Deny, so it holds even if a broad policy such as IAMFullAccess is attached later. StringNotEquals also matches an absent key.
- DenyBoundaryRemoval: the team cannot remove any role's boundary, including its own.
- DenyEditsToGuardrailPolicies stops the team rewriting the boundary or its own permissions through new policy versions.
Pass /app/ roles to Lambda only
IAM identity-based policy. Attach to: The principal that creates and updates Lambda functions: AppTeamIamAdmin, or a separate CI deployment role
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "PassAppRolesToLambdaOnly",
"Effect": "Allow",
"Action": "iam:PassRole",
"Resource": "arn:aws:iam::111122223333:role/app/*",
"Condition": {
"StringEquals": {
"iam:PassedToService": "lambda.amazonaws.com"
}
}
}
]
}Statement by statement
- PassAppRolesToLambdaOnly: iam:PassRole is a permission, not an API call. Lambda checks it on CreateFunction or UpdateFunctionConfiguration, and CloudTrail records the role in that event. iam:PassedToService keeps these roles away from EC2, ECS or Glue. PassRole does not support iam:PermissionsBoundary, so this relies on every /app/ role being bounded, which the other policies enforce only for roles the team creates.
Before you start
- A central administrator creates the boundary and the AppTeamIamAdmin role; the team cannot bootstrap its own guardrails.
- The /app/ path is reserved for bounded roles.
- The deployer also has Lambda permissions such as lambda:CreateFunction (not shown).
Deploy it step by step
- Create the boundary policy. As a central administrator, create AppWorkloadBoundary.
Commands (replace <placeholders>) aws iam create-policy --policy-name AppWorkloadBoundary --policy-document file://boundary.json --description "Ceiling for roles under /app/" - Create the delegated administrator's permissions. Create the AppTeamIamAdmin managed policy (the name the Deny protects), attach it to the team's role, and add the PassRole policy.
Commands (replace <placeholders>) aws iam create-policy --policy-name AppTeamIamAdmin --policy-document file://delegated-admin.json aws iam attach-role-policy --role-name AppTeamIamAdmin --policy-arn arn:aws:iam::111122223333:policy/AppTeamIamAdmin aws iam put-role-policy --role-name AppTeamIamAdmin --policy-name PassAppRolesToLambda --policy-document file://pass-role.json - The team creates an execution role. As AppTeamIamAdmin, create the role under /app/ with the boundary, then add logging and a narrow inline policy for its table.
Commands (replace <placeholders>) aws iam create-role --role-name reports-fn --path /app/ --assume-role-policy-document file://lambda-trust.json --permissions-boundary arn:aws:iam::111122223333:policy/AppWorkloadBoundary aws iam attach-role-policy --role-name reports-fn --policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole aws iam put-role-policy --role-name reports-fn --policy-name ReportsTable --policy-document file://reports-table.json - The team deploys the function. Lambda checks iam:PassRole against the full role ARN.
Commands (replace <placeholders>) aws lambda create-function --function-name reports --runtime python3.12 --handler app.handler --zip-file fileb://function.zip --role arn:aws:iam::111122223333:role/app/reports-fn
Verify it without changing anything
These checks only read state, simulate, or use dry runs, so they are safe to run in any account. Exact error wording varies by service and over time.
Prove what the admin policy cannot grant
Ask Access Analyzer whether delegated-admin.json could allow guardrail-breaking actions.
aws accessanalyzer check-access-not-granted --policy-document file://delegated-admin.json --access actions="iam:DeleteRolePermissionsBoundary","iam:CreatePolicyVersion","iam:SetDefaultPolicyVersion","iam:TagRole" --policy-type IDENTITY_POLICYExpected: result PASS: this document cannot allow those actions. It says nothing about other policies on the same role.
Simulate role creation with and without the boundary
Run it as written, then with another policy ARN as the value, with no --context-entries, and with the resource set to role/platform/test.
aws iam simulate-custom-policy --policy-input-list file://delegated-admin.json --action-names iam:CreateRole --resource-arns arn:aws:iam::111122223333:role/app/reports-fn --context-entries "ContextKeyName='iam:PermissionsBoundary',ContextKeyValues='arn:aws:iam::111122223333:policy/AppWorkloadBoundary',ContextKeyType=string" --query 'EvaluationResults[].EvalDecision'Expected: allowed with the AppWorkloadBoundary ARN. explicitDeny with another ARN or with no context, because StringNotEquals matches both. implicitDeny for the /platform/ role, because nothing allows it.
Simulate the boundary as a ceiling
Pair the boundary with allow-all.json (every action, every resource) to see what an /app/ role with AdministratorAccess could really do.
aws iam simulate-custom-policy --policy-input-list file://allow-all.json --permissions-boundary-policy-input-list file://boundary.json --action-names dynamodb:GetItem s3:PutBucketPolicy iam:CreateUser sts:AssumeRole --query 'EvaluationResults[].[EvalActionName,EvalDecision]' --output tableExpected: dynamodb:GetItem allowed; s3:PutBucketPolicy implicitDeny (not in the boundary); iam:CreateUser and sts:AssumeRole explicitDeny (the boundary's Deny).
Simulate PassRole for each service and role
Repeat with ec2.amazonaws.com as the value, and with the AppTeamIamAdmin role ARN as the resource.
aws iam simulate-custom-policy --policy-input-list file://pass-role.json --action-names iam:PassRole --resource-arns arn:aws:iam::111122223333:role/app/reports-fn --context-entries ContextKeyName=iam:PassedToService,ContextKeyValues=lambda.amazonaws.com,ContextKeyType=string --query 'EvaluationResults[].EvalDecision'Expected: allowed for Lambda with the /app/ role. implicitDeny for EC2, and implicitDeny for any role outside /app/.
Audit the live /app/ roles
ListRoles does not return permissions boundaries, so check each role with get-role.
aws iam list-roles --path-prefix /app/ --query 'Roles[].RoleName' --output text
aws iam get-role --role-name reports-fn --query Role.PermissionsBoundaryExpected: For each /app/ role, a PermissionsBoundary whose PermissionsBoundaryArn is AppWorkloadBoundary. An empty result is an unbounded /app/ role the team could still pass: bound it or move it.
Simulate a creation with the wrong boundary
Instead of attempting a real CreateRole, which would create a role if the policy were not yet attached, ask the policy simulator. Pass the boundary as the iam:PermissionsBoundary context value: first the approved AppWorkloadBoundary ARN, then any other policy ARN.
aws iam simulate-principal-policy --policy-source-arn arn:aws:iam::111122223333:role/platform/AppTeamIamAdmin --action-names iam:CreateRole --resource-arns arn:aws:iam::111122223333:role/app/boundary-test --context-entries ContextKeyName=iam:PermissionsBoundary,ContextKeyType=string,ContextKeyValues=arn:aws:iam::111122223333:policy/AppWorkloadBoundary --query 'EvaluationResults[].EvalDecision'
aws iam simulate-principal-policy --policy-source-arn arn:aws:iam::111122223333:role/platform/AppTeamIamAdmin --action-names iam:CreateRole --resource-arns arn:aws:iam::111122223333:role/app/boundary-test --context-entries ContextKeyName=iam:PermissionsBoundary,ContextKeyType=string,ContextKeyValues=arn:aws:iam::111122223333:policy/SomethingElse --query 'EvaluationResults[].EvalDecision'Expected: allowed for the approved boundary and explicitDeny for any other boundary ARN. The simulator only evaluates the policies attached to the role and the context you pass; it does not create anything.
Common mistakes and troubleshooting
Leaving out the path
role/app/* matches only roles created with --path /app/; without --path a role lands at / and is denied. Use the full ARN, including app/, everywhere, including Lambda's --role.
Tags on CreateRole
Creating a role with --tags also requires iam:TagRole, deliberately not granted here, and TagRole does not support iam:PermissionsBoundary. If you need tags, allow TagRole on role/app/* with aws:TagKeys limited to an allowlist, and skip it if tags drive attribute-based access.
A boundary that is too narrow
VPC-attached functions also need the network interface actions in AWSLambdaVPCAccessExecutionRole. The error names the permissions boundary even though the role's policy allows the call; fix the boundary, not the role.
Another policy granting PassRole on *
Allows add up: a second policy granting iam:PassRole on all roles undoes the third document. Run check-access-not-granted for iam:PassRole on the deployer's other policies.
Limitations and production considerations
- The boundary limits what /app/ roles can do, not who can assume them. The team can write any trust policy, including one trusting an outside account, and IAM has no condition key for trust-policy contents. Detect that with IAM Access Analyzer external access findings, or block it with a resource control policy (RCPs support AWS STS).
- AppTeamIamAdmin has no boundary here; its Denies hold, but other Allows attached later add up. AWS's own delegation example also bounds the delegated administrator.
- A change to AppWorkloadBoundary applies at once to every /app/ role, and boundaries cannot be applied to service-linked roles.
Official AWS documentation
Account IDs, bucket and organization names are AWS documentation placeholders. Test in a non-production account before use (see the methodology).