AWS Login Code As Infrastructure 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 controlfoo-login
– Central login account for federated roles.foo-management
– for centralized automation of other accountsfoo-myservice-cd
– CI/CD account for servicemyservice
foo-myservice-dev
– Development accountfoo-myservice-qa
– QA accountfoo-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.