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:
@@ -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)
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
+83
-17
@@ -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.',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+71
-18
@@ -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;
|
||||
}
|
||||
|
||||
+4
-4
@@ -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,
|
||||
);
|
||||
|
||||
+3
-3
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user