A Brief history of shared AWS accounts

Back in the days, when creating an AWS account was a lot of overhead, common pattern was to have multiple teams sharing a single account. This created a demand to isolate teams from each other by assigning them an IAM policy that would grant access only to their part of the account. While it might have been possible (in theory) to create complex IAM policies to containerize resources by application or team, one fundamental problem did remain. Once you granted a right to create an IAM policy or role, you effectively granted admin level access to everything on the account. While IAM has very granular controls for actions, it can not limit what policies you attach to a role, or content of policy documents.

To solve this problem, AWS introduced in July 2018 feature called IAM permission boundary. If this went unnoticed, don’t feel back about yourself. If you have been working in modern environments, it has been the best practice to use AWS account as boundary and create accounts dedicated to a single team, application or environment.

IAM permission boundary

There are 3 types of IAM polices that control your access to AWS APIs. First and most common is normal identity-based policies that are attached to roles and users. Second type is organization service control policies (SCP) that are attached to AWS accounts or OUs. These can limit what the local admins can do on the account and as they are not part the account, they can not be modified by local admins. Typical use-case for SCPs would be limiting regions or services you are allowed to use within given account.

The Third type is permission boundary policy. It is similar to regular IAM policies inside of an account, but the use-case is similar to SCPs, limiting what can be granted in normal policies.

Policy evaluation logic works so, that you are only allowed for an actions if all three policy types allow it. You may remember that single deny will always overwrite any number of allow statements, but another thing to note is that all policy types must also have allow your actions, ie. the effective permissions are the intersection of all policies applied. This is why you typically want to have “allow-all” -statement in permission boundary or SCP policies, and then deny the parts you don’t want to be granted via normal IAM policies.

Setting boundaries for a team

The Goal is to allow users to manage IAM policies, roles, users, groups and instance-profiles so they can work idependently. But at the same time, protect their resources from others sharing the same AWS account. Ownership of an resource is claimed using a tag with unique value to user, team or role. Ie. something that wasn’t possible before permission boundaries.

Dissecting the boundary policy

As a demonstration of how IAM permission boundary works, I wrote a cloudformation template that creates an IAM user with AdministratorAccess and permission boundary attached. I’ve dissected the boundary policy statements below.

First there is normal IAM policy header stuff. Only interesting piece here is ManagedPolicyName. This is going to be restricted in following statements.

    Type: AWS::IAM::ManagedPolicy
      ManagedPolicyName: !Sub "${AWS::StackName}-boundary"
      Description: IAM permission boundary to force boundary inheritance
        Version: "2012-10-17"


This statement will allow all actions, on all resources, unless there is an owner -tag that has different value than the IAM role or user owner -tag has. Ie. if some resource is missing the owner -tag, they are considered as common property. This is something you have to accept, as it is not possible to tag all resources at creation but create and tagging are 2 separate API calls. It is also assuming that when role or user is created, it will be tagged with team or application specific owner -tag value as StringEqualsIfExists won’t apply when aws:PrincipalTag/owner is not present.

          - Sid: 'AllowUnlessOwnedBySomeoneElse'
            Effect: Allow 
            Action: '*'
            Resource: '*'
                'aws:RequestTag/owner': ${aws:PrincipalTag/owner}
                'aws:ResourceTag/owner': ${aws:PrincipalTag/owner}


Next thing is to deny modification of this boundary policy. Deny rule applies only this single policy. You can still grant access to all other IAM policies.

          - Sid: 'DenyBoundaryModification'
            Effect: Deny
              - iam:CreatePolicyVersion
              - iam:DeletePolicy
              - iam:DeletePolicyVersion
              - iam:SetDefaultPolicyVersion
            Resource: !Sub "arn:aws:iam::${AWS::AccountId}:policy/${AWS::StackName}-boundary"


This is the beef of permission boundary. You are allowed to create a role or user only if that has the same permission boundary attached as you have.

          - Sid: 'ForceBoundaryInheritance'
            Effect: Deny 
              - iam:CreateUser
              - iam:CreateRole
              - iam:PutUserPermissionsBoundary
              - iam:PutRolePermissionsBoundary
            Resource: '*'
                'iam:PermissionsBoundary': !Sub "arn:aws:iam::${AWS::AccountId}:policy/${AWS::StackName}-boundary"


Inheritance isn’t enough, but it is also necessary to deny removal of permission boundary.

          - Sid: 'DenyBoundaryRemoval'
            Effect: Deny
              - iam:DeleteUserPermissionsBoundary
              - iam:DeleteRolePermissionsBoundary
            Resource: '*'
                'iam:PermissionsBoundary': !Sub "arn:aws:iam::${AWS::AccountId}:policy/${AWS::StackName}-boundary"


Final statement of the boundary policy, restricts the IAM resources; users, groups, roles, policies and instance-profiles, to have a prefix of your IAM pricipals owner -tag value. This is ensuring you won’t be able to modify or attach other than your own IAM resources.

          - Sid: 'DenyIAMChangesWithoutPrefix'
            Effect: Deny
              - iam:Add*
              - iam:Attach*
              - iam:Create*
              - iam:Delete*
              - iam:Detach*
              - iam:Pass*
              - !Sub "arn:*:iam::${AWS::AccountId}:group/${!aws:PrincipalTag/owner}-*"
              - !Sub "arn:*:iam::${AWS::AccountId}:policy/${!aws:PrincipalTag/owner}-*"
              - !Sub "arn:*:iam::${AWS::AccountId}:role/${!aws:PrincipalTag/owner}-*"
              - !Sub "arn:*:iam::${AWS::AccountId}:user/${!aws:PrincipalTag/owner}-*"
              - !Sub "arn:*:iam::${AWS::AccountId}:instance-profile/${!aws:PrincipalTag/owner}-*"

And that’s it! Now you have isolated multiple teams within a single account using IAM permission boundary. You can tag now the IAM user with owner -tag and some value. Then using a different user, create resources, some tagged with the same value as the IAM user created, some with different values, and see how access is only granted to resources matching your tag.

NOTE: There is no need to create team specific boundary policies but the same policy can be attached to all roles and users that need to be isolated. This is thanks to IAM policy variable aws:PrincipalTag.

Too little, too late?

But is this “too little, too late”, as now it is also easy to create new accounts using organizations?

If there is an option to have dedicated AWS account, that would be the ultimate boundary. But there are also many large migrations from data center to AWS, where it isn’t feasible to create own account for every workload. This is where shared accounts with permission boundaries can become helpful. And not to forget those shared “legacy” accounts that many of us have created in past, and are not quite yet split into single tenancy model ;-)

So, there actually might be more use IAM boundaries than ever!

P.S. If you find holes in the boundary that I did miss, I’d love to hear from you.

P.P.S. Lambda applies automatic permission boundary to application IAM roles it creates. You can use auto created boundary policy or define your own in SAM template Globals -section.