Weezer at re:Play 2024

Implementing Security Invariants in an AWS Management Account

I’ve spoken a lot about Security Invariants, but all of them have been implemented using Organizational Policies. That’s great, but organizational policies don’t apply to the Organizational Management Account (aka “payer”). So how does one implement invariants in a payer account?

AWS would tell you that you shouldn’t be giving anyone access to the payer account, so the need for invariants should be minimal. However, that doesn’t reflect the reality that AWS never protected its customers from themselves and prevented the enabling of Organizations or Control Tower in an account with existing workloads. I would say this is a failure of Customer Obsession and demonstrates Security is not the Top Priority. AWS would hide behind shared responsibility and blame the customer.

Regardless, there are many cases where workloads are in a payer account, and as a security person, you need to live with those workloads while protecting the rest of the AWS Organization. So, how do we build invariants into a payer account when SCPs and RCPs don’t apply?

Enter Permission Boundaries.

While Service Control Policies (SCPs) define the maximum permissions for an AWS Account, a Permission Boundary defines the maximum permissions for a specific principal (ie IAM Role or IAM User). While SCPs don’t apply to the payer account, permission boundaries do. So, in order to implement invariants in the payer account, we just need to apply a Permission Boundary to every principal. Simple right? That depends a lot on your organization and the nature and type of workloads in your payer account. But first, we need to consider what invariants we need to implement in an organization management account.

Security Invariants for your Organization Management Account.

As I described in previous posts, a Security Invariant must always be true. So, we must consider the roles and personas of those who may need to perform an activity when we define our invariants. Also, there are things that happen in an organizational management account that don’t happen in most other accounts. We need to define things here that we wouldn’t need in other accounts. Here is a semi-exhaustive list of the Invariants I’d want in any payer account:

  1. Only the Cloud Admin Team can mutate CloudTrail Trails
  2. Only the Cloud Admin Team can open, close, remove, or change the root email address of an account from the organization
  3. Only the Cloud Admin Team can enable new Organization services or policies or modify Delegated Admin accounts
  4. Only the Cloud Admin Team, or the appropriate IaC Pipeline, can modify SCPs, RCPs, and Declarative Policies.
  5. Only the Cloud Admin Team can call sts:AssumeRoot or assume the OrganizationAccountAccessRole.
  6. Only the Cloud Admin or Cloud Finance teams can modify ownership, billing, and tax settings for the payer account
  7. Only the Cloud Admin Team, the appropriate IaC Pipeline, SCIM, or JIT permissions tool, can modify IAM Identity Center PermissionSet, User, Group, or Account Assignment.
  8. Only the Cloud Admin Team, or the appropriate IaC Pipeline, can deploy or modify Stacksets using the Delegated Admin permissions.
  9. Nobody ever ever ever can enable and use Control Tower. Remember kids, Control Tower: Not even Once!

The Mechanics of Permission Boundaries

As noted above, permission boundaries define the maximum permissions for an IAM role or user. However, they do not actually grant any permissions. So, how do they work? A permission boundary is a normal IAM Policy. You attach it to a role or user, but unlike the policies that explicitly grant permissions, each role or user can have only one attached boundary.

A super simple boundary to prevent sts:AssumeRoot would look like:

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "AllowAllButAssumeRoot",
            "Effect": "Allow",
            "NotAction": ["sts:AssumeRoot"],
            "Resource": ["*"]
        }
    ]
}

You’d be set if you applied that to all the principals except the Cloud Admin team. The first half of Invariant #5 is done. What about the second half? You’ll need to add a new statement with a condition key for that.

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "AllowAllButAssumeRoot",
            "Effect": "Allow",
            "NotAction": ["sts:AssumeRoot"],
            "Resource": ["*"]
        },
        {
            "Sid": "LimitOrgRoleAccess",
            "Effect": "Deny",
            "NotAction": ["sts:AssumeRole"],
            "Resource": ["arn:aws:iam::*:role/OrganizationAccountAccessRole"]
        }
    ]
}

Here we are allowing everything but AssumeRoot and explicitly denying AssumeRole if the role is OrganizationAccountAccessRole.

The invariants above define several roles. We could craft a special boundary policy for each one and carefully apply the right policy to each principal. But that doesn’t scale. So the question is, can we define one boundary policy to rule them all and ensure that every principal has that boundary attached?

