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/.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/.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. 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/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..62df26756f --- /dev/null +++ b/packages/integration-aws-node/README.md @@ -0,0 +1,162 @@ +# @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 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 +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 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 awsCredentialsManager = DefaultAwsCredentialsManager.fromConfig(config); + +// provide the account ID explicitly +const credProvider = await awsCredentialsManager.getCredentialProvider({ + accountId, +}); +// OR extract the account ID from the ARN +const credProvider = await awsCredentialsManager.getCredentialProvider({ arn }); +// OR provide neither to get main account's credentials +const credProvider = await awsCredentialsManager.getCredentialProvider({}); + +// Example constructing an AWS Proton client with the returned credential provider +const client = new ProtonClient({ + region, + credentialDefaultProvider: () => credProvider.sdkCredentialProvider, +}); +``` + +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 containing +either an AWS account ID or ARN: + +```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 credential 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 Backstage app 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 credential provider: + +```typescript +// Read an account ID from your plugin's configuration +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 awsCredentialsManager = DefaultAwsCredentialsManager.fromConfig(config); +const arn = config.getString('my-other-plugin.awsDynamoDbTable'); +const credProvider = await awsCredentialsManager.getCredentialProvider({ 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..d9ed25e193 --- /dev/null +++ b/packages/integration-aws-node/api-report.md @@ -0,0 +1,39 @@ +## 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 AwsCredentialProvider = { + accountId?: string; + stsRegion?: string; + sdkCredentialProvider: AwsCredentialIdentityProvider; +}; + +// @public +export type AwsCredentialProviderOptions = { + accountId?: string; + arn?: string; +}; + +// @public +export interface AwsCredentialsManager { + getCredentialProvider( + opts?: AwsCredentialProviderOptions, + ): Promise; +} + +// @public +export class DefaultAwsCredentialsManager implements AwsCredentialsManager { + // (undocumented) + static fromConfig(config: Config): DefaultAwsCredentialsManager; + getCredentialProvider( + opts?: AwsCredentialProviderOptions, + ): Promise; +} + +// (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/DefaultAwsCredentialsManager.test.ts b/packages/integration-aws-node/src/DefaultAwsCredentialsManager.test.ts new file mode 100644 index 0000000000..1b21d43338 --- /dev/null +++ b/packages/integration-aws-node/src/DefaultAwsCredentialsManager.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 { DefaultAwsCredentialsManager } from './DefaultAwsCredentialsManager'; +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('DefaultAwsCredentialsManager', () => { + 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('#getCredentialProvider', () => { + it('retrieves assume-role creds for the given account ID and caches the provider', async () => { + const provider = DefaultAwsCredentialsManager.fromConfig(config); + const awsCredentialProvider = await provider.getCredentialProvider({ + accountId: '111111111111', + }); + + expect(awsCredentialProvider.accountId).toEqual('111111111111'); + + const creds = await awsCredentialProvider.sdkCredentialProvider(); + expect(creds).toEqual({ + accessKeyId: 'ACCESS_KEY_ID_1', + secretAccessKey: 'SECRET_ACCESS_KEY_1', + sessionToken: 'SESSION_TOKEN_1', + expiration: new Date('2022-01-01'), + }); + + const awsCredentialProvider2 = await provider.getCredentialProvider({ + accountId: '111111111111', + }); + + 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 = DefaultAwsCredentialsManager.fromConfig(config); + const awsCredentialProvider = await provider.getCredentialProvider({ + accountId: '222222222222', + }); + + expect(awsCredentialProvider.accountId).toEqual('222222222222'); + + const creds = await awsCredentialProvider.sdkCredentialProvider(); + 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 = DefaultAwsCredentialsManager.fromConfig(config); + const awsCredentialProvider = await provider.getCredentialProvider({ + accountId: '999999999999', + }); + + expect(awsCredentialProvider.accountId).toEqual('999999999999'); + + const creds = await awsCredentialProvider.sdkCredentialProvider(); + 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 = DefaultAwsCredentialsManager.fromConfig(config); + const awsCredentialProvider = await provider.getCredentialProvider({ + accountId: '333333333333', + }); + + expect(awsCredentialProvider.accountId).toEqual('333333333333'); + + const creds = await awsCredentialProvider.sdkCredentialProvider(); + 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 = DefaultAwsCredentialsManager.fromConfig(minConfig); + const awsCredentialProvider = await provider.getCredentialProvider({ + accountId: '123456789012', + }); + + expect(awsCredentialProvider.accountId).toEqual('123456789012'); + + const creds = await awsCredentialProvider.sdkCredentialProvider(); + 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 = DefaultAwsCredentialsManager.fromConfig(minConfig); + const awsCredentialProvider1 = await provider.getCredentialProvider({}); + const awsCredentialProvider2 = await provider.getCredentialProvider({}); + + expect(awsCredentialProvider1).toBe(awsCredentialProvider2); + expect(stsMock).toHaveReceivedCommandTimes(GetCallerIdentityCommand, 1); + }); + + it('retrieves the ini provider chain for the given account ID', async () => { + const provider = DefaultAwsCredentialsManager.fromConfig(config); + const awsCredentialProvider = await provider.getCredentialProvider({ + accountId: '555555555555', + }); + + expect(awsCredentialProvider.accountId).toEqual('555555555555'); + + const creds = await awsCredentialProvider.sdkCredentialProvider(); + 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 = DefaultAwsCredentialsManager.fromConfig(config); + const awsCredentialProvider = await provider.getCredentialProvider({ + accountId: '444444444444', + }); + + expect(awsCredentialProvider.accountId).toEqual('444444444444'); + + const creds = await awsCredentialProvider.sdkCredentialProvider(); + 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 = DefaultAwsCredentialsManager.fromConfig(minConfig); + const awsCredentialProvider = await provider.getCredentialProvider({ + accountId: '123456789012', + }); + + expect(awsCredentialProvider.accountId).toEqual('123456789012'); + + const creds = await awsCredentialProvider.sdkCredentialProvider(); + 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 = DefaultAwsCredentialsManager.fromConfig(minConfig); + const awsCredentialProvider = await provider.getCredentialProvider({ + accountId: '123456789012', + }); + + expect(awsCredentialProvider.accountId).toEqual('123456789012'); + + const creds = await awsCredentialProvider.sdkCredentialProvider(); + 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 = DefaultAwsCredentialsManager.fromConfig(minConfig); + const awsCredentialProvider = await provider.getCredentialProvider({ + accountId: '123456789012', + }); + + expect(awsCredentialProvider.accountId).toEqual('123456789012'); + + const creds = await awsCredentialProvider.sdkCredentialProvider(); + 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 = DefaultAwsCredentialsManager.fromConfig(config); + const awsCredentialProvider = await provider.getCredentialProvider({ + arn: 'arn:aws:ecs:region:111111111111:service/cluster-name/service-name', + }); + + expect(awsCredentialProvider.accountId).toEqual('111111111111'); + + const creds = await awsCredentialProvider.sdkCredentialProvider(); + 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 = DefaultAwsCredentialsManager.fromConfig(config); + const awsCredentialProvider = await provider.getCredentialProvider({ + arn: 'arn:aws:s3:::bucket_name', + }); + + expect(awsCredentialProvider.accountId).toEqual('123456789012'); + + const creds = await awsCredentialProvider.sdkCredentialProvider(); + 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 = DefaultAwsCredentialsManager.fromConfig(config); + const awsCredentialProvider = await provider.getCredentialProvider({}); + + expect(awsCredentialProvider.accountId).toEqual('123456789012'); + + const creds = await awsCredentialProvider.sdkCredentialProvider(); + expect(creds).toEqual({ + accessKeyId: 'GHI', + secretAccessKey: 'JKL', + }); + }); + + it('falls back to main account credentials when no options are provided', async () => { + const provider = DefaultAwsCredentialsManager.fromConfig(config); + const awsCredentialProvider = await provider.getCredentialProvider(); + + expect(awsCredentialProvider.accountId).toEqual('123456789012'); + + const creds = await awsCredentialProvider.sdkCredentialProvider(); + 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 = DefaultAwsCredentialsManager.fromConfig(minConfig); + await expect( + 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 = DefaultAwsCredentialsManager.fromConfig(config); + await expect(provider.getCredentialProvider({})).rejects.toThrow( + /No credentials found/, + ); + }); + }); +}); diff --git a/packages/integration-aws-node/src/DefaultAwsCredentialsManager.ts b/packages/integration-aws-node/src/DefaultAwsCredentialsManager.ts new file mode 100644 index 0000000000..7799f08b4d --- /dev/null +++ b/packages/integration-aws-node/src/DefaultAwsCredentialsManager.ts @@ -0,0 +1,280 @@ +/* + * 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 { + AwsCredentialsManager, + AwsCredentialProvider, + AwsCredentialProviderOptions, +} 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 credential provider from STS. + */ +async function fillInAccountId(credProvider: AwsCredentialProvider) { + if (credProvider.accountId) { + return; + } + + const client = new STSClient({ + region: credProvider.stsRegion, + customUserAgent: 'backstage-aws-credentials-manager', + credentialDefaultProvider: () => credProvider.sdkCredentialProvider, + }); + const resp = await client.send(new GetCallerIdentityCommand({})); + credProvider.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-manager', + }, + }); +} + +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 getSdkCredentialProvider( + config: AwsIntegrationAccountConfig, + mainAccountCredProvider: 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!) + : mainAccountCredProvider, + params: { + RoleArn: `arn:${partition}:iam::${config.accountId}:role/${config.roleName}`, + RoleSessionName: 'backstage', + ExternalId: config.externalId, + }, + clientConfig: { + region, + customUserAgent: 'backstage-aws-credentials-manager', + }, + }); + } + + 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 getMainAccountSdkCredentialProvider( + 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 DefaultAwsCredentialsManager implements AwsCredentialsManager { + static fromConfig(config: Config): DefaultAwsCredentialsManager { + const awsConfig = config.has('aws') + ? readAwsIntegrationConfig(config.getConfig('aws')) + : { + accounts: [], + mainAccount: {}, + accountDefaults: {}, + }; + + const mainAccountSdkCredProvider = getMainAccountSdkCredentialProvider( + awsConfig.mainAccount, + ); + const mainAccountCredProvider: AwsCredentialProvider = { + sdkCredentialProvider: mainAccountSdkCredProvider, + }; + + const accountCredProviders = new Map(); + for (const accountConfig of awsConfig.accounts) { + const sdkCredentialProvider = getSdkCredentialProvider( + accountConfig, + mainAccountSdkCredProvider, + ); + accountCredProviders.set(accountConfig.accountId, { + accountId: accountConfig.accountId, + stsRegion: accountConfig.region, + sdkCredentialProvider, + }); + } + + return new DefaultAwsCredentialsManager( + accountCredProviders, + awsConfig.accountDefaults, + mainAccountCredProvider, + ); + } + + private constructor( + private readonly accountCredentialProviders: Map< + string, + AwsCredentialProvider + >, + private readonly accountDefaults: AwsIntegrationDefaultAccountConfig, + private readonly mainAccountCredentialProvider: AwsCredentialProvider, + ) {} + + /** + * Returns an {@link AwsCredentialProvider} for a given AWS account. + * + * @example + * ```ts + * const { provider } = await getCredentialProvider({ + * accountId: '0123456789012', + * }) + * + * 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 AwsCredentialProvider}. + */ + async getCredentialProvider( + opts?: AwsCredentialProviderOptions, + ): Promise { + // If no options provided, fall back to the main account + if (!opts) { + await fillInAccountId(this.mainAccountCredentialProvider); + return this.mainAccountCredentialProvider; + } + + // 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.mainAccountCredentialProvider); + return this.mainAccountCredentialProvider; + } + + // Return a cached provider if available + if (this.accountCredentialProviders.has(accountId)) { + return this.accountCredentialProviders.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 sdkCredentialProvider = getSdkCredentialProvider( + config, + this.mainAccountCredentialProvider.sdkCredentialProvider, + ); + 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.mainAccountCredentialProvider); + if (accountId === this.mainAccountCredentialProvider.accountId) { + return this.mainAccountCredentialProvider; + } + + // 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..30f3e21281 --- /dev/null +++ b/packages/integration-aws-node/src/index.ts @@ -0,0 +1,22 @@ +/* + * 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 { DefaultAwsCredentialsManager } from './DefaultAwsCredentialsManager'; +export type { + AwsCredentialsManager, + AwsCredentialProvider, + AwsCredentialProviderOptions, +} 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..3ff069b20b --- /dev/null +++ b/packages/integration-aws-node/src/types.ts @@ -0,0 +1,68 @@ +/* + * 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 AwsCredentialProvider = { + /** + * The AWS account ID of these credentials + */ + accountId?: string; + /** + * The STS region used with these credentials + */ + stsRegion?: string; + /** + * The credential identity provider to use when creating AWS SDK for Javascript V3 clients + */ + sdkCredentialProvider: AwsCredentialIdentityProvider; +}; + +/** + * The options for specifying the AWS credentials to retrieve. + * + * @public + */ +export type AwsCredentialProviderOptions = { + /** + * 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 AwsCredentialsManager { + /** + * Get credentials for an AWS account. + */ + getCredentialProvider( + opts?: AwsCredentialProviderOptions, + ): Promise; +} 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 a7c4d0b966..0998585765 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..f3b2162a6b 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 { + AwsCredentialProvider, + AwsCredentialProviderOptions, + DefaultAwsCredentialsManager, +} 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 getMockCredentialProvider(): Promise { + return Promise.resolve({ + sdkCredentialProvider: async () => { + return Promise.resolve({ + accessKeyId: 'MY_ACCESS_KEY_ID', + secretAccessKey: 'MY_SECRET_ACCESS_KEY', + }); + }, + }); +} +const getCredProviderMock = jest.spyOn( + DefaultAwsCredentialsManager.prototype, + 'getCredentialProvider', +); + 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(); + getCredProviderMock.mockImplementation((_?: AwsCredentialProviderOptions) => + getMockCredentialProvider(), + ); + 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(getCredProviderMock).toHaveBeenCalledWith({ + accountId: '111111111111', + }); + expect(getCredProviderMock).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(getCredProviderMock).toHaveBeenCalledWith(); + expect(getCredProviderMock).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(getCredProviderMock).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..a13baef926 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 { + AwsCredentialsManager, + DefaultAwsCredentialsManager, +} 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 credsManager = DefaultAwsCredentialsManager.fromConfig(config); + const sdkCredentialProvider = await AwsS3Publish.buildCredentials( + credsManager, + accountId, credentialsConfig, region, ); @@ -150,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 }), @@ -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,31 @@ export class AwsS3Publish implements PublisherBase { }; } - private static buildCredentials( + private static async buildCredentials( + credsManager: AwsCredentialsManager, + 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 credsManager.getCredentialProvider({ accountId })) + .sdkCredentialProvider; } + // Fall back to the default credential chain if neither account ID + // nor explicit credentials are provided + if (!config) { + return (await credsManager.getCredentialProvider()).sdkCredentialProvider; + } + + // 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 credsManager.getCredentialProvider()).sdkCredentialProvider; 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 04e642448c..0a40a363d4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -639,7 +639,7 @@ __metadata: languageName: node linkType: hard -"@aws-sdk/client-sts@npm:3.226.0": +"@aws-sdk/client-sts@npm:3.226.0, @aws-sdk/client-sts@npm:^3.208.0": version: 3.226.0 resolution: "@aws-sdk/client-sts@npm:3.226.0" dependencies: @@ -748,7 +748,7 @@ __metadata: languageName: node linkType: hard -"@aws-sdk/credential-provider-node@npm:3.226.0": +"@aws-sdk/credential-provider-node@npm:3.226.0, @aws-sdk/credential-provider-node@npm:^3.208.0": version: 3.226.0 resolution: "@aws-sdk/credential-provider-node@npm:3.226.0" dependencies: @@ -1388,7 +1388,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: @@ -4134,6 +4134,25 @@ __metadata: languageName: unknown linkType: soft +"@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: + "@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.6": version: 1.1.6 resolution: "@backstage/integration-react@npm:1.1.6" @@ -8156,6 +8175,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 @@ -10191,6 +10211,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" @@ -10273,6 +10302,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" @@ -10353,6 +10391,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" @@ -14286,6 +14338,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" @@ -16775,6 +16837,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" @@ -20334,6 +20408,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" @@ -22127,6 +22208,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" @@ -25743,6 +25837,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" @@ -25812,6 +25918,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" @@ -25852,6 +25965,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" @@ -25864,6 +25989,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" @@ -26029,6 +26171,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" @@ -31883,6 +32039,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"