Merge pull request #6468 from RoadieHQ/kubernetes-plugin-assume-role

Add functionality to the kubernetes plugin that allows users to assume role with AWS
This commit is contained in:
Ben Lambert
2021-07-21 16:37:24 +02:00
committed by GitHub
11 changed files with 270 additions and 49 deletions
+23 -1
View File
@@ -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)
+3 -1
View File
@@ -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",
@@ -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,
},
]);
});
});
@@ -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`,
);
}
}
}),
);
}
@@ -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<ClusterDetails[]> {
async getClusters(): Promise<GKEClusterDetails[]> {
const { projectId, region, skipTLSVerify } = this.options;
const request = {
parent: `projects/${projectId}/locations/${region}`,
@@ -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.',
);
});
});
});
@@ -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<any>) => (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<string> {
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<Credentials> => {
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<SigningCreds> {
return new Promise<SigningCreds>(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<string> {
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<ClusterDetails> {
const clusterDetailsWithAuthToken: ClusterDetails = Object.assign(
clusterDetails: AWSClusterDetails,
): Promise<AWSClusterDetails> {
const clusterDetailsWithAuthToken: AWSClusterDetails = Object.assign(
{},
clusterDetails,
);
clusterDetailsWithAuthToken.serviceAccountToken = await this.getBearerToken(
clusterDetails.name,
clusterDetails.assumeRole,
);
return clusterDetailsWithAuthToken;
}
@@ -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<ClusterDetails> {
const clusterDetailsWithAuthToken: ClusterDetails = Object.assign(
): Promise<GKEClusterDetails> {
const clusterDetailsWithAuthToken: GKEClusterDetails = Object.assign(
{},
clusterDetails,
);
@@ -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<ClusterDetails> {
): Promise<ServiceAccountClusterDetails> {
return clusterDetails;
}
}
+11 -1
View File
@@ -27,7 +27,11 @@ export interface CustomResource {
export interface ObjectFetchParams {
serviceId: string;
clusterDetails: ClusterDetails;
clusterDetails:
| AWSClusterDetails
| GKEClusterDetails
| ServiceAccountClusterDetails
| ClusterDetails;
objectTypesToFetch: Set<KubernetesObjectTypes>;
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;
}