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)

AppWorkloadBoundary: the permissions boundary · IAM identity-based policyOpen in Inline Policy Generator
{
  "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/

AppTeamIamAdmin: the delegated administrator's policy · IAM identity-based policyOpen in Inline Policy Generator
{
  "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

Pass /app/ roles to Lambda only · IAM identity-based policyOpen in Inline Policy Generator
{
  "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

  1. 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/"
  2. 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
  3. 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
  4. 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.

Commands (replace <placeholders>)
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_POLICY

Expected: 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.

Commands (replace <placeholders>)
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.

Commands (replace <placeholders>)
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 table

Expected: 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.

Commands (replace <placeholders>)
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.

Commands (replace <placeholders>)
aws iam list-roles --path-prefix /app/ --query 'Roles[].RoleName' --output text
aws iam get-role --role-name reports-fn --query Role.PermissionsBoundary

Expected: 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.

Commands (replace <placeholders>)
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).