Merge pull request #20171 from andmagom/kubernetes/AuthenticationStrategies19999
kubernetesProxy invoke AuthenticationStrategies
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
---
|
||||
'@backstage/plugin-kubernetes-backend': patch
|
||||
'@backstage/plugin-kubernetes-react': patch
|
||||
'@backstage/plugin-kubernetes-common': patch
|
||||
---
|
||||
|
||||
The kubernetes APIs invokes Authentication Strategies when Backstage-Kubernetes-Authorization-X-X headers are provided, this enable the possibility to invoke strategies that executes additional steps to get a kubernetes token like on pinniped or custom strategies
|
||||
@@ -520,6 +520,26 @@ metadata:
|
||||
|
||||
expect(response.body).toStrictEqual({ items: [] });
|
||||
});
|
||||
|
||||
it('should not permit custom auth strategies with dashes', async () => {
|
||||
const throwError = () =>
|
||||
KubernetesBuilder.createBuilder({
|
||||
logger: getVoidLogger(),
|
||||
config,
|
||||
catalogApi,
|
||||
permissions,
|
||||
}).addAuthStrategy('custom-strategy', {
|
||||
getCredential: jest
|
||||
.fn<
|
||||
Promise<KubernetesCredential>,
|
||||
[ClusterDetails, KubernetesRequestAuth]
|
||||
>()
|
||||
.mockResolvedValue({ type: 'anonymous' }),
|
||||
validateCluster: jest.fn().mockReturnValue([]),
|
||||
});
|
||||
|
||||
expect(throwError).toThrow('Strategy name can not include dashes');
|
||||
});
|
||||
});
|
||||
describe('get /.well-known/backstage/permissions/metadata', () => {
|
||||
it('lists permissions supported by the kubernetes plugin', async () => {
|
||||
|
||||
@@ -205,6 +205,9 @@ export class KubernetesBuilder {
|
||||
}
|
||||
|
||||
public addAuthStrategy(key: string, strategy: AuthenticationStrategy) {
|
||||
if (key.includes('-')) {
|
||||
throw new Error('Strategy name can not include dashes');
|
||||
}
|
||||
this.getAuthStrategyMap()[key] = strategy;
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -509,6 +509,134 @@ describe('KubernetesProxy', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should invoke AuthStrategy if Backstage-Kubernetes-Authorization-X-X are provided', async () => {
|
||||
const strategy: jest.Mocked<AuthenticationStrategy> = {
|
||||
getCredential: jest
|
||||
.fn()
|
||||
.mockReturnValue({ type: 'bearer token', token: 'MY_TOKEN3' }),
|
||||
validateCluster: jest.fn(),
|
||||
};
|
||||
|
||||
proxy = new KubernetesProxy({
|
||||
logger: getVoidLogger(),
|
||||
clusterSupplier: clusterSupplier,
|
||||
authStrategy: strategy,
|
||||
});
|
||||
|
||||
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_TOKEN3') {
|
||||
return res(ctx.status(403));
|
||||
}
|
||||
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
kind: 'NamespaceList',
|
||||
apiVersion: 'v1',
|
||||
items: [],
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
clusterSupplier.getClusters.mockResolvedValue([
|
||||
{
|
||||
name: 'cluster1',
|
||||
url: 'https://localhost:9999',
|
||||
authMetadata: {},
|
||||
},
|
||||
]);
|
||||
|
||||
const requestPromise = setupProxyPromise({
|
||||
proxyPath: '/mountpath',
|
||||
requestPath: '/api/v1/namespaces',
|
||||
|
||||
headers: {
|
||||
[HEADER_KUBERNETES_CLUSTER]: 'cluster1',
|
||||
'Backstage-Kubernetes-Authorization-google': 'MY_TOKEN1',
|
||||
'Backstage-Kubernetes-Authorization-aks': 'MY_TOKEN2',
|
||||
'Backstage-Kubernetes-Authorization-oidc-okta': 'MY_TOKEN3',
|
||||
'Backstage-Kubernetes-Authorization-oidc-gitlab': 'MY_TOKEN4',
|
||||
'Backstage-Kubernetes-Authorization-pinniped-audience1': 'MY_TOKEN5',
|
||||
'Backstage-Kubernetes-Authorization-pinniped-au-b-c-d-e': 'MY_TOKEN6',
|
||||
},
|
||||
});
|
||||
|
||||
const response = await requestPromise;
|
||||
|
||||
const authObj = {
|
||||
google: 'MY_TOKEN1',
|
||||
aks: 'MY_TOKEN2',
|
||||
oidc: { okta: 'MY_TOKEN3', gitlab: 'MY_TOKEN4' },
|
||||
pinniped: { audience1: 'MY_TOKEN5', 'au-b-c-d-e': 'MY_TOKEN6' },
|
||||
};
|
||||
|
||||
expect(strategy.getCredential).toHaveBeenCalledTimes(1);
|
||||
expect(strategy.getCredential).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
authObj,
|
||||
);
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toStrictEqual({
|
||||
kind: 'NamespaceList',
|
||||
apiVersion: 'v1',
|
||||
items: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should invoke the Auth strategy with an empty auth object when no Backstage-Kubernetes-Authorization-X-X are provided', async () => {
|
||||
worker.use(
|
||||
rest.get('https://localhost:9999/api/v1/namespaces', (_, res, ctx) => {
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
kind: 'NamespaceList',
|
||||
apiVersion: 'v1',
|
||||
items: [],
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
clusterSupplier.getClusters.mockResolvedValue([
|
||||
{
|
||||
name: 'cluster1',
|
||||
url: 'https://localhost:9999',
|
||||
authMetadata: {},
|
||||
},
|
||||
]);
|
||||
|
||||
const requestPromise = setupProxyPromise({
|
||||
proxyPath: '/mountpath',
|
||||
requestPath: '/api/v1/namespaces',
|
||||
|
||||
headers: {
|
||||
[HEADER_KUBERNETES_CLUSTER]: 'cluster1',
|
||||
},
|
||||
});
|
||||
|
||||
const response = await requestPromise;
|
||||
|
||||
const authObj = {};
|
||||
|
||||
expect(authStrategy.getCredential).toHaveBeenCalledTimes(1);
|
||||
expect(authStrategy.getCredential).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
authObj,
|
||||
);
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toStrictEqual({
|
||||
kind: 'NamespaceList',
|
||||
apiVersion: 'v1',
|
||||
items: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a response with a localKubectlProxy auth provider configuration', async () => {
|
||||
proxy = new KubernetesProxy({
|
||||
logger: getVoidLogger(),
|
||||
|
||||
@@ -21,7 +21,10 @@ import {
|
||||
serializeError,
|
||||
} from '@backstage/errors';
|
||||
import { getBearerTokenFromAuthorizationHeader } from '@backstage/plugin-auth-node';
|
||||
import { kubernetesProxyPermission } from '@backstage/plugin-kubernetes-common';
|
||||
import {
|
||||
KubernetesRequestAuth,
|
||||
kubernetesProxyPermission,
|
||||
} from '@backstage/plugin-kubernetes-common';
|
||||
import {
|
||||
AuthorizeResult,
|
||||
PermissionEvaluator,
|
||||
@@ -34,6 +37,7 @@ import { AuthenticationStrategy } from '../auth';
|
||||
import { ClusterDetails, KubernetesClustersSupplier } from '../types/types';
|
||||
|
||||
import type { Request } from 'express';
|
||||
import { IncomingHttpHeaders } from 'http';
|
||||
|
||||
export const APPLICATION_JSON: string = 'application/json';
|
||||
|
||||
@@ -113,9 +117,15 @@ export class KubernetesProxy {
|
||||
if (authHeader) {
|
||||
req.headers.authorization = authHeader;
|
||||
} else {
|
||||
// Map Backstage-Kubernetes-Authorization-X-X headers to a KubernetesRequestAuth object
|
||||
const authObj = KubernetesProxy.authHeadersToKubernetesRequestAuth(
|
||||
req.headers,
|
||||
);
|
||||
|
||||
const credential = await this.getClusterForRequest(req).then(cd => {
|
||||
return this.authStrategy.getCredential(cd, {});
|
||||
return this.authStrategy.getCredential(cd, authObj);
|
||||
});
|
||||
|
||||
if (credential.type === 'bearer token') {
|
||||
req.headers.authorization = `Bearer ${credential.token}`;
|
||||
}
|
||||
@@ -221,4 +231,52 @@ export class KubernetesProxy {
|
||||
|
||||
return cluster;
|
||||
}
|
||||
|
||||
private static authHeadersToKubernetesRequestAuth(
|
||||
originalHeaders: IncomingHttpHeaders,
|
||||
): KubernetesRequestAuth {
|
||||
return Object.keys(originalHeaders)
|
||||
.filter(header => header.startsWith('backstage-kubernetes-authorization'))
|
||||
.map(header =>
|
||||
KubernetesProxy.headerToDictionary(header, originalHeaders),
|
||||
)
|
||||
.filter(headerAsDic => Object.keys(headerAsDic).length !== 0)
|
||||
.reduce(KubernetesProxy.combineHeaders, {});
|
||||
}
|
||||
|
||||
private static headerToDictionary(
|
||||
header: string,
|
||||
originalHeaders: IncomingHttpHeaders,
|
||||
): KubernetesRequestAuth {
|
||||
const obj: KubernetesRequestAuth = {};
|
||||
const headerSplitted = header.split('-');
|
||||
if (headerSplitted.length >= 4) {
|
||||
const framework = headerSplitted[3].toLowerCase();
|
||||
if (headerSplitted.length >= 5) {
|
||||
const provider = headerSplitted.slice(4).join('-').toLowerCase();
|
||||
obj[framework] = { [provider]: originalHeaders[header] };
|
||||
} else {
|
||||
obj[framework] = originalHeaders[header];
|
||||
}
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
private static combineHeaders(
|
||||
authObj: any,
|
||||
header: any,
|
||||
): KubernetesRequestAuth {
|
||||
const framework = Object.keys(header)[0];
|
||||
|
||||
if (authObj[framework]) {
|
||||
authObj[framework] = {
|
||||
...authObj[framework],
|
||||
...header[framework],
|
||||
};
|
||||
} else {
|
||||
authObj[framework] = header[framework];
|
||||
}
|
||||
|
||||
return authObj;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { BasicPermission } from '@backstage/plugin-permission-common';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { FetchResponse as FetchResponse_2 } from '@backstage/plugin-kubernetes-common';
|
||||
import type { JsonObject } from '@backstage/types';
|
||||
import type { JsonValue } from '@backstage/types';
|
||||
import { ObjectsByEntityResponse as ObjectsByEntityResponse_2 } from '@backstage/plugin-kubernetes-common';
|
||||
import { PodStatus } from '@kubernetes/client-node';
|
||||
import { V1ConfigMap } from '@kubernetes/client-node';
|
||||
@@ -319,7 +320,9 @@ export const kubernetesPermissions: BasicPermission[];
|
||||
export const kubernetesProxyPermission: BasicPermission;
|
||||
|
||||
// @public (undocumented)
|
||||
export type KubernetesRequestAuth = JsonObject;
|
||||
export type KubernetesRequestAuth = {
|
||||
[providerKey: string]: JsonValue | undefined;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export interface KubernetesRequestBody {
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { JsonObject } from '@backstage/types';
|
||||
import type { JsonObject, JsonValue } from '@backstage/types';
|
||||
import {
|
||||
PodStatus,
|
||||
V1ConfigMap,
|
||||
@@ -33,7 +33,9 @@ import {
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
|
||||
/** @public */
|
||||
export type KubernetesRequestAuth = JsonObject;
|
||||
export type KubernetesRequestAuth = {
|
||||
[providerKey: string]: JsonValue | undefined;
|
||||
};
|
||||
|
||||
/** @public */
|
||||
export interface CustomResourceMatcher {
|
||||
|
||||
@@ -398,9 +398,26 @@ describe('KubernetesBackendClient', () => {
|
||||
identityApi.getCredentials.mockResolvedValue({ token: 'idToken' });
|
||||
});
|
||||
|
||||
it('hits the /proxy API', async () => {
|
||||
it('hits the /proxy API with oidc as protocol and okta as auth provider', async () => {
|
||||
worker.use(
|
||||
rest.get(
|
||||
'http://localhost:1234/api/kubernetes/clusters',
|
||||
(_, res, ctx) =>
|
||||
res(
|
||||
ctx.json({
|
||||
items: [
|
||||
{
|
||||
name: 'cluster-a',
|
||||
authProvider: 'oidc',
|
||||
oidcTokenProvider: 'okta',
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
kubernetesAuthProvidersApi.getCredentials.mockResolvedValue({
|
||||
token: 'k8-token',
|
||||
token: 'k8-token3',
|
||||
});
|
||||
const nsResponse = {
|
||||
kind: 'Namespace',
|
||||
@@ -414,8 +431,56 @@ describe('KubernetesBackendClient', () => {
|
||||
'http://localhost:1234/api/kubernetes/proxy/api/v1/namespaces',
|
||||
(req, res, ctx) =>
|
||||
res(
|
||||
req.headers.get('Backstage-Kubernetes-Authorization') ===
|
||||
'Bearer k8-token'
|
||||
req.headers.get(
|
||||
'Backstage-Kubernetes-Authorization-oidc-okta',
|
||||
) === 'k8-token3'
|
||||
? ctx.json(nsResponse)
|
||||
: ctx.status(403),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const request = {
|
||||
clusterName: 'cluster-a',
|
||||
path: '/api/v1/namespaces',
|
||||
};
|
||||
|
||||
const response = await backendClient.proxy(request);
|
||||
|
||||
await expect(response.json()).resolves.toStrictEqual(nsResponse);
|
||||
});
|
||||
|
||||
it('hits the /proxy API with serviceAccount as auth provider', async () => {
|
||||
worker.use(
|
||||
rest.get(
|
||||
'http://localhost:1234/api/kubernetes/clusters',
|
||||
(_, res, ctx) =>
|
||||
res(
|
||||
ctx.json({
|
||||
items: [
|
||||
{
|
||||
name: 'cluster-a',
|
||||
authProvider: 'serviceAccount',
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const nsResponse = {
|
||||
kind: 'Namespace',
|
||||
apiVersion: 'v1',
|
||||
metadata: {
|
||||
name: 'new-ns',
|
||||
},
|
||||
};
|
||||
worker.use(
|
||||
rest.get(
|
||||
'http://localhost:1234/api/kubernetes/proxy/api/v1/namespaces',
|
||||
(req, res, ctx) =>
|
||||
res(
|
||||
req.headers.get('Authorization') === 'Bearer idToken'
|
||||
? ctx.json(nsResponse)
|
||||
: ctx.status(403),
|
||||
),
|
||||
@@ -450,8 +515,8 @@ describe('KubernetesBackendClient', () => {
|
||||
'http://localhost:1234/api/kubernetes/proxy/api/v1/namespaces',
|
||||
(req, res, ctx) =>
|
||||
res(
|
||||
req.headers.get('Backstage-Kubernetes-Authorization') ===
|
||||
'Bearer k8-token'
|
||||
req.headers.get('Backstage-Kubernetes-Authorization-aws') ===
|
||||
'k8-token'
|
||||
? ctx.json(nsResponse)
|
||||
: ctx.status(403),
|
||||
),
|
||||
|
||||
@@ -75,9 +75,11 @@ export class KubernetesBackendClient implements KubernetesApi {
|
||||
return this.handleResponse(response);
|
||||
}
|
||||
|
||||
private async getCluster(
|
||||
clusterName: string,
|
||||
): Promise<{ name: string; authProvider: string }> {
|
||||
private async getCluster(clusterName: string): Promise<{
|
||||
name: string;
|
||||
authProvider: string;
|
||||
oidcTokenProvider?: string;
|
||||
}> {
|
||||
const cluster = await this.getClusters().then(clusters =>
|
||||
clusters.find(c => c.name === clusterName),
|
||||
);
|
||||
@@ -140,23 +142,62 @@ export class KubernetesBackendClient implements KubernetesApi {
|
||||
path: string;
|
||||
init?: RequestInit;
|
||||
}): Promise<Response> {
|
||||
const { authProvider } = await this.getCluster(options.clusterName);
|
||||
const { token: k8sToken } = await this.getCredentials(authProvider);
|
||||
const { authProvider, oidcTokenProvider } = await this.getCluster(
|
||||
options.clusterName,
|
||||
);
|
||||
const kubernetesCredentials = await this.getCredentials(authProvider);
|
||||
const url = `${await this.discoveryApi.getBaseUrl('kubernetes')}/proxy${
|
||||
options.path
|
||||
}`;
|
||||
const identityResponse = await this.identityApi.getCredentials();
|
||||
const headers = {
|
||||
const headers = KubernetesBackendClient.getKubernetesHeaders(
|
||||
options,
|
||||
kubernetesCredentials?.token,
|
||||
identityResponse,
|
||||
authProvider,
|
||||
oidcTokenProvider,
|
||||
);
|
||||
return await fetch(url, { ...options.init, headers });
|
||||
}
|
||||
|
||||
private static getKubernetesHeaders(
|
||||
options: {
|
||||
clusterName: string;
|
||||
path: string;
|
||||
init?: RequestInit;
|
||||
},
|
||||
k8sToken: string | undefined,
|
||||
identityResponse: { token?: string },
|
||||
authProvider: string,
|
||||
oidcTokenProvider: string | undefined,
|
||||
) {
|
||||
const kubernetesAuthHeader =
|
||||
KubernetesBackendClient.getKubernetesAuthHeaderByAuthProvider(
|
||||
authProvider,
|
||||
oidcTokenProvider,
|
||||
);
|
||||
return {
|
||||
...options.init?.headers,
|
||||
[`Backstage-Kubernetes-Cluster`]: options.clusterName,
|
||||
...(k8sToken && {
|
||||
[`Backstage-Kubernetes-Authorization`]: `Bearer ${k8sToken}`,
|
||||
[kubernetesAuthHeader]: k8sToken,
|
||||
}),
|
||||
...(identityResponse.token && {
|
||||
Authorization: `Bearer ${identityResponse.token}`,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return await fetch(url, { ...options.init, headers });
|
||||
private static getKubernetesAuthHeaderByAuthProvider(
|
||||
authProvider: string,
|
||||
oidcTokenProvider: string | undefined,
|
||||
): string {
|
||||
let header: string = 'Backstage-Kubernetes-Authorization';
|
||||
|
||||
header = header.concat('-', authProvider);
|
||||
|
||||
if (oidcTokenProvider) header = header.concat('-', oidcTokenProvider);
|
||||
|
||||
return header;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user