kubernetes-backend proxy API invoke Authentication Strategies when Backstage-Kubernetes-Authorization-X-X are provided

Signed-off-by: Andres Mauricio Gomez P <andmagom@outlook.com>
This commit is contained in:
Andres Mauricio Gomez P
2023-09-26 15:07:50 -05:00
parent a1d0d838f2
commit e4a83c1b85
2 changed files with 188 additions and 2 deletions
@@ -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;
}
}