diff --git a/.changeset/warm-lamps-tease.md b/.changeset/warm-lamps-tease.md new file mode 100644 index 0000000000..954197dd13 --- /dev/null +++ b/.changeset/warm-lamps-tease.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-kubernetes-backend': minor +--- + +Update `aws-sdk` client from v2 to v3. + +**BREAKING**: The `AwsIamKubernetesAuthTranslator` class no longer exposes the following methods `awsGetCredentials`, `getBearerToken`, `getCredentials` and `validCredentials`. There is no replacement for these methods. diff --git a/plugins/kubernetes-backend/api-report.md b/plugins/kubernetes-backend/api-report.md index bc51d504de..7dfb0635bd 100644 --- a/plugins/kubernetes-backend/api-report.md +++ b/plugins/kubernetes-backend/api-report.md @@ -5,7 +5,6 @@ ```ts import { CatalogApi } from '@backstage/catalog-client'; import { Config } from '@backstage/config'; -import { Credentials } from 'aws-sdk'; import type { CustomResourceMatcher } from '@backstage/plugin-kubernetes-common'; import { Duration } from 'luxon'; import { Entity } from '@backstage/catalog-model'; @@ -34,25 +33,11 @@ export interface AWSClusterDetails extends ClusterDetails { export class AwsIamKubernetesAuthTranslator implements KubernetesAuthTranslator { - // (undocumented) - awsGetCredentials: () => Promise; + constructor(opts: { config: Config }); // (undocumented) decorateClusterDetailsWithAuth( clusterDetails: AWSClusterDetails, ): Promise; - // (undocumented) - getBearerToken( - clusterName: string, - assumeRole?: string, - externalId?: string, - ): Promise; - // (undocumented) - getCredentials( - assumeRole?: string, - externalId?: string, - ): Promise; - // (undocumented) - validCredentials(creds: SigningCreds): boolean; } // @public (undocumented) @@ -338,6 +323,8 @@ export interface KubernetesObjectsProvider { // @public (undocumented) export interface KubernetesObjectsProviderOptions { + // (undocumented) + config: Config; // (undocumented) customResources: CustomResource[]; // (undocumented) diff --git a/plugins/kubernetes-backend/package.json b/plugins/kubernetes-backend/package.json index 5d63d97710..963b260b90 100644 --- a/plugins/kubernetes-backend/package.json +++ b/plugins/kubernetes-backend/package.json @@ -49,6 +49,9 @@ "clean": "backstage-cli package clean" }, "dependencies": { + "@aws-crypto/sha256-js": "^3.0.0", + "@aws-sdk/credential-providers": "^3.208.0", + "@aws-sdk/signature-v4": "^3.310.0", "@azure/identity": "^2.0.4", "@backstage/backend-common": "workspace:^", "@backstage/backend-plugin-api": "workspace:^", @@ -56,6 +59,7 @@ "@backstage/catalog-model": "workspace:^", "@backstage/config": "workspace:^", "@backstage/errors": "workspace:^", + "@backstage/integration-aws-node": "workspace:^", "@backstage/plugin-auth-node": "workspace:^", "@backstage/plugin-catalog-node": "workspace:^", "@backstage/plugin-kubernetes-common": "workspace:^", @@ -66,7 +70,6 @@ "@kubernetes/client-node": "0.18.1", "@types/express": "^4.17.6", "@types/luxon": "^3.0.0", - "aws-sdk": "^2.840.0", "aws4": "^1.11.0", "compression": "^1.7.4", "cors": "^2.8.5", @@ -88,7 +91,6 @@ "@backstage/cli": "workspace:^", "@types/aws4": "^1.5.1", "@types/http-proxy-middleware": "^0.19.3", - "aws-sdk-mock": "^5.2.1", "mock-fs": "^5.2.0", "msw": "^1.0.0", "supertest": "^6.1.3" diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/AwsIamKubernetesAuthTranslator.test.ts b/plugins/kubernetes-backend/src/kubernetes-auth-translator/AwsIamKubernetesAuthTranslator.test.ts index 1f8d525ea8..b080aa332b 100644 --- a/plugins/kubernetes-backend/src/kubernetes-auth-translator/AwsIamKubernetesAuthTranslator.test.ts +++ b/plugins/kubernetes-backend/src/kubernetes-auth-translator/AwsIamKubernetesAuthTranslator.test.ts @@ -13,148 +13,127 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import AWS from 'aws-sdk'; -import AWSMock from 'aws-sdk-mock'; import { AwsIamKubernetesAuthTranslator } from './AwsIamKubernetesAuthTranslator'; +import { ConfigReader } from '@backstage/config'; -const awsError: AWS.AWSError = { - code: '123', - message: 'no way', - name: 'nope', - time: new Date(), +let presign = jest.fn(async () => ({ + hostname: 'https://example.com', + query: {}, + path: '/asdf', +})); + +const credsManager = { + getCredentialProvider: async () => ({ + sdkCredentialProvider: { + AccessKeyId: 'asdf', + }, + }), }; +jest.mock('@backstage/integration-aws-node', () => ({ + DefaultAwsCredentialsManager: { + fromConfig: () => credsManager, + }, +})); + +const config = new ConfigReader({}); + +jest.mock('@aws-sdk/signature-v4', () => ({ + SignatureV4: jest.fn().mockImplementation(() => ({ + presign, + })), +})); + +const fromTemporaryCredentials = jest.fn(); +jest.mock('@aws-sdk/credential-providers', () => ({ + fromTemporaryCredentials: (opts: any) => { + return fromTemporaryCredentials(opts); + }, +})); + describe('AwsIamKubernetesAuthTranslator tests', () => { - let role: any = undefined; - const credentials: any = { - accessKeyId: 'bloop', - secretAccessKey: 'omg-so-secret', - sessionToken: 'token', - }; + beforeEach(() => {}); + it('returns a signed url for AWS credentials without assume role', async () => { + const authTranslator = new AwsIamKubernetesAuthTranslator({ config }); - let assumeResponse: any = { - Credentials: { - AccessKeyId: credentials.accessKeyId, - SecretAccessKey: credentials.secretAccessKey, - SessionToken: credentials.sessionToken, - }, - }; - - let mockedCredentials: any = undefined; - - AWS.config.credentials = new AWS.Credentials(credentials); - - AWSMock.setSDKInstance(AWS); - - beforeEach(() => { - jest.resetAllMocks(); - }); - - afterAll(() => { - jest.resetAllMocks(); - }); - - function executeTranslation() { - AWSMock.mock('STS', 'assumeRole', (_params: any, callback: Function) => { - callback(null, assumeResponse); - }); - - const authTranslator = new AwsIamKubernetesAuthTranslator(); - - if (mockedCredentials) { - jest - .spyOn(authTranslator, 'awsGetCredentials') - .mockImplementation(async () => mockedCredentials); - } - - const response = authTranslator.decorateClusterDetailsWithAuth({ - assumeRole: role, + const authPromise = authTranslator.decorateClusterDetailsWithAuth({ name: 'test-cluster', url: '', authProvider: 'aws', }); - - mockedCredentials = undefined; - - return response; - } - - it('returns a signed url for AWS credentials', async () => { - // These credentials are not real. - // Pulled from example in docs: https://docs.aws.amazon.com/general/latest/gr/aws-sec-cred-types.html - AWS.config.credentials = new AWS.Credentials( - 'AKIAIOSFODNN7EXAMPLE', - 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', + expect((await authPromise).serviceAccountToken).toEqual( + 'k8s-aws-v1.aHR0cHM6Ly9odHRwczovL2V4YW1wbGUuY29tL2FzZGY_', ); + }); - const response = await executeTranslation(); - expect(response.serviceAccountToken).toBeDefined(); + it('returns a signed url for AWS credentials with assume role', async () => { + const authTranslator = new AwsIamKubernetesAuthTranslator({ config }); + + const authPromise = authTranslator.decorateClusterDetailsWithAuth({ + assumeRole: 'SomeRole', + name: 'test-cluster', + url: '', + authProvider: 'aws', + }); + expect((await authPromise).serviceAccountToken).toEqual( + 'k8s-aws-v1.aHR0cHM6Ly9odHRwczovL2V4YW1wbGUuY29tL2FzZGY_', + ); + expect(fromTemporaryCredentials).toHaveBeenCalledWith({ + clientConfig: { + region: 'us-east-1', + }, + masterCredentials: { + AccessKeyId: 'asdf', + }, + params: { + ExternalId: undefined, + RoleArn: 'SomeRole', + }, + }); + }); + + it('returns a signed url for AWS credentials and passes the external id', async () => { + const authTranslator = new AwsIamKubernetesAuthTranslator({ config }); + + const authPromise = authTranslator.decorateClusterDetailsWithAuth({ + assumeRole: 'SomeRole', + externalId: 'external-id', + name: 'test-cluster', + url: '', + authProvider: 'aws', + }); + expect((await authPromise).serviceAccountToken).toEqual( + 'k8s-aws-v1.aHR0cHM6Ly9odHRwczovL2V4YW1wbGUuY29tL2FzZGY_', + ); + expect(fromTemporaryCredentials).toHaveBeenCalledWith({ + clientConfig: { + region: 'us-east-1', + }, + masterCredentials: { + AccessKeyId: 'asdf', + }, + params: { + ExternalId: 'external-id', + RoleArn: 'SomeRole', + }, + }); }); describe('When the credentials is failing', () => { beforeEach(() => { - jest.spyOn(AWS.config, 'getCredentials').mockImplementation(cb => { - cb(awsError, null); + presign = jest.fn(async () => { + throw new Error('no way'); }); }); it('throws the right error', async () => { - const authTranslator = new AwsIamKubernetesAuthTranslator(); + const authTranslator = new AwsIamKubernetesAuthTranslator({ config }); await expect( authTranslator.decorateClusterDetailsWithAuth({ - assumeRole: role, name: 'test-cluster', url: '', authProvider: 'aws', }), - ).rejects.toEqual(awsError); - }); - }); - - describe('When the role is assumed', () => { - // These credentials are not real. - // Pulled from example in docs: https://docs.aws.amazon.com/general/latest/gr/aws-sec-cred-types.html - AWS.config.credentials = new AWS.Credentials( - 'AKIAIOSFODNN7EXAMPLE', - 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', - ); - role = 'SomeRole'; - - describe('When the role is valid', () => { - it('returns a signed url for AWS credentials', async () => { - const response = await executeTranslation(); - expect(response.serviceAccountToken).toBeDefined(); - }); - }); - - describe('When the role is invalid', () => { - it('returns the original AWS credentials', async () => { - assumeResponse = undefined; - await expect(executeTranslation()).rejects.toThrow( - /Unable to assume role:/, - ); - }); - }); - }); - - describe('When no AWS creds are available', () => { - it('throws unable to get AWS credentials', async () => { - mockedCredentials = new Error(); - await expect(executeTranslation()).rejects.toThrow( - 'No AWS credentials found.', - ); - }); - }); - - describe('When invalid AWS creds are available', () => { - it('throws credentials are invalid to get AWS credentials', async () => { - const undefinedSecret: any = undefined; - AWS.config.credentials = new AWS.Credentials( - 'AKIAIOSFODNN7EXAMPLE', - undefinedSecret, - ); - await expect(executeTranslation()).rejects.toThrow( - 'Invalid AWS credentials found.', - ); + ).rejects.toThrow('no way'); }); }); }); diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/AwsIamKubernetesAuthTranslator.ts b/plugins/kubernetes-backend/src/kubernetes-auth-translator/AwsIamKubernetesAuthTranslator.ts index 84e60c9c69..cc46fd567a 100644 --- a/plugins/kubernetes-backend/src/kubernetes-auth-translator/AwsIamKubernetesAuthTranslator.ts +++ b/plugins/kubernetes-backend/src/kubernetes-auth-translator/AwsIamKubernetesAuthTranslator.ts @@ -13,10 +13,16 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import AWS, { Credentials } from 'aws-sdk'; -import { sign } from 'aws4'; import { AWSClusterDetails } from '../types/types'; import { KubernetesAuthTranslator } from './types'; +import { fromTemporaryCredentials } from '@aws-sdk/credential-providers'; +import { SignatureV4 } from '@aws-sdk/signature-v4'; +import { Sha256 } from '@aws-crypto/sha256-js'; +import { + AwsCredentialsManager, + DefaultAwsCredentialsManager, +} from '@backstage/integration-aws-node'; +import { Config } from '@backstage/config'; /** * @@ -28,6 +34,8 @@ export type SigningCreds = { sessionToken: string | undefined; }; +const defaultRegion = 'us-east-1'; + /** * * @public @@ -35,90 +43,70 @@ export type SigningCreds = { export class AwsIamKubernetesAuthTranslator implements KubernetesAuthTranslator { - validCredentials(creds: SigningCreds): boolean { - return (creds?.accessKeyId && creds?.secretAccessKey) as unknown as boolean; + private readonly credsManager: AwsCredentialsManager; + constructor(opts: { config: Config }) { + this.credsManager = DefaultAwsCredentialsManager.fromConfig(opts.config); } - awsGetCredentials = async (): Promise => { - return new Promise((resolve, reject) => { - AWS.config.getCredentials(err => { - if (err) { - return reject(err); - } - - return resolve(AWS.config.credentials as Credentials); - }); - }); - }; - - async getCredentials( - assumeRole?: string, - externalId?: string, - ): Promise { - const awsCreds = await this.awsGetCredentials(); - - if (!(awsCreds instanceof Credentials)) - throw new Error('No AWS credentials found.'); - - let creds: SigningCreds = { - accessKeyId: awsCreds.accessKeyId, - secretAccessKey: awsCreds.secretAccessKey, - sessionToken: awsCreds.sessionToken, - }; - - if (!this.validCredentials(creds)) - throw new Error('Invalid AWS credentials found.'); - if (!assumeRole) return creds; - - try { - const params: AWS.STS.Types.AssumeRoleRequest = { - RoleArn: assumeRole, - RoleSessionName: 'backstage-login', - }; - if (externalId) params.ExternalId = externalId; - - const assumedRole = await new AWS.STS().assumeRole(params).promise(); - - if (!assumedRole.Credentials) { - throw new Error(`No credentials returned for role ${assumeRole}`); - } - - creds = { - accessKeyId: assumedRole.Credentials.AccessKeyId, - secretAccessKey: assumedRole.Credentials.SecretAccessKey, - sessionToken: assumedRole.Credentials.SessionToken, - }; - } catch (e) { - console.warn(`There was an error assuming the role: ${e}`); - throw new Error(`Unable to assume role: ${e}`); - } - return creds; - } - async getBearerToken( + private async getBearerToken( clusterName: string, assumeRole?: string, externalId?: string, ): Promise { - const credentials = await this.getCredentials(assumeRole, externalId); + const region = process.env.AWS_REGION ?? defaultRegion; - const request = { - host: `sts.amazonaws.com`, - path: `/?Action=GetCallerIdentity&Version=2011-06-15&X-Amz-Expires=60`, - headers: { - 'x-k8s-aws-id': clusterName, + let credentials = (await this.credsManager.getCredentialProvider()) + .sdkCredentialProvider; + if (assumeRole) { + credentials = fromTemporaryCredentials({ + masterCredentials: credentials, + clientConfig: { + region, + }, + params: { + RoleArn: assumeRole, + ExternalId: externalId, + }, + }); + } + + const signer = new SignatureV4({ + credentials, + region, + service: 'sts', + sha256: Sha256, + }); + + const request = await signer.presign( + { + headers: { + host: `sts.${region}.amazonaws.com`, + 'x-k8s-aws-id': clusterName, + }, + hostname: `sts.${region}.amazonaws.com`, + method: 'GET', + path: '/', + protocol: 'https:', + query: { + Action: 'GetCallerIdentity', + Version: '2011-06-15', + }, }, - signQuery: true, - }; + { expiresIn: 0 }, + ); - const signed = sign(request, credentials); - const url = `https://${signed.host}${signed.path}`; - const base64Url = Buffer.from(url, 'binary').toString('base64'); - const urlSafeBase64Url = base64Url - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, ''); + const query = Object.keys(request?.query ?? {}) + .map( + q => + `${encodeURIComponent(q)}=${encodeURIComponent( + request.query?.[q] as string, + )}`, + ) + .join('&'); - return `k8s-aws-v1.${urlSafeBase64Url}`; + const url = `https://${request.hostname}${request.path}?${query}`; + + return `k8s-aws-v1.${Buffer.from(url).toString('base64url')}`; } async decorateClusterDetailsWithAuth( diff --git a/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts b/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts index 5e885512f7..75b619b412 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts @@ -136,6 +136,7 @@ export class KubernetesBuilder { const objectsProvider = this.getObjectsProvider({ logger, fetcher, + config, serviceLocator, customResources, objectTypesToFetch: this.getObjectTypesToFetch(), @@ -354,7 +355,7 @@ export class KubernetesBuilder { protected buildAuthTranslatorMap() { this.authTranslatorMap = { google: new GoogleKubernetesAuthTranslator(), - aws: new AwsIamKubernetesAuthTranslator(), + aws: new AwsIamKubernetesAuthTranslator({ config: this.env.config }), azure: new AzureIdentityKubernetesAuthTranslator(this.env.logger), serviceAccount: new NoopKubernetesAuthTranslator(), googleServiceAccount: new GoogleServiceAccountAuthTranslator(), diff --git a/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.test.ts b/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.test.ts index de6035b48e..e24c08cafd 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.test.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.test.ts @@ -28,6 +28,7 @@ import { rest } from 'msw'; import { setupServer } from 'msw/node'; import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; import { ObjectsByEntityResponse } from '@backstage/plugin-kubernetes-common'; +import { ConfigReader } from '@backstage/config'; const fetchObjectsForService = jest.fn(); const fetchPodMetricsByNamespaces = jest.fn(); @@ -158,6 +159,7 @@ function mockFetchAndGetKubernetesFanOutHandler( function getKubernetesFanOutHandler(customResources: CustomResource[]) { return new KubernetesFanOutHandler({ logger: getVoidLogger(), + config: new ConfigReader({}), fetcher: { fetchObjectsForService, fetchPodMetricsByNamespaces, @@ -868,6 +870,7 @@ describe('getKubernetesObjectsByEntity', () => { const sut = new KubernetesFanOutHandler({ logger, fetcher: new KubernetesClientBasedFetcher({ logger }), + config: new ConfigReader({}), serviceLocator: fleet, customResources: [], objectTypesToFetch: [ diff --git a/plugins/kubernetes-backend/src/types/types.ts b/plugins/kubernetes-backend/src/types/types.ts index 87011b3bb9..e460edbc29 100644 --- a/plugins/kubernetes-backend/src/types/types.ts +++ b/plugins/kubernetes-backend/src/types/types.ts @@ -25,6 +25,7 @@ import type { KubernetesRequestBody, ObjectsByEntityResponse, } from '@backstage/plugin-kubernetes-common'; +import { Config } from '@backstage/config'; /** * @@ -242,6 +243,7 @@ export interface AWSClusterDetails extends ClusterDetails { */ export interface KubernetesObjectsProviderOptions { logger: Logger; + config: Config; fetcher: KubernetesFetcher; serviceLocator: KubernetesServiceLocator; customResources: CustomResource[]; diff --git a/yarn.lock b/yarn.lock index ee5ebf0cf7..571cf81605 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1426,7 +1426,7 @@ __metadata: languageName: node linkType: hard -"@aws-sdk/signature-v4@npm:3.310.0": +"@aws-sdk/signature-v4@npm:3.310.0, @aws-sdk/signature-v4@npm:^3.310.0": version: 3.310.0 resolution: "@aws-sdk/signature-v4@npm:3.310.0" dependencies: @@ -7069,6 +7069,9 @@ __metadata: version: 0.0.0-use.local resolution: "@backstage/plugin-kubernetes-backend@workspace:plugins/kubernetes-backend" dependencies: + "@aws-crypto/sha256-js": ^3.0.0 + "@aws-sdk/credential-providers": ^3.208.0 + "@aws-sdk/signature-v4": ^3.310.0 "@azure/identity": ^2.0.4 "@backstage/backend-common": "workspace:^" "@backstage/backend-plugin-api": "workspace:^" @@ -7078,6 +7081,7 @@ __metadata: "@backstage/cli": "workspace:^" "@backstage/config": "workspace:^" "@backstage/errors": "workspace:^" + "@backstage/integration-aws-node": "workspace:^" "@backstage/plugin-auth-node": "workspace:^" "@backstage/plugin-catalog-node": "workspace:^" "@backstage/plugin-kubernetes-common": "workspace:^" @@ -7090,8 +7094,6 @@ __metadata: "@types/express": ^4.17.6 "@types/http-proxy-middleware": ^0.19.3 "@types/luxon": ^3.0.0 - aws-sdk: ^2.840.0 - aws-sdk-mock: ^5.2.1 aws4: ^1.11.0 compression: ^1.7.4 cors: ^2.8.5