Node-JS AWS-SDK: Assume Role sequences

One of the biggest issues with the current version of the AWS-SDK-JS is that role credentials aren't initialised as nicely as one might want to. I myself am definitely not someone who writes code every day and doesn't want to understand the issues at hand, but when teams come to me and ask me for advice: I want to be able to give them clear, concrete and concise answers.

One of the many use-cases is cross-account lambda/resource access. In the code below I have created an example which allows you to assume a role from the execution role, write to an s3-bucket, assume another role from the previous assumed role and with another file to another s3 bucket.

Pre-requisits

  • Some role with policy/privileges to Assume
  • Policy / rights to allow to assume said role in your application or lambda (e.g. executionRole/instanceRole

Click here to Jump to the code instead.

Enable Allow Assume Role - Policy

{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Action": "sts:AssumeRole",
    "Resource": "arn:aws:iam::1234567890:role/SomeExampleRole"
  }]
}

You should specify in both the role you assume and the role to be assumed, who does so and who is allowed. AWS uses deny by default, so without specifying an explicit allow, you will get a deny.

Conceptual Role example - YAML snippet

Description: Some Example role that gives some other role acccess to do deploy and request of certificates
AWSTemplateFormatVersion: '2010-09-09'
Metadata:
  Version: 0.0.1
  Author: Some Author Other Than Me!
  Last-Modified-by: Well Not me 
  Note: Some Note
  Version-changes: Added ACM support
  Company: Some Company
  Company-Email: Some@Company.Email
  Email: Some@E.Mail
  Phone: +9876543210

Parameters:  
  SomeSharedSecret:
    Description: Please provide a random guid for ExternalID (shared secret) https://guidgenerator.com/online-guid-generator.aspx 
    Type: String
    MinLength: 8
    MaxLength: 36
    AllowedPattern: ^[a-z0-9\\-]*$

  Environment:
    Default: "dev"
    Type: String
    Description: Environment type (prod/pre-prod/test/dev)
    AllowedValues:
      - "dev"
      - "test"    
      - "preprod"
      - "prod"
      
  Version:
    Default: "0.0.1"
    Type: String
    Description: This templates version
    AllowedValues:
      - "0.0.1"    

Resources:
  ExampleRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
        - Effect: Allow
          Principal:
            AWS:
            - arn:aws:iam::01234567890:role/SomeExecutionRole
          Action: sts:AssumeRole
          Condition: 
            StringEquals:
              'sts:ExternalId': !Ref 'SomeSharedSecret'
      Path: "/"
      ManagedPolicyArns:
      - !Ref ExampleRolePolicy
      RoleName: !Sub "SomeExampleRoleName"

  ExampleRolePolicy: 
    Type: AWS::IAM::ManagedPolicy
    Properties: 
      ManagedPolicyName: !Sub "SomeExampleRolePolicyName-v${Version}"
      PolicyDocument: 
        Version: "2012-10-17"
        Statement: 
          - Effect: Allow
            Action:
            - s3:ListBucket
            Resource:
            - !Sub "arn:aws:s3:::some-example-bucket-${Environment}"
          - Effect: Allow
            Action:
            - s3:GetObject
            - s3:PutObject
            - s3:DeleteObject
            Resource:
            - !Sub "arn:aws:s3:::some-example-bucket-${Environment}/*"
          - Effect: Allow
            Action:
            - s3:GetBucketLocation
            - s3:ListAllMyBuckets
            Resource:
            - "arn:aws:s3:::*"
          - Effect: Allow
            Action:
            - iam:ListServerCertificates
            - iam:UploadServerCertificate
            - iam:UpdateServerCertificate
            - iam:DeleteServerCertificate
            Resource:
            - "*"
          - Effect: Allow
            Action:
            - acm:DescribeCertificate
            - acm:ListCertificates
            - acm:ImportCertificate
            - acm:DeleteCertificate
            Resource:
            - "*"                 
          - Effect: Allow
            Action:
            - route53:ListHostedZones
            - route53:ListHostedZonesByName
            - route53:ListResourceRecordSets
            - route53:GetChange
            - route53:GetHostedZone
            - route53:GetHostedZoneCount
            - route53:ChangeResourceRecordSets
            Resource:
              "*"

Conceptual Policy Example - YAML snippet

Resources:
  SomeLambdaExectutionPolicy: 
    Type: AWS::IAM::ManagedPolicy
    Properties: 
      ManagedPolicyName: !Sub "SomeExampleExecutionRolePolicyName-v${Version}"
      PolicyDocument: 
        Version: "2012-10-17"
        Statement: 
          - Effect: Allow
            Action:
            - sts:assumeRole
            Resource:
            - !Sub "arn:aws:iam::01234567890:role/SomeExampleRole"

Use managed policies over inline policies whenever possible. The advantage of managed policies that they have up to 7 revisions

Code Example - Snippet: getSTS.js

Below code is a compilation of some snippets which one can implement illustrating how to assume a role using sts.assumerole in node.js (e.g., to jump roles and/or assume roles and use them inside the AWS-"client"). You specify the sts-temporary-credentials as arguments when creating the new handler.

import AWS from 'aws-sdk';