Let’s see what that would look like. Note: Most of these actions were enumerated from reviewing the AWS IAM Service Authorization Reference, which lists and describes all the IAM Actions.

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "AllowListForAllButCloudAdmin",
            "Effect": "Allow",
            "NotAction": [
                // Only the Org Admin Team can call `sts:AssumeRoot`
                "sts:AssumeRoot",
                // Only the Org Admin Team can mutate CloudTrail Trails
                "cloudtrail:Put*",
                "cloudtrail:Create*",
                "cloudtrail:Delete*",
                "cloudtrail:Start*",
                "cloudtrail:Stop*",
                "cloudtrail:Update*",

                // Only the Org Admin Team can open, close, remove or
                //  change the root email address of an account from the organization
                // Only the Org Admin Team can enable new Organization services
                //  or policies, or modify Delegated Admin accounts
                // Only the Org Admin Team or the appropriate IaC Pipeline, can
                //  modify SCPs, RCPs, and Declarative Policies.
                "organizations:AttachPolicy",
                "organizations:CloseAccount",
                "organizations:Create*",
                "organizations:Delete*",
                "organizations:DeregisterDelegatedAdministrator",
                "organizations:DetachPolicy",
                "organizations:Disable*",
                "organizations:Enable*",
                "organizations:InviteAccountToOrganization",
                "organizations:LeaveOrganization",
                "organizations:MoveAccount",
                "organizations:PutResourcePolicy",
                "organizations:RegisterDelegatedAdministrator",
                "organizations:RemoveAccountFromOrganization",
                "organizations:Update*",
                "organizations:InviteAccountToOrganization",

                // Only the Org Admin or Cloud Finance team can modify
                // ownership, billing and tax settings for the payer account
                "account:AcceptPrimaryEmailUpdate",
                "account:DeleteAlternateContact",
                "account:DisableRegion",
                "account:EnableRegion",
                "account:PutAlternateContact",
                "account:PutContactInformation",
                "account:StartPrimaryEmailUpdate",
                "aws-portal:Modify*",
                "aws-portal:Update*",
                "billing:PutContractInformation",
                "billing:RedeemCredits",
                "billing:Update*",
                "invoicing:Create*",
                "invoicing:Delete*",
                "invoicing:Put*",
                "invoicing:Update*",
                "tax:Batch*",
                "tax:Delete*",
                "tax:Put*",
                "tax:Update*",

                // Only the Org Admin Team, the appropriate IaC Pipeline,
                // SCIM, or JIT permissions tool can modify IAM Identity Center
                //  PermissionSet, User, Group, or Account Assignment.
                "sso:Associate*",
                "sso:Attach*",
                "sso:Create*",
                "sso:Delete*",
                "sso:Detach*",
                "sso:Disassociate*",
                "sso:Import*",
                "sso:Put*",
                "sso:Start*",
                "sso:Update*",
                "sso-directory:Add*",
                "sso-directory:Complete*",
                "sso-directory:Create*",
                "sso-directory:Delete*",
                "sso-directory:Disable*",
                "sso-directory:Enable*",
                "sso-directory:Import*",
                "sso-directory:Remove*",
                "sso-directory:Start*",
                "sso-directory:Update*",
                "sso-directory:VerifyEmail",
                "sso-oauth:CreateTokenWithIAM",

                // Only the Org Admin Team or the appropriate IaC Pipeline, can
                // deploy or Modify Stacksets using the Delegated Admin permissions.
                "cloudformation:ActivateOrganizationsAccess",
                "cloudformation:CreateStackInstances",
                "cloudformation:CreateStackSets",
                "cloudformation:DeactivateOrganizationsAccess",
                "cloudformation:DeleteStackInstances",
                "cloudformation:DeleteStackSet",
                "cloudformation:ImportStacksToStackSet",
                "cloudformation:StopStackSetOperation",
                "cloudformation:UpdateStackSet",
                "cloudformation:UpdateStackInstances",

                // Nobody ever ever ever can enable and use Control Tower.
                "controltower:*",

                // Housekeeping. We also need to prevent the removal or change of a
                // permission boundary
                "iam:DeleteRolePermissionsBoundary",
                "iam:DeleteUserPermissionsBoundary",
                "iam:PutRolePermissionsBoundary",
                "iam:PutUserPermissionsBoundary",

                ],
            "Resource": ["*"]
        },
        {
            // This statement is needed to explicitly permit stuff for CloudAdmin
            // It's not 100% needed. You can get the same effect by not attaching
            // any boundary to the CLOUDADMIN role. It's here for completeness
            "Sid": "AllowCloudAdminEverything",
            "Effect": "Allow",
            "Action": ["*"],
            "Resource": ["*"],
            "Condition": {
                "ArnLike": {
                    "aws:PrincipalArn": [
                        "arn:aws:iam::*:role/CLOUDADMIN"
                    ]
                }
            }
        },
        {
            // Only the Org Admin or Cloud Finance team can modify ownership,
            //  billing and tax settings for the payer account
            // Here, we add back in these permissions, but only if the role
            // is the CLOUD_FINANCE role
            "Sid": "AllowIdentityCenterAutomation",
            "Effect": "Allow",
            "Action": [
                "account:*",
                "aws-portal:*",
                "billing:*",
                "invoicing:*",
                "tax:*",
            ],
            "Resource": ["*"],
            "Condition": {
                "ArnLike": {
                    "aws:PrincipalArn": [
                        "arn:aws:iam::*:role/CLOUD_FINANCE"
                    ]
                }
            }
        },
        {
            // Only the Org Admin Team, the appropriate IaC Pipeline, SCIM, or
            // JIT permissions tool can modify IAM Identity Center PermissionSet,
            // User, Group, or Account Assignment.
            "Sid": "FinanceRoleAllowedServices",
            "Effect": "Allow",
            "Action": [
                "sso:*",
                "sso-directory:*",
                "sso-oauth:*"
            ],
            "Resource": ["*"],
            "Condition": {
                "ArnLike": {
                    "aws:PrincipalArn": [
                        "arn:aws:iam::*:role/IDENTITY_CENTER_ROLE"
                    ]
                }
            }
        },
        {
            // Only the Org Admin Team or the appropriate IaC Pipeline can
            // modify SCPs, RCPs, and Declarative Policies.
            "Sid": "OrganizationsPipeline",
            "Effect": "Allow",
            "Action": [
                "organizations:AttachPolicy",
                "organizations:CreatePolicy",
                "organizations:DeletePolicy",
                "organizations:DetachPolicy",
                "organizations:DisablePolicyType",
                "organizations:EnablePolicyType",
                "organizations:UpdatePolicy",
            ],
            "Resource": ["*"],
            "Condition": {
                "ArnLike": {
                    "aws:PrincipalArn": [
                        "arn:aws:iam::*:role/Organizations_Terraform_ROLE"
                    ]
                }
            }
        },
        {
            // We must explicitly permit the attachment of the Permissions Boundary
            "Sid": "OrganizationsPipeline",
            "Effect": "Allow",
            "Action": [
                "iam:DeleteRolePermissionsBoundary",
                "iam:DeleteUserPermissionsBoundary",
                "iam:PutRolePermissionsBoundary",
                "iam:PutUserPermissionsBoundary",
            ],
            "Resource": ["*"],
            "Condition": {
                "ArnLike": {
                    "aws:PrincipalArn": [
                        "arn:aws:iam::*:role/CLOUDADMIN",
                        "arn:aws:iam::*:role/BOUNDARY_APPLY_LAMBDA_ROLE"
                    ]
                }
            }
        },
        {
            // We need to do an explicit deny here because this needs
            // to be it's own statement due to the Resource
            "Sid": "LimitOrgRoleAccess",
            "Effect": "Deny",
            "Action": ["sts:AssumeRole"],
            "Resource": ["arn:aws:iam::*:role/OrganizationAccountAccessRole"],
            "Condition": {
                "ArnNotLike": {
                    "aws:PrincipalArn": [
                        // Exclude CLOUDADMIN from this Deny. Add your
                        // automation roles here too if your automation
                        //  needs to use OrganizationAccountAccessRole
                        "arn:aws:iam::*:role/CLOUDADMIN"
                    ]
                }
            }
        },
        {
            // We need to do an explicit deny to prevent anyone from updating this IAM Policy
            "Sid": "ProtectPermBoundary",
            "Effect": "Deny",
            "Action": [
                "iam:CreatePolicyVersion",
                "iam:DeletePolicy",
                "iam:SetDefaultPolicyVersion",

            ],
            "Resource": ["arn:aws:iam::*::policy/GRAND_UNIFIED_PERMISSON_BOUNDARY"],
            "Condition": {
                "ArnNotLike": {
                    "aws:PrincipalArn": [
                        // Exclude CLOUDADMIN from this Deny. Add your
                        // automation roles here too if your automation
                        //  needs to use OrganizationAccountAccessRole
                        "arn:aws:iam::*:role/CLOUDADMIN",
                        "arn:aws:iam::*:role/BOUNDARY_APPLY_LAMBDA_ROLE"
                    ]
                }
            }
        }
    ]
}

