make KubernetesCredential a tagged union

This is easier to read than using primitives like `undefined` and `string` to
represent these states.

Signed-off-by: Jamie Klassen <jklassen@vmware.com>
This commit is contained in:
Jamie Klassen
2023-09-08 13:11:07 -04:00
parent 31dc20dbe7
commit 1df3afb21e
20 changed files with 151 additions and 86 deletions
@@ -24,6 +24,9 @@ describe('AksStrategy', () => {
{ aks: 'aksToken' },
);
expect(credential).toBe('aksToken');
expect(credential).toStrictEqual({
type: 'bearer token',
token: 'aksToken',
});
});
});
@@ -26,7 +26,8 @@ export class AksStrategy implements AuthenticationStrategy {
_: ClusterDetails,
requestAuth: KubernetesRequestAuth,
): Promise<KubernetesCredential> {
return requestAuth.aks;
const token = requestAuth.aks;
return token ? { type: 'bearer token', token } : { type: 'anonymous' };
}
public validate(_: AuthMetadata) {}
}
@@ -22,7 +22,7 @@ import { AuthenticationStrategy, KubernetesCredential } from './types';
*/
export class AnonymousStrategy implements AuthenticationStrategy {
public async getCredential(): Promise<KubernetesCredential> {
return undefined;
return { type: 'anonymous' };
}
public validate() {}
@@ -65,9 +65,10 @@ describe('AwsIamStrategy tests', () => {
url: '',
authMetadata: {},
});
expect(credential).toEqual(
'k8s-aws-v1.aHR0cHM6Ly9odHRwczovL2V4YW1wbGUuY29tL2FzZGY_',
);
expect(credential).toEqual({
type: 'bearer token',
token: 'k8s-aws-v1.aHR0cHM6Ly9odHRwczovL2V4YW1wbGUuY29tL2FzZGY_',
});
});
it('returns a signed url for AWS credentials with assume role', async () => {
@@ -81,9 +82,10 @@ describe('AwsIamStrategy tests', () => {
},
});
expect(credential).toEqual(
'k8s-aws-v1.aHR0cHM6Ly9odHRwczovL2V4YW1wbGUuY29tL2FzZGY_',
);
expect(credential).toEqual({
type: 'bearer token',
token: 'k8s-aws-v1.aHR0cHM6Ly9odHRwczovL2V4YW1wbGUuY29tL2FzZGY_',
});
expect(fromTemporaryCredentials).toHaveBeenCalledWith({
clientConfig: {
region: 'us-east-1',
@@ -109,9 +111,10 @@ describe('AwsIamStrategy tests', () => {
[ANNOTATION_KUBERNETES_AWS_EXTERNAL_ID]: 'external-id',
},
});
expect(credential).toEqual(
'k8s-aws-v1.aHR0cHM6Ly9odHRwczovL2V4YW1wbGUuY29tL2FzZGY_',
);
expect(credential).toEqual({
type: 'bearer token',
token: 'k8s-aws-v1.aHR0cHM6Ly9odHRwczovL2V4YW1wbGUuY29tL2FzZGY_',
});
expect(fromTemporaryCredentials).toHaveBeenCalledWith({
clientConfig: {
region: 'us-east-1',
@@ -51,14 +51,17 @@ export class AwsIamStrategy implements AuthenticationStrategy {
this.credsManager = DefaultAwsCredentialsManager.fromConfig(opts.config);
}
public getCredential(
public async getCredential(
clusterDetails: ClusterDetails,
): Promise<KubernetesCredential> {
return this.getBearerToken(
clusterDetails.name,
clusterDetails.authMetadata[ANNOTATION_KUBERNETES_AWS_ASSUME_ROLE],
clusterDetails.authMetadata[ANNOTATION_KUBERNETES_AWS_EXTERNAL_ID],
);
return {
type: 'bearer token',
token: await this.getBearerToken(
clusterDetails.name,
clusterDetails.authMetadata[ANNOTATION_KUBERNETES_AWS_ASSUME_ROLE],
clusterDetails.authMetadata[ANNOTATION_KUBERNETES_AWS_EXTERNAL_ID],
),
};
}
public validate() {}
@@ -51,7 +51,7 @@ describe('AzureIdentityStrategy tests', () => {
);
const credential = await strategy.getCredential();
expect(credential).toEqual('MY_TOKEN_1');
expect(credential).toEqual({ type: 'bearer token', token: 'MY_TOKEN_1' });
});
it('should re-use token before expiry', async () => {
@@ -61,10 +61,10 @@ describe('AzureIdentityStrategy tests', () => {
);
const credential = await strategy.getCredential();
expect(credential).toEqual('MY_TOKEN_1');
expect(credential).toEqual({ type: 'bearer token', token: 'MY_TOKEN_1' });
const credential2 = await strategy.getCredential();
expect(credential2).toEqual('MY_TOKEN_1');
expect(credential2).toEqual({ type: 'bearer token', token: 'MY_TOKEN_1' });
});
it('should issue new token 15 minutes befory expiry', async () => {
@@ -76,12 +76,12 @@ describe('AzureIdentityStrategy tests', () => {
);
const credential = await strategy.getCredential();
expect(credential).toEqual('MY_TOKEN_1');
expect(credential).toEqual({ type: 'bearer token', token: 'MY_TOKEN_1' });
jest.setSystemTime(Date.now() + 2 * 60 * 1000); // advance time by 2mins
const credential2 = await strategy.getCredential();
expect(credential2).toEqual('MY_TOKEN_2');
expect(credential2).toEqual({ type: 'bearer token', token: 'MY_TOKEN_2' });
});
it('should re-use existing token if there is afailure', async () => {
@@ -93,20 +93,20 @@ describe('AzureIdentityStrategy tests', () => {
);
const credential = await strategy.getCredential();
expect(credential).toEqual('MY_TOKEN_1');
expect(credential).toEqual({ type: 'bearer token', token: 'MY_TOKEN_1' });
jest.setSystemTime(Date.now() + 2 * 60 * 1000); // advance time by 2min
const credential2 = await strategy.getCredential();
expect(credential2).toEqual('MY_TOKEN_2');
expect(credential2).toEqual({ type: 'bearer token', token: 'MY_TOKEN_2' });
jest.setSystemTime(Date.now() + 2 * 60 * 1000); // advance time by 2min
const credential3 = await strategy.getCredential();
expect(credential3).toEqual('MY_TOKEN_2');
expect(credential3).toEqual({ type: 'bearer token', token: 'MY_TOKEN_2' });
const credential4 = await strategy.getCredential();
expect(credential4).toEqual('MY_TOKEN_4');
expect(credential4).toEqual({ type: 'bearer token', token: 'MY_TOKEN_4' });
});
it('should throw if existing token expired and failed to fetch a new one', async () => {
@@ -118,12 +118,12 @@ describe('AzureIdentityStrategy tests', () => {
);
const credential = await strategy.getCredential();
expect(credential).toEqual('MY_TOKEN_1');
expect(credential).toEqual({ type: 'bearer token', token: 'MY_TOKEN_1' });
jest.setSystemTime(Date.now() + 2 * 60 * 1000); // advance time by 2min
const credential2 = await strategy.getCredential();
expect(credential2).toEqual('MY_TOKEN_2');
expect(credential2).toEqual({ type: 'bearer token', token: 'MY_TOKEN_2' });
jest.setSystemTime(Date.now() + 17 * 60 * 1000); // advance time by 17min
@@ -39,14 +39,16 @@ export class AzureIdentityStrategy implements AuthenticationStrategy {
public async getCredential(): Promise<KubernetesCredential> {
if (!this.tokenRequiresRefresh()) {
return this.accessToken.token;
return { type: 'bearer token', token: this.accessToken.token };
}
if (!this.newTokenPromise) {
this.newTokenPromise = this.fetchNewToken();
}
return this.newTokenPromise;
return this.newTokenPromise
? { type: 'bearer token', token: await this.newTokenPromise }
: { type: 'anonymous' };
}
public validate() {}
@@ -43,7 +43,10 @@ describe('getCredential', () => {
name: 'randomName',
authMetadata: { [ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'google' },
};
mockStrategy.getCredential.mockResolvedValue('added by mock strategy');
mockStrategy.getCredential.mockResolvedValue({
type: 'bearer token',
token: 'added by mock strategy',
});
const returnedValue = await strategy.getCredential(
clusterDetails,
@@ -54,7 +57,10 @@ describe('getCredential', () => {
clusterDetails,
authObject,
);
expect(returnedValue).toBe('added by mock strategy');
expect(returnedValue).toStrictEqual({
type: 'bearer token',
token: 'added by mock strategy',
});
});
it('throws an error when asked for a strategy for an unsupported auth type', () => {
@@ -23,14 +23,14 @@ import * as container from '@google-cloud/container';
export class GoogleServiceAccountStrategy implements AuthenticationStrategy {
public async getCredential(): Promise<KubernetesCredential> {
const client = new container.v1.ClusterManagerClient();
const accessToken = await client.auth.getAccessToken();
const token = await client.auth.getAccessToken();
if (!accessToken) {
if (!token) {
throw new Error(
'Unable to obtain access token for the current Google Application Default Credentials',
);
}
return accessToken;
return { type: 'bearer token', token };
}
public validate() {}
@@ -27,13 +27,13 @@ export class GoogleStrategy implements AuthenticationStrategy {
_: ClusterDetails,
requestAuth: KubernetesRequestAuth,
): Promise<KubernetesCredential> {
const authToken = requestAuth.google;
if (!authToken) {
const token = requestAuth.google;
if (!token) {
throw new Error(
'Google token not found under auth.google in request body',
);
}
return authToken;
return { type: 'bearer token', token };
}
public validate(_: AuthMetadata) {}
}
@@ -38,7 +38,10 @@ describe('OidcStrategy', () => {
},
);
expect(credential).toBe('fakeToken');
expect(credential).toStrictEqual({
type: 'bearer token',
token: 'fakeToken',
});
});
it('fails when token provider is not configured', async () => {
@@ -38,14 +38,14 @@ export class OidcStrategy implements AuthenticationStrategy {
);
}
const authToken = authConfig.oidc?.[oidcTokenProvider];
const token = authConfig.oidc?.[oidcTokenProvider];
if (!authToken) {
if (!token) {
throw new Error(
`Auth token not found under oidc.${oidcTokenProvider} in request body`,
);
}
return authToken;
return { type: 'bearer token', token };
}
public validate(authMetadata: AuthMetadata) {
@@ -25,7 +25,8 @@ export class ServiceAccountStrategy implements AuthenticationStrategy {
public async getCredential(
clusterDetails: ClusterDetails,
): Promise<KubernetesCredential> {
return clusterDetails.authMetadata.serviceAccountToken;
const token = clusterDetails.authMetadata.serviceAccountToken;
return token ? { type: 'bearer token', token } : { type: 'anonymous' };
}
public validate() {}
+3 -1
View File
@@ -21,7 +21,9 @@ import { KubernetesRequestAuth } from '@backstage/plugin-kubernetes-common';
* Authentication data used to make a request to Kubernetes
* @public
*/
export type KubernetesCredential = string | undefined;
export type KubernetesCredential =
| { type: 'bearer token'; token: string }
| { type: 'anonymous' };
/**
*
@@ -28,7 +28,10 @@ import { KubernetesClientBasedFetcher } from './KubernetesFetcher';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { setupRequestMockHandlers } from '@backstage/backend-test-utils';
import { ObjectsByEntityResponse } from '@backstage/plugin-kubernetes-common';
import {
KubernetesRequestAuth,
ObjectsByEntityResponse,
} from '@backstage/plugin-kubernetes-common';
import { Config, ConfigReader } from '@backstage/config';
import { Entity } from '@backstage/catalog-model';
@@ -186,7 +189,12 @@ describe('KubernetesFanOutHandler', () => {
},
customResources: customResources,
authStrategy: {
getCredential: jest.fn().mockResolvedValue(undefined),
getCredential: jest
.fn<
Promise<KubernetesCredential>,
[ClusterDetails, KubernetesRequestAuth]
>()
.mockResolvedValue({ type: 'anonymous' }),
validate: jest.fn(),
},
config,
@@ -373,7 +381,7 @@ describe('KubernetesFanOutHandler', () => {
expect(fetchPodMetricsByNamespaces).toHaveBeenCalledTimes(1);
expect(fetchPodMetricsByNamespaces).toHaveBeenCalledWith(
expect.anything(),
undefined,
{ type: 'anonymous' },
new Set(['ns-test-component-test-cluster']),
expect.anything(),
);
@@ -665,7 +673,7 @@ describe('KubernetesFanOutHandler', () => {
expect(fetchPodMetricsByNamespaces).toHaveBeenCalledTimes(1);
expect(fetchPodMetricsByNamespaces).toHaveBeenCalledWith(
expect.anything(),
undefined,
{ type: 'anonymous' },
new Set(['ns-a', 'ns-b']),
expect.anything(),
);
@@ -1132,7 +1140,12 @@ describe('KubernetesFanOutHandler', () => {
},
],
authStrategy: {
getCredential: jest.fn().mockResolvedValue('token'),
getCredential: jest
.fn<
Promise<KubernetesCredential>,
[ClusterDetails, KubernetesRequestAuth]
>()
.mockResolvedValue({ type: 'bearer token', token: 'token' }),
validate: jest.fn(),
},
config,
@@ -141,7 +141,7 @@ describe('KubernetesFetcher', () => {
url: 'http://localhost:9999',
authMetadata: {},
},
credential: 'token',
credential: { type: 'bearer token', token: 'token' },
objectTypesToFetch: OBJECTS_TO_FETCH,
labelSelector: '',
customResources: [],
@@ -202,7 +202,7 @@ describe('KubernetesFetcher', () => {
url: 'http://localhost:9999/k8s/clusters/1234',
authMetadata: {},
},
credential: 'token',
credential: { type: 'bearer token', token: 'token' },
objectTypesToFetch: OBJECTS_TO_FETCH,
labelSelector: '',
customResources: [],
@@ -258,7 +258,7 @@ describe('KubernetesFetcher', () => {
[ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'localKubectlProxy',
},
},
credential: undefined,
credential: { type: 'anonymous' },
objectTypesToFetch: new Set([
{
group: '',
@@ -315,7 +315,7 @@ describe('KubernetesFetcher', () => {
url: 'http://localhost:9999',
authMetadata: {},
},
credential: 'token',
credential: { type: 'bearer token', token: 'token' },
objectTypesToFetch: OBJECTS_TO_FETCH,
labelSelector: '',
customResources: [],
@@ -389,7 +389,7 @@ describe('KubernetesFetcher', () => {
url: 'http://localhost:9999',
authMetadata: {},
},
credential: 'token',
credential: { type: 'bearer token', token: 'token' },
objectTypesToFetch: OBJECTS_TO_FETCH,
labelSelector: '',
customResources: [
@@ -481,7 +481,7 @@ describe('KubernetesFetcher', () => {
url: 'http://localhost:9999',
authMetadata: {},
},
credential: 'token',
credential: { type: 'bearer token', token: 'token' },
objectTypesToFetch: OBJECTS_TO_FETCH,
labelSelector: '',
customResources: [],
@@ -569,7 +569,7 @@ describe('KubernetesFetcher', () => {
url: 'http://badurl.does.not.exist',
authMetadata: {},
},
credential: 'token',
credential: { type: 'bearer token', token: 'token' },
objectTypesToFetch: OBJECTS_TO_FETCH,
labelSelector: '',
customResources: [],
@@ -606,7 +606,7 @@ describe('KubernetesFetcher', () => {
url: 'http://localhost:9999',
authMetadata: {},
},
credential: 'token',
credential: { type: 'bearer token', token: 'token' },
objectTypesToFetch: OBJECTS_TO_FETCH,
labelSelector: 'service-label=value',
customResources: [],
@@ -674,7 +674,7 @@ describe('KubernetesFetcher', () => {
authMetadata: {},
caData: 'MOCKCA',
},
credential: 'token',
credential: { type: 'bearer token', token: 'token' },
objectTypesToFetch: new Set<ObjectToFetch>([
{
group: '',
@@ -710,7 +710,7 @@ describe('KubernetesFetcher', () => {
url: 'https://localhost:9999',
authMetadata: {},
},
credential: 'token',
credential: { type: 'bearer token', token: 'token' },
objectTypesToFetch: new Set<ObjectToFetch>([
{
group: '',
@@ -754,7 +754,7 @@ describe('KubernetesFetcher', () => {
authMetadata: {},
caFile: '/path/to/ca.crt',
},
credential: 'token',
credential: { type: 'bearer token', token: 'token' },
objectTypesToFetch: new Set<ObjectToFetch>([
{
group: '',
@@ -792,7 +792,7 @@ describe('KubernetesFetcher', () => {
authMetadata: {},
skipTLSVerify: true,
},
credential: 'token',
credential: { type: 'bearer token', token: 'token' },
objectTypesToFetch: new Set<ObjectToFetch>([
{
group: '',
@@ -842,7 +842,7 @@ describe('KubernetesFetcher', () => {
url: 'http://localhost:9999',
authMetadata: {},
},
credential: 'token',
credential: { type: 'bearer token', token: 'token' },
objectTypesToFetch: OBJECTS_TO_FETCH,
labelSelector: '',
namespace: 'some-namespace',
@@ -886,7 +886,7 @@ describe('KubernetesFetcher', () => {
url: 'http://ignored',
authMetadata: {},
},
credential: undefined, // no token
credential: { type: 'anonymous' },
objectTypesToFetch: OBJECTS_TO_FETCH,
labelSelector: '',
customResources: [],
@@ -930,7 +930,7 @@ describe('KubernetesFetcher', () => {
url: 'http://ignored',
authMetadata: {},
},
credential: undefined, // no token
credential: { type: 'anonymous' },
objectTypesToFetch: new Set<ObjectToFetch>([
{
group: '',
@@ -1027,7 +1027,7 @@ describe('KubernetesFetcher', () => {
url: 'http://localhost:9999',
authMetadata: {},
},
'token',
{ type: 'bearer token', token: 'token' },
new Set(['ns-a']),
);
expect(result).toMatchObject({
@@ -1114,7 +1114,7 @@ describe('KubernetesFetcher', () => {
url: 'http://localhost:9999',
authMetadata: {},
},
'token',
{ type: 'bearer token', token: 'token' },
new Set(['ns-a', 'ns-b']),
);
@@ -215,7 +215,7 @@ export class KubernetesClientBasedFetcher implements KubernetesFetcher {
let url: URL;
let requestInit: RequestInit;
if (
credential ||
credential.type === 'bearer token' ||
clusterDetails.authMetadata[ANNOTATION_KUBERNETES_AUTH_PROVIDER] ===
'localKubectlProxy'
) {
@@ -252,7 +252,9 @@ export class KubernetesClientBasedFetcher implements KubernetesFetcher {
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(credential && { Authorization: `Bearer ${credential}` }),
...(credential.type === 'bearer token' && {
Authorization: `Bearer ${credential.token}`,
}),
},
};
@@ -23,6 +23,7 @@ import {
AuthorizeResult,
PermissionEvaluator,
} from '@backstage/plugin-permission-common';
import { KubernetesRequestAuth } from '@backstage/plugin-kubernetes-common';
import { getMockReq, getMockRes } from '@jest-mock/express';
import express from 'express';
import Router from 'express-promise-router';
@@ -33,7 +34,11 @@ import request from 'supertest';
import { AddressInfo, WebSocket, WebSocketServer } from 'ws';
import { LocalKubectlProxyClusterLocator } from '../cluster-locator/LocalKubectlProxyLocator';
import { AuthenticationStrategy, AnonymousStrategy } from '../auth';
import {
AuthenticationStrategy,
AnonymousStrategy,
KubernetesCredential,
} from '../auth';
import { ClusterDetails, KubernetesClustersSupplier } from '../types/types';
import {
APPLICATION_JSON,
@@ -47,6 +52,7 @@ import type { Request } from 'express';
describe('KubernetesProxy', () => {
let proxy: KubernetesProxy;
let authStrategy: jest.Mocked<AuthenticationStrategy>;
const worker = setupServer();
const logger = getVoidLogger();
@@ -59,11 +65,6 @@ describe('KubernetesProxy', () => {
authorizeConditional: jest.fn(),
};
const authStrategy: jest.Mocked<AuthenticationStrategy> = {
getCredential: jest.fn(),
validate: jest.fn(),
};
setupRequestMockHandlers(worker);
const buildMockRequest = (clusterName: any, path: string): Request => {
@@ -124,6 +125,15 @@ describe('KubernetesProxy', () => {
beforeEach(() => {
jest.resetAllMocks();
authStrategy = {
getCredential: jest
.fn<
Promise<KubernetesCredential>,
[ClusterDetails, KubernetesRequestAuth]
>()
.mockResolvedValue({ type: 'anonymous' }),
validate: jest.fn(),
};
proxy = new KubernetesProxy({ logger, clusterSupplier, authStrategy });
permissionApi.authorize.mockResolvedValue([
{ result: AuthorizeResult.ALLOW },
@@ -325,7 +335,10 @@ describe('KubernetesProxy', () => {
},
]);
authStrategy.getCredential.mockResolvedValue('strategy-provided-token');
authStrategy.getCredential.mockResolvedValue({
type: 'bearer token',
token: 'strategy-provided-token',
});
const requestPromise = setupProxyPromise({
proxyPath: '/mountpath',
@@ -369,7 +382,10 @@ describe('KubernetesProxy', () => {
},
]);
authStrategy.getCredential.mockResolvedValue('my-token');
authStrategy.getCredential.mockResolvedValue({
type: 'bearer token',
token: 'my-token',
});
const requestPromise = setupProxyPromise({
proxyPath: '/mountpath',
@@ -418,7 +434,10 @@ describe('KubernetesProxy', () => {
},
]);
authStrategy.getCredential.mockResolvedValue('tokenA');
authStrategy.getCredential.mockResolvedValue({
type: 'bearer token',
token: 'tokenA',
});
const requestPromise = setupProxyPromise({
proxyPath: '/mountpath',
@@ -691,7 +710,7 @@ describe('KubernetesProxy', () => {
event: 'connection' | 'open' | 'close' | 'error' | 'message',
) => new Promise(resolve => ws.once(event, x => resolve(x?.toString())));
beforeAll(async () => {
beforeEach(async () => {
await new Promise(resolve => {
expressServer = express()
.use(
@@ -722,7 +741,7 @@ describe('KubernetesProxy', () => {
wsEchoServer.on('error', console.error);
});
afterAll(() => {
afterEach(() => {
wsEchoServer.close();
expressServer.close();
});
@@ -112,11 +112,11 @@ export class KubernetesProxy {
if (authHeader) {
req.headers.authorization = authHeader;
} else {
const serviceAccountToken = await this.getClusterForRequest(req).then(
cd => this.authStrategy.getCredential(cd, {}),
);
if (serviceAccountToken) {
req.headers.authorization = `Bearer ${serviceAccountToken}`;
const credential = await this.getClusterForRequest(req).then(cd => {
return this.authStrategy.getCredential(cd, {});
});
if (credential.type === 'bearer token') {
req.headers.authorization = `Bearer ${credential.token}`;
}
}