AWSCloudTechnology

AWS Login Code As Infrastructure CloudFormation

AWS CloudFormation

I set up AWS accounts in a hub and spoke model for access using AWS Organizations for login. This meaning that I have an AWS organization that is under my master account. I have my SSO federated login in a single login account whose only purpose is to do the federated login. After logging in, the user can assume roles in the other accounts from that login account.

The intent is to separate concerns and reduce risk by doing so. In an ideal world, there would be separation between billing, organization, access to the other accounts. AWS doesn’t fully support this setup. You need to control the organization and billing from the master billing account, and protect your AWS login.

Infrastructure / Architecture

With that in mind, I set things up with a service and lane approach

  • foo-master – Master billing, asset control and organizational control
  • foo-login – Central login account for federated roles.
  • foo-management – for centralized automation of other accounts
  • foo-myservice-cd – CI/CD account for service myservice
  • foo-myservice-dev – Development account
  • foo-myservice-qa – QA account
  • foo-myservice-prod – Production account

So access is granted to all the accounts via the roles in the foo-login account via federation (using an IDP like Google or Okta). Login in via the external system (or AWS SSO) to federated roles in this account.

The roles in the foo-login account are set up to allow assuming roles into the other accounts, and those roles in turn are set up to trust the role in that account, and grant permissions in that account. So we deploy a CloudFormation stack in the foo-management account that builds the roles for a service in each of the accounts based on the access level needed foo-readonly , foo-poweruser and foo-admin as example would be deployed to the DEV, QA and Prod accounts.

The user authenticates which gives them credentials in the foo-login that allow them to assume into these accounts.

