change decorateClusterDetailsWithAuth method to use the authTranslator provided by the KubernetesFanOutHandler constructor
Signed-off-by: Ruben Vallejo <rvallejo@vmware.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-kubernetes-backend': minor
|
||||
---
|
||||
|
||||
Plugins that instantiate the `KubernetesProxy` must now provide a parameter of the type `KubernetesProxyOptions` which includes providing a `KubernetesAuthTranslator`. The `KubernetesBuilder` now builds its own `KubernetesAuthTranslatorMap` that it provides to the `KubernetesProxy`. The `DispatchingKubernetesAuthTranslator` expects a `KubernetesTranslatorMap` to be provided as a parameter. The `KubernetesBuilder` now has a method called `setAuthTranslatorMap` which allows integrators to bring their own `KubernetesAuthTranslator's` to the `KubernetesPlugin`.
|
||||
@@ -110,6 +110,25 @@ export interface CustomResourcesByEntity extends KubernetesObjectsByEntity {
|
||||
// @public (undocumented)
|
||||
export const DEFAULT_OBJECTS: ObjectToFetch[];
|
||||
|
||||
// @public
|
||||
export class DispatchingKubernetesAuthTranslator
|
||||
implements KubernetesAuthTranslator
|
||||
{
|
||||
constructor(options: DispatchingKubernetesAuthTranslatorOptions);
|
||||
// (undocumented)
|
||||
decorateClusterDetailsWithAuth(
|
||||
clusterDetails: ClusterDetails,
|
||||
auth: KubernetesRequestAuth,
|
||||
): Promise<ClusterDetails>;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export type DispatchingKubernetesAuthTranslatorOptions = {
|
||||
authTranslatorMap: {
|
||||
[key: string]: KubernetesAuthTranslator;
|
||||
};
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export interface FetchResponseWrapper {
|
||||
// (undocumented)
|
||||
@@ -157,23 +176,16 @@ export interface KubernetesAuthTranslator {
|
||||
): Promise<ClusterDetails>;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export class KubernetesAuthTranslatorGenerator {
|
||||
// (undocumented)
|
||||
static getKubernetesAuthTranslatorInstance(
|
||||
authProvider: string,
|
||||
options: {
|
||||
logger: Logger;
|
||||
},
|
||||
): KubernetesAuthTranslator;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export class KubernetesBuilder {
|
||||
constructor(env: KubernetesEnvironment);
|
||||
// (undocumented)
|
||||
build(): KubernetesBuilderReturn;
|
||||
// (undocumented)
|
||||
protected buildAuthTranslatorMap(): {
|
||||
[key: string]: KubernetesAuthTranslator;
|
||||
};
|
||||
// (undocumented)
|
||||
protected buildClusterSupplier(
|
||||
refreshInterval: Duration,
|
||||
): KubernetesClustersSupplier;
|
||||
@@ -220,6 +232,10 @@ export class KubernetesBuilder {
|
||||
clusterSupplier: KubernetesClustersSupplier,
|
||||
): Promise<ClusterDetails[]>;
|
||||
// (undocumented)
|
||||
protected getAuthTranslatorMap(): {
|
||||
[key: string]: KubernetesAuthTranslator;
|
||||
};
|
||||
// (undocumented)
|
||||
protected getClusterSupplier(): KubernetesClustersSupplier;
|
||||
// (undocumented)
|
||||
protected getFetcher(): KubernetesFetcher;
|
||||
@@ -239,6 +255,10 @@ export class KubernetesBuilder {
|
||||
// (undocumented)
|
||||
protected getServiceLocatorMethod(): ServiceLocatorMethod;
|
||||
// (undocumented)
|
||||
setAuthTranslatorMap(authTranslatorMap: {
|
||||
[key: string]: KubernetesAuthTranslator;
|
||||
}): void;
|
||||
// (undocumented)
|
||||
setClusterSupplier(clusterSupplier?: KubernetesClustersSupplier): this;
|
||||
// (undocumented)
|
||||
setDefaultClusterRefreshInterval(refreshInterval: Duration): this;
|
||||
@@ -261,6 +281,9 @@ export type KubernetesBuilderReturn = Promise<{
|
||||
proxy: KubernetesProxy;
|
||||
objectsProvider: KubernetesObjectsProvider;
|
||||
serviceLocator: KubernetesServiceLocator;
|
||||
authTranslatorMap: {
|
||||
[key: string]: KubernetesAuthTranslator;
|
||||
};
|
||||
}>;
|
||||
|
||||
// @public
|
||||
@@ -345,7 +368,7 @@ export type KubernetesObjectTypes =
|
||||
|
||||
// @public
|
||||
export class KubernetesProxy {
|
||||
constructor(logger: Logger, clusterSupplier: KubernetesClustersSupplier);
|
||||
constructor(options: KubernetesProxyOptions);
|
||||
// (undocumented)
|
||||
createRequestHandler(
|
||||
options: KubernetesProxyCreateRequestHandlerOptions,
|
||||
@@ -357,6 +380,13 @@ export type KubernetesProxyCreateRequestHandlerOptions = {
|
||||
permissionApi: PermissionEvaluator;
|
||||
};
|
||||
|
||||
// @public
|
||||
export type KubernetesProxyOptions = {
|
||||
logger: Logger;
|
||||
clusterSupplier: KubernetesClustersSupplier;
|
||||
authTranslator: KubernetesAuthTranslator;
|
||||
};
|
||||
|
||||
// @public
|
||||
export interface KubernetesServiceLocator {
|
||||
// (undocumented)
|
||||
|
||||
+4
-4
@@ -31,7 +31,7 @@ describe('decorateClusterDetailsWithAuth', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('can decorate cluster details if the auth provider is in the translator map', () => {
|
||||
it('can decorate cluster details if the auth provider is in the translator map', async () => {
|
||||
const expectedClusterDetails: ClusterDetails = {
|
||||
url: 'notanything.com',
|
||||
name: 'randomName',
|
||||
@@ -39,11 +39,11 @@ describe('decorateClusterDetailsWithAuth', () => {
|
||||
serviceAccountToken: 'added by mock translator',
|
||||
};
|
||||
|
||||
mockTranslator.decorateClusterDetailsWithAuth.mockReturnValue(
|
||||
expectedClusterDetails as unknown as Promise<ClusterDetails>,
|
||||
mockTranslator.decorateClusterDetailsWithAuth.mockResolvedValue(
|
||||
expectedClusterDetails,
|
||||
);
|
||||
|
||||
const returnedValue = authTranslator.decorateClusterDetailsWithAuth(
|
||||
const returnedValue = await authTranslator.decorateClusterDetailsWithAuth(
|
||||
{ name: 'googleCluster', url: 'anything.com', authProvider: 'google' },
|
||||
authObject,
|
||||
);
|
||||
|
||||
+2
-2
@@ -22,7 +22,7 @@ import { KubernetesRequestAuth } from '@backstage/plugin-kubernetes-common';
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type KubernetesAuthTranslatorGeneratorOptions = {
|
||||
export type DispatchingKubernetesAuthTranslatorOptions = {
|
||||
authTranslatorMap: {
|
||||
[key: string]: KubernetesAuthTranslator;
|
||||
};
|
||||
@@ -36,7 +36,7 @@ export class DispatchingKubernetesAuthTranslator
|
||||
{
|
||||
private readonly translatorMap: { [key: string]: KubernetesAuthTranslator };
|
||||
|
||||
constructor(options: KubernetesAuthTranslatorGeneratorOptions) {
|
||||
constructor(options: DispatchingKubernetesAuthTranslatorOptions) {
|
||||
this.translatorMap = options.authTranslatorMap;
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,17 @@ import { Duration } from 'luxon';
|
||||
import { Logger } from 'winston';
|
||||
|
||||
import { getCombinedClusterSupplier } from '../cluster-locator';
|
||||
import {
|
||||
KubernetesAuthTranslator,
|
||||
DispatchingKubernetesAuthTranslator,
|
||||
GoogleKubernetesAuthTranslator,
|
||||
NoopKubernetesAuthTranslator,
|
||||
AwsIamKubernetesAuthTranslator,
|
||||
GoogleServiceAccountAuthTranslator,
|
||||
AzureIdentityKubernetesAuthTranslator,
|
||||
OidcKubernetesAuthTranslator,
|
||||
} from '../kubernetes-auth-translator';
|
||||
|
||||
import { addResourceRoutesToRouter } from '../routes/resourcesRoutes';
|
||||
import { MultiTenantServiceLocator } from '../service-locator/MultiTenantServiceLocator';
|
||||
import {
|
||||
@@ -68,6 +79,7 @@ export type KubernetesBuilderReturn = Promise<{
|
||||
proxy: KubernetesProxy;
|
||||
objectsProvider: KubernetesObjectsProvider;
|
||||
serviceLocator: KubernetesServiceLocator;
|
||||
authTranslatorMap: { [key: string]: KubernetesAuthTranslator };
|
||||
}>;
|
||||
|
||||
/**
|
||||
@@ -83,6 +95,7 @@ export class KubernetesBuilder {
|
||||
private fetcher?: KubernetesFetcher;
|
||||
private serviceLocator?: KubernetesServiceLocator;
|
||||
private proxy?: KubernetesProxy;
|
||||
private authTranslatorMap?: { [key: string]: KubernetesAuthTranslator };
|
||||
|
||||
static createBuilder(env: KubernetesEnvironment) {
|
||||
return new KubernetesBuilder(env);
|
||||
@@ -114,6 +127,8 @@ export class KubernetesBuilder {
|
||||
|
||||
const clusterSupplier = this.getClusterSupplier();
|
||||
|
||||
const authTranslatorMap = this.getAuthTranslatorMap();
|
||||
|
||||
const proxy = this.getProxy(logger, clusterSupplier);
|
||||
|
||||
const serviceLocator = this.getServiceLocator();
|
||||
@@ -142,6 +157,7 @@ export class KubernetesBuilder {
|
||||
objectsProvider,
|
||||
router,
|
||||
serviceLocator,
|
||||
authTranslatorMap,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -175,6 +191,12 @@ export class KubernetesBuilder {
|
||||
return this;
|
||||
}
|
||||
|
||||
public setAuthTranslatorMap(authTranslatorMap: {
|
||||
[key: string]: KubernetesAuthTranslator;
|
||||
}) {
|
||||
this.authTranslatorMap = authTranslatorMap;
|
||||
}
|
||||
|
||||
protected buildCustomResources() {
|
||||
const customResources: CustomResource[] = (
|
||||
this.env.config.getOptionalConfigArray('kubernetes.customResources') ?? []
|
||||
@@ -210,7 +232,14 @@ export class KubernetesBuilder {
|
||||
protected buildObjectsProvider(
|
||||
options: KubernetesObjectsProviderOptions,
|
||||
): KubernetesObjectsProvider {
|
||||
this.objectsProvider = new KubernetesFanOutHandler(options);
|
||||
const authTranslatorMap = this.getAuthTranslatorMap();
|
||||
this.objectsProvider = new KubernetesFanOutHandler({
|
||||
...options,
|
||||
authTranslator: new DispatchingKubernetesAuthTranslator({
|
||||
authTranslatorMap,
|
||||
}),
|
||||
});
|
||||
|
||||
return this.objectsProvider;
|
||||
}
|
||||
|
||||
@@ -259,7 +288,15 @@ export class KubernetesBuilder {
|
||||
logger: Logger,
|
||||
clusterSupplier: KubernetesClustersSupplier,
|
||||
): KubernetesProxy {
|
||||
this.proxy = new KubernetesProxy(logger, clusterSupplier);
|
||||
const authTranslatorMap = this.getAuthTranslatorMap();
|
||||
const authTranslator = new DispatchingKubernetesAuthTranslator({
|
||||
authTranslatorMap,
|
||||
});
|
||||
this.proxy = new KubernetesProxy({
|
||||
logger,
|
||||
clusterSupplier,
|
||||
authTranslator,
|
||||
});
|
||||
return this.proxy;
|
||||
}
|
||||
|
||||
@@ -314,6 +351,19 @@ export class KubernetesBuilder {
|
||||
return router;
|
||||
}
|
||||
|
||||
protected buildAuthTranslatorMap() {
|
||||
this.authTranslatorMap = {
|
||||
google: new GoogleKubernetesAuthTranslator(),
|
||||
aws: new AwsIamKubernetesAuthTranslator(),
|
||||
azure: new AzureIdentityKubernetesAuthTranslator(this.env.logger),
|
||||
serviceAccount: new NoopKubernetesAuthTranslator(),
|
||||
googleServiceAccount: new GoogleServiceAccountAuthTranslator(),
|
||||
oidc: new OidcKubernetesAuthTranslator(),
|
||||
localKubectlProxy: new NoopKubernetesAuthTranslator(),
|
||||
};
|
||||
return this.authTranslatorMap;
|
||||
}
|
||||
|
||||
protected async fetchClusterDetails(
|
||||
clusterSupplier: KubernetesClustersSupplier,
|
||||
) {
|
||||
@@ -393,4 +443,8 @@ export class KubernetesBuilder {
|
||||
) {
|
||||
return this.proxy ?? this.buildProxy(logger, clusterSupplier);
|
||||
}
|
||||
|
||||
protected getAuthTranslatorMap() {
|
||||
return this.authTranslatorMap ?? this.buildAuthTranslatorMap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,6 +166,11 @@ function getKubernetesFanOutHandler(customResources: CustomResource[]) {
|
||||
getClustersByEntity,
|
||||
},
|
||||
customResources: customResources,
|
||||
authTranslator: {
|
||||
decorateClusterDetailsWithAuth: async (clusterDetails, _) => {
|
||||
return clusterDetails;
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -879,6 +884,11 @@ describe('getKubernetesObjectsByEntity', () => {
|
||||
objectType: 'services',
|
||||
},
|
||||
],
|
||||
authTranslator: {
|
||||
decorateClusterDetailsWithAuth: async (clusterDetails, _) => {
|
||||
return clusterDetails;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await sut.getKubernetesObjectsByEntity({
|
||||
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
ServiceLocatorRequestContext,
|
||||
} from '../types/types';
|
||||
import { KubernetesAuthTranslator } from '../kubernetes-auth-translator/types';
|
||||
import { KubernetesAuthTranslatorGenerator } from '../kubernetes-auth-translator/KubernetesAuthTranslatorGenerator';
|
||||
import {
|
||||
ClientContainerStatus,
|
||||
ClientCurrentResourceUsage,
|
||||
@@ -137,7 +136,9 @@ export const DEFAULT_OBJECTS: ObjectToFetch[] = [
|
||||
];
|
||||
|
||||
export interface KubernetesFanOutHandlerOptions
|
||||
extends KubernetesObjectsProviderOptions {}
|
||||
extends KubernetesObjectsProviderOptions {
|
||||
authTranslator: KubernetesAuthTranslator;
|
||||
}
|
||||
|
||||
export interface KubernetesRequestBody extends ObjectsByEntityRequest {}
|
||||
|
||||
@@ -195,7 +196,7 @@ export class KubernetesFanOutHandler {
|
||||
private readonly serviceLocator: KubernetesServiceLocator;
|
||||
private readonly customResources: CustomResource[];
|
||||
private readonly objectTypesToFetch: Set<ObjectToFetch>;
|
||||
private readonly authTranslators: Record<string, KubernetesAuthTranslator>;
|
||||
private readonly authTranslator: KubernetesAuthTranslator;
|
||||
|
||||
constructor({
|
||||
logger,
|
||||
@@ -203,13 +204,14 @@ export class KubernetesFanOutHandler {
|
||||
serviceLocator,
|
||||
customResources,
|
||||
objectTypesToFetch = DEFAULT_OBJECTS,
|
||||
authTranslator,
|
||||
}: KubernetesFanOutHandlerOptions) {
|
||||
this.logger = logger;
|
||||
this.fetcher = fetcher;
|
||||
this.serviceLocator = serviceLocator;
|
||||
this.customResources = customResources;
|
||||
this.objectTypesToFetch = new Set(objectTypesToFetch);
|
||||
this.authTranslators = {};
|
||||
this.authTranslator = authTranslator;
|
||||
}
|
||||
|
||||
async getCustomResourcesByEntity({
|
||||
@@ -313,12 +315,7 @@ export class KubernetesFanOutHandler {
|
||||
// Execute all of these async actions simultaneously/without blocking sequentially as no common object is modified by them
|
||||
const promiseResults = await Promise.allSettled(
|
||||
clusterDetails.map(cd => {
|
||||
const kubernetesAuthTranslator: KubernetesAuthTranslator =
|
||||
this.getAuthTranslator(cd.authProvider);
|
||||
return kubernetesAuthTranslator.decorateClusterDetailsWithAuth(
|
||||
cd,
|
||||
auth,
|
||||
);
|
||||
return this.authTranslator.decorateClusterDetailsWithAuth(cd, auth);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -393,19 +390,4 @@ export class KubernetesFanOutHandler {
|
||||
result.errors.push(...podMetrics.errors);
|
||||
return [result, podMetrics.responses as PodStatusFetchResponse[]];
|
||||
}
|
||||
|
||||
private getAuthTranslator(provider: string): KubernetesAuthTranslator {
|
||||
if (this.authTranslators[provider]) {
|
||||
return this.authTranslators[provider];
|
||||
}
|
||||
|
||||
this.authTranslators[provider] =
|
||||
KubernetesAuthTranslatorGenerator.getKubernetesAuthTranslatorInstance(
|
||||
provider,
|
||||
{
|
||||
logger: this.logger,
|
||||
},
|
||||
);
|
||||
return this.authTranslators[provider];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,16 +28,19 @@ import { ClusterDetails, KubernetesClustersSupplier } from '../types/types';
|
||||
import {
|
||||
APPLICATION_JSON,
|
||||
HEADER_KUBERNETES_CLUSTER,
|
||||
HEADER_KUBERNETES_AUTH,
|
||||
KubernetesProxy,
|
||||
} from './KubernetesProxy';
|
||||
import {
|
||||
AuthorizeResult,
|
||||
PermissionEvaluator,
|
||||
} from '@backstage/plugin-permission-common';
|
||||
import { KubernetesAuthTranslator } from '../kubernetes-auth-translator';
|
||||
|
||||
describe('KubernetesProxy', () => {
|
||||
let proxy: KubernetesProxy;
|
||||
const worker = setupServer();
|
||||
const logger = getVoidLogger();
|
||||
|
||||
setupRequestMockHandlers(worker);
|
||||
|
||||
@@ -73,9 +76,13 @@ describe('KubernetesProxy', () => {
|
||||
authorizeConditional: jest.fn(),
|
||||
};
|
||||
|
||||
const authTranslator: jest.Mocked<KubernetesAuthTranslator> = {
|
||||
decorateClusterDetailsWithAuth: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
proxy = new KubernetesProxy(getVoidLogger(), clusterSupplier);
|
||||
proxy = new KubernetesProxy({ logger, clusterSupplier, authTranslator });
|
||||
});
|
||||
|
||||
it('should return a ERROR_NOT_FOUND if no clusters are found', async () => {
|
||||
@@ -112,21 +119,30 @@ describe('KubernetesProxy', () => {
|
||||
authProvider: 'serviceAccount',
|
||||
},
|
||||
] as ClusterDetails[]);
|
||||
|
||||
permissionApi.authorize.mockReturnValue(
|
||||
Promise.resolve([{ result: AuthorizeResult.ALLOW }]),
|
||||
);
|
||||
|
||||
authTranslator.decorateClusterDetailsWithAuth.mockResolvedValue({
|
||||
name: 'cluster1',
|
||||
url: 'https://localhost:9999',
|
||||
serviceAccountToken: '',
|
||||
authProvider: 'serviceAccount',
|
||||
} as ClusterDetails);
|
||||
|
||||
const app = express().use(
|
||||
'/mountpath',
|
||||
proxy.createRequestHandler({ permissionApi }),
|
||||
);
|
||||
permissionApi.authorize.mockReturnValue(
|
||||
Promise.resolve([{ result: AuthorizeResult.ALLOW }]),
|
||||
);
|
||||
const requestPromise = request(app)
|
||||
.get('/mountpath/api')
|
||||
.set(HEADER_KUBERNETES_CLUSTER, 'cluster1');
|
||||
worker.use(
|
||||
rest.get('https://localhost:9999/api', (_, res, ctx) =>
|
||||
rest.get('https://localhost:9999/api', (_: any, res: any, ctx: any) =>
|
||||
res(ctx.status(299), ctx.json(apiResponse)),
|
||||
),
|
||||
rest.all(requestPromise.url, (req, _res, _ctx) => req.passthrough()),
|
||||
rest.all(requestPromise.url, (req: any) => req.passthrough()),
|
||||
);
|
||||
|
||||
const response = await requestPromise;
|
||||
@@ -134,4 +150,188 @@ describe('KubernetesProxy', () => {
|
||||
expect(response.status).toEqual(299);
|
||||
expect(response.body).toStrictEqual(apiResponse);
|
||||
});
|
||||
|
||||
it('should default to using a provided authorization header', async () => {
|
||||
worker.use(
|
||||
rest.get(
|
||||
'https://localhost:9999/api/v1/namespaces',
|
||||
(req: any, res: any, ctx: any) => {
|
||||
if (!req.headers.get('Authorization')) {
|
||||
return res(ctx.status(401));
|
||||
}
|
||||
|
||||
if (req.headers.get('Authorization') !== 'my-token') {
|
||||
return res(ctx.status(403));
|
||||
}
|
||||
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
kind: 'NamespaceList',
|
||||
apiVersion: 'v1',
|
||||
items: [],
|
||||
}),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
permissionApi.authorize.mockReturnValue(
|
||||
Promise.resolve([{ result: AuthorizeResult.ALLOW }]),
|
||||
);
|
||||
|
||||
clusterSupplier.getClusters.mockResolvedValue([
|
||||
{
|
||||
name: 'cluster1',
|
||||
url: 'https://localhost:9999',
|
||||
serviceAccountToken: '',
|
||||
authProvider: 'serviceAccount',
|
||||
},
|
||||
] as ClusterDetails[]);
|
||||
|
||||
authTranslator.decorateClusterDetailsWithAuth.mockResolvedValue({
|
||||
name: 'cluster1',
|
||||
url: 'https://localhost:9999',
|
||||
serviceAccountToken: 'random-token',
|
||||
authProvider: 'serviceAccount',
|
||||
} as ClusterDetails);
|
||||
|
||||
const app = express().use(
|
||||
'/mountpath',
|
||||
proxy.createRequestHandler({ permissionApi }),
|
||||
);
|
||||
const requestPromise = request(app)
|
||||
.get('/mountpath/api/v1/namespaces')
|
||||
.set(HEADER_KUBERNETES_CLUSTER, 'cluster1')
|
||||
.set('Authorization', 'my-token');
|
||||
|
||||
worker.use(rest.all(requestPromise.url, (req: any) => req.passthrough()));
|
||||
|
||||
const response = await requestPromise;
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
});
|
||||
|
||||
it('should add a serviceAccountToken to the request headers if one isnt provided in request and one isnt set up in cluster details', async () => {
|
||||
worker.use(
|
||||
rest.get('https://localhost:9999/api/v1/namespaces', (req, res, ctx) => {
|
||||
if (!req.headers.get('Authorization')) {
|
||||
return res(ctx.status(401));
|
||||
}
|
||||
|
||||
if (req.headers.get('Authorization') !== 'Bearer my-token') {
|
||||
return res(ctx.status(403));
|
||||
}
|
||||
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
kind: 'NamespaceList',
|
||||
apiVersion: 'v1',
|
||||
items: [],
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
permissionApi.authorize.mockReturnValue(
|
||||
Promise.resolve([{ result: AuthorizeResult.ALLOW }]),
|
||||
);
|
||||
|
||||
clusterSupplier.getClusters.mockResolvedValue([
|
||||
{
|
||||
name: 'cluster1',
|
||||
url: 'https://localhost:9999',
|
||||
authProvider: 'googleServiceAccount',
|
||||
},
|
||||
] as ClusterDetails[]);
|
||||
|
||||
authTranslator.decorateClusterDetailsWithAuth.mockResolvedValue({
|
||||
name: 'cluster1',
|
||||
url: 'https://localhost:9999',
|
||||
serviceAccountToken: 'my-token',
|
||||
authProvider: 'googleServiceAccount',
|
||||
} as ClusterDetails);
|
||||
|
||||
const app = express().use(
|
||||
'/mountpath',
|
||||
proxy.createRequestHandler({ permissionApi }),
|
||||
);
|
||||
const requestPromise = request(app)
|
||||
.get('/mountpath/api/v1/namespaces')
|
||||
.set(HEADER_KUBERNETES_CLUSTER, 'cluster1');
|
||||
|
||||
worker.use(rest.all(requestPromise.url, (req: any) => req.passthrough()));
|
||||
|
||||
const response = await requestPromise;
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toStrictEqual({
|
||||
kind: 'NamespaceList',
|
||||
apiVersion: 'v1',
|
||||
items: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should append the Backstage-Kubernetes-Auth field to the requests authorization header if one is provided', async () => {
|
||||
worker.use(
|
||||
rest.get('https://localhost:9999/api/v1/namespaces', (req, res, ctx) => {
|
||||
if (!req.headers.get('Authorization')) {
|
||||
return res(ctx.status(401));
|
||||
}
|
||||
|
||||
if (req.headers.get('Authorization') !== 'tokenB') {
|
||||
return res(ctx.status(403));
|
||||
}
|
||||
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
kind: 'NamespaceList',
|
||||
apiVersion: 'v1',
|
||||
items: [],
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
permissionApi.authorize.mockReturnValue(
|
||||
Promise.resolve([{ result: AuthorizeResult.ALLOW }]),
|
||||
);
|
||||
|
||||
clusterSupplier.getClusters.mockResolvedValue([
|
||||
{
|
||||
name: 'cluster1',
|
||||
url: 'https://localhost:9999',
|
||||
authProvider: 'googleServiceAccount',
|
||||
},
|
||||
] as ClusterDetails[]);
|
||||
|
||||
authTranslator.decorateClusterDetailsWithAuth.mockResolvedValue({
|
||||
name: 'cluster1',
|
||||
url: 'https://localhost:9999',
|
||||
serviceAccountToken: 'tokenA',
|
||||
authProvider: 'googleServiceAccount',
|
||||
} as ClusterDetails);
|
||||
|
||||
const app = express().use(
|
||||
'/mountpath',
|
||||
proxy.createRequestHandler({ permissionApi }),
|
||||
);
|
||||
const requestPromise = request(app)
|
||||
.get('/mountpath/api/v1/namespaces')
|
||||
.set(HEADER_KUBERNETES_CLUSTER, 'cluster1')
|
||||
.set(HEADER_KUBERNETES_AUTH, 'tokenB');
|
||||
|
||||
worker.use(rest.all(requestPromise.url, (req: any) => req.passthrough()));
|
||||
|
||||
const response = await requestPromise;
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toStrictEqual({
|
||||
kind: 'NamespaceList',
|
||||
apiVersion: 'v1',
|
||||
items: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,6 +32,7 @@ import { bufferFromFileOrString } from '@kubernetes/client-node';
|
||||
import type { Request, RequestHandler } from 'express';
|
||||
import { createProxyMiddleware } from 'http-proxy-middleware';
|
||||
import { Logger } from 'winston';
|
||||
import { KubernetesAuthTranslator } from '../kubernetes-auth-translator';
|
||||
import { ClusterDetails, KubernetesClustersSupplier } from '../types/types';
|
||||
|
||||
export const APPLICATION_JSON: string = 'application/json';
|
||||
@@ -60,6 +61,17 @@ export type KubernetesProxyCreateRequestHandlerOptions = {
|
||||
permissionApi: PermissionEvaluator;
|
||||
};
|
||||
|
||||
/**
|
||||
* Options accepted as a parameter by the KubernetesProxy
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type KubernetesProxyOptions = {
|
||||
logger: Logger;
|
||||
clusterSupplier: KubernetesClustersSupplier;
|
||||
authTranslator: KubernetesAuthTranslator;
|
||||
};
|
||||
|
||||
/**
|
||||
* A proxy that routes requests to the Kubernetes API.
|
||||
*
|
||||
@@ -67,11 +79,15 @@ export type KubernetesProxyCreateRequestHandlerOptions = {
|
||||
*/
|
||||
export class KubernetesProxy {
|
||||
private readonly middlewareForClusterName = new Map<string, RequestHandler>();
|
||||
private readonly logger: Logger;
|
||||
private readonly clusterSupplier: KubernetesClustersSupplier;
|
||||
private readonly authTranslator: KubernetesAuthTranslator;
|
||||
|
||||
constructor(
|
||||
private readonly logger: Logger,
|
||||
private readonly clusterSupplier: KubernetesClustersSupplier,
|
||||
) {}
|
||||
constructor(options: KubernetesProxyOptions) {
|
||||
this.logger = options.logger;
|
||||
this.clusterSupplier = options.clusterSupplier;
|
||||
this.authTranslator = options.authTranslator;
|
||||
}
|
||||
|
||||
public createRequestHandler(
|
||||
options: KubernetesProxyCreateRequestHandlerOptions,
|
||||
@@ -96,6 +112,12 @@ export class KubernetesProxy {
|
||||
return;
|
||||
}
|
||||
|
||||
const cluster = await this.getClusterForRequest(req).then(cd =>
|
||||
this.authTranslator.decorateClusterDetailsWithAuth(cd, {}),
|
||||
);
|
||||
if (!req.headers.authorization) {
|
||||
req.headers.authorization = `Bearer ${cluster.serviceAccountToken}`;
|
||||
}
|
||||
const middleware = await this.getMiddleware(req);
|
||||
middleware(req, res, next);
|
||||
};
|
||||
@@ -108,13 +130,6 @@ export class KubernetesProxy {
|
||||
const originalCluster = await this.getClusterForRequest(originalReq);
|
||||
let middleware = this.middlewareForClusterName.get(originalCluster.name);
|
||||
if (!middleware) {
|
||||
// Probably too risky without permissions protecting this endpoint
|
||||
// if (cluster.serviceAccountToken) {
|
||||
// options.headers = {
|
||||
// Authorization: `Bearer ${cluster.serviceAccountToken}`,
|
||||
// };
|
||||
// }
|
||||
|
||||
const logger = this.logger.child({ cluster: originalCluster.name });
|
||||
middleware = createProxyMiddleware({
|
||||
logProvider: () => logger,
|
||||
@@ -146,19 +161,18 @@ export class KubernetesProxy {
|
||||
request: { method: req.method, url: req.originalUrl },
|
||||
response: { statusCode: 500 },
|
||||
};
|
||||
|
||||
res.status(500).json(body);
|
||||
},
|
||||
onProxyReq: (proxyReq, req) => {
|
||||
// the kubernetes proxy endpoint expects a header field labeled `Backstage-Kubernetes-Authorization` that will be used to authenticate with the Kubernetes Api. The token provided as a value should be an bearer token for the target cluster.
|
||||
const token = req.header(HEADER_KUBERNETES_AUTH) ?? '';
|
||||
proxyReq.setHeader('Authorization', token);
|
||||
if (req.header(HEADER_KUBERNETES_AUTH)) {
|
||||
const token = req.header(HEADER_KUBERNETES_AUTH) ?? '';
|
||||
proxyReq.setHeader('Authorization', token);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
this.middlewareForClusterName.set(originalCluster.name, middleware);
|
||||
}
|
||||
|
||||
return middleware;
|
||||
}
|
||||
|
||||
|
||||
@@ -21,5 +21,8 @@ export {
|
||||
KubernetesProxy,
|
||||
HEADER_KUBERNETES_AUTH,
|
||||
} from './KubernetesProxy';
|
||||
export type { KubernetesProxyCreateRequestHandlerOptions } from './KubernetesProxy';
|
||||
export type {
|
||||
KubernetesProxyCreateRequestHandlerOptions,
|
||||
KubernetesProxyOptions,
|
||||
} from './KubernetesProxy';
|
||||
export * from './router';
|
||||
|
||||
Reference in New Issue
Block a user