This is a very large and complex policy. Remember, it does not grant any permissions to any user or role; it only defines the maximum permissions a user or role can have. Teams must still define user/role policies and attach them to their principals using the principle of least privilege!*** Note that we don’t disallow IAM. Users in the payer account can create new IAM Users and Roles and write whatever inline or managed policies they like.

Many permission boundary examples use “sticky” logic to ensure the boundary is attached to all newly created principals. Those only allow role or user creation if the CreateRole/CreateUser API call includes the same permission boundary policy ARN. This ensures that a user cannot create a principal with more privileges than they themselves possess. However, every IaC resource that creates a user or role must be updated to include this feature. That may or may not work for you and your organization.

Great, I have a 300-line JSON file, how do I get security?

For this to provide invariant protection, it must be applied to all the users and roles in your payer account. You could require all developers to add the boundary to their IaC repos and enforce this with the sticky boundary. Or, you could just use the power of event-driven architectures to apply the boundary to every IAM User and Role on creation.

You’ll note I reference a BOUNDARY_APPLY_LAMBDA_ROLE permitted to Delete and Put User and Role permission boundaries. The basic idea here is a Lambda Function that is invoked whenever a user or role is created, or a permissions boundary is modified. The function will attach the GRAND_UNIFIED_PERMISSON_BOUNDARY policy to the modified principal as its permission boundary. Since the policy document defines all use cases, it can be automatically applied to every user/role. This happens nearly instantly, but it is not a fool-proof solution. A determined attacker can exploit the window between principal creation and boundary application to execute some of the forbidden actions.