const getSTS = async () => {
  const sts = new AWS.STS({ region: process.env.REGION });
  const params = {
    RoleArn: 'arn:aws:iam::1234567890:role/someRole',
    RoleSessionName: 'CrossAccountCredentials',
    ExternalId: '1234567-1234-1234-1234-123456789012',
    DurationSeconds: 3600,
  };

  const assumeRoleStep1 = await sts.assumeRole(params).promise();
  console.log('Changed Credentials');

  const accessparams = {
    accessKeyId: assumeRoleStep1.Credentials.AccessKeyId,
    secretAccessKey: assumeRoleStep1.Credentials.SecretAccessKey,
    sessionToken: assumeRoleStep1.Credentials.SessionToken,
  };

  const s3 = await new AWS.S3(accessparams);

  s3.putObject({
    Body: `Angeliques test ${new Date().toLocaleString()}`,
    Bucket: 'bucket1',
    Key: 'helloworld.txt',
  }, (err, data) => {
    if (err) console.log(err, err.stack); // an error occurred
    else console.log(data); // successful response
  });

  const sts2 = new AWS.STS(accessparams);
  const params2 = {
    RoleArn: 'arn:aws:iam::1234567890:role/someRole2',
    RoleSessionName: 'CrossAccountCredentials2',
    ExternalId: '1234567-1234-1234-1234-123456789012',
  };

  const assumeRoleStep2 = await sts2.assumeRole(params2).promise();
  console.log(assumeRoleStep2);

  const accessparams2 = {
    accessKeyId: assumeRoleStep2.Credentials.AccessKeyId,
    secretAccessKey: assumeRoleStep2.Credentials.SecretAccessKey,
    sessionToken: assumeRoleStep2.Credentials.SessionToken,
  };

  const s32 = await new AWS.S3(accessparams2);

  s32.putObject({
    Body: `Angeliques test ${new Date().toLocaleString()}`,
    Bucket: 'bucket2',
    ServerSideEncryption: 'AES256',
    Key: 'helloworld.txt',
  }, (err, data) => {
    if (err) console.log(err, err.stack); // an error occurred
    else console.log(data); // successful response
  });

  return assumeRoleStep1;
};

module.exports = getSTS;

This code is not complete, prettified or in single style. It requires additional implementation and revision! It is written using ES6 and will require transpiling (babel etc) or modern implementation. Use this as snippet-example only. Not as-is!

Step-by-step

Create a new STS service client using the already known credentials from the Lambda execution role.

  const sts = new AWS.STS({ region: process.env.REGION });

AWS.Config.Credentials are pulled from the environment variables. These credentials are provided via the metadata service and STS. The chain-provider will ensure there will always be correct credentials available and always ensure they are "renewed" using retry & callback mechanisms.

Do not change your AWS.config unless you really need to. Tons of examples will tell you to do the following: AWS.config.update({region: 'some-region',accessKeyId: 'some-key',secretAccessKey: 'some-secret-key',sessionToken: 'some-session-token'});. This does work but has the side-effect that this will also result in your logging and everything else to use those credentials as it will update the global AWS object. (This global, does not behave as you would think and has very unpredictable behaviour). Normally this is not what you want to achieve in 99% of all cases. You just want to create new service clients with specific credentials that you can re-use and/or destroy when leaving scope.

The credentials are stored in clear-text in memory. You could store these in a different way. This example does not cover this.

Define the parameters of the role you want to assume

  const params = {
    RoleArn: 'arn:aws:iam::1234567890:role/someRole',
    RoleSessionName: 'CrossAccountCredentials',
    ExternalId: '1234567-1234-1234-1234-123456789012',
    DurationSeconds: 3600,
  };

Create a promise object and store the AWS request object.

  const assumeRoleStep1 = await sts.assumeRole(params).promise();

I use async/await (ecma2015). You could use regular callback sequences etc, but both code and execution become so much more concise with await/async. You could rewrite the above code as .promise().then().catch() or try{}catch(err){} etc. Current example does not catch any errors!

Avoid unnecessary nesting and/or callback hell whenever you can. The use of async and await makes life much simpler, by making async functions seem as if they are synchronous code. All of these functions execute in a nonblocking fashion, having the benefit of nonblocking async functions, with the simplicity and readability of synchronous code. Click here for a Google primer on async/await-functions.

Create an array which contains the credentials received from STS to pass to the constructer of the service client. In this case S3 or STS.

  const accessparams = {
    accessKeyId: assumeRoleStep1.Credentials.AccessKeyId,
    secretAccessKey: assumeRoleStep1.Credentials.SecretAccessKey,
    sessionToken: assumeRoleStep1.Credentials.SessionToken,
  };

Now create the service-client and start using the role/temporary security credentials:

  const s3 = await new AWS.S3(accessparams);
  const sts2 = new AWS.STS(accessparams);

I could have used let instead of const when creating the "handles" for sts and s3. If you do not re-assign variables, const is deemed better than let. Never use var! (scoping)

Author: Angelique Dawnbringer Published: 2018-02-27 00:00:00 Keywords:
  • AssumeRole
  • AWS
  • STS
  • Javascript
  • aws-sdk-js
  • Node-js
Modified: 2018-03-16 11:19:31