Search code examples
aws-lambdaamazon-iamaws-cdkaws-stsaws-app-config

how to dynamically assume a role to access DynamoDB from a Lambda using appConfig?


I have two AWS stacks :

one has a dynamoDB table and "exports" (to appConfig) the tableArn, tableName and tableRoleArn (which ideally should allow access to the table).

import { App, Stack, StackProps } from '@aws-cdk/core';
import * as dynamodb from '@aws-cdk/aws-dynamodb';
import * as cdk from '@aws-cdk/core';
import * as appconfig from '@aws-cdk/aws-appconfig';
import { Effect, PolicyStatement, Role, ServicePrincipal } from '@aws-cdk/aws-iam';

export class ExportingStack extends Stack {
    constructor(scope: App, id: string, props: StackProps) {
        super(scope, id, props);

        const table = new dynamodb.Table(this, id, {
            billingMode: dynamodb.BillingMode.PROVISIONED,
            readCapacity: 1,
            writeCapacity: 1,
            removalPolicy: cdk.RemovalPolicy.DESTROY,
            partitionKey: { name: 'id', type: dynamodb.AttributeType.STRING },
            sortKey: { name: 'createdAt', type: dynamodb.AttributeType.NUMBER },
            pointInTimeRecovery: true
        });

        const tablePolicy = new PolicyStatement({
            effect: Effect.ALLOW,
            resources: [table.tableArn],
            actions: ['*']
        });
        const role = new Role(this, 'tableRoleArn', {
            assumedBy: new ServicePrincipal('lambda.amazonaws.com')
        });
        role.addToPolicy(
            tablePolicy
        );

        const app = '***';
        const environment = '***';
        const profile = '***';
        const strategy = 'v';

        const newConfig = new appconfig.CfnHostedConfigurationVersion(this, 'ConfigurationName', {
            applicationId: app,
            configurationProfileId: profile,
            contentType: 'application/json',
            content: JSON.stringify({
                tableArn: table.tableArn,
                tableName: table.tableName,
                tableRoleArn: role.roleArn
            }),
            description: 'table config'
        });

        const cfnDeployment = new appconfig.CfnDeployment(this, 'MyCfnDeployment', {
            applicationId: app,
            configurationProfileId: profile,
            environmentId: environment,
            configurationVersion: newConfig.ref,
            deploymentStrategyId: strategy
        });
    }
}

The second has a function which I would like to be able to use the appConfig configuration to dynamically access the table.

import { App, CfnOutput, Stack, StackProps } from '@aws-cdk/core';
import { LayerVersion, Runtime } from '@aws-cdk/aws-lambda';
import { NodejsFunction } from '@aws-cdk/aws-lambda-nodejs';
import { Effect, PolicyStatement } from '@aws-cdk/aws-iam';

export class ConsumingStack extends Stack {
    constructor(scope: App, id: string, props: StackProps) {
        super(scope, id, props);

        const fn = new NodejsFunction(this, 'foo', {
            runtime: Runtime.NODEJS_12_X,
            handler: 'foo',
            entry: `stack/foo.ts`
        });

        fn.addToRolePolicy(
            new PolicyStatement({
                effect: Effect.ALLOW,
                resources: ['*'],
                actions: [
                    'ssm:*', 
                    'appconfig:*',
                    'sts:*',
                ]
            })
        );

        new CfnOutput(this, 'functionArn', { value: fn.functionArn});

        // https://docs.aws.amazon.com/appconfig/latest/userguide/appconfig-integration-lambda-extensions.html
        // https://github.com/aws-samples/aws-appconfig-codepipeline-cdk/blob/main/infrastructure/src/main/kotlin/com/app/config/ServerlessAppStack.kt
        const appConfigLayer = LayerVersion.fromLayerVersionArn(
            this,
            'appconfigLayer',
            'arn:aws:lambda:eu-west-2:282860088358:layer:AWS-AppConfig-Extension:47'
        );

        fn.addLayers(appConfigLayer);
    }
}

and handler

import type { Context } from 'aws-lambda';
import fetch from 'node-fetch';
import { DynamoDB, STS } from 'aws-sdk';
import { Agent } from 'https';

