New package for AWS integration node library

Signed-off-by: Clare Liguori <liguori@amazon.com>
This commit is contained in:
Clare Liguori
2022-10-27 13:10:41 -07:00
parent 89c0b0e6e6
commit 13278732f6
13 changed files with 2016 additions and 3 deletions
@@ -0,0 +1,430 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DefaultAwsCredentialsProvider } from './DefaultAwsCredentialsProvider';
import { mockClient, AwsClientStub } from 'aws-sdk-client-mock';
import 'aws-sdk-client-mock-jest';
import {
STSClient,
GetCallerIdentityCommand,
AssumeRoleCommand,
} from '@aws-sdk/client-sts';
import { Config, ConfigReader } from '@backstage/config';
import { promises } from 'fs';
const env = process.env;
let stsMock: AwsClientStub<STSClient>;
let config: Config;
jest.mock('fs', () => ({ promises: { readFile: jest.fn() } }));
describe('DefaultAwsCredentialsProvider', () => {
beforeEach(() => {
process.env = { ...env };
jest.resetAllMocks();
stsMock = mockClient(STSClient);
config = new ConfigReader({
aws: {
accounts: [
{
accountId: '111111111111',
roleName: 'hello',
externalId: 'world',
},
{
accountId: '222222222222',
roleName: 'hi',
partition: 'aws-other',
region: 'not-us-east-1',
accessKeyId: 'ABC',
secretAccessKey: 'EDF',
},
{
accountId: '333333333333',
accessKeyId: 'my-access-key',
secretAccessKey: 'my-secret-access-key',
},
{
accountId: '444444444444',
},
{
accountId: '555555555555',
profile: 'my-profile',
},
],
accountDefaults: {
roleName: 'backstage-role',
externalId: 'my-id',
},
mainAccount: {
accessKeyId: 'GHI',
secretAccessKey: 'JKL',
region: 'ap-northeast-1',
},
},
});
stsMock.on(GetCallerIdentityCommand).resolvesOnce({
Account: '123456789012',
});
stsMock
.on(AssumeRoleCommand, {
RoleArn: 'arn:aws:iam::111111111111:role/hello',
RoleSessionName: 'backstage',
ExternalId: 'world',
})
.resolves({
Credentials: {
AccessKeyId: 'ACCESS_KEY_ID_1',
SecretAccessKey: 'SECRET_ACCESS_KEY_1',
SessionToken: 'SESSION_TOKEN_1',
Expiration: new Date('2022-01-01'),
},
});
stsMock
.on(AssumeRoleCommand, {
RoleArn: 'arn:aws-other:iam::222222222222:role/hi',
RoleSessionName: 'backstage',
})
.resolves({
Credentials: {
AccessKeyId: 'ACCESS_KEY_ID_2',
SecretAccessKey: 'SECRET_ACCESS_KEY_2',
SessionToken: 'SESSION_TOKEN_2',
Expiration: new Date('2022-01-02'),
},
});
stsMock
.on(AssumeRoleCommand, {
RoleArn: 'arn:aws:iam::999999999999:role/backstage-role',
RoleSessionName: 'backstage',
ExternalId: 'my-id',
})
.resolves({
Credentials: {
AccessKeyId: 'ACCESS_KEY_ID_9',
SecretAccessKey: 'SECRET_ACCESS_KEY_9',
SessionToken: 'SESSION_TOKEN_9',
Expiration: new Date('2022-01-09'),
},
});
process.env.AWS_ACCESS_KEY_ID = 'ACCESS_KEY_ID_10';
process.env.AWS_SECRET_ACCESS_KEY = 'SECRET_ACCESS_KEY_10';
process.env.AWS_SESSION_TOKEN = 'SESSION_TOKEN_10';
process.env.AWS_CREDENTIAL_EXPIRATION = new Date(
'2022-01-10',
).toISOString();
const mockProfile = `[my-profile]
aws_access_key_id=ACCESS_KEY_ID_9
aws_secret_access_key=SECRET_ACCESS_KEY_9
`;
(promises.readFile as jest.Mock).mockResolvedValue(mockProfile);
});
afterEach(() => {
process.env = env;
});
describe('#getCredentials', () => {
it('retrieves assume-role creds for the given account ID and caches the provider', async () => {
const provider = DefaultAwsCredentialsProvider.fromConfig(config);
const awsCredentials = await provider.getCredentials({
accountId: '111111111111',
});
expect(awsCredentials.accountId).toEqual('111111111111');
const creds = await awsCredentials.provider();
expect(creds).toEqual({
accessKeyId: 'ACCESS_KEY_ID_1',
secretAccessKey: 'SECRET_ACCESS_KEY_1',
sessionToken: 'SESSION_TOKEN_1',
expiration: new Date('2022-01-01'),
});
const awsCredentials2 = await provider.getCredentials({
accountId: '111111111111',
});
expect(awsCredentials).toBe(awsCredentials2);
expect(stsMock).toHaveReceivedCommandTimes(AssumeRoleCommand, 1);
});
it('retrieves assume-role creds in another partition for the given account ID', async () => {
const provider = DefaultAwsCredentialsProvider.fromConfig(config);
const awsCredentials = await provider.getCredentials({
accountId: '222222222222',
});
expect(awsCredentials.accountId).toEqual('222222222222');
const creds = await awsCredentials.provider();
expect(creds).toEqual({
accessKeyId: 'ACCESS_KEY_ID_2',
secretAccessKey: 'SECRET_ACCESS_KEY_2',
sessionToken: 'SESSION_TOKEN_2',
expiration: new Date('2022-01-02'),
});
});
it('retrieves assume-role creds for an account using the account defaults', async () => {
const provider = DefaultAwsCredentialsProvider.fromConfig(config);
const awsCredentials = await provider.getCredentials({
accountId: '999999999999',
});
expect(awsCredentials.accountId).toEqual('999999999999');
const creds = await awsCredentials.provider();
expect(creds).toEqual({
accessKeyId: 'ACCESS_KEY_ID_9',
secretAccessKey: 'SECRET_ACCESS_KEY_9',
sessionToken: 'SESSION_TOKEN_9',
expiration: new Date('2022-01-09'),
});
});
it('retrieves static creds for the given account ID', async () => {
const provider = DefaultAwsCredentialsProvider.fromConfig(config);
const awsCredentials = await provider.getCredentials({
accountId: '333333333333',
});
expect(awsCredentials.accountId).toEqual('333333333333');
const creds = await awsCredentials.provider();
expect(creds).toEqual({
accessKeyId: 'my-access-key',
secretAccessKey: 'my-secret-access-key',
});
});
it('retrieves static creds from the main account', async () => {
const minConfig = new ConfigReader({
aws: {
mainAccount: {
accessKeyId: 'GHI',
secretAccessKey: 'JKL',
},
},
});
const provider = DefaultAwsCredentialsProvider.fromConfig(minConfig);
const awsCredentials = await provider.getCredentials({
accountId: '123456789012',
});
expect(awsCredentials.accountId).toEqual('123456789012');
const creds = await awsCredentials.provider();
expect(creds).toEqual({
accessKeyId: 'GHI',
secretAccessKey: 'JKL',
});
});
it('only queries the main account ID once from STS', async () => {
const minConfig = new ConfigReader({
aws: {
mainAccount: {
accessKeyId: 'GHI',
secretAccessKey: 'JKL',
},
},
});
const provider = DefaultAwsCredentialsProvider.fromConfig(minConfig);
const awsCredentials1 = await provider.getCredentials({});
const awsCredentials2 = await provider.getCredentials({});
expect(awsCredentials1).toBe(awsCredentials2);
expect(stsMock).toHaveReceivedCommandTimes(GetCallerIdentityCommand, 1);
});
it('retrieves the ini provider chain for the given account ID', async () => {
const provider = DefaultAwsCredentialsProvider.fromConfig(config);
const awsCredentials = await provider.getCredentials({
accountId: '555555555555',
});
expect(awsCredentials.accountId).toEqual('555555555555');
const creds = await awsCredentials.provider();
expect(creds).toEqual({
accessKeyId: 'ACCESS_KEY_ID_9',
secretAccessKey: 'SECRET_ACCESS_KEY_9',
});
});
it('retrieves the default cred provider chain for the given account ID', async () => {
const provider = DefaultAwsCredentialsProvider.fromConfig(config);
const awsCredentials = await provider.getCredentials({
accountId: '444444444444',
});
expect(awsCredentials.accountId).toEqual('444444444444');
const creds = await awsCredentials.provider();
expect(creds).toEqual({
accessKeyId: 'ACCESS_KEY_ID_10',
secretAccessKey: 'SECRET_ACCESS_KEY_10',
sessionToken: 'SESSION_TOKEN_10',
expiration: new Date('2022-01-10'),
});
});
it('retrieves ini provider chain from the main account', async () => {
const minConfig = new ConfigReader({
aws: {
mainAccount: {
profile: 'my-profile',
},
},
});
const provider = DefaultAwsCredentialsProvider.fromConfig(minConfig);
const awsCredentials = await provider.getCredentials({
accountId: '123456789012',
});
expect(awsCredentials.accountId).toEqual('123456789012');
const creds = await awsCredentials.provider();
expect(creds).toEqual({
accessKeyId: 'ACCESS_KEY_ID_9',
secretAccessKey: 'SECRET_ACCESS_KEY_9',
});
});
it('retrieves default cred provider chain from the main account', async () => {
const minConfig = new ConfigReader({
aws: {},
});
const provider = DefaultAwsCredentialsProvider.fromConfig(minConfig);
const awsCredentials = await provider.getCredentials({
accountId: '123456789012',
});
expect(awsCredentials.accountId).toEqual('123456789012');
const creds = await awsCredentials.provider();
expect(creds).toEqual({
accessKeyId: 'ACCESS_KEY_ID_10',
secretAccessKey: 'SECRET_ACCESS_KEY_10',
sessionToken: 'SESSION_TOKEN_10',
expiration: new Date('2022-01-10'),
});
});
it('retrieves default cred provider chain from the main account when there is no AWS integration config', async () => {
const minConfig = new ConfigReader({});
const provider = DefaultAwsCredentialsProvider.fromConfig(minConfig);
const awsCredentials = await provider.getCredentials({
accountId: '123456789012',
});
expect(awsCredentials.accountId).toEqual('123456789012');
const creds = await awsCredentials.provider();
expect(creds).toEqual({
accessKeyId: 'ACCESS_KEY_ID_10',
secretAccessKey: 'SECRET_ACCESS_KEY_10',
sessionToken: 'SESSION_TOKEN_10',
expiration: new Date('2022-01-10'),
});
});
it('extracts the account ID from an ARN', async () => {
const provider = DefaultAwsCredentialsProvider.fromConfig(config);
const awsCredentials = await provider.getCredentials({
arn: 'arn:aws:ecs:region:111111111111:service/cluster-name/service-name',
});
expect(awsCredentials.accountId).toEqual('111111111111');
const creds = await awsCredentials.provider();
expect(creds).toEqual({
accessKeyId: 'ACCESS_KEY_ID_1',
secretAccessKey: 'SECRET_ACCESS_KEY_1',
sessionToken: 'SESSION_TOKEN_1',
expiration: new Date('2022-01-01'),
});
});
it('falls back to main account credentials when account ID cannot be extracted from the ARN', async () => {
const provider = DefaultAwsCredentialsProvider.fromConfig(config);
const awsCredentials = await provider.getCredentials({
arn: 'arn:aws:s3:::bucket_name',
});
expect(awsCredentials.accountId).toEqual('123456789012');
const creds = await awsCredentials.provider();
expect(creds).toEqual({
accessKeyId: 'GHI',
secretAccessKey: 'JKL',
});
});
it('falls back to main account credentials when neither account ID nor ARN are provided', async () => {
const provider = DefaultAwsCredentialsProvider.fromConfig(config);
const awsCredentials = await provider.getCredentials({});
expect(awsCredentials.accountId).toEqual('123456789012');
const creds = await awsCredentials.provider();
expect(creds).toEqual({
accessKeyId: 'GHI',
secretAccessKey: 'JKL',
});
});
it('falls back to main account credentials when no options are provided', async () => {
const provider = DefaultAwsCredentialsProvider.fromConfig(config);
const awsCredentials = await provider.getCredentials();
expect(awsCredentials.accountId).toEqual('123456789012');
const creds = await awsCredentials.provider();
expect(creds).toEqual({
accessKeyId: 'GHI',
secretAccessKey: 'JKL',
});
});
it('rejects account that is not configured, with no account defaults', async () => {
const minConfig = new ConfigReader({
aws: {},
});
const provider = DefaultAwsCredentialsProvider.fromConfig(minConfig);
await expect(
provider.getCredentials({ accountId: '111222333444' }),
).rejects.toThrow(/no AWS integration that matches 111222333444/);
});
it('rejects main account that has invalid credentials', async () => {
stsMock.on(GetCallerIdentityCommand).rejects('No credentials found');
const provider = DefaultAwsCredentialsProvider.fromConfig(config);
await expect(provider.getCredentials({})).rejects.toThrow(
/No credentials found/,
);
});
});
});
@@ -0,0 +1,274 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
readAwsIntegrationConfig,
AwsIntegrationAccountConfig,
AwsIntegrationDefaultAccountConfig,
AwsIntegrationMainAccountConfig,
} from './config';
import {
AwsCredentials,
AwsCredentialsProvider,
AwsCredentialsProviderOptions,
} from './types';
import { GetCallerIdentityCommand, STSClient } from '@aws-sdk/client-sts';
import {
fromIni,
fromNodeProviderChain,
fromTemporaryCredentials,
} from '@aws-sdk/credential-providers';
import { AwsCredentialIdentityProvider } from '@aws-sdk/types';
import { parse } from '@aws-sdk/util-arn-parser';
import { Config } from '@backstage/config';
/**
* Retrieves the account ID for the given credentials provider from STS.
*/
async function fillInAccountId(creds: AwsCredentials) {
if (creds.accountId) {
return;
}
const client = new STSClient({
region: creds.stsRegion,
customUserAgent: 'backstage-aws-credentials-provider',
credentialDefaultProvider: () => creds.provider,
});
const resp = await client.send(new GetCallerIdentityCommand({}));
creds.accountId = resp.Account!;
}
function getStaticCredentials(
accessKeyId: string,
secretAccessKey: string,
): AwsCredentialIdentityProvider {
return async () => {
return Promise.resolve({
accessKeyId: accessKeyId,
secretAccessKey: secretAccessKey,
});
};
}
function getProfileCredentials(
profile: string,
region?: string,
): AwsCredentialIdentityProvider {
return fromIni({
profile,
clientConfig: {
region,
customUserAgent: 'backstage-aws-credentials-provider',
},
});
}
function getDefaultCredentialsChain(): AwsCredentialIdentityProvider {
return fromNodeProviderChain();
}
/**
* Constructs the credential provider needed by the AWS SDK from the given account config
*
* Order of precedence:
* 1. Assume role with static creds
* 2. Assume role with main account creds
* 3. Static creds
* 4. Profile creds
* 5. Default AWS SDK creds chain
*/
function getAccountCredentialsProvider(
config: AwsIntegrationAccountConfig,
mainAccountCreds: AwsCredentialIdentityProvider,
): AwsCredentialIdentityProvider {
if (config.roleName) {
const region = config.region ?? 'us-east-1';
const partition = config.partition ?? 'aws';
return fromTemporaryCredentials({
masterCredentials: config.accessKeyId
? getStaticCredentials(config.accessKeyId!, config.secretAccessKey!)
: mainAccountCreds,
params: {
RoleArn: `arn:${partition}:iam::${config.accountId}:role/${config.roleName}`,
RoleSessionName: 'backstage',
ExternalId: config.externalId,
},
clientConfig: {
region,
customUserAgent: 'backstage-aws-credentials-provider',
},
});
}
if (config.accessKeyId) {
return getStaticCredentials(config.accessKeyId!, config.secretAccessKey!);
}
if (config.profile) {
return getProfileCredentials(config.profile!, config.region);
}
return getDefaultCredentialsChain();
}
/**
* Constructs the credential provider needed by the AWS SDK for the main account
*
* Order of precedence:
* 1. Static creds
* 2. Profile creds
* 3. Default AWS SDK creds chain
*/
function getMainAccountCredentialsProvider(
config: AwsIntegrationMainAccountConfig,
): AwsCredentialIdentityProvider {
if (config.accessKeyId) {
return getStaticCredentials(config.accessKeyId!, config.secretAccessKey!);
}
if (config.profile) {
return getProfileCredentials(config.profile!, config.region);
}
return getDefaultCredentialsChain();
}
/**
* Handles the creation and caching of credential providers for AWS accounts.
*
* @public
*/
export class DefaultAwsCredentialsProvider implements AwsCredentialsProvider {
static fromConfig(config: Config): DefaultAwsCredentialsProvider {
const awsConfig = config.has('aws')
? readAwsIntegrationConfig(config.getConfig('aws'))
: {
accounts: [],
mainAccount: {},
accountDefaults: {},
};
const mainAccountProvider = getMainAccountCredentialsProvider(
awsConfig.mainAccount,
);
const mainAccountCreds: AwsCredentials = {
provider: mainAccountProvider,
};
const accountCreds = new Map<string, AwsCredentials>();
for (const accountConfig of awsConfig.accounts) {
const provider = getAccountCredentialsProvider(
accountConfig,
mainAccountCreds.provider,
);
accountCreds.set(accountConfig.accountId, {
accountId: accountConfig.accountId,
stsRegion: accountConfig.region,
provider,
});
}
return new DefaultAwsCredentialsProvider(
accountCreds,
awsConfig.accountDefaults,
mainAccountCreds,
);
}
private constructor(
private readonly accountCredentials: Map<string, AwsCredentials>,
private readonly accountDefaults: AwsIntegrationDefaultAccountConfig,
private readonly mainAccountCredentials: AwsCredentials,
) {}
/**
* Returns {@link AwsCredentials} for a given AWS account.
*
* @example
* ```ts
* const { provider } = await getCredentials({
* accountId: '0123456789012',
* })
*
* const { provider } = await getCredentials({
* arn: 'arn:aws:ecs:us-west-2:123456789012:service/my-http-service'
* })
* ```
*
* @param opts - the AWS account ID or AWS resource ARN
* @returns A promise of {@link AwsCredentials}.
*/
async getCredentials(
opts?: AwsCredentialsProviderOptions,
): Promise<AwsCredentials> {
// If no options provided, fall back to the main account
if (!opts) {
await fillInAccountId(this.mainAccountCredentials);
return this.mainAccountCredentials;
}
// Determine the account ID: either explicitly provided or extracted from the provided ARN
let accountId = opts.accountId;
if (opts.arn && !accountId) {
const arnComponents = parse(opts.arn);
accountId = arnComponents.accountId;
}
// If the account ID was not provided (explicitly or in the ARN),
// fall back to the main account
if (!accountId) {
await fillInAccountId(this.mainAccountCredentials);
return this.mainAccountCredentials;
}
// Return a cached provider if available
if (this.accountCredentials.has(accountId)) {
return this.accountCredentials.get(accountId)!;
}
// First, fall back to using the account defaults
if (this.accountDefaults.roleName) {
const config: AwsIntegrationAccountConfig = {
accountId,
roleName: this.accountDefaults.roleName,
partition: this.accountDefaults.partition,
region: this.accountDefaults.region,
externalId: this.accountDefaults.externalId,
};
const provider = getAccountCredentialsProvider(
config,
this.mainAccountCredentials.provider,
);
const creds: AwsCredentials = { accountId, provider };
this.accountCredentials.set(accountId, creds);
return creds;
}
// Then, fall back to using the main account, but only
// if the account requested matches the main account ID
await fillInAccountId(this.mainAccountCredentials);
if (accountId === this.mainAccountCredentials.accountId) {
return this.mainAccountCredentials;
}
// Otherwise, the account needs to be explicitly configured in Backstage
throw new Error(
`There is no AWS integration that matches ${accountId}. Please add a configuration for this AWS account.`,
);
}
}
@@ -0,0 +1,335 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Config, ConfigReader } from '@backstage/config';
import { AwsIntegrationConfig, readAwsIntegrationConfig } from './config';
describe('readAwsIntegrationConfig', () => {
function buildConfig(data: Partial<AwsIntegrationConfig>): Config {
return new ConfigReader(data);
}
it('reads all values', () => {
const output = readAwsIntegrationConfig(
buildConfig({
accounts: [
{
accountId: '111111111111',
accessKeyId: 'ABC',
secretAccessKey: 'EDF',
roleName: 'hello',
partition: 'aws',
region: 'us-east-1',
externalId: 'world',
},
{
accountId: '222222222222',
accessKeyId: 'GHI',
secretAccessKey: 'JKL',
},
{
accountId: '333333333333',
roleName: 'hi',
partition: 'aws-other',
region: 'not-us-east-1',
externalId: 'there',
},
{
accountId: '444444444444',
profile: 'my-profile',
},
],
accountDefaults: {
roleName: 'backstage-role',
partition: 'aws',
region: 'us-east-1',
externalId: 'my-id',
},
mainAccount: {
accessKeyId: 'GHI',
secretAccessKey: 'JKL',
region: 'ap-northeast-1',
},
}),
);
expect(output).toEqual({
accounts: [
{
accountId: '111111111111',
accessKeyId: 'ABC',
secretAccessKey: 'EDF',
roleName: 'hello',
partition: 'aws',
region: 'us-east-1',
externalId: 'world',
},
{
accountId: '222222222222',
accessKeyId: 'GHI',
secretAccessKey: 'JKL',
},
{
accountId: '333333333333',
roleName: 'hi',
partition: 'aws-other',
region: 'not-us-east-1',
externalId: 'there',
},
{
accountId: '444444444444',
profile: 'my-profile',
},
],
accountDefaults: {
roleName: 'backstage-role',
partition: 'aws',
region: 'us-east-1',
externalId: 'my-id',
},
mainAccount: {
accessKeyId: 'GHI',
secretAccessKey: 'JKL',
region: 'ap-northeast-1',
},
});
});
it('reads profile for main account', () => {
const output = readAwsIntegrationConfig(
buildConfig({
accounts: [
{
accountId: '111111111111',
accessKeyId: 'ABC',
secretAccessKey: 'EDF',
roleName: 'hello',
partition: 'aws',
region: 'us-east-1',
externalId: 'world',
},
],
accountDefaults: {
roleName: 'backstage-role',
partition: 'aws',
region: 'us-east-1',
externalId: 'my-id',
},
mainAccount: {
profile: 'my-profile',
},
}),
);
expect(output).toEqual({
accounts: [
{
accountId: '111111111111',
accessKeyId: 'ABC',
secretAccessKey: 'EDF',
roleName: 'hello',
partition: 'aws',
region: 'us-east-1',
externalId: 'world',
},
],
accountDefaults: {
roleName: 'backstage-role',
partition: 'aws',
region: 'us-east-1',
externalId: 'my-id',
},
mainAccount: {
profile: 'my-profile',
},
});
});
it('does not fail when config is not set', () => {
const output = readAwsIntegrationConfig(buildConfig({}));
expect(output).toEqual({
accountDefaults: {},
accounts: [],
mainAccount: {},
});
});
it('rejects invalid combinations of account attributes', () => {
const validAccount: any = {
accountId: '111111111111',
accessKeyId: 'ABC',
secretAccessKey: 'EDF',
roleName: 'hello',
partition: 'aws',
region: 'us-east-1',
externalId: 'world',
};
expect(() =>
readAwsIntegrationConfig(
buildConfig({
accounts: [
validAccount,
{
accountId: '222222222222',
accessKeyId: 'ABC',
},
],
}),
),
).toThrow(/no secret access key/);
expect(() =>
readAwsIntegrationConfig(
buildConfig({
accounts: [
validAccount,
{
accountId: '222222222222',
secretAccessKey: 'ABC',
},
],
}),
),
).toThrow(/no access key ID/);
expect(() =>
readAwsIntegrationConfig(
buildConfig({
accounts: [
validAccount,
{
accountId: '222222222222',
accessKeyId: 'ABC',
secretAccessKey: 'DEF',
profile: 'my-profile',
},
],
}),
),
).toThrow(/only one must be specified/);
expect(() =>
readAwsIntegrationConfig(
buildConfig({
accounts: [
validAccount,
{
accountId: '222222222222',
roleName: 'my-role',
profile: 'my-profile',
},
],
}),
),
).toThrow(/only one must be specified/);
expect(() =>
readAwsIntegrationConfig(
buildConfig({
accounts: [
validAccount,
{
accountId: '222222222222',
partition: 'aws',
},
],
}),
),
).toThrow(/no role name/);
expect(() =>
readAwsIntegrationConfig(
buildConfig({
accounts: [
validAccount,
{
accountId: '222222222222',
region: 'not-us-east-1',
},
],
}),
),
).toThrow(/no role name/);
expect(() =>
readAwsIntegrationConfig(
buildConfig({
accounts: [
validAccount,
{
accountId: '222222222222',
externalId: 'hello',
},
],
}),
),
).toThrow(/no role name/);
});
it('rejects invalid combinations of main account attributes', () => {
expect(() =>
readAwsIntegrationConfig(
buildConfig({
mainAccount: {
accessKeyId: 'ABC',
},
}),
),
).toThrow(/no secret access key/);
expect(() =>
readAwsIntegrationConfig(
buildConfig({
mainAccount: {
secretAccessKey: 'ABC',
},
}),
),
).toThrow(/no access key ID/);
expect(() =>
readAwsIntegrationConfig(
buildConfig({
mainAccount: {
accessKeyId: 'ABC',
secretAccessKey: 'DEF',
profile: 'my-profile',
},
}),
),
).toThrow(/only one must be specified/);
});
it('rejects invalid combinations of account default attributes', () => {
expect(() =>
readAwsIntegrationConfig(
buildConfig({
accountDefaults: {
partition: 'aws',
},
}),
),
).toThrow(/no role name/);
expect(() =>
readAwsIntegrationConfig(
buildConfig({
accountDefaults: {
region: 'not-us-east-1',
},
}),
),
).toThrow(/no role name/);
expect(() =>
readAwsIntegrationConfig(
buildConfig({
accountDefaults: {
externalId: 'hello',
},
}),
),
).toThrow(/no role name/);
});
});
+307
View File
@@ -0,0 +1,307 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Config } from '@backstage/config';
/**
* The configuration parameters for a single AWS account for the AWS integration.
*
* @public
*/
export type AwsIntegrationAccountConfig = {
/**
* The account ID of the target account that this matches on, e.g. "123456789012"
*/
accountId: string;
/**
* The access key ID for a set of static AWS credentials
*/
accessKeyId?: string;
/**
* The secret access key for a set of static AWS credentials
*/
secretAccessKey?: string;
/**
* The configuration profile from a credentials file at ~/.aws/credentials and
* a configuration file at ~/.aws/config.
*/
profile?: string;
/**
* The IAM role to assume to retrieve temporary AWS credentials
*/
roleName?: string;
/**
* The AWS partition of the IAM role, e.g. "aws", "aws-cn"
*/
partition?: string;
/**
* The STS regional endpoint to use when retrieving temporary AWS credentials, e.g. "ap-northeast-1"
*/
region?: string;
/**
* The unique identifier needed to assume the role to retrieve temporary AWS credentials
*/
externalId?: string;
};
/**
* The configuration parameters for the main AWS account for the AWS integration.
*
* @public
*/
export type AwsIntegrationMainAccountConfig = {
/**
* The access key ID for a set of static AWS credentials
*/
accessKeyId?: string;
/**
* The secret access key for a set of static AWS credentials
*/
secretAccessKey?: string;
/**
* The configuration profile from a credentials file at ~/.aws/credentials and
* a configuration file at ~/.aws/config.
*/
profile?: string;
/**
* The STS regional endpoint to use for the main account, e.g. "ap-northeast-1"
*/
region?: string;
};
/**
* The default configuration parameters to use for accounts for the AWS integration.
*
* @public
*/
export type AwsIntegrationDefaultAccountConfig = {
/**
* The IAM role to assume to retrieve temporary AWS credentials
*/
roleName?: string;
/**
* The AWS partition of the IAM role, e.g. "aws", "aws-cn"
*/
partition?: string;
/**
* The STS regional endpoint to use when retrieving temporary AWS credentials, e.g. "ap-northeast-1"
*/
region?: string;
/**
* The unique identifier needed to assume the role to retrieve temporary AWS credentials
*/
externalId?: string;
};
/**
* The configuration parameters for AWS account integration.
*
* @public
*/
export type AwsIntegrationConfig = {
/**
* Configuration for retrieving AWS accounts credentials
*/
accounts: AwsIntegrationAccountConfig[];
/**
* Defaults for retrieving AWS account credentials
*/
accountDefaults: AwsIntegrationDefaultAccountConfig;
/**
* Main account to use for retrieving AWS account credentials
*/
mainAccount: AwsIntegrationMainAccountConfig;
};
/**
* Reads an AWS integration account config.
*
* @param config - The config object of a single account
*/
function readAwsIntegrationAccountConfig(
config: Config,
): AwsIntegrationAccountConfig {
const accountConfig = {
accountId: config.getString('accountId'),
accessKeyId: config.getOptionalString('accessKeyId'),
secretAccessKey: config.getOptionalString('secretAccessKey'),
profile: config.getOptionalString('profile'),
roleName: config.getOptionalString('roleName'),
region: config.getOptionalString('region'),
partition: config.getOptionalString('partition'),
externalId: config.getOptionalString('externalId'),
};
// Validate that the account config has the right combination of attributes
if (accountConfig.accessKeyId && !accountConfig.secretAccessKey) {
throw new Error(
`AWS integration account ${accountConfig.accountId} has an access key ID configured, but no secret access key.`,
);
}
if (!accountConfig.accessKeyId && accountConfig.secretAccessKey) {
throw new Error(
`AWS integration account ${accountConfig.accountId} has a secret access key configured, but no access key ID`,
);
}
if (accountConfig.profile && accountConfig.accessKeyId) {
throw new Error(
`AWS integration account ${accountConfig.accountId} has both an access key ID and a profile configured, but only one must be specified`,
);
}
if (accountConfig.profile && accountConfig.roleName) {
throw new Error(
`AWS integration account ${accountConfig.accountId} has both an access key ID and a role name configured, but only one must be specified`,
);
}
if (!accountConfig.roleName && accountConfig.externalId) {
throw new Error(
`AWS integration account ${accountConfig.accountId} has an external ID configured, but no role name.`,
);
}
if (!accountConfig.roleName && accountConfig.region) {
throw new Error(
`AWS integration account ${accountConfig.accountId} has an STS region configured, but no role name.`,
);
}
if (!accountConfig.roleName && accountConfig.partition) {
throw new Error(
`AWS integration account ${accountConfig.accountId} has an IAM partition configured, but no role name.`,
);
}
return accountConfig;
}
/**
* Reads the main AWS integration account config.
*
* @param config - The config object of the main account
*/
function readMainAwsIntegrationAccountConfig(
config: Config,
): AwsIntegrationMainAccountConfig {
const mainAccountConfig = {
accessKeyId: config.getOptionalString('accessKeyId'),
secretAccessKey: config.getOptionalString('secretAccessKey'),
profile: config.getOptionalString('profile'),
region: config.getOptionalString('region'),
};
// Validate that the account config has the right combination of attributes
if (mainAccountConfig.accessKeyId && !mainAccountConfig.secretAccessKey) {
throw new Error(
`The main AWS integration account has an access key ID configured, but no secret access key.`,
);
}
if (!mainAccountConfig.accessKeyId && mainAccountConfig.secretAccessKey) {
throw new Error(
`The main AWS integration account has a secret access key configured, but no access key ID`,
);
}
if (mainAccountConfig.profile && mainAccountConfig.accessKeyId) {
throw new Error(
`The main AWS integration account has both an access key ID and a profile configured, but only one must be specified`,
);
}
return mainAccountConfig;
}
/**
* Reads the default settings for retrieving credentials from AWS integration accounts.
*
* @param config - The config object of the default account settings
*/
function readAwsIntegrationAccountDefaultsConfig(
config: Config,
): AwsIntegrationDefaultAccountConfig {
const defaultAccountConfig = {
roleName: config.getOptionalString('roleName'),
partition: config.getOptionalString('partition'),
region: config.getOptionalString('region'),
externalId: config.getOptionalString('externalId'),
};
// Validate that the account config has the right combination of attributes
if (!defaultAccountConfig.roleName && defaultAccountConfig.externalId) {
throw new Error(
`AWS integration account default configuration has an external ID configured, but no role name.`,
);
}
if (!defaultAccountConfig.roleName && defaultAccountConfig.region) {
throw new Error(
`AWS integration account default configuration has an STS region configured, but no role name.`,
);
}
if (!defaultAccountConfig.roleName && defaultAccountConfig.partition) {
throw new Error(
`AWS integration account default configuration has an IAM partition configured, but no role name.`,
);
}
return defaultAccountConfig;
}
/**
* Reads an AWS integration configuration
*
* @param config - the integration config object
* @public
*/
export function readAwsIntegrationConfig(config: Config): AwsIntegrationConfig {
const accounts = config
.getOptionalConfigArray('accounts')
?.map(readAwsIntegrationAccountConfig);
const mainAccount = config.has('mainAccount')
? readMainAwsIntegrationAccountConfig(config.getConfig('mainAccount'))
: {};
const accountDefaults = config.has('accountDefaults')
? readAwsIntegrationAccountDefaultsConfig(
config.getConfig('accountDefaults'),
)
: {};
return {
accounts: accounts ?? [],
mainAccount,
accountDefaults,
};
}
@@ -0,0 +1,29 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { readAwsIntegrationConfig } from './config';
export type {
AwsIntegrationConfig,
AwsIntegrationAccountConfig,
AwsIntegrationDefaultAccountConfig,
AwsIntegrationMainAccountConfig,
} from './config';
export { DefaultAwsCredentialsProvider } from './DefaultAwsCredentialsProvider';
export type {
AwsCredentials,
AwsCredentialsProvider,
AwsCredentialsProviderOptions,
} from './types';
@@ -0,0 +1,57 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { AwsCredentialIdentityProvider } from '@aws-sdk/types';
/**
* A set of credentials information for an AWS account.
*
* @public
*/
export type AwsCredentials = {
accountId?: string;
stsRegion?: string;
provider: AwsCredentialIdentityProvider;
};
/**
* The options for specifying the AWS credentials to retrieve.
*
* @public
*/
export type AwsCredentialsProviderOptions = {
/**
* The AWS account ID, e.g. '0123456789012'
*/
accountId?: string;
/**
* The resource ARN that will be accessed with the returned credentials.
* If account ID or region are not specified, they will be inferred from the ARN.
*/
arn?: string;
};
/**
* This allows implementations to be provided to retrieve AWS credentials.
*
* @public
*/
export interface AwsCredentialsProvider {
/**
* Get credentials for an AWS account.
*/
getCredentials(opts?: AwsCredentialsProviderOptions): Promise<AwsCredentials>;
}