All of this assumes you’re not already leveraging permission boundaries. But lets face it: if you’re running workloads in your payer, you’re probably not yet using this advanced feature. If you need to know if your payer account has any users/roles with a boundary, you can use this Steampipe query:

SELECT
    title,
    permissions_boundary_arn,
    permissions_boundary_type
FROM aws_payer.aws_iam_role
WHERE permissions_boundary_arn IS NOT null
   OR permissions_boundary_type IS NOT null

For the sake of brevity of this post, I’m not going to include the Lambda Function or EventBridge Rules that are needed to make this work. If you want to leverage that code, you can find it in GitHub at the pht-payer-invariants repo.

This creates an Enforcer Lambda and EventBridge rule that listens for the following events and applies them to all IAM Users and Roles:

  • iam:CreateUser (Note: boundaries only apply to users, not to IAM User Groups)
  • iam:CreateRole
  • iam:DeleteRolePermissionsBoundary
  • iam:DeleteUserPermissionsBoundary
  • iam:PutRolePermissionsBoundary
  • iam:PutUserPermissionsBoundary

The repo also contains the above GRAND_UNIFIED_PERMISSON_BOUNDARY policy and a script that will strip out comments and substitute some placeholders with your roles.

There is one more thing to note - AWS IAM Identity Center. The Enforcer Lambda function cannot apply boundaries to these AWS-managed roles. So you’ll need a setup to ensure that permission boundaries are also enforced on all Identity Center permission sets. That means you either need separate roles for the payer or to deploy the GRAND_UNIFIED_PERMISSON_BOUNDARY policy to every account.

Ideally, you don’t have the need to grant a large number of folks access to your management accounts or the ability for them to manipulate IAM or other things. But if you do… Hopefully, this can reduce your risk.