Merge pull request #20171 from andmagom/kubernetes/AuthenticationStrategies19999

kubernetesProxy invoke AuthenticationStrategies
This commit is contained in:
Jamie Klassen
2023-10-04 10:26:42 -04:00
committed by GitHub
9 changed files with 346 additions and 19 deletions
+7
View File
@@ -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;
}
}
+4 -1
View File
@@ -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 {
+4 -2
View File
@@ -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;
}
}