Merge pull request #16649 from RubenV-dev/server-side-auth-proxy

Enable Server Side Authentication when using the Kubernetes Proxy
This commit is contained in:
Ben Lambert
2023-04-04 14:00:56 +02:00
committed by GitHub
14 changed files with 491 additions and 198 deletions
+5
View File
@@ -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`.
+2 -8
View File
@@ -69,15 +69,9 @@ Overall, the only changes to each request are:
- the endpoint's base URL prefix is stripped.
- the `Backstage-Kubernetes-Authorization` header becomes the `Authorization` header that is used when forwarding the request.
## Authentication
The proxy expects a `KubernetesAuthTranslator` to be provided that is used to decorate all requests with `Auth` by default. It does this by supplying a `serviceAccountToken` field into `clusterDetails` using the defined `authProvider` in `clusterDetails`.
Until some security and permission decisions are made (see [this
conversation](https://github.com/backstage/backstage/pull/13026/files#r1029376939)
for context), contributors consuming the proxy endpoint in their plugin code are
responsible for negotiating their own bearer token out-of-band. This requires
knowing some auth details about the cluster being contacted -- in practice, only
clusters with [client side auth
providers](https://backstage.io/docs/features/kubernetes/authentication#client-side-providers) can reasonably be reached.
## Authentication
The proxy has no provisions for mTLS, so it cannot be used to connect to
clusters using the [x509 Client
+42 -12
View File
@@ -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)
@@ -0,0 +1,72 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DispatchingKubernetesAuthTranslator } from './DispatchingKubernetesAuthTranslator';
import { ClusterDetails } from '../types';
import { KubernetesRequestAuth } from '@backstage/plugin-kubernetes-common';
import { KubernetesAuthTranslator } from './types';
describe('decorateClusterDetailsWithAuth', () => {
let authTranslator: DispatchingKubernetesAuthTranslator;
let mockTranslator: jest.Mocked<KubernetesAuthTranslator>;
const authObject: KubernetesRequestAuth = {};
beforeEach(() => {
mockTranslator = { decorateClusterDetailsWithAuth: jest.fn() };
authTranslator = new DispatchingKubernetesAuthTranslator({
authTranslatorMap: { google: mockTranslator },
});
});
it('can decorate cluster details if the auth provider is in the translator map', async () => {
const expectedClusterDetails: ClusterDetails = {
url: 'notanything.com',
name: 'randomName',
authProvider: 'google',
serviceAccountToken: 'added by mock translator',
};
mockTranslator.decorateClusterDetailsWithAuth.mockResolvedValue(
expectedClusterDetails,
);
const returnedValue = await authTranslator.decorateClusterDetailsWithAuth(
{ name: 'googleCluster', url: 'anything.com', authProvider: 'google' },
authObject,
);
expect(mockTranslator.decorateClusterDetailsWithAuth).toHaveBeenCalledWith(
{ name: 'googleCluster', url: 'anything.com', authProvider: 'google' },
authObject,
);
expect(returnedValue).toBe(expectedClusterDetails);
});
it('throws an error when asked for an auth translator for an unsupported auth type', () => {
expect(() =>
authTranslator.decorateClusterDetailsWithAuth(
{
name: 'test-cluster',
url: 'anything.com',
authProvider: 'linode',
},
authObject,
),
).toThrow(
'authProvider "linode" has no KubernetesAuthTranslator associated with it',
);
});
});
@@ -0,0 +1,56 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { KubernetesAuthTranslator } from './types';
import { ClusterDetails } from '../types';
import { KubernetesRequestAuth } from '@backstage/plugin-kubernetes-common';
/**
*
* @public
*/
export type DispatchingKubernetesAuthTranslatorOptions = {
authTranslatorMap: {
[key: string]: KubernetesAuthTranslator;
};
};
/**
* used to direct a KubernetesAuthProvider to its corresponding KubernetesAuthTranslator
* @public
*/
export class DispatchingKubernetesAuthTranslator
implements KubernetesAuthTranslator
{
private readonly translatorMap: { [key: string]: KubernetesAuthTranslator };
constructor(options: DispatchingKubernetesAuthTranslatorOptions) {
this.translatorMap = options.authTranslatorMap;
}
public decorateClusterDetailsWithAuth(
clusterDetails: ClusterDetails,
auth: KubernetesRequestAuth,
) {
if (this.translatorMap[clusterDetails.authProvider]) {
return this.translatorMap[
clusterDetails.authProvider
].decorateClusterDetailsWithAuth(clusterDetails, auth);
}
throw new Error(
`authProvider "${clusterDetails.authProvider}" has no KubernetesAuthTranslator associated with it`,
);
}
}
@@ -1,61 +0,0 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { KubernetesAuthTranslator } from './types';
import { GoogleKubernetesAuthTranslator } from './GoogleKubernetesAuthTranslator';
import { KubernetesAuthTranslatorGenerator } from './KubernetesAuthTranslatorGenerator';
import { NoopKubernetesAuthTranslator } from './NoopKubernetesAuthTranslator';
import { AwsIamKubernetesAuthTranslator } from './AwsIamKubernetesAuthTranslator';
import { OidcKubernetesAuthTranslator } from './OidcKubernetesAuthTranslator';
import { getVoidLogger } from '@backstage/backend-common';
const logger = getVoidLogger();
describe('getKubernetesAuthTranslatorInstance', () => {
const sut = KubernetesAuthTranslatorGenerator;
it('can return an auth translator for google auth', () => {
const authTranslator: KubernetesAuthTranslator =
sut.getKubernetesAuthTranslatorInstance('google', { logger });
expect(authTranslator instanceof GoogleKubernetesAuthTranslator).toBe(true);
});
it('can return an auth translator for aws auth', () => {
const authTranslator: KubernetesAuthTranslator =
sut.getKubernetesAuthTranslatorInstance('aws', { logger });
expect(authTranslator instanceof AwsIamKubernetesAuthTranslator).toBe(true);
});
it('can return an auth translator for serviceAccount auth', () => {
const authTranslator: KubernetesAuthTranslator =
sut.getKubernetesAuthTranslatorInstance('serviceAccount', { logger });
expect(authTranslator instanceof NoopKubernetesAuthTranslator).toBe(true);
});
it('can return an auth translator for oidc auth', () => {
const authTranslator: KubernetesAuthTranslator =
sut.getKubernetesAuthTranslatorInstance('oidc', { logger });
expect(authTranslator instanceof OidcKubernetesAuthTranslator).toBe(true);
});
it('throws an error when asked for an auth translator for an unsupported auth type', () => {
expect(() =>
sut.getKubernetesAuthTranslatorInstance('linode', { logger }),
).toThrow(
'authProvider "linode" has no KubernetesAuthTranslator associated with it',
);
});
});
@@ -1,66 +0,0 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Logger } from 'winston';
import { KubernetesAuthTranslator } from './types';
import { GoogleKubernetesAuthTranslator } from './GoogleKubernetesAuthTranslator';
import { NoopKubernetesAuthTranslator } from './NoopKubernetesAuthTranslator';
import { AwsIamKubernetesAuthTranslator } from './AwsIamKubernetesAuthTranslator';
import { GoogleServiceAccountAuthTranslator } from './GoogleServiceAccountAuthProvider';
import { AzureIdentityKubernetesAuthTranslator } from './AzureIdentityKubernetesAuthTranslator';
import { OidcKubernetesAuthTranslator } from './OidcKubernetesAuthTranslator';
/**
*
* @public
*/
export class KubernetesAuthTranslatorGenerator {
static getKubernetesAuthTranslatorInstance(
authProvider: string,
options: {
logger: Logger;
},
): KubernetesAuthTranslator {
switch (authProvider) {
case 'google': {
return new GoogleKubernetesAuthTranslator();
}
case 'aws': {
return new AwsIamKubernetesAuthTranslator();
}
case 'azure': {
return new AzureIdentityKubernetesAuthTranslator(options.logger);
}
case 'serviceAccount': {
return new NoopKubernetesAuthTranslator();
}
case 'googleServiceAccount': {
return new GoogleServiceAccountAuthTranslator();
}
case 'oidc': {
return new OidcKubernetesAuthTranslator();
}
case 'localKubectlProxy': {
return new NoopKubernetesAuthTranslator();
}
default: {
throw new Error(
`authProvider "${authProvider}" has no KubernetesAuthTranslator associated with it`,
);
}
}
}
}
@@ -18,7 +18,7 @@ export * from './AwsIamKubernetesAuthTranslator';
export * from './AzureIdentityKubernetesAuthTranslator';
export * from './GoogleKubernetesAuthTranslator';
export * from './GoogleServiceAccountAuthProvider';
export * from './KubernetesAuthTranslatorGenerator';
export * from './DispatchingKubernetesAuthTranslator';
export * from './NoopKubernetesAuthTranslator';
export * from './OidcKubernetesAuthTranslator';
export * from './types';
@@ -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';