Allow for Kubernetes cluster authentication via google auth (#2691)

* Allow for Kubernetes cluster authentication via google auth

- For the kubernetes and kubernetes-backend plugins
  - The kubernetes (front-end) plugin uses the googleAuthApi and goes
    through oauth flow to fetch a Google auth token for the current user
    and pass that in a request to the kubernetes-backend plugin, which
    uses this token as the service account token (and hence as the
    authentication token for making K8s API requests).
- Related to https://github.com/spotify/backstage/issues/2552

* Use KubernetesAuthProvider and KubernetesAuthTranslator interfaces for K8s auth

- Implementations of KubernetesAuthProvider decorate the bodies of requests (for
  Kubernetes resources) sent from the kubernetes plugin (frontend) to the
  kubernetes-backend plugin (backend) with whatever information is
  needed for K8s authentication
- Implementations of KubernetesAuthTranslator take the contents of these request
  bodies sent from the kubernetes plugin (frontend) to the kubernetes-backend plugin
  (backend) and use specific values in the bodies to properly set up tokens for K8s auth
- Start with KubernetesAuthProvider + KubernetesAuthTranslator
  implementations for 'serviceAccount' and 'google' as auth providers
- Implementation of what was proposed at https://github.com/spotify/backstage/issues/2552#issuecomment-702545382
- Load in and prepare KubernetesAuthProvider implementations at plugin
  startup time via KubernetesAuthProviders API (that essentially stores
  or wraps these KubernetesAuthProvider instances)
- Related to https://github.com/spotify/backstage/issues/2552
This commit is contained in:
James Wen
2020-10-07 10:21:40 -04:00
committed by GitHub
parent c1de0de4bd
commit 108ce37949
24 changed files with 524 additions and 34 deletions
@@ -43,6 +43,7 @@ describe('MultiTenantConfigClusterLocator', () => {
{
name: 'cluster1',
url: 'http://localhost:8080',
authProvider: 'serviceAccount',
},
],
},
@@ -60,6 +61,7 @@ describe('MultiTenantConfigClusterLocator', () => {
name: 'cluster1',
serviceAccountToken: undefined,
url: 'http://localhost:8080',
authProvider: 'serviceAccount',
},
]);
});
@@ -70,13 +72,14 @@ describe('MultiTenantConfigClusterLocator', () => {
clusters: [
{
name: 'cluster1',
serviceAccountToken: undefined,
serviceAccountToken: 'token',
url: 'http://localhost:8080',
authProvider: 'serviceAccount',
},
{
name: 'cluster2',
serviceAccountToken: undefined,
url: 'http://localhost:8081',
authProvider: 'google',
},
],
},
@@ -92,13 +95,15 @@ describe('MultiTenantConfigClusterLocator', () => {
expect(result).toStrictEqual([
{
name: 'cluster1',
serviceAccountToken: undefined,
serviceAccountToken: 'token',
url: 'http://localhost:8080',
authProvider: 'serviceAccount',
},
{
name: 'cluster2',
serviceAccountToken: undefined,
url: 'http://localhost:8081',
authProvider: 'google',
},
]);
});
@@ -28,12 +28,15 @@ export class MultiTenantConfigClusterLocator
}
static fromConfig(config: Config[]): MultiTenantConfigClusterLocator {
// TODO: Add validation that authProvider is required and serviceAccountToken
// is required if authProvider is serviceAccount
return new MultiTenantConfigClusterLocator(
config.map(c => {
return {
name: c.getString('name'),
url: c.getString('url'),
serviceAccountToken: c.getOptionalString('serviceAccountToken'),
authProvider: c.getString('authProvider'),
};
}),
);
@@ -15,3 +15,4 @@
*/
export type ClusterLocatorMethod = 'configMultiTenant' | 'http';
export type AuthProviderType = 'google' | 'serviceAccount';
@@ -0,0 +1,41 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { KubernetesAuthTranslator } from './types';
import { AuthRequestBody, ClusterDetails } from '../types/types';
export class GoogleKubernetesAuthTranslator
implements KubernetesAuthTranslator {
async decorateClusterDetailsWithAuth(
clusterDetails: ClusterDetails,
requestBody: AuthRequestBody,
): Promise<ClusterDetails> {
const clusterDetailsWithAuthToken: ClusterDetails = Object.assign(
{},
clusterDetails,
);
const authToken: string | undefined = requestBody.auth?.google;
if (authToken) {
clusterDetailsWithAuthToken.serviceAccountToken = authToken;
} else {
throw new Error(
'Google token not found under auth.google in request body',
);
}
return clusterDetailsWithAuthToken;
}
}
@@ -0,0 +1,46 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { KubernetesAuthTranslator } from './types';
import { GoogleKubernetesAuthTranslator } from './GoogleKubernetesAuthTranslator';
import { KubernetesAuthTranslatorGenerator } from './KubernetesAuthTranslatorGenerator';
import { ServiceAccountKubernetesAuthTranslator } from './ServiceAccountKubernetesAuthTranslator';
describe('getKubernetesAuthTranslatorInstance', () => {
const sut = KubernetesAuthTranslatorGenerator;
it('can return an auth translator for google auth', () => {
const authTranslator: KubernetesAuthTranslator = sut.getKubernetesAuthTranslatorInstance(
'google',
);
expect(authTranslator instanceof GoogleKubernetesAuthTranslator).toBe(true);
});
it('can return an auth translator for serviceAccount auth', () => {
const authTranslator: KubernetesAuthTranslator = sut.getKubernetesAuthTranslatorInstance(
'serviceAccount',
);
expect(
authTranslator instanceof ServiceAccountKubernetesAuthTranslator,
).toBe(true);
});
it('throws an error when asked for an auth translator for an unsupported auth type', () => {
expect(() => sut.getKubernetesAuthTranslatorInstance('linode')).toThrow(
'authProvider "linode" has no KubernetesAuthTranslator associated with it',
);
});
});
@@ -0,0 +1,39 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { KubernetesAuthTranslator } from './types';
import { GoogleKubernetesAuthTranslator } from './GoogleKubernetesAuthTranslator';
import { ServiceAccountKubernetesAuthTranslator } from './ServiceAccountKubernetesAuthTranslator';
export class KubernetesAuthTranslatorGenerator {
static getKubernetesAuthTranslatorInstance(
authProvider: String,
): KubernetesAuthTranslator {
switch (authProvider) {
case 'google': {
return new GoogleKubernetesAuthTranslator();
}
case 'serviceAccount': {
return new ServiceAccountKubernetesAuthTranslator();
}
default: {
throw new Error(
`authProvider "${authProvider}" has no KubernetesAuthTranslator associated with it`,
);
}
}
}
}
@@ -0,0 +1,31 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { KubernetesAuthTranslator } from './types';
import { AuthRequestBody, ClusterDetails } from '../types/types';
export class ServiceAccountKubernetesAuthTranslator
implements KubernetesAuthTranslator {
async decorateClusterDetailsWithAuth(
clusterDetails: ClusterDetails,
// To ignore TS6133 linting error where it detects 'requestBody' is declared but its value is never read.
// @ts-ignore-start
requestBody: AuthRequestBody, // eslint-disable-line @typescript-eslint/no-unused-vars
// @ts-ignore-end
): Promise<ClusterDetails> {
return clusterDetails;
}
}
@@ -0,0 +1,24 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { AuthRequestBody, ClusterDetails } from '../types/types';
export interface KubernetesAuthTranslator {
decorateClusterDetailsWithAuth(
clusterDetails: ClusterDetails,
requestBody: AuthRequestBody,
): Promise<ClusterDetails>;
}
@@ -33,6 +33,7 @@ describe('KubernetesClientProvider', () => {
name: 'cluster-name',
url: 'http://localhost:9999',
serviceAccountToken: 'TOKEN',
authProvider: 'serviceAccount',
});
expect(result.basePath).toBe('http://localhost:9999');
@@ -55,12 +56,14 @@ describe('KubernetesClientProvider', () => {
name: 'cluster-name',
url: 'http://localhost:9999',
serviceAccountToken: 'TOKEN',
authProvider: 'serviceAccount',
});
const result2 = sut.getCoreClientByClusterDetails({
name: 'cluster-name',
url: 'http://localhost:9999',
serviceAccountToken: 'TOKEN',
authProvider: 'serviceAccount',
});
expect(result1.basePath).toBe('http://localhost:9999');
@@ -89,6 +92,7 @@ describe('KubernetesClientProvider', () => {
name: 'cluster-name',
url: 'http://localhost:9999',
serviceAccountToken: 'TOKEN',
authProvider: 'serviceAccount',
});
expect(result.basePath).toBe('http://localhost:9999');
@@ -111,12 +115,14 @@ describe('KubernetesClientProvider', () => {
name: 'cluster-name',
url: 'http://localhost:9999',
serviceAccountToken: 'TOKEN',
authProvider: 'serviceAccount',
});
const result2 = sut.getAppsClientByClusterDetails({
name: 'cluster-name',
url: 'http://localhost:9999',
serviceAccountToken: 'TOKEN',
authProvider: 'serviceAccount',
});
expect(result1.basePath).toBe('http://localhost:9999');
@@ -62,7 +62,8 @@ describe('KubernetesClientProvider', () => {
{
name: 'cluster1',
url: 'http://localhost:9999',
serviceAccountToken: undefined,
serviceAccountToken: 'token',
authProvider: 'serviceAccount',
},
new Set(['pods', 'services']),
);
@@ -124,7 +125,8 @@ describe('KubernetesClientProvider', () => {
{
name: 'cluster1',
url: 'http://localhost:9999',
serviceAccountToken: undefined,
serviceAccountToken: 'token',
authProvider: 'serviceAccount',
},
new Set(['pods', 'services']),
);
@@ -172,7 +174,8 @@ describe('KubernetesClientProvider', () => {
{
name: 'cluster1',
url: 'http://localhost:9999',
serviceAccountToken: undefined,
serviceAccountToken: 'token',
authProvider: 'serviceAccount',
},
new Set<any>(['foo']),
),
@@ -74,6 +74,7 @@ describe('handleGetKubernetesObjectsByServiceId', () => {
Promise.resolve([
{
name: 'test-cluster',
authProvider: 'serviceAccount',
},
]),
);
@@ -89,6 +90,7 @@ describe('handleGetKubernetesObjectsByServiceId', () => {
getClusterByServiceId,
},
getVoidLogger(),
{},
);
expect(getClusterByServiceId.mock.calls.length).toBe(1);
@@ -142,9 +144,11 @@ describe('handleGetKubernetesObjectsByServiceId', () => {
Promise.resolve([
{
name: 'test-cluster',
authProvider: 'serviceAccount',
},
{
name: 'other-cluster',
authProvider: 'google',
},
]),
);
@@ -160,6 +164,11 @@ describe('handleGetKubernetesObjectsByServiceId', () => {
getClusterByServiceId,
},
getVoidLogger(),
{
auth: {
google: 'google_token_123',
},
},
);
expect(getClusterByServiceId.mock.calls.length).toBe(1);
@@ -16,17 +16,22 @@
import { Logger } from 'winston';
import {
AuthRequestBody,
ClusterDetails,
KubernetesClusterLocator,
KubernetesFetcher,
KubernetesObjectTypes,
ObjectsByServiceIdResponse,
} from '..';
} from '../types/types';
import { KubernetesAuthTranslator } from '../kubernetes-auth-translator/types';
import { KubernetesAuthTranslatorGenerator } from '../kubernetes-auth-translator/KubernetesAuthTranslatorGenerator';
export type GetKubernetesObjectsByServiceIdHandler = (
serviceId: string,
fetcher: KubernetesFetcher,
clusterLocator: KubernetesClusterLocator,
logger: Logger,
requestBody: AuthRequestBody,
objectsToFetch?: Set<KubernetesObjectTypes>,
) => Promise<ObjectsByServiceIdResponse>;
@@ -46,18 +51,35 @@ export const handleGetKubernetesObjectsByServiceId: GetKubernetesObjectsByServic
fetcher,
clusterLocator,
logger,
requestBody,
objectsToFetch = DEFAULT_OBJECTS,
) => {
const clusterDetails = await clusterLocator.getClusterByServiceId(serviceId);
const clusterDetails: ClusterDetails[] = await clusterLocator.getClusterByServiceId(
serviceId,
);
// Execute all of these async actions simultaneously/without blocking sequentially as no common object is modified by them
const promises: Promise<ClusterDetails>[] = clusterDetails.map(cd => {
const kubernetesAuthTranslator: KubernetesAuthTranslator = KubernetesAuthTranslatorGenerator.getKubernetesAuthTranslatorInstance(
cd.authProvider,
);
return kubernetesAuthTranslator.decorateClusterDetailsWithAuth(
cd,
requestBody,
);
});
const clusterDetailsDecoratedForAuth: ClusterDetails[] = await Promise.all(
promises,
);
logger.info(
`serviceId=${serviceId} clusterDetails=[${clusterDetails
`serviceId=${serviceId} clusterDetails=[${clusterDetailsDecoratedForAuth
.map(c => c.name)
.join(', ')}]`,
);
return Promise.all(
clusterDetails.map(cd => {
clusterDetailsDecoratedForAuth.map(cd => {
return fetcher
.fetchObjectsByServiceId(serviceId, cd, objectsToFetch)
.then(result => {
@@ -54,8 +54,8 @@ describe('router', () => {
jest.resetAllMocks();
});
describe('GET /services/:serviceId', () => {
it('happy path: lists kubernetes objects', async () => {
describe('post /services/:serviceId', () => {
it('happy path: lists kubernetes objects without auth in request body', async () => {
const result = {
clusterOne: {
pods: [
@@ -69,7 +69,34 @@ describe('router', () => {
} as any;
handleGetByServiceId.mockReturnValueOnce(Promise.resolve(result));
const response = await request(app).get('/services/test-service');
const response = await request(app).post('/services/test-service');
expect(response.status).toEqual(200);
expect(response.body).toEqual(result);
});
it('happy path: lists kubernetes objects with auth in request body', async () => {
const result = {
clusterOne: {
pods: [
{
metadata: {
name: 'pod1',
},
},
],
},
} as any;
handleGetByServiceId.mockReturnValueOnce(Promise.resolve(result));
const response = await request(app)
.post('/services/test-service')
.send({
auth: {
google: 'google_token_123',
},
})
.set('Content-Type', 'application/json');
expect(response.status).toEqual(200);
expect(response.body).toEqual(result);
@@ -78,7 +105,7 @@ describe('router', () => {
it('internal error: lists kubernetes objects', async () => {
handleGetByServiceId.mockRejectedValue(Error('some internal error'));
const response = await request(app).get('/services/test-service');
const response = await request(app).post('/services/test-service');
expect(response.status).toEqual(500);
expect(response.body).toEqual({ error: 'some internal error' });
@@ -26,7 +26,11 @@ import {
GetKubernetesObjectsByServiceIdHandler,
handleGetKubernetesObjectsByServiceId,
} from './getKubernetesObjectsByServiceIdHandler';
import { KubernetesClusterLocator, KubernetesFetcher } from '..';
import {
AuthRequestBody,
KubernetesClusterLocator,
KubernetesFetcher,
} from '../types/types';
export interface RouterOptions {
logger: Logger;
@@ -62,15 +66,16 @@ export const makeRouter = (
router.use(express.json());
// TODO error handling
router.get('/services/:serviceId', async (req, res) => {
router.post('/services/:serviceId', async (req, res) => {
const serviceId = req.params.serviceId;
const requestBody: AuthRequestBody = req.body;
try {
const response = await handleGetByServiceId(
serviceId,
fetcher,
clusterLocator,
logger,
requestBody,
);
res.send(response);
} catch (e) {
@@ -27,8 +27,14 @@ import {
export interface ClusterDetails {
name: string;
url: string;
// TODO this will eventually be configured by the auth translation work
serviceAccountToken: string | undefined;
authProvider: string;
serviceAccountToken?: string | undefined;
}
export interface AuthRequestBody {
auth?: {
google?: string;
};
}
export interface ClusterObjects {