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:
@@ -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`.
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
+72
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
+56
@@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
-61
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
-66
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user