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:
@@ -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',
|
||||
|
||||
+64
-16
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
+69
-22
@@ -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;
|
||||
}
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
+6
-9
@@ -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);
|
||||
|
||||
+2
-1
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user