diff --git a/.changeset/forty-carpets-refuse.md b/.changeset/forty-carpets-refuse.md new file mode 100644 index 0000000000..a66121658d --- /dev/null +++ b/.changeset/forty-carpets-refuse.md @@ -0,0 +1,5 @@ +--- +'@backstage/integration-aws-node': minor +--- + +New package for AWS integration node library diff --git a/packages/integration-aws-node/.eslintrc.js b/packages/integration-aws-node/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/packages/integration-aws-node/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/packages/integration-aws-node/README.md b/packages/integration-aws-node/README.md new file mode 100644 index 0000000000..6132f4240a --- /dev/null +++ b/packages/integration-aws-node/README.md @@ -0,0 +1,157 @@ +# @backstage/integration-aws-node + +This package providers helpers for fetching AWS account credentials +to be used by AWS SDK clients in backend packages and plugins. + +## Backstage app configuration + +Users of plugins and packages that use this library +will configure their AWS account information and credentials in their +Backstage app config. +Users can configure IAM user credentials, IAM roles, and profile names +for their AWS accounts in their Backstage config. + +If the AWS integration configuration is missing, the credentials provider +from this package will fall back to the AWS SDK default credentials chain for +resources in the main AWS account. +The default credentials chain for Node resolves credentials in the +following order of precedence: + +1. Environment variables +2. SSO credentials from token cache +3. Web identity token credentials +4. Shared credentials files +5. The EC2/ECS Instance Metadata Service + +See more about the AWS SDK default credentials chain in the +[AWS SDK for Javascript Developer Guide](https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/setting-credentials-node.html). + +Configuration examples: + +```yaml +aws: + # The main account is used as the source of credentials for calling + # the STS AssumeRole API to assume IAM roles in other AWS accounts. + # This section can be omitted to fall back to the AWS SDK's default creds chain. + mainAccount: + accessKeyId: ${MY_ACCESS_KEY_ID} + secretAccessKey: ${MY_SECRET_ACCESS_KEY} + + # Account credentials can be configured individually per account + accounts: + # Credentials can come from a role in the account + - accountId: '111111111111' + roleName: 'my-iam-role-name' + externalId: 'my-external-id' + + # Credentials can come from other AWS partitions + - accountId: '222222222222' + partition: 'aws-other' + roleName: 'my-iam-role-name' + # The STS region to use for the AssumeRole call + region: 'not-us-east-1' + # The creds to use when calling AssumeRole + accessKeyId: ${MY_ACCESS_KEY_ID_FOR_ANOTHER_PARTITION} + secretAccessKey: ${MY_SECRET_ACCESS_KEY_FOR_ANOTHER_PARTITION} + + # Credentials can come from static credentials + - accountId: '333333333333' + accessKeyId: ${MY_OTHER_ACCESS_KEY_ID} + secretAccessKey: ${MY_OTHER_SECRET_ACCESS_KEY} + + # Credentials can come from a profile in a shared config file on disk + - accountId: '444444444444' + profile: my-profile-name + + # Credentials can come from the AWS SDK's default creds chain + - accountId: '555555555555' + + # Credentials for accounts can fall back to a common role name. + # This is useful for account discovery use cases where the account + # IDs may not be known when writing the static config. + # If all accounts have a role with the same name, then the "accounts" + # section can be omitted entirely. + accountDefaults: + roleName: 'my-backstage-role' + externalId: 'my-id' +``` + +## Integrate new plugins + +Backend plugins can provide an AWS ARN or account ID to this library in order to +retrieve a credentials provider for the relevant account that can be fed directly +to an AWS SDK client. +The AWS SDK for Javascript V3 must be used. + +```typescript +const awsCredentialsProvider = DefaultAwsCredentialsProvider.fromConfig(config); + +// provide the account ID explicitly +const creds = await awsCredentialsProvider.getCredentials({ accountId }); +// OR extract the account ID from the ARN +const creds = await awsCredentialsProvider.getCredentials({ arn }); +// OR provide neither to get main account's credentials +const creds = await awsCredentialsProvider.getCredentials({}); + +// Example constructing an AWS Proton client with the returned credentials provider +const client = new ProtonClient({ + region, + credentialDefaultProvider: () => creds.provider, +}); +``` + +Depending on the nature of your plguin, you may either have the user specify the +relevant ARN or account ID in a catalog entity annotation or in the static Backstage +app configuration for your plugin. + +For example, you can create a new catalog entity annotation for your plugin: + +```yaml +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + annotations: + # Plugin annotation to specify an AWS account ID + my-plugin.io/aws-account-id: '123456789012' + # Plugin annotation to specify the AWS ARN of a specific resource + my-other-plugin.io/aws-dynamodb-table: 'arn:aws:dynamodb:us-east-2:123456789012:table/example-table' +``` + +In your plugin, read the annotation value so that you can retrieve the credentials provider: + +```typescript +const MY_AWS_ACCOUNT_ID_ANNOTATION = 'my-plugin.io/aws-account-id'; + +const getAwsAccountId = (entity: Entity) => + entity.metadata.annotations?.[MY_AWS_ACCOUNT_ID_ANNOTATION]); +``` + +Alternatively, you can create a new configuration field for your plugin: + +```yaml +# app-config.yaml +my-plugin: + # Statically configure the AWS account ID to use + awsAccountId: '123456789012' +my-other-plugin: + # Statically configure the AWS ARN of a specific resource + awsDynamoDbTable: 'arn:aws:dynamodb:us-east-2:123456789012:table/example-table' +``` + +In your plugin, read the configuration value so that you can retrieve the credentials provider: + +```typescript +// Read an account ID from your plugin's configuration +const awsCredentialsProvider = DefaultAwsCredentialsProvider.fromConfig(config); +const accountId = config.getString('my-plugin.awsAccountId'); +const creds = await awsCredentialsProvider.getCredentials({ accountId }); + +// Or, read an AWS ARN from your plugin's configuration +const awsCredentialsProvider = DefaultAwsCredentialsProvider.fromConfig(config); +const arn = config.getString('my-other-plugin.awsDynamoDbTable'); +const creds = await awsCredentialsProvider.getCredentials({ arn }); +``` + +## Links + +- [The Backstage homepage](https://backstage.io) diff --git a/packages/integration-aws-node/api-report.md b/packages/integration-aws-node/api-report.md new file mode 100644 index 0000000000..bac43c67d4 --- /dev/null +++ b/packages/integration-aws-node/api-report.md @@ -0,0 +1,73 @@ +## API Report File for "@backstage/integration-aws-node" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { AwsCredentialIdentityProvider } from '@aws-sdk/types'; +import { Config } from '@backstage/config'; + +// @public +export type AwsCredentials = { + accountId?: string; + stsRegion?: string; + provider: AwsCredentialIdentityProvider; +}; + +// @public +export interface AwsCredentialsProvider { + getCredentials(opts?: AwsCredentialsProviderOptions): Promise; +} + +// @public +export type AwsCredentialsProviderOptions = { + accountId?: string; + arn?: string; +}; + +// @public +export type AwsIntegrationAccountConfig = { + accountId: string; + accessKeyId?: string; + secretAccessKey?: string; + profile?: string; + roleName?: string; + partition?: string; + region?: string; + externalId?: string; +}; + +// @public +export type AwsIntegrationConfig = { + accounts: AwsIntegrationAccountConfig[]; + accountDefaults: AwsIntegrationDefaultAccountConfig; + mainAccount: AwsIntegrationMainAccountConfig; +}; + +// @public +export type AwsIntegrationDefaultAccountConfig = { + roleName?: string; + partition?: string; + region?: string; + externalId?: string; +}; + +// @public +export type AwsIntegrationMainAccountConfig = { + accessKeyId?: string; + secretAccessKey?: string; + profile?: string; + region?: string; +}; + +// @public +export class DefaultAwsCredentialsProvider implements AwsCredentialsProvider { + // (undocumented) + static fromConfig(config: Config): DefaultAwsCredentialsProvider; + getCredentials(opts?: AwsCredentialsProviderOptions): Promise; +} + +// @public +export function readAwsIntegrationConfig(config: Config): AwsIntegrationConfig; + +// (No @packageDocumentation comment for this package) +``` diff --git a/packages/integration-aws-node/config.d.ts b/packages/integration-aws-node/config.d.ts new file mode 100644 index 0000000000..3c5600efc3 --- /dev/null +++ b/packages/integration-aws-node/config.d.ts @@ -0,0 +1,123 @@ +/* + * 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 interface Config { + /** Configuration for access to AWS accounts */ + aws?: { + /** + * Defaults for retrieving AWS account credentials + */ + accountDefaults?: { + /** + * 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 + * @visibility secret + */ + externalId?: string; + }; + + /** + * Main account to use for retrieving AWS account credentials + */ + mainAccount?: { + /** + * The access key ID for a set of static AWS credentials + * @visibility secret + */ + accessKeyId?: string; + + /** + * The secret access key for a set of static AWS credentials + * @visibility secret + */ + 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; + }; + + /** + * Configuration for retrieving AWS accounts credentials + */ + accounts?: Array<{ + /** + * 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 + * @visibility secret + */ + accessKeyId?: string; + + /** + * The secret access key for a set of static AWS credentials + * @visibility secret + */ + 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 + * @visibility secret + */ + externalId?: string; + }>; + }; +} diff --git a/packages/integration-aws-node/package.json b/packages/integration-aws-node/package.json new file mode 100644 index 0000000000..b3560668b8 --- /dev/null +++ b/packages/integration-aws-node/package.json @@ -0,0 +1,55 @@ +{ + "name": "@backstage/integration-aws-node", + "description": "Helpers for fetching AWS account credentials", + "version": "0.0.0", + "main": "src/index.ts", + "types": "src/index.ts", + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "module": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "backstage": { + "role": "node-library" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "packages/integration-aws-node" + }, + "keywords": [ + "backstage" + ], + "license": "Apache-2.0", + "scripts": { + "build": "backstage-cli package build", + "lint": "backstage-cli package lint", + "test": "backstage-cli package test", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack", + "clean": "backstage-cli package clean" + }, + "dependencies": { + "@aws-sdk/client-sts": "^3.208.0", + "@aws-sdk/credential-provider-node": "^3.208.0", + "@aws-sdk/credential-providers": "^3.208.0", + "@aws-sdk/types": "^3.208.0", + "@aws-sdk/util-arn-parser": "^3.208.0", + "@backstage/config": "workspace:^", + "@backstage/errors": "workspace:^" + }, + "devDependencies": { + "@backstage/cli": "workspace:^", + "@backstage/config-loader": "workspace:^", + "@backstage/test-utils": "workspace:^", + "aws-sdk-client-mock": "^2.0.0", + "aws-sdk-client-mock-jest": "^2.0.0" + }, + "files": [ + "dist", + "config.d.ts" + ], + "configSchema": "config.d.ts" +} diff --git a/packages/integration-aws-node/src/DefaultAwsCredentialsProvider.test.ts b/packages/integration-aws-node/src/DefaultAwsCredentialsProvider.test.ts new file mode 100644 index 0000000000..60f2f558b8 --- /dev/null +++ b/packages/integration-aws-node/src/DefaultAwsCredentialsProvider.test.ts @@ -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; +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/, + ); + }); + }); +}); diff --git a/packages/integration-aws-node/src/DefaultAwsCredentialsProvider.ts b/packages/integration-aws-node/src/DefaultAwsCredentialsProvider.ts new file mode 100644 index 0000000000..8b26b5fe84 --- /dev/null +++ b/packages/integration-aws-node/src/DefaultAwsCredentialsProvider.ts @@ -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(); + 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, + 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 { + // 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.`, + ); + } +} diff --git a/packages/integration-aws-node/src/config.test.ts b/packages/integration-aws-node/src/config.test.ts new file mode 100644 index 0000000000..f761a8ac14 --- /dev/null +++ b/packages/integration-aws-node/src/config.test.ts @@ -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): 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/); + }); +}); diff --git a/packages/integration-aws-node/src/config.ts b/packages/integration-aws-node/src/config.ts new file mode 100644 index 0000000000..0bc8c0ed06 --- /dev/null +++ b/packages/integration-aws-node/src/config.ts @@ -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, + }; +} diff --git a/packages/integration-aws-node/src/index.ts b/packages/integration-aws-node/src/index.ts new file mode 100644 index 0000000000..d0a86acb47 --- /dev/null +++ b/packages/integration-aws-node/src/index.ts @@ -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'; diff --git a/packages/integration-aws-node/src/types.ts b/packages/integration-aws-node/src/types.ts new file mode 100644 index 0000000000..44f7850cf8 --- /dev/null +++ b/packages/integration-aws-node/src/types.ts @@ -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; +} diff --git a/yarn.lock b/yarn.lock index 98614b0736..efdb297cbf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -639,7 +639,7 @@ __metadata: languageName: node linkType: hard -"@aws-sdk/client-sts@npm:3.218.0": +"@aws-sdk/client-sts@npm:3.218.0, @aws-sdk/client-sts@npm:^3.208.0": version: 3.218.0 resolution: "@aws-sdk/client-sts@npm:3.218.0" dependencies: @@ -748,7 +748,7 @@ __metadata: languageName: node linkType: hard -"@aws-sdk/credential-provider-node@npm:3.218.0": +"@aws-sdk/credential-provider-node@npm:3.218.0, @aws-sdk/credential-provider-node@npm:^3.208.0": version: 3.218.0 resolution: "@aws-sdk/credential-provider-node@npm:3.218.0" dependencies: @@ -1386,7 +1386,7 @@ __metadata: languageName: node linkType: hard -"@aws-sdk/util-arn-parser@npm:3.208.0": +"@aws-sdk/util-arn-parser@npm:3.208.0, @aws-sdk/util-arn-parser@npm:^3.208.0": version: 3.208.0 resolution: "@aws-sdk/util-arn-parser@npm:3.208.0" dependencies: @@ -4135,6 +4135,25 @@ __metadata: languageName: unknown linkType: soft +"@backstage/integration-aws-node@workspace:packages/integration-aws-node": + version: 0.0.0-use.local + resolution: "@backstage/integration-aws-node@workspace:packages/integration-aws-node" + dependencies: + "@aws-sdk/client-sts": ^3.208.0 + "@aws-sdk/credential-provider-node": ^3.208.0 + "@aws-sdk/credential-providers": ^3.208.0 + "@aws-sdk/types": ^3.208.0 + "@aws-sdk/util-arn-parser": ^3.208.0 + "@backstage/cli": "workspace:^" + "@backstage/config": "workspace:^" + "@backstage/config-loader": "workspace:^" + "@backstage/errors": "workspace:^" + "@backstage/test-utils": "workspace:^" + aws-sdk-client-mock: ^2.0.0 + aws-sdk-client-mock-jest: ^2.0.0 + languageName: unknown + linkType: soft + "@backstage/integration-react@npm:^1.1.5": version: 1.1.6 resolution: "@backstage/integration-react@npm:1.1.6" @@ -10137,6 +10156,15 @@ __metadata: languageName: node linkType: hard +"@jest/expect-utils@npm:^28.1.3": + version: 28.1.3 + resolution: "@jest/expect-utils@npm:28.1.3" + dependencies: + jest-get-type: ^28.0.2 + checksum: 808ea3a68292a7e0b95490fdd55605c430b4cf209ea76b5b61bfb2a1badcb41bc046810fe4e364bd5fe04663978aa2bd73d8f8465a761dd7c655aeb44cf22987 + languageName: node + linkType: hard + "@jest/expect-utils@npm:^29.3.1": version: 29.3.1 resolution: "@jest/expect-utils@npm:29.3.1" @@ -10219,6 +10247,15 @@ __metadata: languageName: node linkType: hard +"@jest/schemas@npm:^28.1.3": + version: 28.1.3 + resolution: "@jest/schemas@npm:28.1.3" + dependencies: + "@sinclair/typebox": ^0.24.1 + checksum: 3cf1d4b66c9c4ffda58b246de1ddcba8e6ad085af63dccdf07922511f13b68c0cc480a7bc620cb4f3099a6f134801c747e1df7bfc7a4ef4dceefbdea3e31e1de + languageName: node + linkType: hard + "@jest/schemas@npm:^29.0.0": version: 29.0.0 resolution: "@jest/schemas@npm:29.0.0" @@ -10299,6 +10336,20 @@ __metadata: languageName: node linkType: hard +"@jest/types@npm:^28.1.3": + version: 28.1.3 + resolution: "@jest/types@npm:28.1.3" + dependencies: + "@jest/schemas": ^28.1.3 + "@types/istanbul-lib-coverage": ^2.0.0 + "@types/istanbul-reports": ^3.0.0 + "@types/node": "*" + "@types/yargs": ^17.0.8 + chalk: ^4.0.0 + checksum: 1e258d9c063fcf59ebc91e46d5ea5984674ac7ae6cae3e50aa780d22b4405bf2c925f40350bf30013839eb5d4b5e521d956ddf8f3b7c78debef0e75a07f57350 + languageName: node + linkType: hard + "@jest/types@npm:^29.3.1": version: 29.3.1 resolution: "@jest/types@npm:29.3.1" @@ -14168,6 +14219,16 @@ __metadata: languageName: node linkType: hard +"@types/jest@npm:^28.1.3": + version: 28.1.8 + resolution: "@types/jest@npm:28.1.8" + dependencies: + expect: ^28.0.0 + pretty-format: ^28.0.0 + checksum: d4cd36158a3ae1d4b42cc48a77c95de74bc56b84cf81e09af3ee0399c34f4a7da8ab9e787570f10004bd642f9e781b0033c37327fbbf4a8e4b6e37e8ee3693a7 + languageName: node + linkType: hard + "@types/jquery@npm:^3.3.34": version: 3.5.14 resolution: "@types/jquery@npm:3.5.14" @@ -16664,6 +16725,18 @@ __metadata: languageName: node linkType: hard +"aws-sdk-client-mock-jest@npm:^2.0.0": + version: 2.0.0 + resolution: "aws-sdk-client-mock-jest@npm:2.0.0" + dependencies: + "@types/jest": ^28.1.3 + tslib: ^2.1.0 + peerDependencies: + aws-sdk-client-mock: 2.0.0 + checksum: 57dc95b52f0c41166af44c743f298992f688ad55c4492fd2f09d7be59277c57a9c0ce408c8081b88358cb75c802922ca9d299e747797b023518ea57c01fa4ece + languageName: node + linkType: hard + "aws-sdk-client-mock@npm:^2.0.0": version: 2.0.1 resolution: "aws-sdk-client-mock@npm:2.0.1" @@ -20223,6 +20296,13 @@ __metadata: languageName: node linkType: hard +"diff-sequences@npm:^28.1.1": + version: 28.1.1 + resolution: "diff-sequences@npm:28.1.1" + checksum: e2529036505567c7ca5a2dea86b6bcd1ca0e3ae63bf8ebf529b8a99cfa915bbf194b7021dc1c57361a4017a6d95578d4ceb29fabc3232a4f4cb866a2726c7690 + languageName: node + linkType: hard + "diff-sequences@npm:^29.3.1": version: 29.3.1 resolution: "diff-sequences@npm:29.3.1" @@ -22015,6 +22095,19 @@ __metadata: languageName: node linkType: hard +"expect@npm:^28.0.0": + version: 28.1.3 + resolution: "expect@npm:28.1.3" + dependencies: + "@jest/expect-utils": ^28.1.3 + jest-get-type: ^28.0.2 + jest-matcher-utils: ^28.1.3 + jest-message-util: ^28.1.3 + jest-util: ^28.1.3 + checksum: 101e0090de300bcafedb7dbfd19223368a2251ce5fe0105bbb6de5720100b89fb6b64290ebfb42febc048324c76d6a4979cdc4b61eb77747857daf7a5de9b03d + languageName: node + linkType: hard + "expect@npm:^29.0.0, expect@npm:^29.3.1": version: 29.3.1 resolution: "expect@npm:29.3.1" @@ -25656,6 +25749,18 @@ __metadata: languageName: node linkType: hard +"jest-diff@npm:^28.1.3": + version: 28.1.3 + resolution: "jest-diff@npm:28.1.3" + dependencies: + chalk: ^4.0.0 + diff-sequences: ^28.1.1 + jest-get-type: ^28.0.2 + pretty-format: ^28.1.3 + checksum: fa8583e0ccbe775714ce850b009be1b0f6b17a4b6759f33ff47adef27942ebc610dbbcc8a5f7cfb7f12b3b3b05afc9fb41d5f766674616025032ff1e4f9866e0 + languageName: node + linkType: hard + "jest-diff@npm:^29.3.1": version: 29.3.1 resolution: "jest-diff@npm:29.3.1" @@ -25725,6 +25830,13 @@ __metadata: languageName: node linkType: hard +"jest-get-type@npm:^28.0.2": + version: 28.0.2 + resolution: "jest-get-type@npm:28.0.2" + checksum: 5281d7c89bc8156605f6d15784f45074f4548501195c26e9b188742768f72d40948252d13230ea905b5349038865a1a8eeff0e614cc530ff289dfc41fe843abd + languageName: node + linkType: hard + "jest-get-type@npm:^29.2.0": version: 29.2.0 resolution: "jest-get-type@npm:29.2.0" @@ -25765,6 +25877,18 @@ __metadata: languageName: node linkType: hard +"jest-matcher-utils@npm:^28.1.3": + version: 28.1.3 + resolution: "jest-matcher-utils@npm:28.1.3" + dependencies: + chalk: ^4.0.0 + jest-diff: ^28.1.3 + jest-get-type: ^28.0.2 + pretty-format: ^28.1.3 + checksum: 6b34f0cf66f6781e92e3bec97bf27796bd2ba31121e5c5997218d9adba6deea38a30df5203937d6785b68023ed95cbad73663cc9aad6fb0cb59aeb5813a58daf + languageName: node + linkType: hard + "jest-matcher-utils@npm:^29.3.1": version: 29.3.1 resolution: "jest-matcher-utils@npm:29.3.1" @@ -25777,6 +25901,23 @@ __metadata: languageName: node linkType: hard +"jest-message-util@npm:^28.1.3": + version: 28.1.3 + resolution: "jest-message-util@npm:28.1.3" + dependencies: + "@babel/code-frame": ^7.12.13 + "@jest/types": ^28.1.3 + "@types/stack-utils": ^2.0.0 + chalk: ^4.0.0 + graceful-fs: ^4.2.9 + micromatch: ^4.0.4 + pretty-format: ^28.1.3 + slash: ^3.0.0 + stack-utils: ^2.0.3 + checksum: 1f266854166dcc6900d75a88b54a25225a2f3710d463063ff1c99021569045c35c7d58557b25447a17eb3a65ce763b2f9b25550248b468a9d4657db365f39e96 + languageName: node + linkType: hard + "jest-message-util@npm:^29.3.1": version: 29.3.1 resolution: "jest-message-util@npm:29.3.1" @@ -25942,6 +26083,20 @@ __metadata: languageName: node linkType: hard +"jest-util@npm:^28.1.3": + version: 28.1.3 + resolution: "jest-util@npm:28.1.3" + dependencies: + "@jest/types": ^28.1.3 + "@types/node": "*" + chalk: ^4.0.0 + ci-info: ^3.2.0 + graceful-fs: ^4.2.9 + picomatch: ^2.2.3 + checksum: fd6459742c941f070223f25e38a2ac0719aad92561591e9fb2a50d602a5d19d754750b79b4074327a42b00055662b95da3b006542ceb8b54309da44d4a62e721 + languageName: node + linkType: hard + "jest-util@npm:^29.3.1": version: 29.3.1 resolution: "jest-util@npm:29.3.1" @@ -31752,6 +31907,18 @@ __metadata: languageName: node linkType: hard +"pretty-format@npm:^28.0.0, pretty-format@npm:^28.1.3": + version: 28.1.3 + resolution: "pretty-format@npm:28.1.3" + dependencies: + "@jest/schemas": ^28.1.3 + ansi-regex: ^5.0.1 + ansi-styles: ^5.0.0 + react-is: ^18.0.0 + checksum: e69f857358a3e03d271252d7524bec758c35e44680287f36c1cb905187fbc82da9981a6eb07edfd8a03bc3cbeebfa6f5234c13a3d5b59f2bbdf9b4c4053e0a7f + languageName: node + linkType: hard + "pretty-format@npm:^29.0.0, pretty-format@npm:^29.3.1": version: 29.3.1 resolution: "pretty-format@npm:29.3.1"