Note: This can lead to confusion (particularly in the console. The initial login only has rights to assume other roles and has no other permissions. In the console, you have to “Switch Roles” to get into the account where you are going to do your work.

Implementation code

I use scripts that will create the accounts for each lane. The script provides parameters to build the roles and trust relationships I need for this hub-and-spoke method. The first script (which is typically python or Go) is used to create the service accounts (in the example above that is the foo-myservice-* accounts. That script also does initial setup stuff like removing default VPCs, turning on/off services, and general housekeeping tasks.

The script uses the organizations API to create the accounts. In each service account we use CloudFormation to build a stack in the login account. This stack contains the roles that are able to assume, granting permissions in each service account.

So for our example above the login roles would be things like myservice-readonly or myservice-poweruser. The create script has these values from creating those accounts. The account creation script grabs the new account IDs and passes them to the stack.

Login roles (CloudFormation)

In the foo-management account the parameters we need are the account IDs which are passed by parameter. To keep them unique, we also have a parameter for the service name:

  # Name of the team/service we are adding
  ServiceName:
    Type: String
    Description:  Name of the service

  # Manage account (for automation)
  ServiceCDAccountID:
    Type: String
    Description: Enter the CI/CD account ID (cd)
    Default: ''

<meta charset="utf-8">  # Dev account ID
  ServiceDevAccountID:
    Type: String
    Description: Development (dev) Account ID
    Default: ''<meta charset="utf-8">  # Dev account ID
  
  ServiceQAAccountID:
    Type: String
    Description: QA (qa) Account ID
    Default: ''<meta charset="utf-8">  # QA account ID

<meta charset="utf-8">  ServiceProd1AccountID:
    Type: String
    Description: Prod 1 (prod-1) Account ID
    Default: ''

<meta charset="utf-8">  ServiceProd2AccountID:
    Type: String
    Description: Prod 2 (prod-2) Account ID
    Default: ''

Login Role Conditions (CloudFormation)

In the Conditions section, I establish conditions that will be true if the account ID is not empty. Each of those conditions controls whether we create the trust relationship for that lane or not. (See: AWS Cloudformation with Optional Resources for more)

  HasCDAccount: !Not [!Equals [!Ref ServiceCDAccountID, ""]]
  HasDevAccount: !Not [!Equals [!Ref ServiceCDAccountID, ""]]
  HasQAAccount: !Not [!Equals [!Ref ServiceQAAccountID, ""]]
  HasProd1Account: !Not [!Equals [!Ref ServiceProd1AccountID, ""]]
  HasProd2Account: !Not [!Equals [!Ref ServiceProd2AccountID, ""]]
  

Login Role Policy (CloudFormation)

These conditions are used to set the trust relationships, so the policy for the role has a bunch of Fn::If statements that either add the ARN of the role, or use the special property for CloudFormation of “AWS::NoValue” to not add the role for that condition.

  ReadOnlyPolicy:
    Type: AWS::IAM::ManagedPolicy
    Properties:
      ManagedPolicyName: !Sub '${ServiceName}-readonly'
      PolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Action: sts:AssumeRole
            Resource:
              - Fn::If:
                  - HasDevAccount
                  - !Sub 'arn:aws:iam::${ServiceDevAccountID}:role/readonly'
                  - !Ref "AWS::NoValue"
<meta charset="utf-8">              - Fn::If:
                  - HasQAAccount
                  - !Sub 'arn:aws:iam::${ServiceQAAccountID}:role/readonly'
                  - !Ref "AWS::NoValue"
               - Fn::If:
                  - HasProd1Account
                  - !Sub 'arn:aws:iam::${ServiceProd1AccountID}:role/readonly'
                  - !Ref "AWS::NoValue"
              - Fn::If:
                  - HasProd2Account
                  - !Sub 'arn:aws:iam::${ServiceProd2AccountID}:role/readonly'
                  - !Ref "AWS::NoValue"
              - Fn::If:
                  - HasCDAccount
                  - !Sub 'arn:aws:iam::${ServiceCDAccountID}:role/readonly'
                  - !Ref "AWS::NoValue"


ReadOnly Login Role (CloudFormation)

The policy is tied to the role, which has the name that matches the access, in this case we’ve named it readonly as shown below

  ReadOnlyRole:
    Type: AWS::IAM::Role
    DependsOn: ReadOnlyPolicy
    Properties:
      RoleName: !Sub '${ServiceName}-readonly'
      MaxSessionDuration: !Ref NonAdminSessionTimeout
      AssumeRolePolicyDocument:
        Statement:
            - Effect: Allow
            Principal:
              Federated:
                - !Sub 'arn:aws:iam::${AWS::AccountId}:saml-provider/GoogleApps'
            Action: sts:AssumeRoleWithSAML
            Condition:
              StringEquals:
                'SAML:aud': https://signin.aws.amazon.com/saml
      ManagedPolicyArns:
        - !Sub 'arn:aws:iam::${AWS::AccountId}:policy/${ServiceName}-readonly'

The AssumeRolePolicy document is what connects us to the IDP (in this example it’s Google Apps, the name of the role would end up being myservice-readonly Which in this example would have the ability to assume a role named readonly in each of the service accounts.

Service Roles in Accounts (CloudFormation)

And the last script deploys the roles that the above would assume into each service account. Again this is a CloudFormation stack that allows assumption from the role in the login account with something like:

  ReadOnlyRole:
      Type: AWS::IAM::Role
      Properties:
        RoleName: readonly
        MaxSessionDuration: !Ref NonAdminSessionTimeout
        AssumeRolePolicyDocument:
          Statement:
          - Effect: Allow
            Principal:
              AWS:
                - !Sub 'arn:aws:iam::${loginAccountID}:role/${ServiceName}-readonly'
            Action: sts:AssumeRole
        ManagedPolicyArns:
        - 'arn:aws:iam::aws:policy/ReadOnlyAccess'

Basically you give this role the permissions it needs (in this case the generic AWS ReadOnlyAccess) and make sure it can only be assumed by the role that is in the login account (myservice-readonly for this one).

Once you have everything set up, you should have all of your login code as infrastructure CloudFormation scripts and a good setup for AWS login.

Hi, I’m Rob Weaver