Skip to content

Commit

Permalink
feat(cli): support credential_source in aws shared config file (#10272)
Browse files Browse the repository at this point in the history
Added support for using `credential_source` in the standard aws config file.

This wasn't previously supported because the JavaScript SDK does [not](aws/aws-sdk-js#1916) support it. 

This PR bypasses the limitation.

----

*By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
  • Loading branch information
iliapolo committed Sep 10, 2020
1 parent e3f3332 commit 940a443
Show file tree
Hide file tree
Showing 9 changed files with 577 additions and 61 deletions.
85 changes: 61 additions & 24 deletions packages/aws-cdk/lib/api/aws-auth/aws-sdk-inifile.ts
Expand Up @@ -20,7 +20,11 @@ import * as AWS from 'aws-sdk';
* `getProfilesFromSharedConfig` overwrites ALL `config` data with `credentials`
* data, so we also need to do extra work to fish the `region` out of the config.
*
* 3. The 'credential_source' option is not supported. Meaning credentials
* for assume-role cannot be fetched using EC2/ESC metadata.
*
* See https://github.com/aws/aws-sdk-js/issues/3418 for all the gory details.
* See https://github.com/aws/aws-sdk-js/issues/1916 for some more glory details.
*/
export class PatchedSharedIniFileCredentials extends AWS.SharedIniFileCredentials {
declare private profile: string;
Expand Down Expand Up @@ -53,40 +57,30 @@ export class PatchedSharedIniFileCredentials extends AWS.SharedIniFileCredential
var roleSessionName = roleProfile.role_session_name;
var externalId = roleProfile.external_id;
var mfaSerial = roleProfile.mfa_serial;
var sourceProfileName = roleProfile.source_profile;

if (!sourceProfileName) {
throw (AWS as any).util.error(
new Error('source_profile is not set using profile ' + this.profile),
{ code: 'SharedIniFileCredentialsProviderFailure' },
);
}
var sourceProfile = roleProfile.source_profile;
var credentialSource = roleProfile.credential_source;

var sourceProfileExistanceTest = creds[sourceProfileName];
const credentialError = (AWS as any).util.error(
new Error(`When using 'role_arn' in profile ('${this.profile}'), you must also configure exactly one of 'source_profile' or 'credential_source'`),
{ code: 'SharedIniFileCredentialsProviderFailure' },
);

if (typeof sourceProfileExistanceTest !== 'object') {
throw (AWS as any).util.error(
new Error('source_profile ' + sourceProfileName + ' using profile '
+ this.profile + ' does not exist'),
{ code: 'SharedIniFileCredentialsProviderFailure' },
);
if (sourceProfile && credentialSource) {
throw credentialError;
}

var sourceCredentials = new AWS.SharedIniFileCredentials(
(AWS as any).util.merge(this.options || {}, {
profile: sourceProfileName,
preferStaticCredentials: true,
}),
);
if (!sourceProfile && !credentialSource) {
throw credentialError;
}

// --------- THIS IS NEW ----------------------
const profiles = loadProfilesProper(this.filename);
const region = profiles[this.profile]?.region ?? profiles.default?.region ?? 'us-east-1';
// --------- /THIS IS NEW ----------------------

const stsCreds = sourceProfile ? this.sourceProfileCredentials(sourceProfile, creds) : this.credentialSourceCredentials(credentialSource);

this.roleArn = roleArn;
var sts = new AWS.STS({
credentials: sourceCredentials,
credentials: stsCreds,
region,
httpOptions: this.httpOptions,
});
Expand Down Expand Up @@ -126,6 +120,49 @@ export class PatchedSharedIniFileCredentials extends AWS.SharedIniFileCredential
sts.assumeRole(roleParams, callback);

}

private sourceProfileCredentials(sourceProfile: string, profiles: Record<string, Record<string, string>>) {

var sourceProfileExistanceTest = profiles[sourceProfile];

if (typeof sourceProfileExistanceTest !== 'object') {
throw (AWS as any).util.error(
new Error('source_profile ' + sourceProfile + ' using profile '
+ this.profile + ' does not exist'),
{ code: 'SharedIniFileCredentialsProviderFailure' },
);
}

return new AWS.SharedIniFileCredentials(
(AWS as any).util.merge(this.options || {}, {
profile: sourceProfile,
preferStaticCredentials: true,
}),
);

}

// the aws-sdk for js does not support 'credential_source' (https://github.com/aws/aws-sdk-js/issues/1916)
// so unfortunately we need to implement this ourselves.
private credentialSourceCredentials(sourceCredential: string) {

// see https://docs.aws.amazon.com/credref/latest/refdocs/setting-global-credential_source.html
switch (sourceCredential) {
case 'Environment': {
return new AWS.EnvironmentCredentials('AWS');
}
case 'Ec2InstanceMetadata': {
return new AWS.EC2MetadataCredentials();
}
case 'EcsContainer': {
return new AWS.ECSCredentials();
}
default: {
throw new Error(`credential_source ${sourceCredential} in profile ${this.profile} is unsupported. choose one of [Environment, Ec2InstanceMetadata, EcsContainer]`);
}
}

}
}

/**
Expand Down
26 changes: 1 addition & 25 deletions packages/aws-cdk/test/account-cache.test.ts
@@ -1,6 +1,7 @@
import * as path from 'path';
import * as fs from 'fs-extra';
import { AccountAccessKeyCache } from '../lib/api/aws-auth/account-cache';
import { withMocked } from './util';

async function makeCache() {
const dir = await fs.mkdtemp('/tmp/account-cache-test');
Expand Down Expand Up @@ -110,28 +111,3 @@ test(`cache is nuked if it exceeds ${AccountAccessKeyCache.MAX_ENTRIES} entries`
await nukeCache(cacheDir);
}
});

function withMocked<A extends object, K extends keyof A, B>(obj: A, key: K, block: (fn: jest.Mocked<A>[K]) => B): B {
const original = obj[key];
const mockFn = jest.fn();
(obj as any)[key] = mockFn;

let ret;
try {
ret = block(mockFn as any);
} catch (e) {
obj[key] = original;
throw e;
}

if (!isPromise(ret)) {
obj[key] = original;
return ret;
}

return ret.finally(() => { obj[key] = original; }) as any;
}

function isPromise<A>(object: any): object is Promise<A> {
return Promise.resolve(object) === object;
}
155 changes: 155 additions & 0 deletions packages/aws-cdk/test/api/sdk-provider.test.ts
Expand Up @@ -7,6 +7,7 @@ import { PluginHost } from '../../lib';
import { ISDK, Mode, SdkProvider } from '../../lib/api/aws-auth';
import * as logging from '../../lib/logging';
import * as bockfs from '../bockfs';
import { withMocked } from '../util';

// Mock promptly prompt to test MFA support
jest.mock('promptly', () => ({
Expand Down Expand Up @@ -318,6 +319,160 @@ test('can assume role without a [default] profile', async () => {
expect(account?.accountId).toEqual(`${uid}the_account_#`);
});

test('can assume role with ecs credentials', async () => {

return withMocked(AWS.ECSCredentials.prototype, 'needsRefresh', async (needsRefresh) => {

// GIVEN
bockfs({
'/home/me/.bxt/credentials': dedent(`
`),
'/home/me/.bxt/config': dedent(`
[profile ecs]
role_arn=arn:aws:iam::12356789012:role/Assumable
credential_source = EcsContainer
`),
});

// Set environment variables that we want
process.env.AWS_CONFIG_FILE = bockfs.path('/home/me/.bxt/config');
process.env.AWS_SHARED_CREDENTIALS_FILE = bockfs.path('/home/me/.bxt/credentials');

// WHEN
const provider = await SdkProvider.withAwsCliCompatibleDefaults({
...defaultCredOptions,
profile: 'ecs',
httpOptions: {
proxyAddress: 'http://DOESNTMATTER/',
},
});

await provider.defaultAccount();

// THEN
// expect(account?.accountId).toEqual(`${uid}the_account_#`);
expect(needsRefresh).toHaveBeenCalled();

});

});

test('can assume role with ec2 credentials', async () => {

return withMocked(AWS.EC2MetadataCredentials.prototype, 'needsRefresh', async (needsRefresh) => {

// GIVEN
bockfs({
'/home/me/.bxt/credentials': dedent(`
`),
'/home/me/.bxt/config': dedent(`
[profile ecs]
role_arn=arn:aws:iam::12356789012:role/Assumable
credential_source = Ec2InstanceMetadata
`),
});

// Set environment variables that we want
process.env.AWS_CONFIG_FILE = bockfs.path('/home/me/.bxt/config');
process.env.AWS_SHARED_CREDENTIALS_FILE = bockfs.path('/home/me/.bxt/credentials');

// WHEN
const provider = await SdkProvider.withAwsCliCompatibleDefaults({
...defaultCredOptions,
profile: 'ecs',
httpOptions: {
proxyAddress: 'http://DOESNTMATTER/',
},
});

await provider.defaultAccount();

// THEN
// expect(account?.accountId).toEqual(`${uid}the_account_#`);
expect(needsRefresh).toHaveBeenCalled();

});

});

test('can assume role with env credentials', async () => {

return withMocked(AWS.EnvironmentCredentials.prototype, 'needsRefresh', async (needsRefresh) => {

// GIVEN
bockfs({
'/home/me/.bxt/credentials': dedent(`
`),
'/home/me/.bxt/config': dedent(`
[profile ecs]
role_arn=arn:aws:iam::12356789012:role/Assumable
credential_source = Environment
`),
});

// Set environment variables that we want
process.env.AWS_CONFIG_FILE = bockfs.path('/home/me/.bxt/config');
process.env.AWS_SHARED_CREDENTIALS_FILE = bockfs.path('/home/me/.bxt/credentials');

// WHEN
const provider = await SdkProvider.withAwsCliCompatibleDefaults({
...defaultCredOptions,
profile: 'ecs',
httpOptions: {
proxyAddress: 'http://DOESNTMATTER/',
},
});

await provider.defaultAccount();

// THEN
// expect(account?.accountId).toEqual(`${uid}the_account_#`);
expect(needsRefresh).toHaveBeenCalled();

});

});

test('assume fails with unsupported credential_source', async () => {
// GIVEN
bockfs({
'/home/me/.bxt/config': dedent(`
[profile assumable]
role_arn=arn:aws:iam::12356789012:role/Assumable
credential_source = unsupported
`),
});

SDKMock.mock('STS', 'assumeRole', (_request: AWS.STS.AssumeRoleRequest, cb: AwsCallback<AWS.STS.AssumeRoleResponse>) => {
return cb(null, {
Credentials: {
AccessKeyId: `${uid}access`, // Needs UID in here otherwise key will be cached
Expiration: new Date(Date.now() + 10000),
SecretAccessKey: 'b',
SessionToken: 'c',
},
});
});

// Set environment variables that we want
process.env.AWS_CONFIG_FILE = bockfs.path('/home/me/.bxt/config');
process.env.AWS_SHARED_CREDENTIALS_FILE = bockfs.path('/home/me/.bxt/credentials');

// WHEN
const provider = await SdkProvider.withAwsCliCompatibleDefaults({
...defaultCredOptions,
profile: 'assumable',
httpOptions: {
proxyAddress: 'http://DOESNTMATTER/',
},
});

const account = await provider.defaultAccount();

// THEN
expect(account?.accountId).toEqual(undefined);
});

/**
* Strip shared whitespace from the start of lines
*/
Expand Down
@@ -0,0 +1,2 @@
Tests now take longer than hour and cause token expiration.
Added creddentials refreshing in in aws-helper but the older tests don't have it.

0 comments on commit 940a443

Please sign in to comment.