Merge pull request #14125 from clareliguori/aws-creds-provider

Standardize AWS credentials configuration
This commit is contained in:
Patrik Oldsberg
2022-12-12 16:27:45 +01:00
committed by GitHub
22 changed files with 2201 additions and 64 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/integration-aws-node': minor
---
New package for AWS integration node library
+5
View File
@@ -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.
+5
View File
@@ -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.
+14 -2
View File
@@ -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:
@@ -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
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
+162
View File
@@ -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)
@@ -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<AwsCredentialProvider>;
}
// @public
export class DefaultAwsCredentialsManager implements AwsCredentialsManager {
// (undocumented)
static fromConfig(config: Config): DefaultAwsCredentialsManager;
getCredentialProvider(
opts?: AwsCredentialProviderOptions,
): Promise<AwsCredentialProvider>;
}
// (No @packageDocumentation comment for this package)
```
+123
View File
@@ -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;
}>;
};
}
@@ -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"
}
@@ -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<STSClient>;
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/,
);
});
});
});
@@ -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<string, AwsCredentialProvider>();
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<AwsCredentialProvider> {
// 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.`,
);
}
}
@@ -0,0 +1,335 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Config, ConfigReader } from '@backstage/config';
import { AwsIntegrationConfig, readAwsIntegrationConfig } from './config';
describe('readAwsIntegrationConfig', () => {
function buildConfig(data: Partial<AwsIntegrationConfig>): Config {
return new ConfigReader(data);
}
it('reads all values', () => {
const output = readAwsIntegrationConfig(
buildConfig({
accounts: [
{
accountId: '111111111111',
accessKeyId: 'ABC',
secretAccessKey: 'EDF',
roleName: 'hello',
partition: 'aws',
region: 'us-east-1',
externalId: 'world',
},
{
accountId: '222222222222',
accessKeyId: 'GHI',
secretAccessKey: 'JKL',
},
{
accountId: '333333333333',
roleName: 'hi',
partition: 'aws-other',
region: 'not-us-east-1',
externalId: 'there',
},
{
accountId: '444444444444',
profile: 'my-profile',
},
],
accountDefaults: {
roleName: 'backstage-role',
partition: 'aws',
region: 'us-east-1',
externalId: 'my-id',
},
mainAccount: {
accessKeyId: 'GHI',
secretAccessKey: 'JKL',
region: 'ap-northeast-1',
},
}),
);
expect(output).toEqual({
accounts: [
{
accountId: '111111111111',
accessKeyId: 'ABC',
secretAccessKey: 'EDF',
roleName: 'hello',
partition: 'aws',
region: 'us-east-1',
externalId: 'world',
},
{
accountId: '222222222222',
accessKeyId: 'GHI',
secretAccessKey: 'JKL',
},
{
accountId: '333333333333',
roleName: 'hi',
partition: 'aws-other',
region: 'not-us-east-1',
externalId: 'there',
},
{
accountId: '444444444444',
profile: 'my-profile',
},
],
accountDefaults: {
roleName: 'backstage-role',
partition: 'aws',
region: 'us-east-1',
externalId: 'my-id',
},
mainAccount: {
accessKeyId: 'GHI',
secretAccessKey: 'JKL',
region: 'ap-northeast-1',
},
});
});
it('reads profile for main account', () => {
const output = readAwsIntegrationConfig(
buildConfig({
accounts: [
{
accountId: '111111111111',
accessKeyId: 'ABC',
secretAccessKey: 'EDF',
roleName: 'hello',
partition: 'aws',
region: 'us-east-1',
externalId: 'world',
},
],
accountDefaults: {
roleName: 'backstage-role',
partition: 'aws',
region: 'us-east-1',
externalId: 'my-id',
},
mainAccount: {
profile: 'my-profile',
},
}),
);
expect(output).toEqual({
accounts: [
{
accountId: '111111111111',
accessKeyId: 'ABC',
secretAccessKey: 'EDF',
roleName: 'hello',
partition: 'aws',
region: 'us-east-1',
externalId: 'world',
},
],
accountDefaults: {
roleName: 'backstage-role',
partition: 'aws',
region: 'us-east-1',
externalId: 'my-id',
},
mainAccount: {
profile: 'my-profile',
},
});
});
it('does not fail when config is not set', () => {
const output = readAwsIntegrationConfig(buildConfig({}));
expect(output).toEqual({
accountDefaults: {},
accounts: [],
mainAccount: {},
});
});
it('rejects invalid combinations of account attributes', () => {
const validAccount: any = {
accountId: '111111111111',
accessKeyId: 'ABC',
secretAccessKey: 'EDF',
roleName: 'hello',
partition: 'aws',
region: 'us-east-1',
externalId: 'world',
};
expect(() =>
readAwsIntegrationConfig(
buildConfig({
accounts: [
validAccount,
{
accountId: '222222222222',
accessKeyId: 'ABC',
},
],
}),
),
).toThrow(/no secret access key/);
expect(() =>
readAwsIntegrationConfig(
buildConfig({
accounts: [
validAccount,
{
accountId: '222222222222',
secretAccessKey: 'ABC',
},
],
}),
),
).toThrow(/no access key ID/);
expect(() =>
readAwsIntegrationConfig(
buildConfig({
accounts: [
validAccount,
{
accountId: '222222222222',
accessKeyId: 'ABC',
secretAccessKey: 'DEF',
profile: 'my-profile',
},
],
}),
),
).toThrow(/only one must be specified/);
expect(() =>
readAwsIntegrationConfig(
buildConfig({
accounts: [
validAccount,
{
accountId: '222222222222',
roleName: 'my-role',
profile: 'my-profile',
},
],
}),
),
).toThrow(/only one must be specified/);
expect(() =>
readAwsIntegrationConfig(
buildConfig({
accounts: [
validAccount,
{
accountId: '222222222222',
partition: 'aws',
},
],
}),
),
).toThrow(/no role name/);
expect(() =>
readAwsIntegrationConfig(
buildConfig({
accounts: [
validAccount,
{
accountId: '222222222222',
region: 'not-us-east-1',
},
],
}),
),
).toThrow(/no role name/);
expect(() =>
readAwsIntegrationConfig(
buildConfig({
accounts: [
validAccount,
{
accountId: '222222222222',
externalId: 'hello',
},
],
}),
),
).toThrow(/no role name/);
});
it('rejects invalid combinations of main account attributes', () => {
expect(() =>
readAwsIntegrationConfig(
buildConfig({
mainAccount: {
accessKeyId: 'ABC',
},
}),
),
).toThrow(/no secret access key/);
expect(() =>
readAwsIntegrationConfig(
buildConfig({
mainAccount: {
secretAccessKey: 'ABC',
},
}),
),
).toThrow(/no access key ID/);
expect(() =>
readAwsIntegrationConfig(
buildConfig({
mainAccount: {
accessKeyId: 'ABC',
secretAccessKey: 'DEF',
profile: 'my-profile',
},
}),
),
).toThrow(/only one must be specified/);
});
it('rejects invalid combinations of account default attributes', () => {
expect(() =>
readAwsIntegrationConfig(
buildConfig({
accountDefaults: {
partition: 'aws',
},
}),
),
).toThrow(/no role name/);
expect(() =>
readAwsIntegrationConfig(
buildConfig({
accountDefaults: {
region: 'not-us-east-1',
},
}),
),
).toThrow(/no role name/);
expect(() =>
readAwsIntegrationConfig(
buildConfig({
accountDefaults: {
externalId: 'hello',
},
}),
),
).toThrow(/no role name/);
});
});
+307
View File
@@ -0,0 +1,307 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Config } from '@backstage/config';
/**
* The configuration parameters for a single AWS account for the AWS integration.
*
* @public
*/
export type AwsIntegrationAccountConfig = {
/**
* The account ID of the target account that this matches on, e.g. "123456789012"
*/
accountId: string;
/**
* The access key ID for a set of static AWS credentials
*/
accessKeyId?: string;
/**
* The secret access key for a set of static AWS credentials
*/
secretAccessKey?: string;
/**
* The configuration profile from a credentials file at ~/.aws/credentials and
* a configuration file at ~/.aws/config.
*/
profile?: string;
/**
* The IAM role to assume to retrieve temporary AWS credentials
*/
roleName?: string;
/**
* The AWS partition of the IAM role, e.g. "aws", "aws-cn"
*/
partition?: string;
/**
* The STS regional endpoint to use when retrieving temporary AWS credentials, e.g. "ap-northeast-1"
*/
region?: string;
/**
* The unique identifier needed to assume the role to retrieve temporary AWS credentials
*/
externalId?: string;
};
/**
* The configuration parameters for the main AWS account for the AWS integration.
*
* @public
*/
export type AwsIntegrationMainAccountConfig = {
/**
* The access key ID for a set of static AWS credentials
*/
accessKeyId?: string;
/**
* The secret access key for a set of static AWS credentials
*/
secretAccessKey?: string;
/**
* The configuration profile from a credentials file at ~/.aws/credentials and
* a configuration file at ~/.aws/config.
*/
profile?: string;
/**
* The STS regional endpoint to use for the main account, e.g. "ap-northeast-1"
*/
region?: string;
};
/**
* The default configuration parameters to use for accounts for the AWS integration.
*
* @public
*/
export type AwsIntegrationDefaultAccountConfig = {
/**
* The IAM role to assume to retrieve temporary AWS credentials
*/
roleName?: string;
/**
* The AWS partition of the IAM role, e.g. "aws", "aws-cn"
*/
partition?: string;
/**
* The STS regional endpoint to use when retrieving temporary AWS credentials, e.g. "ap-northeast-1"
*/
region?: string;
/**
* The unique identifier needed to assume the role to retrieve temporary AWS credentials
*/
externalId?: string;
};
/**
* The configuration parameters for AWS account integration.
*
* @public
*/
export type AwsIntegrationConfig = {
/**
* Configuration for retrieving AWS accounts credentials
*/
accounts: AwsIntegrationAccountConfig[];
/**
* Defaults for retrieving AWS account credentials
*/
accountDefaults: AwsIntegrationDefaultAccountConfig;
/**
* Main account to use for retrieving AWS account credentials
*/
mainAccount: AwsIntegrationMainAccountConfig;
};
/**
* Reads an AWS integration account config.
*
* @param config - The config object of a single account
*/
function readAwsIntegrationAccountConfig(
config: Config,
): AwsIntegrationAccountConfig {
const accountConfig = {
accountId: config.getString('accountId'),
accessKeyId: config.getOptionalString('accessKeyId'),
secretAccessKey: config.getOptionalString('secretAccessKey'),
profile: config.getOptionalString('profile'),
roleName: config.getOptionalString('roleName'),
region: config.getOptionalString('region'),
partition: config.getOptionalString('partition'),
externalId: config.getOptionalString('externalId'),
};
// Validate that the account config has the right combination of attributes
if (accountConfig.accessKeyId && !accountConfig.secretAccessKey) {
throw new Error(
`AWS integration account ${accountConfig.accountId} has an access key ID configured, but no secret access key.`,
);
}
if (!accountConfig.accessKeyId && accountConfig.secretAccessKey) {
throw new Error(
`AWS integration account ${accountConfig.accountId} has a secret access key configured, but no access key ID`,
);
}
if (accountConfig.profile && accountConfig.accessKeyId) {
throw new Error(
`AWS integration account ${accountConfig.accountId} has both an access key ID and a profile configured, but only one must be specified`,
);
}
if (accountConfig.profile && accountConfig.roleName) {
throw new Error(
`AWS integration account ${accountConfig.accountId} has both an access key ID and a role name configured, but only one must be specified`,
);
}
if (!accountConfig.roleName && accountConfig.externalId) {
throw new Error(
`AWS integration account ${accountConfig.accountId} has an external ID configured, but no role name.`,
);
}
if (!accountConfig.roleName && accountConfig.region) {
throw new Error(
`AWS integration account ${accountConfig.accountId} has an STS region configured, but no role name.`,
);
}
if (!accountConfig.roleName && accountConfig.partition) {
throw new Error(
`AWS integration account ${accountConfig.accountId} has an IAM partition configured, but no role name.`,
);
}
return accountConfig;
}
/**
* Reads the main AWS integration account config.
*
* @param config - The config object of the main account
*/
function readMainAwsIntegrationAccountConfig(
config: Config,
): AwsIntegrationMainAccountConfig {
const mainAccountConfig = {
accessKeyId: config.getOptionalString('accessKeyId'),
secretAccessKey: config.getOptionalString('secretAccessKey'),
profile: config.getOptionalString('profile'),
region: config.getOptionalString('region'),
};
// Validate that the account config has the right combination of attributes
if (mainAccountConfig.accessKeyId && !mainAccountConfig.secretAccessKey) {
throw new Error(
`The main AWS integration account has an access key ID configured, but no secret access key.`,
);
}
if (!mainAccountConfig.accessKeyId && mainAccountConfig.secretAccessKey) {
throw new Error(
`The main AWS integration account has a secret access key configured, but no access key ID`,
);
}
if (mainAccountConfig.profile && mainAccountConfig.accessKeyId) {
throw new Error(
`The main AWS integration account has both an access key ID and a profile configured, but only one must be specified`,
);
}
return mainAccountConfig;
}
/**
* Reads the default settings for retrieving credentials from AWS integration accounts.
*
* @param config - The config object of the default account settings
*/
function readAwsIntegrationAccountDefaultsConfig(
config: Config,
): AwsIntegrationDefaultAccountConfig {
const defaultAccountConfig = {
roleName: config.getOptionalString('roleName'),
partition: config.getOptionalString('partition'),
region: config.getOptionalString('region'),
externalId: config.getOptionalString('externalId'),
};
// Validate that the account config has the right combination of attributes
if (!defaultAccountConfig.roleName && defaultAccountConfig.externalId) {
throw new Error(
`AWS integration account default configuration has an external ID configured, but no role name.`,
);
}
if (!defaultAccountConfig.roleName && defaultAccountConfig.region) {
throw new Error(
`AWS integration account default configuration has an STS region configured, but no role name.`,
);
}
if (!defaultAccountConfig.roleName && defaultAccountConfig.partition) {
throw new Error(
`AWS integration account default configuration has an IAM partition configured, but no role name.`,
);
}
return defaultAccountConfig;
}
/**
* Reads an AWS integration configuration
*
* @param config - the integration config object
* @public
*/
export function readAwsIntegrationConfig(config: Config): AwsIntegrationConfig {
const accounts = config
.getOptionalConfigArray('accounts')
?.map(readAwsIntegrationAccountConfig);
const mainAccount = config.has('mainAccount')
? readMainAwsIntegrationAccountConfig(config.getConfig('mainAccount'))
: {};
const accountDefaults = config.has('accountDefaults')
? readAwsIntegrationAccountDefaultsConfig(
config.getConfig('accountDefaults'),
)
: {};
return {
accounts: accounts ?? [],
mainAccount,
accountDefaults,
};
}
@@ -0,0 +1,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';
@@ -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<AwsCredentialProvider>;
}
+15 -1
View File
@@ -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
+1
View File
@@ -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",
@@ -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<S3Client>;
function getMockCredentialProvider(): Promise<AwsCredentialProvider> {
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,
});
@@ -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<PublisherBase> {
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<AwsCredentialIdentityProvider> {
// 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) {
@@ -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',
+171 -3
View File
@@ -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"