diff --git a/plugins/kubernetes-backend/package.json b/plugins/kubernetes-backend/package.json index 6bc7382f54..720321a180 100644 --- a/plugins/kubernetes-backend/package.json +++ b/plugins/kubernetes-backend/package.json @@ -55,7 +55,9 @@ "devDependencies": { "@backstage/cli": "^0.7.4", "@types/aws4": "^1.5.1", - "supertest": "^6.1.3" + "supertest": "^6.1.3", + "aws-sdk-mock": "^5.2.1", + "bdd-lazy-var": "^2.6.0" }, "files": [ "dist", diff --git a/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.test.ts b/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.test.ts index ac4a742828..eb5e6ff605 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.test.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.test.ts @@ -35,6 +35,7 @@ describe('ConfigClusterLocator', () => { const config: Config = new ConfigReader({ clusters: [ { + assumeRole: 'SomeRole', name: 'cluster1', url: 'http://localhost:8080', authProvider: 'serviceAccount', @@ -48,6 +49,7 @@ describe('ConfigClusterLocator', () => { expect(result).toStrictEqual([ { + assumeRole: 'SomeRole', name: 'cluster1', serviceAccountToken: undefined, url: 'http://localhost:8080', @@ -61,6 +63,7 @@ describe('ConfigClusterLocator', () => { const config: Config = new ConfigReader({ clusters: [ { + assumeRole: 'SomeRole', name: 'cluster1', serviceAccountToken: 'token', url: 'http://localhost:8080', @@ -68,6 +71,7 @@ describe('ConfigClusterLocator', () => { skipTLSVerify: false, }, { + assumeRole: undefined, name: 'cluster2', url: 'http://localhost:8081', authProvider: 'google', @@ -82,6 +86,7 @@ describe('ConfigClusterLocator', () => { expect(result).toStrictEqual([ { + assumeRole: 'SomeRole', name: 'cluster1', serviceAccountToken: 'token', url: 'http://localhost:8080', @@ -89,6 +94,7 @@ describe('ConfigClusterLocator', () => { skipTLSVerify: false, }, { + assumeRole: undefined, name: 'cluster2', serviceAccountToken: undefined, url: 'http://localhost:8081', diff --git a/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts b/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts index 268cca0193..d2fdbe4211 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts @@ -35,6 +35,7 @@ export class ConfigClusterLocator implements KubernetesClustersSupplier { serviceAccountToken: c.getOptionalString('serviceAccountToken'), skipTLSVerify: c.getOptionalBoolean('skipTLSVerify') ?? false, authProvider: c.getString('authProvider'), + assumeRole: c.getOptionalString('assumeRole'), }; }), ); diff --git a/plugins/kubernetes-backend/src/cluster-locator/index.test.ts b/plugins/kubernetes-backend/src/cluster-locator/index.test.ts index 95be99a8a5..9725d294a9 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/index.test.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/index.test.ts @@ -27,6 +27,7 @@ describe('getCombinedClusterDetails', () => { type: 'config', clusters: [ { + assumeRole: 'SomeRole', name: 'cluster1', serviceAccountToken: 'token', url: 'http://localhost:8080', @@ -49,6 +50,7 @@ describe('getCombinedClusterDetails', () => { expect(result).toStrictEqual([ { + assumeRole: 'SomeRole', name: 'cluster1', serviceAccountToken: 'token', url: 'http://localhost:8080', @@ -56,6 +58,7 @@ describe('getCombinedClusterDetails', () => { skipTLSVerify: false, }, { + assumeRole: undefined, name: 'cluster2', serviceAccountToken: undefined, url: 'http://localhost:8081', 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 348ad541d5..d5179fc07a 100644 --- a/plugins/kubernetes-backend/src/kubernetes-auth-translator/AwsIamKubernetesAuthTranslator.test.ts +++ b/plugins/kubernetes-backend/src/kubernetes-auth-translator/AwsIamKubernetesAuthTranslator.test.ts @@ -14,15 +14,49 @@ * limitations under the License. */ import AWS from 'aws-sdk'; +import AWSMock from 'aws-sdk-mock'; import { AwsIamKubernetesAuthTranslator } from './AwsIamKubernetesAuthTranslator'; +import { get, def } from 'bdd-lazy-var'; describe('AwsIamKubernetesAuthTranslator tests', () => { + let valid: boolean = true; + let role: any = undefined; + let response: any = { + Credentials: { + AccessKeyId: 'bloop', + SecretAccessKey: 'omg-so-secret', + SessionToken: 'token', + }, + }; + + AWSMock.setSDKInstance(AWS); + beforeEach(() => { jest.resetAllMocks(); }); - it('returns a signed url for aws credentials', async () => { - const authTranslator = new AwsIamKubernetesAuthTranslator(); + afterAll(() => { + jest.resetAllMocks(); + }); + + def('subject', () => { + AWSMock.mock('STS', 'assumeRole', (_params: any, callback: Function) => { + callback(null, response); + }); + + const authTranslator = new AwsIamKubernetesAuthTranslator(); + jest + .spyOn(authTranslator, 'validCredentials') + .mockImplementation(() => valid); + return authTranslator.decorateClusterDetailsWithAuth({ + assumeRole: role, + name: 'test-cluster', + url: '', + authProvider: 'aws', + }); + }); + + 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( @@ -30,24 +64,38 @@ describe('AwsIamKubernetesAuthTranslator tests', () => { 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', ); - const clusterDetails = await authTranslator.decorateClusterDetailsWithAuth({ - name: 'test-cluster', - url: '', - authProvider: 'aws', + const subject = await get('subject'); + expect(subject.serviceAccountToken).toBeDefined(); + }); + + 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 subject = await get('subject'); + expect(subject.serviceAccountToken).toBeDefined(); + }); + }); + + describe('When the role is invalid', () => { + it('returns the original AWS credentials', async () => { + response = undefined; + + await expect(get('subject')).rejects.toThrow(/Unable to assume role:/); + }); }); - expect(clusterDetails.serviceAccountToken).toBeDefined(); }); it('throws when unable to get aws credentials', async () => { + valid = false; AWS.config.credentials = undefined; - const authTranslator = new AwsIamKubernetesAuthTranslator(); - const promise = authTranslator.decorateClusterDetailsWithAuth({ - name: 'test-cluster', - url: '', - authProvider: 'aws', - }); - await expect(promise).rejects.toThrow( - 'Could not load credentials from any providers', - ); + await expect(get('subject')).rejects.toThrow('No AWS credentials found'); }); }); diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/AwsIamKubernetesAuthTranslator.ts b/plugins/kubernetes-backend/src/kubernetes-auth-translator/AwsIamKubernetesAuthTranslator.ts index 0909926d0d..15dfa9e14e 100644 --- a/plugins/kubernetes-backend/src/kubernetes-auth-translator/AwsIamKubernetesAuthTranslator.ts +++ b/plugins/kubernetes-backend/src/kubernetes-auth-translator/AwsIamKubernetesAuthTranslator.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import AWS, { Credentials } from 'aws-sdk'; +import AWS from 'aws-sdk'; import { sign } from 'aws4'; import { ClusterDetails } from '../types/types'; import { KubernetesAuthTranslator } from './types'; @@ -21,31 +21,80 @@ import { KubernetesAuthTranslator } from './types'; const base64 = (str: string) => Buffer.from(str.toString(), 'binary').toString('base64'); const prepend = (prep: string) => (str: string) => prep + str; -const replace = (search: string | RegExp, substitution: string) => ( - str: string, -) => str.replace(search, substitution); -const pipe = (fns: ReadonlyArray) => (thing: string): string => - fns.reduce((val, fn) => fn(val), thing); +const replace = + (search: string | RegExp, substitution: string) => (str: string) => + str.replace(search, substitution); +const pipe = + (fns: ReadonlyArray) => + (thing: string): string => + fns.reduce((val, fn) => fn(val), thing); const removePadding = replace(/=+$/, ''); const makeUrlSafe = pipe([replace('+', '-'), replace('/', '_')]); +type SigningCreds = { + accessKeyId: string | undefined; + secretAccessKey: string | undefined; + sessionToken: string | undefined; +}; + export class AwsIamKubernetesAuthTranslator - implements KubernetesAuthTranslator { - async getBearerToken(clusterName: string): Promise { - const credentials = await new Promise((resolve, reject) => { - AWS.config.getCredentials(err => { + implements KubernetesAuthTranslator +{ + validCredentials(creds: SigningCreds): boolean { + if (!creds.accessKeyId || !creds.secretAccessKey || !creds.sessionToken) { + return false; + } + return true; + } + + async getCredentials(assumeRole: string | undefined): Promise { + return new Promise(async (resolve, reject) => { + await AWS.config.getCredentials(err => { if (err) { + console.error('Unable to load aws config.'); reject(err); - } else { - resolve(AWS.config.credentials); } }); - }); - if (!(credentials instanceof Credentials)) { - throw new Error('no AWS credentials found.'); - } - await credentials.getPromise(); + let creds: SigningCreds = { + accessKeyId: AWS.config.credentials?.accessKeyId, + secretAccessKey: AWS.config.credentials?.secretAccessKey, + sessionToken: AWS.config.credentials?.sessionToken, + }; + + if (!this.validCredentials(creds)) + return reject(Error('No AWS credentials found.')); + if (!assumeRole) return resolve(creds); + + try { + const params = { + RoleArn: assumeRole, + RoleSessionName: 'backstage-login', + }; + 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}`); + return reject(Error(`Unable to assume role: ${e}`)); + } + return resolve(creds); + }); + } + async getBearerToken( + clusterName: string, + assumeRole: string | undefined, + ): Promise { + const credentials = await this.getCredentials(assumeRole); + const request = { host: `sts.amazonaws.com`, path: `/?Action=GetCallerIdentity&Version=2011-06-15&X-Amz-Expires=60`, @@ -54,11 +103,8 @@ export class AwsIamKubernetesAuthTranslator }, signQuery: true, }; - const signedRequest = sign(request, { - accessKeyId: credentials.accessKeyId, - secretAccessKey: credentials.secretAccessKey, - sessionToken: credentials.sessionToken, - }); + + const signedRequest = sign(request, credentials); return pipe([ (signed: any) => `https://${signed.host}${signed.path}`, @@ -79,6 +125,7 @@ export class AwsIamKubernetesAuthTranslator clusterDetailsWithAuthToken.serviceAccountToken = await this.getBearerToken( clusterDetails.name, + clusterDetails.assumeRole, ); return clusterDetailsWithAuthToken; } diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/GoogleKubernetesAuthTranslator.ts b/plugins/kubernetes-backend/src/kubernetes-auth-translator/GoogleKubernetesAuthTranslator.ts index 02a7f314af..62ffd823cf 100644 --- a/plugins/kubernetes-backend/src/kubernetes-auth-translator/GoogleKubernetesAuthTranslator.ts +++ b/plugins/kubernetes-backend/src/kubernetes-auth-translator/GoogleKubernetesAuthTranslator.ts @@ -19,7 +19,8 @@ import { ClusterDetails } from '../types/types'; import { KubernetesRequestBody } from '@backstage/plugin-kubernetes-common'; export class GoogleKubernetesAuthTranslator - implements KubernetesAuthTranslator { + implements KubernetesAuthTranslator +{ async decorateClusterDetailsWithAuth( clusterDetails: ClusterDetails, requestBody: KubernetesRequestBody, diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.test.ts b/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.test.ts index 2d900b0bd1..33592b1c41 100644 --- a/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.test.ts +++ b/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.test.ts @@ -24,23 +24,20 @@ describe('getKubernetesAuthTranslatorInstance', () => { const sut = KubernetesAuthTranslatorGenerator; it('can return an auth translator for google auth', () => { - const authTranslator: KubernetesAuthTranslator = sut.getKubernetesAuthTranslatorInstance( - 'google', - ); + const authTranslator: KubernetesAuthTranslator = + sut.getKubernetesAuthTranslatorInstance('google'); expect(authTranslator instanceof GoogleKubernetesAuthTranslator).toBe(true); }); it('can return an auth translator for aws auth', () => { - const authTranslator: KubernetesAuthTranslator = sut.getKubernetesAuthTranslatorInstance( - 'aws', - ); + const authTranslator: KubernetesAuthTranslator = + sut.getKubernetesAuthTranslatorInstance('aws'); expect(authTranslator instanceof AwsIamKubernetesAuthTranslator).toBe(true); }); it('can return an auth translator for serviceAccount auth', () => { - const authTranslator: KubernetesAuthTranslator = sut.getKubernetesAuthTranslatorInstance( - 'serviceAccount', - ); + const authTranslator: KubernetesAuthTranslator = + sut.getKubernetesAuthTranslatorInstance('serviceAccount'); expect( authTranslator instanceof ServiceAccountKubernetesAuthTranslator, ).toBe(true); diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/ServiceAccountKubernetesAuthTranslator.ts b/plugins/kubernetes-backend/src/kubernetes-auth-translator/ServiceAccountKubernetesAuthTranslator.ts index c433abf4de..098ffe3330 100644 --- a/plugins/kubernetes-backend/src/kubernetes-auth-translator/ServiceAccountKubernetesAuthTranslator.ts +++ b/plugins/kubernetes-backend/src/kubernetes-auth-translator/ServiceAccountKubernetesAuthTranslator.ts @@ -19,7 +19,8 @@ import { ClusterDetails } from '../types/types'; import { KubernetesRequestBody } from '@backstage/plugin-kubernetes-common'; export class ServiceAccountKubernetesAuthTranslator - implements KubernetesAuthTranslator { + implements KubernetesAuthTranslator +{ async decorateClusterDetailsWithAuth( clusterDetails: ClusterDetails, // To ignore TS6133 linting error where it detects 'requestBody' is declared but its value is never read. diff --git a/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts b/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts index 3039560aad..e088fddd41 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts @@ -63,15 +63,15 @@ export class KubernetesFanOutHandler { 'backstage.io/kubernetes-id' ] || requestBody.entity?.metadata?.name; - const clusterDetails: ClusterDetails[] = await this.serviceLocator.getClustersByServiceId( - entityName, - ); + const clusterDetails: ClusterDetails[] = + await this.serviceLocator.getClustersByServiceId(entityName); // Execute all of these async actions simultaneously/without blocking sequentially as no common object is modified by them const promises: Promise[] = clusterDetails.map(cd => { - const kubernetesAuthTranslator: KubernetesAuthTranslator = KubernetesAuthTranslatorGenerator.getKubernetesAuthTranslatorInstance( - cd.authProvider, - ); + const kubernetesAuthTranslator: KubernetesAuthTranslator = + KubernetesAuthTranslatorGenerator.getKubernetesAuthTranslatorInstance( + cd.authProvider, + ); return kubernetesAuthTranslator.decorateClusterDetailsWithAuth( cd, requestBody, diff --git a/plugins/kubernetes-backend/src/service/KubernetesFetcher.ts b/plugins/kubernetes-backend/src/service/KubernetesFetcher.ts index 76c3b0aac0..e17b47e17a 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesFetcher.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesFetcher.ts @@ -167,10 +167,9 @@ export class KubernetesClientBasedFetcher implements KubernetesFetcher { labelSelector, ).then(r => ({ type: type, resources: r })); case 'services': - return this.fetchServicesForService( - clusterDetails, - labelSelector, - ).then(r => ({ type: type, resources: r })); + return this.fetchServicesForService(clusterDetails, labelSelector).then( + r => ({ type: type, resources: r }), + ); case 'horizontalpodautoscalers': return this.fetchHorizontalPodAutoscalersForService( clusterDetails, @@ -192,9 +191,8 @@ export class KubernetesClientBasedFetcher implements KubernetesFetcher { customResource: CustomResource, labelSelector: string, ): Promise { - const customObjects = this.kubernetesClientProvider.getCustomObjectsClient( - clusterDetails, - ); + const customObjects = + this.kubernetesClientProvider.getCustomObjectsClient(clusterDetails); return customObjects .listClusterCustomObject( @@ -217,18 +215,20 @@ export class KubernetesClientBasedFetcher implements KubernetesFetcher { client: Clients, ) => Promise<{ body: { items: Array }; response: http.IncomingMessage }>, ): Promise> { - const core = this.kubernetesClientProvider.getCoreClientByClusterDetails( - clusterDetails, - ); - const apps = this.kubernetesClientProvider.getAppsClientByClusterDetails( - clusterDetails, - ); - const autoscaling = this.kubernetesClientProvider.getAutoscalingClientByClusterDetails( - clusterDetails, - ); - const networkingBeta1 = this.kubernetesClientProvider.getNetworkingBeta1Client( - clusterDetails, - ); + const core = + this.kubernetesClientProvider.getCoreClientByClusterDetails( + clusterDetails, + ); + const apps = + this.kubernetesClientProvider.getAppsClientByClusterDetails( + clusterDetails, + ); + const autoscaling = + this.kubernetesClientProvider.getAutoscalingClientByClusterDetails( + clusterDetails, + ); + const networkingBeta1 = + this.kubernetesClientProvider.getNetworkingBeta1Client(clusterDetails); this.logger.debug(`calling cluster=${clusterDetails.name}`); return fn({ core, apps, autoscaling, networkingBeta1 }).then(({ body }) => { diff --git a/plugins/kubernetes-backend/src/service/router.ts b/plugins/kubernetes-backend/src/service/router.ts index 984b3f14a0..beebd6fa65 100644 --- a/plugins/kubernetes-backend/src/service/router.ts +++ b/plugins/kubernetes-backend/src/service/router.ts @@ -70,9 +70,8 @@ export const makeRouter = ( const serviceId = req.params.serviceId; const requestBody: KubernetesRequestBody = req.body; try { - const response = await kubernetesFanOutHandler.getKubernetesObjectsByEntity( - requestBody, - ); + const response = + await kubernetesFanOutHandler.getKubernetesObjectsByEntity(requestBody); res.json(response); } catch (e) { logger.error( diff --git a/plugins/kubernetes-backend/src/types/types.ts b/plugins/kubernetes-backend/src/types/types.ts index c6e1668a5a..5150eba8cf 100644 --- a/plugins/kubernetes-backend/src/types/types.ts +++ b/plugins/kubernetes-backend/src/types/types.ts @@ -76,4 +76,5 @@ export interface ClusterDetails { authProvider: string; serviceAccountToken?: string | undefined; skipTLSVerify?: boolean; + assumeRole?: string; }