Merge pull request #21842 from andmagom/kubernetes/mTLS
Enabling authentication to kubernetes clusters with mTLS x509 client certs
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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' };
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user