diff --git a/.changeset/blue-feet-poke.md b/.changeset/blue-feet-poke.md new file mode 100644 index 0000000000..1a9e32a11a --- /dev/null +++ b/.changeset/blue-feet-poke.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-kubernetes-backend': patch +--- + +Support assume role on kubernetes api configuration for AWS. diff --git a/plugins/kubernetes-backend/api-report.md b/plugins/kubernetes-backend/api-report.md index 1592f20880..72d80050d8 100644 --- a/plugins/kubernetes-backend/api-report.md +++ b/plugins/kubernetes-backend/api-report.md @@ -10,6 +10,14 @@ import { KubernetesFetchError } from '@backstage/plugin-kubernetes-common'; import { KubernetesRequestBody } from '@backstage/plugin-kubernetes-common'; import { Logger as Logger_2 } from 'winston'; +// Warning: (ae-missing-release-tag) "AWSClusterDetails" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface AWSClusterDetails extends ClusterDetails { + // (undocumented) + assumeRole?: string; +} + // Warning: (ae-missing-release-tag) "ClusterDetails" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -53,6 +61,11 @@ export interface FetchResponseWrapper { responses: FetchResponse[]; } +// Warning: (ae-missing-release-tag) "GKEClusterDetails" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface GKEClusterDetails extends ClusterDetails {} + // Warning: (ae-missing-release-tag) "KubernetesClustersSupplier" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -107,7 +120,11 @@ export const makeRouter: ( // @public (undocumented) export interface ObjectFetchParams { // (undocumented) - clusterDetails: ClusterDetails; + clusterDetails: + | AWSClusterDetails + | GKEClusterDetails + | ServiceAccountClusterDetails + | ClusterDetails; // (undocumented) customResources: CustomResource[]; // (undocumented) @@ -130,6 +147,11 @@ export interface RouterOptions { logger: Logger_2; } +// Warning: (ae-missing-release-tag) "ServiceAccountClusterDetails" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface ServiceAccountClusterDetails extends ClusterDetails {} + // Warning: (ae-missing-release-tag) "ServiceLocatorMethod" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) 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..1642ca5580 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.test.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.test.ts @@ -97,4 +97,48 @@ describe('ConfigClusterLocator', () => { }, ]); }); + + it('one aws cluster with assumeRole and one without', async () => { + const config: Config = new ConfigReader({ + clusters: [ + { + name: 'cluster1', + serviceAccountToken: 'token', + url: 'http://localhost:8080', + authProvider: 'aws', + skipTLSVerify: false, + }, + { + assumeRole: 'SomeRole', + name: 'cluster2', + url: 'http://localhost:8081', + authProvider: 'aws', + skipTLSVerify: true, + }, + ], + }); + + const sut = ConfigClusterLocator.fromConfig(config); + + const result = await sut.getClusters(); + + expect(result).toStrictEqual([ + { + assumeRole: undefined, + name: 'cluster1', + serviceAccountToken: 'token', + url: 'http://localhost:8080', + authProvider: 'aws', + skipTLSVerify: false, + }, + { + assumeRole: 'SomeRole', + name: 'cluster2', + serviceAccountToken: undefined, + url: 'http://localhost:8081', + authProvider: 'aws', + skipTLSVerify: true, + }, + ]); + }); }); diff --git a/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts b/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts index 268cca0193..2899445b2a 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts @@ -29,13 +29,32 @@ export class ConfigClusterLocator implements KubernetesClustersSupplier { // is required if authProvider is serviceAccount return new ConfigClusterLocator( config.getConfigArray('clusters').map(c => { - return { + const authProvider = c.getString('authProvider'); + const clusterDetails = { name: c.getString('name'), url: c.getString('url'), serviceAccountToken: c.getOptionalString('serviceAccountToken'), skipTLSVerify: c.getOptionalBoolean('skipTLSVerify') ?? false, - authProvider: c.getString('authProvider'), + authProvider: authProvider, }; + + switch (authProvider) { + case 'google': { + return clusterDetails; + } + case 'aws': { + const assumeRole = c.getOptionalString('assumeRole'); + return { assumeRole, ...clusterDetails }; + } + case 'serviceAccount': { + return clusterDetails; + } + default: { + throw new Error( + `authProvider "${authProvider}" has no config associated with it`, + ); + } + } }), ); } diff --git a/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.ts b/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.ts index 92212d0224..3cc6c216ae 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.ts @@ -16,7 +16,7 @@ import { Config } from '@backstage/config'; import * as container from '@google-cloud/container'; -import { ClusterDetails, KubernetesClustersSupplier } from '../types/types'; +import { GKEClusterDetails, KubernetesClustersSupplier } from '../types/types'; type GkeClusterLocatorOptions = { projectId: string; @@ -49,7 +49,7 @@ export class GkeClusterLocator implements KubernetesClustersSupplier { ); } - async getClusters(): Promise { + async getClusters(): Promise { const { projectId, region, skipTLSVerify } = this.options; const request = { parent: `projects/${projectId}/locations/${region}`, 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..9060ce9239 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,58 @@ * 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 role: any = undefined; + const credentials: any = { + accessKeyId: 'bloop', + secretAccessKey: 'omg-so-secret', + sessionToken: 'token', + }; + + let assumeResponse: any = { + Credentials: { + AccessKeyId: credentials.accessKeyId, + SecretAccessKey: credentials.secretAccessKey, + SessionToken: credentials.sessionToken, + }, + }; + + let credentialsResponse: any = new AWS.Credentials(credentials); + + AWSMock.setSDKInstance(AWS); + beforeEach(() => { jest.resetAllMocks(); }); - it('returns a signed url for aws credentials', async () => { + + afterAll(() => { + jest.resetAllMocks(); + }); + + def('subject', () => { + AWSMock.mock('STS', 'assumeRole', (_params: any, callback: Function) => { + callback(null, assumeResponse); + }); + const authTranslator = new AwsIamKubernetesAuthTranslator(); + jest + .spyOn(authTranslator, 'awsGetCredentials') + .mockImplementation(async () => credentialsResponse); + + 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 +73,47 @@ describe('AwsIamKubernetesAuthTranslator tests', () => { 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', ); - const clusterDetails = await authTranslator.decorateClusterDetailsWithAuth({ - name: 'test-cluster', - url: '', - authProvider: 'aws', - }); - expect(clusterDetails.serviceAccountToken).toBeDefined(); + const subject = await get('subject'); + expect(subject.serviceAccountToken).toBeDefined(); }); - it('throws when unable to get aws credentials', async () => { - 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', + 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 () => { + assumeResponse = undefined; + await expect(get('subject')).rejects.toThrow(/Unable to assume role:/); + }); + }); + }); + + describe('When no creds are returned from AWS', () => { + it('throws unable to get aws credentials', async () => { + credentialsResponse = new Error(); + await expect(get('subject')).rejects.toThrow('No AWS credentials found.'); + }); + }); + + describe('When invalid creds are returned from AWS', () => { + it('throws credentials are invalid to get aws credentials', async () => { + credentialsResponse = new AWS.Credentials(credentialsResponse); + await expect(get('subject')).rejects.toThrow( + 'Invalid 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..101bed32a3 100644 --- a/plugins/kubernetes-backend/src/kubernetes-auth-translator/AwsIamKubernetesAuthTranslator.ts +++ b/plugins/kubernetes-backend/src/kubernetes-auth-translator/AwsIamKubernetesAuthTranslator.ts @@ -15,7 +15,7 @@ */ import AWS, { Credentials } from 'aws-sdk'; import { sign } from 'aws4'; -import { ClusterDetails } from '../types/types'; +import { AWSClusterDetails } from '../types/types'; import { KubernetesAuthTranslator } from './types'; const base64 = (str: string) => @@ -29,23 +29,78 @@ const pipe = (fns: ReadonlyArray) => (thing: string): string => 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) => { + validCredentials(creds: SigningCreds): boolean { + return ((creds?.accessKeyId && + creds?.secretAccessKey && + creds?.sessionToken) as unknown) as boolean; + } + + awsGetCredentials = async (): Promise => { + return new Promise((resolve, reject) => { AWS.config.getCredentials(err => { if (err) { - reject(err); - } else { - resolve(AWS.config.credentials); + return reject(err); } + + return resolve(AWS.config.credentials as Credentials); }); }); + }; + + async getCredentials(assumeRole: string | undefined): Promise { + return new Promise(async (resolve, reject) => { + const awsCreds = await this.awsGetCredentials(); + + if (!(awsCreds instanceof Credentials)) + return reject(Error('No AWS credentials found.')); + + let creds: SigningCreds = { + accessKeyId: awsCreds.accessKeyId, + secretAccessKey: awsCreds.secretAccessKey, + sessionToken: awsCreds.sessionToken, + }; + + if (!this.validCredentials(creds)) + return reject(Error('Invalid 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); - if (!(credentials instanceof Credentials)) { - throw new Error('no AWS credentials found.'); - } - await credentials.getPromise(); const request = { host: `sts.amazonaws.com`, path: `/?Action=GetCallerIdentity&Version=2011-06-15&X-Amz-Expires=60`, @@ -54,11 +109,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}`, @@ -70,15 +122,16 @@ export class AwsIamKubernetesAuthTranslator } async decorateClusterDetailsWithAuth( - clusterDetails: ClusterDetails, - ): Promise { - const clusterDetailsWithAuthToken: ClusterDetails = Object.assign( + clusterDetails: AWSClusterDetails, + ): Promise { + const clusterDetailsWithAuthToken: AWSClusterDetails = Object.assign( {}, clusterDetails, ); 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..eacba4d3e1 100644 --- a/plugins/kubernetes-backend/src/kubernetes-auth-translator/GoogleKubernetesAuthTranslator.ts +++ b/plugins/kubernetes-backend/src/kubernetes-auth-translator/GoogleKubernetesAuthTranslator.ts @@ -15,16 +15,16 @@ */ import { KubernetesAuthTranslator } from './types'; -import { ClusterDetails } from '../types/types'; +import { GKEClusterDetails } from '../types/types'; import { KubernetesRequestBody } from '@backstage/plugin-kubernetes-common'; export class GoogleKubernetesAuthTranslator implements KubernetesAuthTranslator { async decorateClusterDetailsWithAuth( - clusterDetails: ClusterDetails, + clusterDetails: GKEClusterDetails, requestBody: KubernetesRequestBody, - ): Promise { - const clusterDetailsWithAuthToken: ClusterDetails = Object.assign( + ): Promise { + const clusterDetailsWithAuthToken: GKEClusterDetails = Object.assign( {}, clusterDetails, ); diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/ServiceAccountKubernetesAuthTranslator.ts b/plugins/kubernetes-backend/src/kubernetes-auth-translator/ServiceAccountKubernetesAuthTranslator.ts index c433abf4de..1c3add3c0d 100644 --- a/plugins/kubernetes-backend/src/kubernetes-auth-translator/ServiceAccountKubernetesAuthTranslator.ts +++ b/plugins/kubernetes-backend/src/kubernetes-auth-translator/ServiceAccountKubernetesAuthTranslator.ts @@ -15,18 +15,18 @@ */ import { KubernetesAuthTranslator } from './types'; -import { ClusterDetails } from '../types/types'; +import { ServiceAccountClusterDetails } from '../types/types'; import { KubernetesRequestBody } from '@backstage/plugin-kubernetes-common'; export class ServiceAccountKubernetesAuthTranslator implements KubernetesAuthTranslator { async decorateClusterDetailsWithAuth( - clusterDetails: ClusterDetails, + clusterDetails: ServiceAccountClusterDetails, // To ignore TS6133 linting error where it detects 'requestBody' is declared but its value is never read. // @ts-ignore-start requestBody: KubernetesRequestBody, // eslint-disable-line @typescript-eslint/no-unused-vars // @ts-ignore-end - ): Promise { + ): Promise { return clusterDetails; } } diff --git a/plugins/kubernetes-backend/src/types/types.ts b/plugins/kubernetes-backend/src/types/types.ts index c6e1668a5a..6409229968 100644 --- a/plugins/kubernetes-backend/src/types/types.ts +++ b/plugins/kubernetes-backend/src/types/types.ts @@ -27,7 +27,11 @@ export interface CustomResource { export interface ObjectFetchParams { serviceId: string; - clusterDetails: ClusterDetails; + clusterDetails: + | AWSClusterDetails + | GKEClusterDetails + | ServiceAccountClusterDetails + | ClusterDetails; objectTypesToFetch: Set; labelSelector: string; customResources: CustomResource[]; @@ -77,3 +81,9 @@ export interface ClusterDetails { serviceAccountToken?: string | undefined; skipTLSVerify?: boolean; } + +export interface GKEClusterDetails extends ClusterDetails {} +export interface ServiceAccountClusterDetails extends ClusterDetails {} +export interface AWSClusterDetails extends ClusterDetails { + assumeRole?: string; +}