diff --git a/.changeset/sixty-chicken-ring.md b/.changeset/sixty-chicken-ring.md new file mode 100644 index 0000000000..0fff26d6f9 --- /dev/null +++ b/.changeset/sixty-chicken-ring.md @@ -0,0 +1,7 @@ +--- +'@backstage/techdocs-common': patch +'@backstage/plugin-catalog-backend': patch +'@backstage/plugin-techdocs': patch +--- + +Add support for assuming role in AWS integrations diff --git a/docs/features/techdocs/using-cloud-storage.md b/docs/features/techdocs/using-cloud-storage.md index 32c44e9595..cd4b058644 100644 --- a/docs/features/techdocs/using-cloud-storage.md +++ b/docs/features/techdocs/using-cloud-storage.md @@ -191,6 +191,27 @@ techdocs: Refer to the [official AWS documentation for obtaining the credentials](https://docs.aws.amazon.com/sdk-for-javascript/v2/developer-guide/getting-your-credentials.html). +**3c. Authentication using an assumed role** Users with multiple AWS accounts +may want to use a role for S3 storage that is in a different AWS account. Using +the `roleArn` parameter as seen below, you can instruct the TechDocs publisher +to assume a role before accessing S3. + +```yaml +techdocs: + publisher: + type: 'awsS3' + awsS3: + bucketName: 'name-of-techdocs-storage-bucket' + region: + $env: AWS_REGION + credentials: + roleArn: arn:aws:iam::123456789012:role/my-backstage-role +``` + +Note: Assuming a role requires that primary credentials are already configured +at `AWS.config.credentials`. Read more about +[assuming roles in AWS](https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRole.html). + **4. That's it!** Your Backstage app is now ready to use AWS S3 for TechDocs, to store and read diff --git a/packages/techdocs-common/src/stages/publish/awsS3.ts b/packages/techdocs-common/src/stages/publish/awsS3.ts index 96cce331ec..fc45a2d5cd 100644 --- a/packages/techdocs-common/src/stages/publish/awsS3.ts +++ b/packages/techdocs-common/src/stages/publish/awsS3.ts @@ -15,12 +15,13 @@ */ import { Entity, EntityName } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; -import aws from 'aws-sdk'; +import aws, { Credentials } from 'aws-sdk'; import { ManagedUpload } from 'aws-sdk/clients/s3'; import express from 'express'; import fs from 'fs-extra'; import JSON5 from 'json5'; import createLimiter from 'p-limit'; +import { CredentialsOptions } from 'aws-sdk/lib/credentials'; import path from 'path'; import { Readable } from 'stream'; import { Logger } from 'winston'; @@ -59,14 +60,34 @@ export class AwsS3Publish implements PublisherBase { // 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 - const credentials = config.getOptionalConfig( + const credentialsConfig = config.getOptionalConfig( 'techdocs.publisher.awsS3.credentials', ); let accessKeyId = undefined; let secretAccessKey = undefined; - if (credentials) { - accessKeyId = credentials.getOptionalString('accessKeyId'); - secretAccessKey = credentials.getOptionalString('secretAccessKey'); + let credentials: Credentials | CredentialsOptions | undefined = undefined; + if (credentialsConfig) { + const roleArn = credentialsConfig.getOptionalString('roleArn'); + if (roleArn && aws.config.credentials instanceof Credentials) { + credentials = new aws.ChainableTemporaryCredentials({ + masterCredentials: aws.config.credentials as Credentials, + params: { + RoleSessionName: 'backstage-aws-techdocs-s3-publisher', + RoleArn: roleArn, + }, + }); + } else { + accessKeyId = credentialsConfig.getOptionalString('accessKeyId'); + secretAccessKey = credentialsConfig.getOptionalString( + 'secretAccessKey', + ); + if (accessKeyId && secretAccessKey) { + credentials = { + accessKeyId, + secretAccessKey, + }; + } + } } // AWS Region is an optional config. If missing, default AWS env variable AWS_REGION @@ -74,14 +95,7 @@ export class AwsS3Publish implements PublisherBase { const region = config.getOptionalString('techdocs.publisher.awsS3.region'); const storageClient = new aws.S3({ - ...(credentials && - accessKeyId && - secretAccessKey && { - credentials: { - accessKeyId, - secretAccessKey, - }, - }), + credentials, ...(region && { region, }), diff --git a/plugins/catalog-backend/config.d.ts b/plugins/catalog-backend/config.d.ts index 7877f34dcf..32ff44457c 100644 --- a/plugins/catalog-backend/config.d.ts +++ b/plugins/catalog-backend/config.d.ts @@ -334,6 +334,19 @@ export interface Config { }>; }; + /** + * AwsOrganizationCloudAccountProcessor configuration + */ + awsOrganization?: { + provider: { + /** + * The role to be assumed by this processor + * + */ + roleArn?: string; + }; + }; + /** * MicrosoftGraphOrgReaderProcessor configuration */ diff --git a/plugins/catalog-backend/src/ingestion/processors/AwsOrganizationCloudAccountProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/AwsOrganizationCloudAccountProcessor.test.ts index ba163fa0c3..292564dc4f 100644 --- a/plugins/catalog-backend/src/ingestion/processors/AwsOrganizationCloudAccountProcessor.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/AwsOrganizationCloudAccountProcessor.test.ts @@ -15,10 +15,14 @@ */ import { AwsOrganizationCloudAccountProcessor } from './AwsOrganizationCloudAccountProcessor'; +import * as winston from 'winston'; describe('AwsOrganizationCloudAccountProcessor', () => { describe('readLocation', () => { - const processor = new AwsOrganizationCloudAccountProcessor(); + const processor = new AwsOrganizationCloudAccountProcessor({ + provider: {}, + logger: winston.createLogger(), + }); const location = { type: 'aws-cloud-accounts', target: '' }; const emit = jest.fn(); const listAccounts = jest.fn(); diff --git a/plugins/catalog-backend/src/ingestion/processors/AwsOrganizationCloudAccountProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/AwsOrganizationCloudAccountProcessor.ts index 0e988e7307..7964541840 100644 --- a/plugins/catalog-backend/src/ingestion/processors/AwsOrganizationCloudAccountProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/AwsOrganizationCloudAccountProcessor.ts @@ -14,11 +14,17 @@ * limitations under the License. */ import { LocationSpec, ResourceEntityV1alpha1 } from '@backstage/catalog-model'; -import AWS, { Organizations } from 'aws-sdk'; +import AWS, { Credentials, Organizations } from 'aws-sdk'; import { Account, ListAccountsResponse } from 'aws-sdk/clients/organizations'; import * as results from './results'; import { CatalogProcessor, CatalogProcessorEmit } from './types'; +import { Config } from '@backstage/config'; +import { Logger } from 'winston'; +import { + AwsOrganizationProviderConfig, + readAwsOrganizationConfig, +} from './awsOrganization/config'; const AWS_ORGANIZATION_REGION = 'us-east-1'; const LOCATION_TYPE = 'aws-cloud-accounts'; @@ -33,9 +39,39 @@ const ORGANIZATION_ANNOTATION: string = 'amazonaws.com/organization-id'; * If custom authentication is needed, it can be achieved by configuring the global AWS.credentials object. */ export class AwsOrganizationCloudAccountProcessor implements CatalogProcessor { + logger: Logger; organizations: Organizations; - constructor() { + provider: AwsOrganizationProviderConfig; + + static fromConfig(config: Config, options: { logger: Logger }) { + const c = config.getOptionalConfig('catalog.processors.awsOrganization'); + return new AwsOrganizationCloudAccountProcessor({ + ...options, + provider: c ? readAwsOrganizationConfig(c) : {}, + }); + } + + constructor(options: { + provider: AwsOrganizationProviderConfig; + logger: Logger; + }) { + this.provider = options.provider; + this.logger = options.logger; + let credentials = undefined; + if ( + this.provider.roleArn !== undefined && + AWS.config.credentials instanceof Credentials + ) { + credentials = new AWS.ChainableTemporaryCredentials({ + masterCredentials: AWS.config.credentials as Credentials, + params: { + RoleSessionName: 'backstage-aws-organization-processor', + RoleArn: this.provider.roleArn, + }, + }); + } this.organizations = new AWS.Organizations({ + credentials, region: AWS_ORGANIZATION_REGION, }); // Only available in us-east-1 } diff --git a/plugins/catalog-backend/src/ingestion/processors/awsOrganization/config.test.ts b/plugins/catalog-backend/src/ingestion/processors/awsOrganization/config.test.ts new file mode 100644 index 0000000000..09d95b479d --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/awsOrganization/config.test.ts @@ -0,0 +1,33 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { ConfigReader } from '@backstage/config'; +import { readAwsOrganizationConfig } from './config'; + +describe('readAwsOrganizationConfig', () => { + it('applies all of the defaults', () => { + const config = { + provider: { + roleArn: 'aws::arn::foo', + }, + }; + const actual = readAwsOrganizationConfig(new ConfigReader(config)); + const expected = { + roleArn: 'aws::arn::foo', + }; + expect(actual).toEqual(expected); + }); +}); diff --git a/plugins/catalog-backend/src/ingestion/processors/awsOrganization/config.ts b/plugins/catalog-backend/src/ingestion/processors/awsOrganization/config.ts new file mode 100644 index 0000000000..90206f504c --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/awsOrganization/config.ts @@ -0,0 +1,38 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 Organization Processor + */ +export type AwsOrganizationProviderConfig = { + /** + * The role to assume for the processor. + */ + roleArn?: string; +}; + +export function readAwsOrganizationConfig( + config: Config, +): AwsOrganizationProviderConfig { + const providerConfig = config.getOptionalConfig('provider'); + + const roleArn = providerConfig?.getOptionalString('roleArn'); + return { + roleArn, + }; +} diff --git a/plugins/techdocs/config.d.ts b/plugins/techdocs/config.d.ts index d85269d2ec..1c49833d68 100644 --- a/plugins/techdocs/config.d.ts +++ b/plugins/techdocs/config.d.ts @@ -65,6 +65,11 @@ export interface Config { * @visibility secret */ secretAccessKey: string; + /** + * ARN of role to be assumed + * @visibility backend + */ + roleArn?: string; }; /** * (Required) Cloud Storage Bucket Name