From 13278732f6e42b4991c993eda629f0cc9cf8cb03 Mon Sep 17 00:00:00 2001 From: Clare Liguori Date: Thu, 27 Oct 2022 13:10:41 -0700 Subject: [PATCH 1/5] New package for AWS integration node library Signed-off-by: Clare Liguori --- .changeset/forty-carpets-refuse.md | 5 + packages/integration-aws-node/.eslintrc.js | 1 + packages/integration-aws-node/README.md | 157 +++++++ packages/integration-aws-node/api-report.md | 73 +++ packages/integration-aws-node/config.d.ts | 123 +++++ packages/integration-aws-node/package.json | 55 +++ .../src/DefaultAwsCredentialsProvider.test.ts | 430 ++++++++++++++++++ .../src/DefaultAwsCredentialsProvider.ts | 274 +++++++++++ .../integration-aws-node/src/config.test.ts | 335 ++++++++++++++ packages/integration-aws-node/src/config.ts | 307 +++++++++++++ packages/integration-aws-node/src/index.ts | 29 ++ packages/integration-aws-node/src/types.ts | 57 +++ yarn.lock | 173 ++++++- 13 files changed, 2016 insertions(+), 3 deletions(-) create mode 100644 .changeset/forty-carpets-refuse.md create mode 100644 packages/integration-aws-node/.eslintrc.js create mode 100644 packages/integration-aws-node/README.md create mode 100644 packages/integration-aws-node/api-report.md create mode 100644 packages/integration-aws-node/config.d.ts create mode 100644 packages/integration-aws-node/package.json create mode 100644 packages/integration-aws-node/src/DefaultAwsCredentialsProvider.test.ts create mode 100644 packages/integration-aws-node/src/DefaultAwsCredentialsProvider.ts create mode 100644 packages/integration-aws-node/src/config.test.ts create mode 100644 packages/integration-aws-node/src/config.ts create mode 100644 packages/integration-aws-node/src/index.ts create mode 100644 packages/integration-aws-node/src/types.ts 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" From e40790d0c2e50c36ddd0cd0fa28d6011ab61ccff Mon Sep 17 00:00:00 2001 From: Clare Liguori Date: Mon, 28 Nov 2022 13:23:26 -0800 Subject: [PATCH 2/5] Use integration-aws-node for credentials in S3 Techdocs Signed-off-by: Clare Liguori --- .changeset/silly-knives-warn.md | 5 + docs/features/techdocs/configuration.md | 16 +- docs/features/techdocs/using-cloud-storage.md | 9 +- plugins/techdocs-backend/config.d.ts | 16 +- plugins/techdocs-node/package.json | 1 + .../src/stages/publish/awsS3.test.ts | 147 ++++++++++++++---- .../techdocs-node/src/stages/publish/awsS3.ts | 60 ++++--- .../src/stages/publish/publish.ts | 2 +- yarn.lock | 3 +- 9 files changed, 198 insertions(+), 61 deletions(-) create mode 100644 .changeset/silly-knives-warn.md diff --git a/.changeset/silly-knives-warn.md b/.changeset/silly-knives-warn.md new file mode 100644 index 0000000000..f5fb61b493 --- /dev/null +++ b/.changeset/silly-knives-warn.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-techdocs-node': patch +--- + +Add support for specifying an S3 bucket's account ID and retrieving the credentials from the `aws` app config section. This is now the preferred way to configure AWS credentials for Techdocs. diff --git a/docs/features/techdocs/configuration.md b/docs/features/techdocs/configuration.md index 4e806296e5..097d31f760 100644 --- a/docs/features/techdocs/configuration.md +++ b/docs/features/techdocs/configuration.md @@ -106,8 +106,20 @@ techdocs: # If not set, the default location will be the root of the storage bucket bucketRootPath: '/' - # (Optional) An API key is required to write to a storage bucket. - # If not set, environment variables or aws config file will be used to authenticate. + # (Optional) The AWS account ID where the storage bucket is located. + # Credentials for the account ID must be configured in the 'aws' app config section. + # See the integration-aws-node package for details on how to configure credentials in + # the 'aws' app config section. + # https://www.npmjs.com/package/@backstage/integration-aws-node + # If account ID is not set and no credentials are set, environment variables or aws config file will be used to authenticate. + # https://www.npmjs.com/package/@aws-sdk/credential-provider-node + # https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/setting-credentials-node.html + accountId: ${TECHDOCS_AWSS3_ACCOUNT_ID} + + # (Optional) AWS credentials to use to write to the storage bucket. + # This configuration section is now deprecated. + # Configuring the account ID is now preferred, with credentials in the 'aws' app config section. + # If credentials are not set and no account ID is set, environment variables or aws config file will be used to authenticate. # https://www.npmjs.com/package/@aws-sdk/credential-provider-node # https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/setting-credentials-node.html credentials: diff --git a/docs/features/techdocs/using-cloud-storage.md b/docs/features/techdocs/using-cloud-storage.md index 3ea57edc6b..f38a806413 100644 --- a/docs/features/techdocs/using-cloud-storage.md +++ b/docs/features/techdocs/using-cloud-storage.md @@ -244,10 +244,13 @@ techdocs: type: 'awsS3' awsS3: bucketName: 'name-of-techdocs-storage-bucket' + accountId: '123456789012' region: ${AWS_REGION} - credentials: - accessKeyId: ${AWS_ACCESS_KEY_ID} - secretAccessKey: ${AWS_SECRET_ACCESS_KEY} +aws: + accounts: + - accountId: '123456789012' + accessKeyId: ${AWS_ACCESS_KEY_ID} + secretAccessKey: ${AWS_SECRET_ACCESS_KEY} ``` Refer to the diff --git a/plugins/techdocs-backend/config.d.ts b/plugins/techdocs-backend/config.d.ts index ec35505a78..3a05beabdc 100644 --- a/plugins/techdocs-backend/config.d.ts +++ b/plugins/techdocs-backend/config.d.ts @@ -81,9 +81,23 @@ export interface Config { * Required when 'type' is set to awsS3 */ awsS3?: { + /** + * (Optional) The AWS account ID where the storage bucket is located. + * Credentials for the account ID will be sourced from the 'aws' app config section. + * See the + * [integration-aws-node package](https://github.com/backstage/backstage/blob/master/packages/integration-aws-node/README.md) + * for details on how to configure the credentials in the app config. + * If account ID is not set and no credentials are set, environment variables or aws config file will be used to authenticate. + * @see https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/loading-node-credentials-environment.html + * @see https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/loading-node-credentials-shared.html + * @visibility secret + */ + accountId?: string; /** * (Optional) Credentials used to access a storage bucket. - * If not set, environment variables or aws config file will be used to authenticate. + * This section is now deprecated. Configuring the account ID is now preferred, with credentials in the 'aws' + * app config section. + * If not set and no account ID is set, environment variables or aws config file will be used to authenticate. * @see https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/loading-node-credentials-environment.html * @see https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/loading-node-credentials-shared.html * @visibility secret diff --git a/plugins/techdocs-node/package.json b/plugins/techdocs-node/package.json index 668fe58239..631de02b55 100644 --- a/plugins/techdocs-node/package.json +++ b/plugins/techdocs-node/package.json @@ -50,6 +50,7 @@ "@backstage/config": "workspace:^", "@backstage/errors": "workspace:^", "@backstage/integration": "workspace:^", + "@backstage/integration-aws-node": "workspace:^", "@backstage/plugin-search-common": "workspace:^", "@google-cloud/storage": "^6.0.0", "@trendyol-js/openstack-swift-sdk": "^0.0.5", diff --git a/plugins/techdocs-node/src/stages/publish/awsS3.test.ts b/plugins/techdocs-node/src/stages/publish/awsS3.test.ts index 08facf2208..ff61ac58a2 100644 --- a/plugins/techdocs-node/src/stages/publish/awsS3.test.ts +++ b/plugins/techdocs-node/src/stages/publish/awsS3.test.ts @@ -27,6 +27,11 @@ import { import { getVoidLogger } from '@backstage/backend-common'; import { Entity, DEFAULT_NAMESPACE } from '@backstage/catalog-model'; import { ConfigReader } from '@backstage/config'; +import { + AwsCredentials, + AwsCredentialsProviderOptions, + DefaultAwsCredentialsProvider, +} from '@backstage/integration-aws-node'; import { mockClient, AwsClientStub } from 'aws-sdk-client-mock'; import express from 'express'; import request from 'supertest'; @@ -40,6 +45,21 @@ import { Readable } from 'stream'; const env = process.env; let s3Mock: AwsClientStub; +function getMockCredentials(): Promise { + return Promise.resolve({ + provider: async () => { + return Promise.resolve({ + accessKeyId: 'MY_ACCESS_KEY_ID', + secretAccessKey: 'MY_SECRET_ACCESS_KEY', + }); + }, + }); +} +const credsProviderMock = jest.spyOn( + DefaultAwsCredentialsProvider.prototype, + 'getCredentials', +); + const getEntityRootDir = (entity: Entity) => { const { kind, @@ -70,7 +90,7 @@ const logger = getVoidLogger(); const loggerInfoSpy = jest.spyOn(logger, 'info'); const loggerErrorSpy = jest.spyOn(logger, 'error'); -const createPublisherFromConfig = ({ +const createPublisherFromConfig = async ({ bucketName = 'bucketName', bucketRootPath = '/', legacyUseCaseSensitiveTripletPaths = false, @@ -86,10 +106,7 @@ const createPublisherFromConfig = ({ publisher: { type: 'awsS3', awsS3: { - credentials: { - accessKeyId: 'accessKeyId', - secretAccessKey: 'secretAccessKey', - }, + accountId: '111111111111', bucketName, bucketRootPath, sse, @@ -97,9 +114,18 @@ const createPublisherFromConfig = ({ }, legacyUseCaseSensitiveTripletPaths, }, + aws: { + accounts: [ + { + accountId: '111111111111', + accessKeyId: 'my-access-key', + secretAccessKey: 'my-secret-access-key', + }, + ], + }, }); - return AwsS3Publish.fromConfig(mockConfig, logger); + return await AwsS3Publish.fromConfig(mockConfig, logger); }; describe('AwsS3Publish', () => { @@ -151,6 +177,11 @@ describe('AwsS3Publish', () => { process.env = { ...env }; process.env.AWS_REGION = 'us-west-2'; + jest.resetAllMocks(); + credsProviderMock.mockImplementation((_?: AwsCredentialsProviderOptions) => + getMockCredentials(), + ); + mockFs({ [directory]: files, }); @@ -215,16 +246,64 @@ describe('AwsS3Publish', () => { process.env = env; }); + describe('buildCredentials', () => { + it('should retrieve credentials for a specific account ID', async () => { + await createPublisherFromConfig(); + expect(credsProviderMock).toHaveBeenCalledWith({ + accountId: '111111111111', + }); + expect(credsProviderMock).toHaveBeenCalledTimes(1); + }); + + it('should retrieve default credentials when no config is present', async () => { + const mockConfig = new ConfigReader({ + techdocs: { + publisher: { + type: 'awsS3', + awsS3: { + bucketName: 'bucketName', + }, + }, + }, + }); + + await AwsS3Publish.fromConfig(mockConfig, logger); + expect(credsProviderMock).toHaveBeenCalledWith(); + expect(credsProviderMock).toHaveBeenCalledTimes(1); + }); + + it('should fall back to deprecated method of retrieving credentials', async () => { + const mockConfig = new ConfigReader({ + techdocs: { + publisher: { + type: 'awsS3', + awsS3: { + credentials: { + accessKeyId: 'accessKeyId', + secretAccessKey: 'secretAccessKey', + }, + bucketName: 'bucketName', + bucketRootPath: '/', + }, + }, + }, + }); + + await AwsS3Publish.fromConfig(mockConfig, logger); + expect(credsProviderMock).toHaveBeenCalledTimes(0); + }); + }); + describe('getReadiness', () => { it('should validate correct config', async () => { - const publisher = createPublisherFromConfig(); + const publisher = await createPublisherFromConfig(); expect(await publisher.getReadiness()).toEqual({ isAvailable: true, }); }); it('should reject incorrect config', async () => { - const publisher = createPublisherFromConfig({ + const publisher = await createPublisherFromConfig({ bucketName: 'errorBucket', }); expect(await publisher.getReadiness()).toEqual({ @@ -235,7 +314,7 @@ describe('AwsS3Publish', () => { describe('publish', () => { it('should publish a directory', async () => { - const publisher = createPublisherFromConfig(); + const publisher = await createPublisherFromConfig(); expect(await publisher.publish({ entity, directory })).toMatchObject({ objects: expect.arrayContaining([ 'default/component/backstage/404.html', @@ -246,7 +325,7 @@ describe('AwsS3Publish', () => { }); it('should publish a directory as well when legacy casing is used', async () => { - const publisher = createPublisherFromConfig({ + const publisher = await createPublisherFromConfig({ legacyUseCaseSensitiveTripletPaths: true, }); expect(await publisher.publish({ entity, directory })).toMatchObject({ @@ -259,7 +338,7 @@ describe('AwsS3Publish', () => { }); it('should publish a directory when root path is specified', async () => { - const publisher = createPublisherFromConfig({ + const publisher = await createPublisherFromConfig({ bucketRootPath: 'backstage-data/techdocs', }); expect(await publisher.publish({ entity, directory })).toMatchObject({ @@ -272,7 +351,7 @@ describe('AwsS3Publish', () => { }); it('should publish a directory when root path is specified and legacy casing is used', async () => { - const publisher = createPublisherFromConfig({ + const publisher = await createPublisherFromConfig({ bucketRootPath: 'backstage-data/techdocs', legacyUseCaseSensitiveTripletPaths: true, }); @@ -286,7 +365,7 @@ describe('AwsS3Publish', () => { }); it('should publish a directory when sse is specified', async () => { - const publisher = createPublisherFromConfig({ + const publisher = await createPublisherFromConfig({ sse: 'aws:kms', }); expect(await publisher.publish({ entity, directory })).toMatchObject({ @@ -307,7 +386,7 @@ describe('AwsS3Publish', () => { 'generatedDirectory', ); - const publisher = createPublisherFromConfig(); + const publisher = await createPublisherFromConfig(); const fails = publisher.publish({ entity, @@ -327,7 +406,9 @@ describe('AwsS3Publish', () => { it('should delete stale files after upload', async () => { const bucketName = 'delete_stale_files_success'; - const publisher = createPublisherFromConfig({ bucketName: bucketName }); + const publisher = await createPublisherFromConfig({ + bucketName: bucketName, + }); await publisher.publish({ entity, directory }); expect(loggerInfoSpy).toHaveBeenLastCalledWith( `Successfully deleted stale files for Entity ${entity.metadata.name}. Total number of files: 1`, @@ -336,7 +417,9 @@ describe('AwsS3Publish', () => { it('should log error when the stale files deletion fails', async () => { const bucketName = 'delete_stale_files_error'; - const publisher = createPublisherFromConfig({ bucketName: bucketName }); + const publisher = await createPublisherFromConfig({ + bucketName: bucketName, + }); await publisher.publish({ entity, directory }); expect(loggerErrorSpy).toHaveBeenLastCalledWith( 'Unable to delete file(s) from AWS S3. Error: Message', @@ -346,13 +429,13 @@ describe('AwsS3Publish', () => { describe('hasDocsBeenGenerated', () => { it('should return true if docs has been generated', async () => { - const publisher = createPublisherFromConfig(); + const publisher = await createPublisherFromConfig(); await publisher.publish({ entity, directory }); expect(await publisher.hasDocsBeenGenerated(entity)).toBe(true); }); it('should return true if docs has been generated even if the legacy case is enabled', async () => { - const publisher = createPublisherFromConfig({ + const publisher = await createPublisherFromConfig({ legacyUseCaseSensitiveTripletPaths: true, }); await publisher.publish({ entity, directory }); @@ -360,7 +443,7 @@ describe('AwsS3Publish', () => { }); it('should return true if docs has been generated if root path is specified', async () => { - const publisher = createPublisherFromConfig({ + const publisher = await createPublisherFromConfig({ bucketRootPath: 'backstage-data/techdocs', }); await publisher.publish({ entity, directory }); @@ -368,7 +451,7 @@ describe('AwsS3Publish', () => { }); it('should return true if docs has been generated if root path is specified and legacy casing is used', async () => { - const publisher = createPublisherFromConfig({ + const publisher = await createPublisherFromConfig({ bucketRootPath: 'backstage-data/techdocs', legacyUseCaseSensitiveTripletPaths: true, }); @@ -377,7 +460,7 @@ describe('AwsS3Publish', () => { }); it('should return false if docs has not been generated', async () => { - const publisher = createPublisherFromConfig(); + const publisher = await createPublisherFromConfig(); expect( await publisher.hasDocsBeenGenerated({ kind: 'entity', @@ -392,7 +475,7 @@ describe('AwsS3Publish', () => { describe('fetchTechDocsMetadata', () => { it('should return tech docs metadata', async () => { - const publisher = createPublisherFromConfig(); + const publisher = await createPublisherFromConfig(); await publisher.publish({ entity, directory }); expect(await publisher.fetchTechDocsMetadata(entityName)).toStrictEqual( techdocsMetadata, @@ -400,7 +483,7 @@ describe('AwsS3Publish', () => { }); it('should return tech docs metadata even if the legacy case is enabled', async () => { - const publisher = createPublisherFromConfig({ + const publisher = await createPublisherFromConfig({ legacyUseCaseSensitiveTripletPaths: true, }); await publisher.publish({ entity, directory }); @@ -410,7 +493,7 @@ describe('AwsS3Publish', () => { }); it('should return tech docs metadata even if root path is specified', async () => { - const publisher = createPublisherFromConfig({ + const publisher = await createPublisherFromConfig({ bucketRootPath: 'backstage-data/techdocs', }); await publisher.publish({ entity, directory }); @@ -420,7 +503,7 @@ describe('AwsS3Publish', () => { }); it('should return tech docs metadata if root path is specified and legacy casing is used', async () => { - const publisher = createPublisherFromConfig({ + const publisher = await createPublisherFromConfig({ bucketRootPath: 'backstage-data/techdocs', legacyUseCaseSensitiveTripletPaths: true, }); @@ -442,7 +525,7 @@ describe('AwsS3Publish', () => { techdocsMetadataContent.replace(/"/g, "'"), ); - const publisher = createPublisherFromConfig(); + const publisher = await createPublisherFromConfig(); await publisher.publish({ entity, directory }); expect(await publisher.fetchTechDocsMetadata(entityName)).toStrictEqual( @@ -453,7 +536,7 @@ describe('AwsS3Publish', () => { }); it('should return an error if the techdocs_metadata.json file is not present', async () => { - const publisher = createPublisherFromConfig(); + const publisher = await createPublisherFromConfig(); const invalidEntityName = { namespace: 'invalid', @@ -477,7 +560,7 @@ describe('AwsS3Publish', () => { }; }); - const publisher = createPublisherFromConfig(); + const publisher = await createPublisherFromConfig(); const invalidEntityName = { namespace: 'invalid', @@ -501,7 +584,7 @@ describe('AwsS3Publish', () => { let app: express.Express; beforeEach(async () => { - const publisher = createPublisherFromConfig(); + const publisher = await createPublisherFromConfig(); await publisher.publish({ entity, directory }); app = express().use(publisher.docsRouter()); }); @@ -521,7 +604,7 @@ describe('AwsS3Publish', () => { }); it('should pass expected object path to bucket even if the legacy case is enabled', async () => { - const publisher = createPublisherFromConfig({ + const publisher = await createPublisherFromConfig({ legacyUseCaseSensitiveTripletPaths: true, }); await publisher.publish({ entity, directory }); @@ -541,7 +624,7 @@ describe('AwsS3Publish', () => { it('should pass expected object path to bucket if root path is specified', async () => { const rootPath = 'backstage-data/techdocs'; - const publisher = createPublisherFromConfig({ + const publisher = await createPublisherFromConfig({ bucketRootPath: rootPath, }); await publisher.publish({ entity, directory }); @@ -561,7 +644,7 @@ describe('AwsS3Publish', () => { it('should pass expected object path to bucket if root path is specified and legacy case is enabled', async () => { const rootPath = 'backstage-data/techdocs'; - const publisher = createPublisherFromConfig({ + const publisher = await createPublisherFromConfig({ bucketRootPath: rootPath, legacyUseCaseSensitiveTripletPaths: true, }); diff --git a/plugins/techdocs-node/src/stages/publish/awsS3.ts b/plugins/techdocs-node/src/stages/publish/awsS3.ts index 7c2405cc1c..c71a25cfe4 100644 --- a/plugins/techdocs-node/src/stages/publish/awsS3.ts +++ b/plugins/techdocs-node/src/stages/publish/awsS3.ts @@ -16,6 +16,10 @@ import { Entity, CompoundEntityRef } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; import { assertError, ForwardedError } from '@backstage/errors'; +import { + AwsCredentialsProvider, + DefaultAwsCredentialsProvider, +} from '@backstage/integration-aws-node'; import { GetObjectCommand, CopyObjectCommand, @@ -27,12 +31,9 @@ import { ListObjectsV2Command, S3Client, } from '@aws-sdk/client-s3'; -import { - fromNodeProviderChain, - fromTemporaryCredentials, -} from '@aws-sdk/credential-providers'; +import { fromTemporaryCredentials } from '@aws-sdk/credential-providers'; import { Upload } from '@aws-sdk/lib-storage'; -import { CredentialProvider } from '@aws-sdk/types'; +import { AwsCredentialIdentityProvider } from '@aws-sdk/types'; import express from 'express'; import fs from 'fs-extra'; import JSON5 from 'json5'; @@ -97,7 +98,10 @@ export class AwsS3Publish implements PublisherBase { this.sse = options.sse; } - static fromConfig(config: Config, logger: Logger): PublisherBase { + static async fromConfig( + config: Config, + logger: Logger, + ): Promise { let bucketName = ''; try { bucketName = config.getString('techdocs.publisher.awsS3.bucketName'); @@ -121,17 +125,21 @@ export class AwsS3Publish implements PublisherBase { // or AWS shared credentials file at ~/.aws/credentials will be used. const region = config.getOptionalString('techdocs.publisher.awsS3.region'); - // Credentials is an optional config. If missing, the default ways of authenticating AWS SDK V2 will be used. - // 1. AWS environment variables - // https://docs.aws.amazon.com/sdk-for-javascript/v2/developer-guide/loading-node-credentials-environment.html - // 2. AWS shared credentials file at ~/.aws/credentials - // https://docs.aws.amazon.com/sdk-for-javascript/v2/developer-guide/loading-node-credentials-shared.html - // 3. IAM Roles for EC2 - // https://docs.aws.amazon.com/sdk-for-javascript/v2/developer-guide/loading-node-credentials-iam.html + // Credentials can optionally be configured by specifying the AWS account ID, which will retrieve credentials + // for the account from the 'aws' section of the app config. + // Credentials can also optionally be directly configured in the techdocs awsS3 config, but this method is + // deprecated. + // If no credentials are configured, the AWS SDK V3's default credential chain will be used. + const accountId = config.getOptionalString( + 'techdocs.publisher.awsS3.accountId', + ); const credentialsConfig = config.getOptionalConfig( 'techdocs.publisher.awsS3.credentials', ); - const credentials = AwsS3Publish.buildCredentials( + const credsProvider = DefaultAwsCredentialsProvider.fromConfig(config); + const credentials = await AwsS3Publish.buildCredentials( + credsProvider, + accountId, credentialsConfig, region, ); @@ -174,7 +182,7 @@ export class AwsS3Publish implements PublisherBase { private static buildStaticCredentials( accessKeyId: string, secretAccessKey: string, - ): CredentialProvider { + ): AwsCredentialIdentityProvider { return async () => { return Promise.resolve({ accessKeyId, @@ -183,20 +191,30 @@ export class AwsS3Publish implements PublisherBase { }; } - private static buildCredentials( + private static async buildCredentials( + credsProvider: AwsCredentialsProvider, + accountId?: string, config?: Config, region?: string, - ): CredentialProvider { - if (!config) { - return fromNodeProviderChain(); + ): Promise { + // Pull credentials for the specified account ID from the 'aws' config section + if (accountId) { + return (await credsProvider.getCredentials({ accountId })).provider; } + // Fall back to the default credential chain if neither account ID + // nor explicit credentials are provided + if (!config) { + return (await credsProvider.getCredentials()).provider; + } + + // Pull credentials from the techdocs config section (deprecated) const accessKeyId = config.getOptionalString('accessKeyId'); const secretAccessKey = config.getOptionalString('secretAccessKey'); - const explicitCredentials: CredentialProvider = + const explicitCredentials: AwsCredentialIdentityProvider = accessKeyId && secretAccessKey ? AwsS3Publish.buildStaticCredentials(accessKeyId, secretAccessKey) - : fromNodeProviderChain(); + : (await credsProvider.getCredentials()).provider; const roleArn = config.getOptionalString('roleArn'); if (roleArn) { diff --git a/plugins/techdocs-node/src/stages/publish/publish.ts b/plugins/techdocs-node/src/stages/publish/publish.ts index bc7f50b5a3..2d9d07eb83 100644 --- a/plugins/techdocs-node/src/stages/publish/publish.ts +++ b/plugins/techdocs-node/src/stages/publish/publish.ts @@ -47,7 +47,7 @@ export class Publisher { return GoogleGCSPublish.fromConfig(config, logger); case 'awsS3': logger.info('Creating AWS S3 Bucket publisher for TechDocs'); - return AwsS3Publish.fromConfig(config, logger); + return await AwsS3Publish.fromConfig(config, logger); case 'azureBlobStorage': logger.info( 'Creating Azure Blob Storage Container publisher for TechDocs', diff --git a/yarn.lock b/yarn.lock index efdb297cbf..4041e30e7b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4135,7 +4135,7 @@ __metadata: languageName: unknown linkType: soft -"@backstage/integration-aws-node@workspace:packages/integration-aws-node": +"@backstage/integration-aws-node@workspace:^, @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: @@ -8142,6 +8142,7 @@ __metadata: "@backstage/config": "workspace:^" "@backstage/errors": "workspace:^" "@backstage/integration": "workspace:^" + "@backstage/integration-aws-node": "workspace:^" "@backstage/plugin-search-common": "workspace:^" "@google-cloud/storage": ^6.0.0 "@trendyol-js/openstack-swift-sdk": ^0.0.5 From f248b75bde5247333e208c37bacd2cfa3c6a55cd Mon Sep 17 00:00:00 2001 From: Clare Liguori Date: Thu, 1 Dec 2022 12:06:32 -0800 Subject: [PATCH 3/5] Rename AwsCredentialsProvider.getCredentials -> AwsCredentialsManager.getCredentialProvider Signed-off-by: Clare Liguori --- packages/integration-aws-node/README.md | 41 +++--- packages/integration-aws-node/api-report.md | 26 ++-- ...s => DefaultAwsCredentialsManager.test.ts} | 138 +++++++++--------- ...der.ts => DefaultAwsCredentialsManager.ts} | 110 +++++++------- packages/integration-aws-node/src/index.ts | 8 +- packages/integration-aws-node/src/types.ts | 21 ++- .../src/stages/publish/awsS3.test.ts | 30 ++-- .../techdocs-node/src/stages/publish/awsS3.ts | 21 +-- 8 files changed, 211 insertions(+), 184 deletions(-) rename packages/integration-aws-node/src/{DefaultAwsCredentialsProvider.test.ts => DefaultAwsCredentialsManager.test.ts} (66%) rename packages/integration-aws-node/src/{DefaultAwsCredentialsProvider.ts => DefaultAwsCredentialsManager.ts} (66%) diff --git a/packages/integration-aws-node/README.md b/packages/integration-aws-node/README.md index 6132f4240a..62df26756f 100644 --- a/packages/integration-aws-node/README.md +++ b/packages/integration-aws-node/README.md @@ -11,7 +11,7 @@ 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 +If the AWS integration configuration is missing, the credentials manager 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 @@ -79,32 +79,35 @@ aws: ## 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 +retrieve a credential 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); +const awsCredentialsManager = DefaultAwsCredentialsManager.fromConfig(config); // provide the account ID explicitly -const creds = await awsCredentialsProvider.getCredentials({ accountId }); +const credProvider = await awsCredentialsManager.getCredentialProvider({ + accountId, +}); // OR extract the account ID from the ARN -const creds = await awsCredentialsProvider.getCredentials({ arn }); +const credProvider = await awsCredentialsManager.getCredentialProvider({ arn }); // OR provide neither to get main account's credentials -const creds = await awsCredentialsProvider.getCredentials({}); +const credProvider = await awsCredentialsManager.getCredentialProvider({}); -// Example constructing an AWS Proton client with the returned credentials provider +// Example constructing an AWS Proton client with the returned credential provider const client = new ProtonClient({ region, - credentialDefaultProvider: () => creds.provider, + credentialDefaultProvider: () => credProvider.sdkCredentialProvider, }); ``` -Depending on the nature of your plguin, you may either have the user specify the +Depending on the nature of your plugin, 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: +For example, you can create a new catalog entity annotation for your plugin containing +either an AWS account ID or ARN: ```yaml apiVersion: backstage.io/v1alpha1 @@ -117,7 +120,7 @@ metadata: 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: +In your plugin, read the annotation value so that you can retrieve the credential provider: ```typescript const MY_AWS_ACCOUNT_ID_ANNOTATION = 'my-plugin.io/aws-account-id'; @@ -126,7 +129,7 @@ const getAwsAccountId = (entity: Entity) => entity.metadata.annotations?.[MY_AWS_ACCOUNT_ID_ANNOTATION]); ``` -Alternatively, you can create a new configuration field for your plugin: +Alternatively, you can create a new Backstage app configuration field for your plugin: ```yaml # app-config.yaml @@ -138,18 +141,20 @@ my-other-plugin: 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: +In your plugin, read the configuration value so that you can retrieve the credential 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 }); +const awsCredentialsManager = DefaultAwsCredentialsManager.fromConfig(config); +const accountId = config.getOptionalString('my-plugin.awsAccountId'); +const credProvider = await awsCredentialsManager.getCredentialProvider({ + accountId, +}); // Or, read an AWS ARN from your plugin's configuration -const awsCredentialsProvider = DefaultAwsCredentialsProvider.fromConfig(config); +const awsCredentialsManager = DefaultAwsCredentialsManager.fromConfig(config); const arn = config.getString('my-other-plugin.awsDynamoDbTable'); -const creds = await awsCredentialsProvider.getCredentials({ arn }); +const credProvider = await awsCredentialsManager.getCredentialProvider({ arn }); ``` ## Links diff --git a/packages/integration-aws-node/api-report.md b/packages/integration-aws-node/api-report.md index bac43c67d4..58bdde4090 100644 --- a/packages/integration-aws-node/api-report.md +++ b/packages/integration-aws-node/api-report.md @@ -7,23 +7,25 @@ import { AwsCredentialIdentityProvider } from '@aws-sdk/types'; import { Config } from '@backstage/config'; // @public -export type AwsCredentials = { +export type AwsCredentialProvider = { accountId?: string; stsRegion?: string; - provider: AwsCredentialIdentityProvider; + sdkCredentialProvider: AwsCredentialIdentityProvider; }; // @public -export interface AwsCredentialsProvider { - getCredentials(opts?: AwsCredentialsProviderOptions): Promise; -} - -// @public -export type AwsCredentialsProviderOptions = { +export type AwsCredentialProviderOptions = { accountId?: string; arn?: string; }; +// @public +export interface AwsCredentialsManager { + getCredentialProvider( + opts?: AwsCredentialProviderOptions, + ): Promise; +} + // @public export type AwsIntegrationAccountConfig = { accountId: string; @@ -60,10 +62,12 @@ export type AwsIntegrationMainAccountConfig = { }; // @public -export class DefaultAwsCredentialsProvider implements AwsCredentialsProvider { +export class DefaultAwsCredentialsManager implements AwsCredentialsManager { // (undocumented) - static fromConfig(config: Config): DefaultAwsCredentialsProvider; - getCredentials(opts?: AwsCredentialsProviderOptions): Promise; + static fromConfig(config: Config): DefaultAwsCredentialsManager; + getCredentialProvider( + opts?: AwsCredentialProviderOptions, + ): Promise; } // @public diff --git a/packages/integration-aws-node/src/DefaultAwsCredentialsProvider.test.ts b/packages/integration-aws-node/src/DefaultAwsCredentialsManager.test.ts similarity index 66% rename from packages/integration-aws-node/src/DefaultAwsCredentialsProvider.test.ts rename to packages/integration-aws-node/src/DefaultAwsCredentialsManager.test.ts index 60f2f558b8..1b21d43338 100644 --- a/packages/integration-aws-node/src/DefaultAwsCredentialsProvider.test.ts +++ b/packages/integration-aws-node/src/DefaultAwsCredentialsManager.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { DefaultAwsCredentialsProvider } from './DefaultAwsCredentialsProvider'; +import { DefaultAwsCredentialsManager } from './DefaultAwsCredentialsManager'; import { mockClient, AwsClientStub } from 'aws-sdk-client-mock'; import 'aws-sdk-client-mock-jest'; import { @@ -31,7 +31,7 @@ let config: Config; jest.mock('fs', () => ({ promises: { readFile: jest.fn() } })); -describe('DefaultAwsCredentialsProvider', () => { +describe('DefaultAwsCredentialsManager', () => { beforeEach(() => { process.env = { ...env }; jest.resetAllMocks(); @@ -145,16 +145,16 @@ describe('DefaultAwsCredentialsProvider', () => { process.env = env; }); - describe('#getCredentials', () => { + describe('#getCredentialProvider', () => { 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({ + const provider = DefaultAwsCredentialsManager.fromConfig(config); + const awsCredentialProvider = await provider.getCredentialProvider({ accountId: '111111111111', }); - expect(awsCredentials.accountId).toEqual('111111111111'); + expect(awsCredentialProvider.accountId).toEqual('111111111111'); - const creds = await awsCredentials.provider(); + const creds = await awsCredentialProvider.sdkCredentialProvider(); expect(creds).toEqual({ accessKeyId: 'ACCESS_KEY_ID_1', secretAccessKey: 'SECRET_ACCESS_KEY_1', @@ -162,23 +162,23 @@ describe('DefaultAwsCredentialsProvider', () => { expiration: new Date('2022-01-01'), }); - const awsCredentials2 = await provider.getCredentials({ + const awsCredentialProvider2 = await provider.getCredentialProvider({ accountId: '111111111111', }); - expect(awsCredentials).toBe(awsCredentials2); + expect(awsCredentialProvider).toBe(awsCredentialProvider2); 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({ + const provider = DefaultAwsCredentialsManager.fromConfig(config); + const awsCredentialProvider = await provider.getCredentialProvider({ accountId: '222222222222', }); - expect(awsCredentials.accountId).toEqual('222222222222'); + expect(awsCredentialProvider.accountId).toEqual('222222222222'); - const creds = await awsCredentials.provider(); + const creds = await awsCredentialProvider.sdkCredentialProvider(); expect(creds).toEqual({ accessKeyId: 'ACCESS_KEY_ID_2', secretAccessKey: 'SECRET_ACCESS_KEY_2', @@ -188,14 +188,14 @@ describe('DefaultAwsCredentialsProvider', () => { }); it('retrieves assume-role creds for an account using the account defaults', async () => { - const provider = DefaultAwsCredentialsProvider.fromConfig(config); - const awsCredentials = await provider.getCredentials({ + const provider = DefaultAwsCredentialsManager.fromConfig(config); + const awsCredentialProvider = await provider.getCredentialProvider({ accountId: '999999999999', }); - expect(awsCredentials.accountId).toEqual('999999999999'); + expect(awsCredentialProvider.accountId).toEqual('999999999999'); - const creds = await awsCredentials.provider(); + const creds = await awsCredentialProvider.sdkCredentialProvider(); expect(creds).toEqual({ accessKeyId: 'ACCESS_KEY_ID_9', secretAccessKey: 'SECRET_ACCESS_KEY_9', @@ -205,14 +205,14 @@ describe('DefaultAwsCredentialsProvider', () => { }); it('retrieves static creds for the given account ID', async () => { - const provider = DefaultAwsCredentialsProvider.fromConfig(config); - const awsCredentials = await provider.getCredentials({ + const provider = DefaultAwsCredentialsManager.fromConfig(config); + const awsCredentialProvider = await provider.getCredentialProvider({ accountId: '333333333333', }); - expect(awsCredentials.accountId).toEqual('333333333333'); + expect(awsCredentialProvider.accountId).toEqual('333333333333'); - const creds = await awsCredentials.provider(); + const creds = await awsCredentialProvider.sdkCredentialProvider(); expect(creds).toEqual({ accessKeyId: 'my-access-key', secretAccessKey: 'my-secret-access-key', @@ -228,14 +228,14 @@ describe('DefaultAwsCredentialsProvider', () => { }, }, }); - const provider = DefaultAwsCredentialsProvider.fromConfig(minConfig); - const awsCredentials = await provider.getCredentials({ + const provider = DefaultAwsCredentialsManager.fromConfig(minConfig); + const awsCredentialProvider = await provider.getCredentialProvider({ accountId: '123456789012', }); - expect(awsCredentials.accountId).toEqual('123456789012'); + expect(awsCredentialProvider.accountId).toEqual('123456789012'); - const creds = await awsCredentials.provider(); + const creds = await awsCredentialProvider.sdkCredentialProvider(); expect(creds).toEqual({ accessKeyId: 'GHI', secretAccessKey: 'JKL', @@ -251,23 +251,23 @@ describe('DefaultAwsCredentialsProvider', () => { }, }, }); - const provider = DefaultAwsCredentialsProvider.fromConfig(minConfig); - const awsCredentials1 = await provider.getCredentials({}); - const awsCredentials2 = await provider.getCredentials({}); + const provider = DefaultAwsCredentialsManager.fromConfig(minConfig); + const awsCredentialProvider1 = await provider.getCredentialProvider({}); + const awsCredentialProvider2 = await provider.getCredentialProvider({}); - expect(awsCredentials1).toBe(awsCredentials2); + expect(awsCredentialProvider1).toBe(awsCredentialProvider2); 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({ + const provider = DefaultAwsCredentialsManager.fromConfig(config); + const awsCredentialProvider = await provider.getCredentialProvider({ accountId: '555555555555', }); - expect(awsCredentials.accountId).toEqual('555555555555'); + expect(awsCredentialProvider.accountId).toEqual('555555555555'); - const creds = await awsCredentials.provider(); + const creds = await awsCredentialProvider.sdkCredentialProvider(); expect(creds).toEqual({ accessKeyId: 'ACCESS_KEY_ID_9', secretAccessKey: 'SECRET_ACCESS_KEY_9', @@ -275,14 +275,14 @@ describe('DefaultAwsCredentialsProvider', () => { }); it('retrieves the default cred provider chain for the given account ID', async () => { - const provider = DefaultAwsCredentialsProvider.fromConfig(config); - const awsCredentials = await provider.getCredentials({ + const provider = DefaultAwsCredentialsManager.fromConfig(config); + const awsCredentialProvider = await provider.getCredentialProvider({ accountId: '444444444444', }); - expect(awsCredentials.accountId).toEqual('444444444444'); + expect(awsCredentialProvider.accountId).toEqual('444444444444'); - const creds = await awsCredentials.provider(); + const creds = await awsCredentialProvider.sdkCredentialProvider(); expect(creds).toEqual({ accessKeyId: 'ACCESS_KEY_ID_10', secretAccessKey: 'SECRET_ACCESS_KEY_10', @@ -299,14 +299,14 @@ describe('DefaultAwsCredentialsProvider', () => { }, }, }); - const provider = DefaultAwsCredentialsProvider.fromConfig(minConfig); - const awsCredentials = await provider.getCredentials({ + const provider = DefaultAwsCredentialsManager.fromConfig(minConfig); + const awsCredentialProvider = await provider.getCredentialProvider({ accountId: '123456789012', }); - expect(awsCredentials.accountId).toEqual('123456789012'); + expect(awsCredentialProvider.accountId).toEqual('123456789012'); - const creds = await awsCredentials.provider(); + const creds = await awsCredentialProvider.sdkCredentialProvider(); expect(creds).toEqual({ accessKeyId: 'ACCESS_KEY_ID_9', secretAccessKey: 'SECRET_ACCESS_KEY_9', @@ -317,14 +317,14 @@ describe('DefaultAwsCredentialsProvider', () => { const minConfig = new ConfigReader({ aws: {}, }); - const provider = DefaultAwsCredentialsProvider.fromConfig(minConfig); - const awsCredentials = await provider.getCredentials({ + const provider = DefaultAwsCredentialsManager.fromConfig(minConfig); + const awsCredentialProvider = await provider.getCredentialProvider({ accountId: '123456789012', }); - expect(awsCredentials.accountId).toEqual('123456789012'); + expect(awsCredentialProvider.accountId).toEqual('123456789012'); - const creds = await awsCredentials.provider(); + const creds = await awsCredentialProvider.sdkCredentialProvider(); expect(creds).toEqual({ accessKeyId: 'ACCESS_KEY_ID_10', secretAccessKey: 'SECRET_ACCESS_KEY_10', @@ -335,14 +335,14 @@ describe('DefaultAwsCredentialsProvider', () => { 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({ + const provider = DefaultAwsCredentialsManager.fromConfig(minConfig); + const awsCredentialProvider = await provider.getCredentialProvider({ accountId: '123456789012', }); - expect(awsCredentials.accountId).toEqual('123456789012'); + expect(awsCredentialProvider.accountId).toEqual('123456789012'); - const creds = await awsCredentials.provider(); + const creds = await awsCredentialProvider.sdkCredentialProvider(); expect(creds).toEqual({ accessKeyId: 'ACCESS_KEY_ID_10', secretAccessKey: 'SECRET_ACCESS_KEY_10', @@ -352,14 +352,14 @@ describe('DefaultAwsCredentialsProvider', () => { }); it('extracts the account ID from an ARN', async () => { - const provider = DefaultAwsCredentialsProvider.fromConfig(config); - const awsCredentials = await provider.getCredentials({ + const provider = DefaultAwsCredentialsManager.fromConfig(config); + const awsCredentialProvider = await provider.getCredentialProvider({ arn: 'arn:aws:ecs:region:111111111111:service/cluster-name/service-name', }); - expect(awsCredentials.accountId).toEqual('111111111111'); + expect(awsCredentialProvider.accountId).toEqual('111111111111'); - const creds = await awsCredentials.provider(); + const creds = await awsCredentialProvider.sdkCredentialProvider(); expect(creds).toEqual({ accessKeyId: 'ACCESS_KEY_ID_1', secretAccessKey: 'SECRET_ACCESS_KEY_1', @@ -369,14 +369,14 @@ describe('DefaultAwsCredentialsProvider', () => { }); 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({ + const provider = DefaultAwsCredentialsManager.fromConfig(config); + const awsCredentialProvider = await provider.getCredentialProvider({ arn: 'arn:aws:s3:::bucket_name', }); - expect(awsCredentials.accountId).toEqual('123456789012'); + expect(awsCredentialProvider.accountId).toEqual('123456789012'); - const creds = await awsCredentials.provider(); + const creds = await awsCredentialProvider.sdkCredentialProvider(); expect(creds).toEqual({ accessKeyId: 'GHI', secretAccessKey: 'JKL', @@ -384,12 +384,12 @@ describe('DefaultAwsCredentialsProvider', () => { }); 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({}); + const provider = DefaultAwsCredentialsManager.fromConfig(config); + const awsCredentialProvider = await provider.getCredentialProvider({}); - expect(awsCredentials.accountId).toEqual('123456789012'); + expect(awsCredentialProvider.accountId).toEqual('123456789012'); - const creds = await awsCredentials.provider(); + const creds = await awsCredentialProvider.sdkCredentialProvider(); expect(creds).toEqual({ accessKeyId: 'GHI', secretAccessKey: 'JKL', @@ -397,12 +397,12 @@ describe('DefaultAwsCredentialsProvider', () => { }); it('falls back to main account credentials when no options are provided', async () => { - const provider = DefaultAwsCredentialsProvider.fromConfig(config); - const awsCredentials = await provider.getCredentials(); + const provider = DefaultAwsCredentialsManager.fromConfig(config); + const awsCredentialProvider = await provider.getCredentialProvider(); - expect(awsCredentials.accountId).toEqual('123456789012'); + expect(awsCredentialProvider.accountId).toEqual('123456789012'); - const creds = await awsCredentials.provider(); + const creds = await awsCredentialProvider.sdkCredentialProvider(); expect(creds).toEqual({ accessKeyId: 'GHI', secretAccessKey: 'JKL', @@ -413,16 +413,16 @@ describe('DefaultAwsCredentialsProvider', () => { const minConfig = new ConfigReader({ aws: {}, }); - const provider = DefaultAwsCredentialsProvider.fromConfig(minConfig); + const provider = DefaultAwsCredentialsManager.fromConfig(minConfig); await expect( - provider.getCredentials({ accountId: '111222333444' }), + provider.getCredentialProvider({ 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( + const provider = DefaultAwsCredentialsManager.fromConfig(config); + await expect(provider.getCredentialProvider({})).rejects.toThrow( /No credentials found/, ); }); diff --git a/packages/integration-aws-node/src/DefaultAwsCredentialsProvider.ts b/packages/integration-aws-node/src/DefaultAwsCredentialsManager.ts similarity index 66% rename from packages/integration-aws-node/src/DefaultAwsCredentialsProvider.ts rename to packages/integration-aws-node/src/DefaultAwsCredentialsManager.ts index 8b26b5fe84..7799f08b4d 100644 --- a/packages/integration-aws-node/src/DefaultAwsCredentialsProvider.ts +++ b/packages/integration-aws-node/src/DefaultAwsCredentialsManager.ts @@ -21,9 +21,9 @@ import { AwsIntegrationMainAccountConfig, } from './config'; import { - AwsCredentials, - AwsCredentialsProvider, - AwsCredentialsProviderOptions, + AwsCredentialsManager, + AwsCredentialProvider, + AwsCredentialProviderOptions, } from './types'; import { GetCallerIdentityCommand, STSClient } from '@aws-sdk/client-sts'; import { @@ -36,20 +36,20 @@ import { parse } from '@aws-sdk/util-arn-parser'; import { Config } from '@backstage/config'; /** - * Retrieves the account ID for the given credentials provider from STS. + * Retrieves the account ID for the given credential provider from STS. */ -async function fillInAccountId(creds: AwsCredentials) { - if (creds.accountId) { +async function fillInAccountId(credProvider: AwsCredentialProvider) { + if (credProvider.accountId) { return; } const client = new STSClient({ - region: creds.stsRegion, - customUserAgent: 'backstage-aws-credentials-provider', - credentialDefaultProvider: () => creds.provider, + region: credProvider.stsRegion, + customUserAgent: 'backstage-aws-credentials-manager', + credentialDefaultProvider: () => credProvider.sdkCredentialProvider, }); const resp = await client.send(new GetCallerIdentityCommand({})); - creds.accountId = resp.Account!; + credProvider.accountId = resp.Account!; } function getStaticCredentials( @@ -72,7 +72,7 @@ function getProfileCredentials( profile, clientConfig: { region, - customUserAgent: 'backstage-aws-credentials-provider', + customUserAgent: 'backstage-aws-credentials-manager', }, }); } @@ -91,9 +91,9 @@ function getDefaultCredentialsChain(): AwsCredentialIdentityProvider { * 4. Profile creds * 5. Default AWS SDK creds chain */ -function getAccountCredentialsProvider( +function getSdkCredentialProvider( config: AwsIntegrationAccountConfig, - mainAccountCreds: AwsCredentialIdentityProvider, + mainAccountCredProvider: AwsCredentialIdentityProvider, ): AwsCredentialIdentityProvider { if (config.roleName) { const region = config.region ?? 'us-east-1'; @@ -102,7 +102,7 @@ function getAccountCredentialsProvider( return fromTemporaryCredentials({ masterCredentials: config.accessKeyId ? getStaticCredentials(config.accessKeyId!, config.secretAccessKey!) - : mainAccountCreds, + : mainAccountCredProvider, params: { RoleArn: `arn:${partition}:iam::${config.accountId}:role/${config.roleName}`, RoleSessionName: 'backstage', @@ -110,7 +110,7 @@ function getAccountCredentialsProvider( }, clientConfig: { region, - customUserAgent: 'backstage-aws-credentials-provider', + customUserAgent: 'backstage-aws-credentials-manager', }, }); } @@ -134,7 +134,7 @@ function getAccountCredentialsProvider( * 2. Profile creds * 3. Default AWS SDK creds chain */ -function getMainAccountCredentialsProvider( +function getMainAccountSdkCredentialProvider( config: AwsIntegrationMainAccountConfig, ): AwsCredentialIdentityProvider { if (config.accessKeyId) { @@ -153,8 +153,8 @@ function getMainAccountCredentialsProvider( * * @public */ -export class DefaultAwsCredentialsProvider implements AwsCredentialsProvider { - static fromConfig(config: Config): DefaultAwsCredentialsProvider { +export class DefaultAwsCredentialsManager implements AwsCredentialsManager { + static fromConfig(config: Config): DefaultAwsCredentialsManager { const awsConfig = config.has('aws') ? readAwsIntegrationConfig(config.getConfig('aws')) : { @@ -163,63 +163,66 @@ export class DefaultAwsCredentialsProvider implements AwsCredentialsProvider { accountDefaults: {}, }; - const mainAccountProvider = getMainAccountCredentialsProvider( + const mainAccountSdkCredProvider = getMainAccountSdkCredentialProvider( awsConfig.mainAccount, ); - const mainAccountCreds: AwsCredentials = { - provider: mainAccountProvider, + const mainAccountCredProvider: AwsCredentialProvider = { + sdkCredentialProvider: mainAccountSdkCredProvider, }; - const accountCreds = new Map(); + const accountCredProviders = new Map(); for (const accountConfig of awsConfig.accounts) { - const provider = getAccountCredentialsProvider( + const sdkCredentialProvider = getSdkCredentialProvider( accountConfig, - mainAccountCreds.provider, + mainAccountSdkCredProvider, ); - accountCreds.set(accountConfig.accountId, { + accountCredProviders.set(accountConfig.accountId, { accountId: accountConfig.accountId, stsRegion: accountConfig.region, - provider, + sdkCredentialProvider, }); } - return new DefaultAwsCredentialsProvider( - accountCreds, + return new DefaultAwsCredentialsManager( + accountCredProviders, awsConfig.accountDefaults, - mainAccountCreds, + mainAccountCredProvider, ); } private constructor( - private readonly accountCredentials: Map, + private readonly accountCredentialProviders: Map< + string, + AwsCredentialProvider + >, private readonly accountDefaults: AwsIntegrationDefaultAccountConfig, - private readonly mainAccountCredentials: AwsCredentials, + private readonly mainAccountCredentialProvider: AwsCredentialProvider, ) {} /** - * Returns {@link AwsCredentials} for a given AWS account. + * Returns an {@link AwsCredentialProvider} for a given AWS account. * * @example * ```ts - * const { provider } = await getCredentials({ + * const { provider } = await getCredentialProvider({ * accountId: '0123456789012', * }) * - * const { provider } = await getCredentials({ + * const { provider } = await getCredentialProvider({ * 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}. + * @returns A promise of {@link AwsCredentialProvider}. */ - async getCredentials( - opts?: AwsCredentialsProviderOptions, - ): Promise { + async getCredentialProvider( + opts?: AwsCredentialProviderOptions, + ): Promise { // If no options provided, fall back to the main account if (!opts) { - await fillInAccountId(this.mainAccountCredentials); - return this.mainAccountCredentials; + await fillInAccountId(this.mainAccountCredentialProvider); + return this.mainAccountCredentialProvider; } // Determine the account ID: either explicitly provided or extracted from the provided ARN @@ -232,13 +235,13 @@ export class DefaultAwsCredentialsProvider implements AwsCredentialsProvider { // 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; + await fillInAccountId(this.mainAccountCredentialProvider); + return this.mainAccountCredentialProvider; } // Return a cached provider if available - if (this.accountCredentials.has(accountId)) { - return this.accountCredentials.get(accountId)!; + if (this.accountCredentialProviders.has(accountId)) { + return this.accountCredentialProviders.get(accountId)!; } // First, fall back to using the account defaults @@ -250,20 +253,23 @@ export class DefaultAwsCredentialsProvider implements AwsCredentialsProvider { region: this.accountDefaults.region, externalId: this.accountDefaults.externalId, }; - const provider = getAccountCredentialsProvider( + const sdkCredentialProvider = getSdkCredentialProvider( config, - this.mainAccountCredentials.provider, + this.mainAccountCredentialProvider.sdkCredentialProvider, ); - const creds: AwsCredentials = { accountId, provider }; - this.accountCredentials.set(accountId, creds); - return creds; + const credProvider: AwsCredentialProvider = { + accountId, + sdkCredentialProvider, + }; + this.accountCredentialProviders.set(accountId, credProvider); + return credProvider; } // 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; + await fillInAccountId(this.mainAccountCredentialProvider); + if (accountId === this.mainAccountCredentialProvider.accountId) { + return this.mainAccountCredentialProvider; } // Otherwise, the account needs to be explicitly configured in Backstage diff --git a/packages/integration-aws-node/src/index.ts b/packages/integration-aws-node/src/index.ts index d0a86acb47..0b6dc630db 100644 --- a/packages/integration-aws-node/src/index.ts +++ b/packages/integration-aws-node/src/index.ts @@ -21,9 +21,9 @@ export type { AwsIntegrationDefaultAccountConfig, AwsIntegrationMainAccountConfig, } from './config'; -export { DefaultAwsCredentialsProvider } from './DefaultAwsCredentialsProvider'; +export { DefaultAwsCredentialsManager } from './DefaultAwsCredentialsManager'; export type { - AwsCredentials, - AwsCredentialsProvider, - AwsCredentialsProviderOptions, + AwsCredentialsManager, + AwsCredentialProvider, + AwsCredentialProviderOptions, } from './types'; diff --git a/packages/integration-aws-node/src/types.ts b/packages/integration-aws-node/src/types.ts index 44f7850cf8..3ff069b20b 100644 --- a/packages/integration-aws-node/src/types.ts +++ b/packages/integration-aws-node/src/types.ts @@ -20,10 +20,19 @@ import { AwsCredentialIdentityProvider } from '@aws-sdk/types'; * * @public */ -export type AwsCredentials = { +export type AwsCredentialProvider = { + /** + * The AWS account ID of these credentials + */ accountId?: string; + /** + * The STS region used with these credentials + */ stsRegion?: string; - provider: AwsCredentialIdentityProvider; + /** + * The credential identity provider to use when creating AWS SDK for Javascript V3 clients + */ + sdkCredentialProvider: AwsCredentialIdentityProvider; }; /** @@ -31,7 +40,7 @@ export type AwsCredentials = { * * @public */ -export type AwsCredentialsProviderOptions = { +export type AwsCredentialProviderOptions = { /** * The AWS account ID, e.g. '0123456789012' */ @@ -49,9 +58,11 @@ export type AwsCredentialsProviderOptions = { * * @public */ -export interface AwsCredentialsProvider { +export interface AwsCredentialsManager { /** * Get credentials for an AWS account. */ - getCredentials(opts?: AwsCredentialsProviderOptions): Promise; + getCredentialProvider( + opts?: AwsCredentialProviderOptions, + ): Promise; } diff --git a/plugins/techdocs-node/src/stages/publish/awsS3.test.ts b/plugins/techdocs-node/src/stages/publish/awsS3.test.ts index ff61ac58a2..f3b2162a6b 100644 --- a/plugins/techdocs-node/src/stages/publish/awsS3.test.ts +++ b/plugins/techdocs-node/src/stages/publish/awsS3.test.ts @@ -28,9 +28,9 @@ import { getVoidLogger } from '@backstage/backend-common'; import { Entity, DEFAULT_NAMESPACE } from '@backstage/catalog-model'; import { ConfigReader } from '@backstage/config'; import { - AwsCredentials, - AwsCredentialsProviderOptions, - DefaultAwsCredentialsProvider, + AwsCredentialProvider, + AwsCredentialProviderOptions, + DefaultAwsCredentialsManager, } from '@backstage/integration-aws-node'; import { mockClient, AwsClientStub } from 'aws-sdk-client-mock'; import express from 'express'; @@ -45,9 +45,9 @@ import { Readable } from 'stream'; const env = process.env; let s3Mock: AwsClientStub; -function getMockCredentials(): Promise { +function getMockCredentialProvider(): Promise { return Promise.resolve({ - provider: async () => { + sdkCredentialProvider: async () => { return Promise.resolve({ accessKeyId: 'MY_ACCESS_KEY_ID', secretAccessKey: 'MY_SECRET_ACCESS_KEY', @@ -55,9 +55,9 @@ function getMockCredentials(): Promise { }, }); } -const credsProviderMock = jest.spyOn( - DefaultAwsCredentialsProvider.prototype, - 'getCredentials', +const getCredProviderMock = jest.spyOn( + DefaultAwsCredentialsManager.prototype, + 'getCredentialProvider', ); const getEntityRootDir = (entity: Entity) => { @@ -178,8 +178,8 @@ describe('AwsS3Publish', () => { process.env.AWS_REGION = 'us-west-2'; jest.resetAllMocks(); - credsProviderMock.mockImplementation((_?: AwsCredentialsProviderOptions) => - getMockCredentials(), + getCredProviderMock.mockImplementation((_?: AwsCredentialProviderOptions) => + getMockCredentialProvider(), ); mockFs({ @@ -249,10 +249,10 @@ describe('AwsS3Publish', () => { describe('buildCredentials', () => { it('should retrieve credentials for a specific account ID', async () => { await createPublisherFromConfig(); - expect(credsProviderMock).toHaveBeenCalledWith({ + expect(getCredProviderMock).toHaveBeenCalledWith({ accountId: '111111111111', }); - expect(credsProviderMock).toHaveBeenCalledTimes(1); + expect(getCredProviderMock).toHaveBeenCalledTimes(1); }); it('should retrieve default credentials when no config is present', async () => { @@ -268,8 +268,8 @@ describe('AwsS3Publish', () => { }); await AwsS3Publish.fromConfig(mockConfig, logger); - expect(credsProviderMock).toHaveBeenCalledWith(); - expect(credsProviderMock).toHaveBeenCalledTimes(1); + expect(getCredProviderMock).toHaveBeenCalledWith(); + expect(getCredProviderMock).toHaveBeenCalledTimes(1); }); it('should fall back to deprecated method of retrieving credentials', async () => { @@ -290,7 +290,7 @@ describe('AwsS3Publish', () => { }); await AwsS3Publish.fromConfig(mockConfig, logger); - expect(credsProviderMock).toHaveBeenCalledTimes(0); + expect(getCredProviderMock).toHaveBeenCalledTimes(0); }); }); diff --git a/plugins/techdocs-node/src/stages/publish/awsS3.ts b/plugins/techdocs-node/src/stages/publish/awsS3.ts index c71a25cfe4..a13baef926 100644 --- a/plugins/techdocs-node/src/stages/publish/awsS3.ts +++ b/plugins/techdocs-node/src/stages/publish/awsS3.ts @@ -17,8 +17,8 @@ import { Entity, CompoundEntityRef } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; import { assertError, ForwardedError } from '@backstage/errors'; import { - AwsCredentialsProvider, - DefaultAwsCredentialsProvider, + AwsCredentialsManager, + DefaultAwsCredentialsManager, } from '@backstage/integration-aws-node'; import { GetObjectCommand, @@ -136,9 +136,9 @@ export class AwsS3Publish implements PublisherBase { const credentialsConfig = config.getOptionalConfig( 'techdocs.publisher.awsS3.credentials', ); - const credsProvider = DefaultAwsCredentialsProvider.fromConfig(config); - const credentials = await AwsS3Publish.buildCredentials( - credsProvider, + const credsManager = DefaultAwsCredentialsManager.fromConfig(config); + const sdkCredentialProvider = await AwsS3Publish.buildCredentials( + credsManager, accountId, credentialsConfig, region, @@ -158,7 +158,7 @@ export class AwsS3Publish implements PublisherBase { const storageClient = new S3Client({ customUserAgent: 'backstage-aws-techdocs-s3-publisher', - credentialDefaultProvider: () => credentials, + credentialDefaultProvider: () => sdkCredentialProvider, ...(region && { region }), ...(endpoint && { endpoint }), ...(s3ForcePathStyle && { s3ForcePathStyle }), @@ -192,20 +192,21 @@ export class AwsS3Publish implements PublisherBase { } private static async buildCredentials( - credsProvider: AwsCredentialsProvider, + credsManager: AwsCredentialsManager, accountId?: string, config?: Config, region?: string, ): Promise { // Pull credentials for the specified account ID from the 'aws' config section if (accountId) { - return (await credsProvider.getCredentials({ accountId })).provider; + return (await credsManager.getCredentialProvider({ accountId })) + .sdkCredentialProvider; } // Fall back to the default credential chain if neither account ID // nor explicit credentials are provided if (!config) { - return (await credsProvider.getCredentials()).provider; + return (await credsManager.getCredentialProvider()).sdkCredentialProvider; } // Pull credentials from the techdocs config section (deprecated) @@ -214,7 +215,7 @@ export class AwsS3Publish implements PublisherBase { const explicitCredentials: AwsCredentialIdentityProvider = accessKeyId && secretAccessKey ? AwsS3Publish.buildStaticCredentials(accessKeyId, secretAccessKey) - : (await credsProvider.getCredentials()).provider; + : (await credsManager.getCredentialProvider()).sdkCredentialProvider; const roleArn = config.getOptionalString('roleArn'); if (roleArn) { From cf992210ec5775faddd0d8d028f8c6814f1cba8b Mon Sep 17 00:00:00 2001 From: Clare Liguori Date: Tue, 6 Dec 2022 10:50:00 -0800 Subject: [PATCH 4/5] Remove config types from export Signed-off-by: Clare Liguori --- packages/integration-aws-node/api-report.md | 38 --------------------- packages/integration-aws-node/src/index.ts | 7 ---- 2 files changed, 45 deletions(-) diff --git a/packages/integration-aws-node/api-report.md b/packages/integration-aws-node/api-report.md index 58bdde4090..d9ed25e193 100644 --- a/packages/integration-aws-node/api-report.md +++ b/packages/integration-aws-node/api-report.md @@ -26,41 +26,6 @@ export interface AwsCredentialsManager { ): Promise; } -// @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 DefaultAwsCredentialsManager implements AwsCredentialsManager { // (undocumented) @@ -70,8 +35,5 @@ export class DefaultAwsCredentialsManager implements AwsCredentialsManager { ): Promise; } -// @public -export function readAwsIntegrationConfig(config: Config): AwsIntegrationConfig; - // (No @packageDocumentation comment for this package) ``` diff --git a/packages/integration-aws-node/src/index.ts b/packages/integration-aws-node/src/index.ts index 0b6dc630db..30f3e21281 100644 --- a/packages/integration-aws-node/src/index.ts +++ b/packages/integration-aws-node/src/index.ts @@ -14,13 +14,6 @@ * limitations under the License. */ -export { readAwsIntegrationConfig } from './config'; -export type { - AwsIntegrationConfig, - AwsIntegrationAccountConfig, - AwsIntegrationDefaultAccountConfig, - AwsIntegrationMainAccountConfig, -} from './config'; export { DefaultAwsCredentialsManager } from './DefaultAwsCredentialsManager'; export type { AwsCredentialsManager, From dfbdae092eb7c069fb3a4211c2b43a3c8a258e67 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Thu, 8 Dec 2022 14:10:13 +0100 Subject: [PATCH 5/5] add techdocs-backend changeset Signed-off-by: Johan Haals --- .changeset/smooth-bulldogs-fix.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/smooth-bulldogs-fix.md diff --git a/.changeset/smooth-bulldogs-fix.md b/.changeset/smooth-bulldogs-fix.md new file mode 100644 index 0000000000..bbbb02aa13 --- /dev/null +++ b/.changeset/smooth-bulldogs-fix.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-techdocs-backend': minor +--- + +Added a new optional `accountId` to the configuration options of the AWS S3 publisher. Configuring this option will source credentials for the `accountId` in the `aws` app config section. See https://github.com/backstage/backstage/blob/master/packages/integration-aws-node/README.md for more details.