export const foo = async (event: any, lambdaContext: Context): Promise<void> => {
    const application = '*****';
    const environment = '*****';
    const configuration = '*****';

    const response = await fetch(
        `http://localhost:2772/applications/${application}/environments/${environment}/configurations/${configuration}`
    );

    const configurationData = await response.json();

    console.log(configurationData);
    
    const credentials = await assumeRole(configurationData.tableRoleArn);

    const db = new DynamoDB({
        credentials: {
            sessionToken: credentials.sessionToken,
            secretAccessKey: credentials.secretAccessKey,
            accessKeyId: credentials.accessKeyId
        },
        apiVersion: '2012-08-10',
        region: '*****',
        httpOptions: {
            agent: new Agent({ keepAlive: true }),
            connectTimeout: 1000,
            timeout: 5000
        },
        signatureVersion: 'v4',
        maxRetries: 3
    });

    const item = await db
        .getItem({ TableName: configurationData.tableName, Key: { id: { S: 'coolPeople' }, createdAt: { N: '0' } } }, (e) => {
            console.log('e', e);
        })
        .promise();

    console.log('item:', item?.Item?.value?.L);

   
};

/**
 * Assume Role for cross account operations
 */
export const assumeRole = async (tableRoleArn: string): Promise<any> => {

    let params = {
        RoleArn: tableRoleArn,
        RoleSessionName: 'RoleSessionName12345'
    };

    console.info('Assuming Role with params:', params);

    let sts = new STS();

    return new Promise((resolve, reject) => {
        sts.assumeRole(params, (error, data) => {
            if (error) {
                console.log(`Could not assume role, error : ${JSON.stringify(error)}`);
                reject({
                    statusCode: 400,
                    message: error['message']
                });
            } else {
                console.log(`Successfully Assumed Role details data=${JSON.stringify(data)}`);
                resolve({
                    statusCode: 200,
                    body: data
                });
            }
        });
    });
};


The issue is that I get this error when trying to assumeRole within the lambda.

Could not assume role, error : {"message":"User: arn:aws:sts::****:assumed-role/ConsumingStack-fooServiceRole****-***/ConsumingStack-foo****-*** is not authorized to perform: sts:AssumeRole on resource: arn:aws:iam::****:role/ExportingStack-tableRoleArn****-***","code":"AccessDenied","time":"2022-02-21T16:06:44.474Z","requestId":"****-***-****-****","statusCode":403,"retryable":false,"retryDelay":26.827985116659757}

So is it possible for a Lambda to dynamically assume a role to access a table from a different stack?


Solution

  • I've got it working by changing the trust relationship of the table role to be arn:aws:iam::${Stack.of(this).account}:root

    import { App, Stack, StackProps } from '@aws-cdk/core';
    import * as dynamodb from '@aws-cdk/aws-dynamodb';
    import * as cdk from '@aws-cdk/core';
    import * as appconfig from '@aws-cdk/aws-appconfig';
    import { Effect, PolicyStatement, Role, ArnPrincipal } from '@aws-cdk/aws-iam';
    
    export class ExportingStack extends Stack {
        constructor(scope: App, id: string, props: StackProps) {
            super(scope, id, props);
    
            const table = new dynamodb.Table(this, id, {
                billingMode: dynamodb.BillingMode.PROVISIONED,
                readCapacity: 1,
                writeCapacity: 1,
                removalPolicy: cdk.RemovalPolicy.DESTROY,
                partitionKey: { name: 'id', type: dynamodb.AttributeType.STRING },
                sortKey: { name: 'createdAt', type: dynamodb.AttributeType.NUMBER },
                pointInTimeRecovery: true
            });
    
            const tablePolicy = new PolicyStatement({
                effect: Effect.ALLOW,
                resources: [table.tableArn],
                actions: ['*']
            });
    
            const role = new Role(this, 'tableRoleArn', {
                assumedBy: new ArnPrincipal(`arn:aws:iam::${Stack.of(this).account}:root`)
            });
            role.addToPolicy(tablePolicy);
    
            const app = '***';
            const environment = '***';
            const profile = '****';
            const strategy = '****';
    
            const newConfig = new appconfig.CfnHostedConfigurationVersion(this, 'myConfiguration', {
                applicationId: app,
                configurationProfileId: profile,
                contentType: 'application/json',
                content: JSON.stringify({
                    tableArn: table.tableArn,
                    tableName: table.tableName,
                    tableRoleArn: role.roleArn
                }),
                description: 'table config'
            });
    
            const cfnDeployment = new appconfig.CfnDeployment(this, 'MyCfnDeployment', {
                applicationId: app,
                configurationProfileId: profile,
                environmentId: environment,
                configurationVersion: newConfig.ref,
                deploymentStrategyId: strategy
            });
        }
    }