Merge pull request #21842 from andmagom/kubernetes/mTLS

Enabling authentication to kubernetes clusters with mTLS x509 client certs
This commit is contained in:
Fredrik Adelöw
2024-01-18 14:35:34 +01:00
committed by GitHub
7 changed files with 249 additions and 30 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-kubernetes-backend': patch
'@backstage/plugin-kubernetes-node': patch
---
Enabling authentication to kubernetes clusters with mTLS x509 client certs
@@ -812,6 +812,129 @@ describe('KubernetesFetcher', () => {
const [[{ agent }]] = httpsRequest.mock.calls;
expect(agent.options.rejectUnauthorized).toBe(false);
});
it('fetchObjectsForService authenticates with k8s using x509 client cert from authentication strategy', async () => {
worker.use(
rest.get('https://localhost:9999/api/v1/pods', (req, res, ctx) =>
res(
checkToken(req, ctx, 'token'),
withLabels(req, ctx, {
items: [{ metadata: { name: 'pod-name' } }],
}),
),
),
);
const myCert = 'MOCKCert';
const myKey = 'MOCKKey';
const result = sut.fetchObjectsForService({
serviceId: 'some-service',
clusterDetails: {
name: 'cluster1',
url: 'https://localhost:9999',
authMetadata: {},
caData: 'MOCKCA',
},
credential: {
type: 'x509 client certificate',
cert: myCert,
key: myKey,
},
objectTypesToFetch: new Set<ObjectToFetch>([
{
group: '',
apiVersion: 'v1',
plural: 'pods',
objectType: 'pods',
},
]),
labelSelector: '',
customResources: [],
});
await expect(result).rejects.toThrow(/PEM/);
expect(httpsRequest).toHaveBeenCalledTimes(1);
const [[{ agent }]] = httpsRequest.mock.calls;
expect(agent.options.ca.toString('base64')).toMatch('MOCKCA');
expect(agent.options.cert).toEqual(myCert);
expect(agent.options.key).toEqual(myKey);
});
it('fetchPodMetricsByNamespaces authenticates with k8s using x509 client cert from authentication strategy', async () => {
worker.use(
rest.get(
'https://localhost:9999/api/v1/namespaces/:namespace/pods',
(req, res, ctx) =>
res(
withLabels(req, ctx, {
items: [
{
metadata: { name: 'pod-name' },
spec: {
containers: [
{
name: 'container-name',
resources: {
requests: { cpu: '500m', memory: '512M' },
limits: { cpu: '1000m', memory: '1G' },
},
},
],
},
},
],
}),
),
),
rest.get(
'https://localhost:9999/apis/metrics.k8s.io/v1beta1/namespaces/:namespace/pods',
(req, res, ctx) =>
res(
withLabels(req, ctx, {
items: [
{
metadata: { name: 'pod-name' },
containers: [
{
name: 'container-name',
usage: { cpu: '0', memory: '0' },
},
],
},
],
}),
),
),
);
const myCert = 'MOCKCert';
const myKey = 'MOCKKey';
const result = sut.fetchPodMetricsByNamespaces(
{
name: 'cluster1',
url: 'https://localhost:9999',
authMetadata: {},
caData: 'MOCKCA',
},
{
type: 'x509 client certificate',
cert: myCert,
key: myKey,
},
new Set(['ns-a']),
);
await expect(result).rejects.toThrow(/PEM/);
expect(httpsRequest).toHaveBeenCalledTimes(2);
const [[{ agent }]] = httpsRequest.mock.calls;
expect(agent.options.ca.toString('base64')).toMatch('MOCKCA');
expect(agent.options.cert).toEqual(myCert);
expect(agent.options.key).toEqual(myKey);
});
});
it('should use namespace if provided', async () => {
@@ -895,7 +1018,7 @@ describe('KubernetesFetcher', () => {
customResources: [],
});
return expect(result).rejects.toThrow(
"no bearer token for cluster 'unauthenticated-cluster' and not running in Kubernetes",
"no bearer token or client cert for cluster 'unauthenticated-cluster' and not running in Kubernetes",
);
});
});
@@ -216,21 +216,15 @@ export class KubernetesClientBasedFetcher implements KubernetesFetcher {
let requestInit: RequestInit;
const authProvider =
clusterDetails.authMetadata[ANNOTATION_KUBERNETES_AUTH_PROVIDER];
if (
authProvider === 'serviceAccount' &&
!clusterDetails.authMetadata.serviceAccountToken &&
fs.pathExistsSync(Config.SERVICEACCOUNT_CA_PATH)
) {
if (this.isServiceAccountAuthentication(authProvider, clusterDetails)) {
[url, requestInit] = this.fetchArgsInCluster(credential);
} else if (
credential.type === 'bearer token' ||
authProvider === 'localKubectlProxy'
) {
} else if (!this.isCredentialMissing(authProvider, credential)) {
[url, requestInit] = this.fetchArgs(clusterDetails, credential);
} else {
return Promise.reject(
new Error(
`no bearer token for cluster '${clusterDetails.name}' and not running in Kubernetes`,
`no bearer token or client cert for cluster '${clusterDetails.name}' and not running in Kubernetes`,
),
);
}
@@ -248,6 +242,26 @@ export class KubernetesClientBasedFetcher implements KubernetesFetcher {
return fetch(url, requestInit);
}
private isServiceAccountAuthentication(
authProvider: string,
clusterDetails: ClusterDetails,
) {
return (
authProvider === 'serviceAccount' &&
!clusterDetails.authMetadata.serviceAccountToken &&
fs.pathExistsSync(Config.SERVICEACCOUNT_CA_PATH)
);
}
private isCredentialMissing(
authProvider: string,
credential: KubernetesCredential,
) {
return (
authProvider !== 'localKubectlProxy' && credential.type === 'anonymous'
);
}
private fetchArgs(
clusterDetails: ClusterDetails,
credential: KubernetesCredential,
@@ -272,6 +286,10 @@ export class KubernetesClientBasedFetcher implements KubernetesFetcher {
clusterDetails.caData,
) ?? undefined,
rejectUnauthorized: !clusterDetails.skipTLSVerify,
...(credential.type === 'x509 client certificate' && {
cert: credential.cert,
key: credential.key,
}),
});
}
return [url, requestInit];
@@ -820,6 +820,67 @@ describe('KubernetesProxy', () => {
expect(ca).toMatch('MOCKCA');
});
});
it('should use a x509 client cert authentication strategy to consume kubeapi when backstage-kubernetes-auth field is not provided and the authStrategy enables x509 client cert authentication', 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(403));
}
return res(
ctx.status(200),
ctx.json({
kind: 'NamespaceList',
apiVersion: 'v1',
items: [],
}),
);
},
),
);
clusterSupplier.getClusters.mockResolvedValue([
{
name: 'cluster1',
url: 'https://localhost:9999',
authMetadata: {},
},
]);
const myCert = 'MOCKCert';
const myKey = 'MOCKKey';
authStrategy.getCredential.mockResolvedValue({
type: 'x509 client certificate',
cert: myCert,
key: myKey,
});
const requestPromise = setupProxyPromise({
proxyPath: '/mountpath',
requestPath: '/api/v1/namespaces',
headers: { [HEADER_KUBERNETES_CLUSTER]: 'cluster1' },
});
const response = await requestPromise;
expect(authStrategy.getCredential).toHaveBeenCalledTimes(1);
expect(authStrategy.getCredential).toHaveBeenCalledWith(
expect.anything(),
{},
);
const [[{ key, cert }]] = httpsRequest.mock.calls;
expect(cert).toEqual(myCert);
expect(key).toEqual(myKey);
// 500 Since the key and cert are fake
expect(response.status).toEqual(500);
});
});
describe('WebSocket', () => {
@@ -113,24 +113,6 @@ export class KubernetesProxy {
return;
}
const authHeader = req.header(HEADER_KUBERNETES_AUTH);
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, authObj);
});
if (credential.type === 'bearer token') {
req.headers.authorization = `Bearer ${credential.token}`;
}
}
const middleware = await this.getMiddleware(req);
// If req is an upgrade handshake, use middleware upgrade instead of http request handler https://github.com/chimurai/http-proxy-middleware#external-websocket-upgrade
@@ -173,7 +155,7 @@ export class KubernetesProxy {
const cluster = await this.getClusterForRequest(req);
const url = new URL(cluster.url);
return {
const target: any = {
protocol: url.protocol,
host: url.hostname,
port: url.port,
@@ -182,6 +164,29 @@ export class KubernetesProxy {
cluster.caData,
)?.toString(),
};
const authHeader = req.header(HEADER_KUBERNETES_AUTH);
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, authObj);
});
if (credential.type === 'bearer token') {
req.headers.authorization = `Bearer ${credential.token}`;
} else if (credential.type === 'x509 client certificate') {
target.key = credential.key;
target.cert = credential.cert;
}
}
return target;
},
onError: (error, req, res) => {
const wrappedError = new ForwardedError(
+5
View File
@@ -100,6 +100,11 @@ export type KubernetesCredential =
type: 'bearer token';
token: string;
}
| {
type: 'x509 client certificate';
cert: string;
key: string;
}
| {
type: 'anonymous';
};
@@ -139,6 +139,7 @@ export interface KubernetesClustersSupplier {
*/
export type KubernetesCredential =
| { type: 'bearer token'; token: string }
| { type: 'x509 client certificate'; cert: string; key: string }
| { type: 'anonymous' };
/**