Add functionality to the kubernetes plugin that allows users to assume role

This change adds a new field to the kubernetes plugin configuration that allows a user to configure an assumed role so that they can access other kubernetes cluster.

Signed-off-by: Nicolas Arnold <nic@roadie.io>
This commit is contained in:
Nicolas Arnold
2021-07-14 10:20:23 +01:00
parent ea5631a40d
commit 6d59d17a7f
13 changed files with 184 additions and 78 deletions
+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",
@@ -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',
@@ -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'),
};
}),
);
@@ -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',
@@ -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');
});
});
@@ -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<any>) => (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<any>) =>
(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<string> {
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<SigningCreds> {
return new Promise<SigningCreds>(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<string> {
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;
}
@@ -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,
@@ -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);
@@ -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.
@@ -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>[] = clusterDetails.map(cd => {
const kubernetesAuthTranslator: KubernetesAuthTranslator = KubernetesAuthTranslatorGenerator.getKubernetesAuthTranslatorInstance(
cd.authProvider,
);
const kubernetesAuthTranslator: KubernetesAuthTranslator =
KubernetesAuthTranslatorGenerator.getKubernetesAuthTranslatorInstance(
cd.authProvider,
);
return kubernetesAuthTranslator.decorateClusterDetailsWithAuth(
cd,
requestBody,
@@ -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<FetchResponse> {
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<T> }; response: http.IncomingMessage }>,
): Promise<Array<T>> {
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 }) => {
@@ -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(
@@ -76,4 +76,5 @@ export interface ClusterDetails {
authProvider: string;
serviceAccountToken?: string | undefined;
skipTLSVerify?: boolean;
assumeRole?: string;
}