Merge pull request #16237 from RubenV-dev/proxy-restrict

Feature: Enable the Kubernetes Proxy Endpoint to be disabled via PermissionPolicy
This commit is contained in:
Patrik Oldsberg
2023-03-21 14:24:54 +01:00
committed by GitHub
19 changed files with 336 additions and 34 deletions
+17 -1
View File
@@ -17,6 +17,7 @@ import { KubernetesRequestAuth } from '@backstage/plugin-kubernetes-common';
import type { KubernetesRequestBody } from '@backstage/plugin-kubernetes-common';
import { Logger } from 'winston';
import type { ObjectsByEntityResponse } from '@backstage/plugin-kubernetes-common';
import { PermissionEvaluator } from '@backstage/plugin-permission-common';
import { PluginEndpointDiscovery } from '@backstage/backend-common';
import type { RequestHandler } from 'express';
import { TokenCredential } from '@azure/identity';
@@ -141,6 +142,9 @@ export class GoogleServiceAccountAuthTranslator
): Promise<GKEClusterDetails>;
}
// @public
export const HEADER_KUBERNETES_AUTH: string;
// @public
export const HEADER_KUBERNETES_CLUSTER: string;
@@ -200,6 +204,7 @@ export class KubernetesBuilder {
clusterSupplier: KubernetesClustersSupplier,
catalogApi: CatalogApi,
proxy: KubernetesProxy,
permissionApi: PermissionEvaluator,
): express.Router;
// (undocumented)
protected buildServiceLocator(
@@ -271,6 +276,8 @@ export interface KubernetesEnvironment {
config: Config;
// (undocumented)
logger: Logger;
// (undocumented)
permissions: PermissionEvaluator;
}
// @public
@@ -340,9 +347,16 @@ export type KubernetesObjectTypes =
export class KubernetesProxy {
constructor(logger: Logger, clusterSupplier: KubernetesClustersSupplier);
// (undocumented)
createRequestHandler(): RequestHandler;
createRequestHandler(
options: KubernetesProxyCreateRequestHandlerOptions,
): RequestHandler;
}
// @public
export type KubernetesProxyCreateRequestHandlerOptions = {
permissionApi: PermissionEvaluator;
};
// @public
export interface KubernetesServiceLocator {
// (undocumented)
@@ -418,6 +432,8 @@ export interface RouterOptions {
discovery: PluginEndpointDiscovery;
// (undocumented)
logger: Logger;
// (undocumented)
permissions: PermissionEvaluator;
}
// @public (undocumented)
+2
View File
@@ -42,6 +42,8 @@
"@backstage/errors": "workspace:^",
"@backstage/plugin-auth-node": "workspace:^",
"@backstage/plugin-kubernetes-common": "workspace:^",
"@backstage/plugin-permission-common": "workspace:^",
"@backstage/plugin-permission-node": "workspace:^",
"@google-cloud/container": "^4.0.0",
"@jest-mock/express": "^2.0.1",
"@kubernetes/client-node": "0.18.1",
@@ -31,16 +31,24 @@ import {
import { KubernetesBuilder } from './KubernetesBuilder';
import { KubernetesFanOutHandler } from './KubernetesFanOutHandler';
import { CatalogApi } from '@backstage/catalog-client';
import { HEADER_KUBERNETES_CLUSTER } from './KubernetesProxy';
import {
HEADER_KUBERNETES_CLUSTER,
HEADER_KUBERNETES_AUTH,
} from './KubernetesProxy';
import { setupServer } from 'msw/node';
import { setupRequestMockHandlers } from '@backstage/backend-test-utils';
import { rest } from 'msw';
import {
AuthorizeResult,
PermissionEvaluator,
} from '@backstage/plugin-permission-common';
describe('KubernetesBuilder', () => {
let app: express.Express;
let kubernetesFanOutHandler: jest.Mocked<KubernetesFanOutHandler>;
let config: Config;
let catalogApi: CatalogApi;
let permissions: jest.Mocked<PermissionEvaluator>;
beforeAll(async () => {
const logger = getVoidLogger();
@@ -74,10 +82,16 @@ describe('KubernetesBuilder', () => {
getKubernetesObjectsByEntity: jest.fn(),
} as any;
permissions = {
authorize: jest.fn(),
authorizeConditional: jest.fn(),
};
const { router } = await KubernetesBuilder.createBuilder({
config,
logger,
catalogApi,
permissions,
})
.setObjectsProvider(kubernetesFanOutHandler)
.setClusterSupplier(clusterSupplier)
@@ -250,6 +264,7 @@ describe('KubernetesBuilder', () => {
logger,
config,
catalogApi,
permissions,
})
.setClusterSupplier(clusterSupplier)
.setServiceLocator(serviceLocator)
@@ -271,27 +286,32 @@ describe('KubernetesBuilder', () => {
expect(response.status).toEqual(200);
});
});
describe('post /proxy', () => {
const worker = setupServer();
setupRequestMockHandlers(worker);
beforeEach(() => {
worker.use(
rest.post('https://localhost:1234/api/v1/namespaces', (req, res, ctx) =>
req
.arrayBuffer()
.then(body =>
res(
ctx.set('content-type', `${req.headers.get('content-type')}`),
ctx.body(body),
),
),
rest.post(
'https://localhost:1234/api/v1/namespaces',
(req, res, ctx) => {
if (!req.headers.get('Authorization')) {
return res(ctx.status(401));
}
return req
.arrayBuffer()
.then(body =>
res(
ctx.set('content-type', `${req.headers.get('content-type')}`),
ctx.body(body),
),
);
},
),
);
});
it('returns the given request body', async () => {
it('returns the given request body with permission set to allow', async () => {
const requestBody = {
kind: 'Namespace',
apiVersion: 'v1',
@@ -300,9 +320,14 @@ describe('KubernetesBuilder', () => {
},
};
permissions.authorize.mockReturnValue(
Promise.resolve([{ result: AuthorizeResult.ALLOW }]),
);
const proxyEndpointRequest = request(app)
.post('/proxy/api/v1/namespaces')
.set(HEADER_KUBERNETES_CLUSTER, 'some-cluster')
.set(HEADER_KUBERNETES_AUTH, 'randomtoken')
.send(requestBody);
worker.use(rest.all(proxyEndpointRequest.url, req => req.passthrough()));
@@ -312,7 +337,7 @@ describe('KubernetesBuilder', () => {
expect(response.body).toStrictEqual(requestBody);
});
it('supports yaml content type', async () => {
it('supports yaml content type with permission set to allow', async () => {
const requestBody = `---
kind: Namespace
apiVersion: v1
@@ -320,9 +345,14 @@ metadata:
name: new-ns
`;
permissions.authorize.mockReturnValue(
Promise.resolve([{ result: AuthorizeResult.ALLOW }]),
);
const proxyEndpointRequest = request(app)
.post('/proxy/api/v1/namespaces')
.set(HEADER_KUBERNETES_CLUSTER, 'some-cluster')
.set(HEADER_KUBERNETES_AUTH, 'randomtoken')
.set('content-type', 'application/yaml')
.send(requestBody);
@@ -331,5 +361,50 @@ metadata:
const response = await proxyEndpointRequest;
expect(response.text).toEqual(requestBody);
});
it('returns a 403 response if Permission Policy is in place that blocks endpoint', async () => {
const requestBody = {
kind: 'Namespace',
apiVersion: 'v1',
metadata: {
name: 'new-ns',
},
};
permissions.authorize.mockReturnValue(
Promise.resolve([{ result: AuthorizeResult.DENY }]),
);
const proxyEndpointRequest = request(app)
.post('/proxy/api/v1/namespaces')
.set(HEADER_KUBERNETES_CLUSTER, 'some-cluster')
.set(HEADER_KUBERNETES_AUTH, 'randomtoken')
.send(requestBody);
worker.use(rest.all(proxyEndpointRequest.url, req => req.passthrough()));
const response = await proxyEndpointRequest;
expect(response.status).toEqual(403);
});
});
describe('get /.well-known/backstage/permissions/metadata', () => {
it('lists permissions supported by the kubernetes plugin', async () => {
const response = await request(app).get(
'/.well-known/backstage/permissions/metadata',
);
expect(response.status).toEqual(200);
expect(response.body).toMatchObject({
permissions: [
{
type: 'basic',
name: 'kubernetes.proxy',
attributes: {},
},
],
rules: [],
});
});
});
});
@@ -15,6 +15,9 @@
*/
import { CatalogApi } from '@backstage/catalog-client';
import { Config } from '@backstage/config';
import { kubernetesPermissions } from '@backstage/plugin-kubernetes-common';
import { PermissionEvaluator } from '@backstage/plugin-permission-common';
import { createPermissionIntegrationRouter } from '@backstage/plugin-permission-node';
import express from 'express';
import Router from 'express-promise-router';
import { Duration } from 'luxon';
@@ -49,6 +52,7 @@ export interface KubernetesEnvironment {
logger: Logger;
config: Config;
catalogApi: CatalogApi;
permissions: PermissionEvaluator;
}
/**
@@ -89,6 +93,7 @@ export class KubernetesBuilder {
public async build(): KubernetesBuilderReturn {
const logger = this.env.logger;
const config = this.env.config;
const permissions = this.env.permissions;
logger.info('Initializing Kubernetes backend');
@@ -126,6 +131,7 @@ export class KubernetesBuilder {
clusterSupplier,
this.env.catalogApi,
proxy,
permissions,
);
return {
@@ -262,12 +268,17 @@ export class KubernetesBuilder {
clusterSupplier: KubernetesClustersSupplier,
catalogApi: CatalogApi,
proxy: KubernetesProxy,
permissionApi: PermissionEvaluator,
): express.Router {
const logger = this.env.logger;
const router = Router();
router.use('/proxy', proxy.createRequestHandler());
router.use('/proxy', proxy.createRequestHandler({ permissionApi }));
router.use(express.json());
router.use(
createPermissionIntegrationRouter({
permissions: kubernetesPermissions,
}),
);
// @deprecated
router.post('/services/:serviceId', async (req, res) => {
const serviceId = req.params.serviceId;
@@ -30,6 +30,10 @@ import {
HEADER_KUBERNETES_CLUSTER,
KubernetesProxy,
} from './KubernetesProxy';
import {
AuthorizeResult,
PermissionEvaluator,
} from '@backstage/plugin-permission-common';
describe('KubernetesProxy', () => {
let proxy: KubernetesProxy;
@@ -64,6 +68,11 @@ describe('KubernetesProxy', () => {
getClusters: jest.fn(),
};
const permissionApi: jest.Mocked<PermissionEvaluator> = {
authorize: jest.fn(),
authorizeConditional: jest.fn(),
};
beforeEach(() => {
jest.resetAllMocks();
proxy = new KubernetesProxy(getVoidLogger(), clusterSupplier);
@@ -71,13 +80,16 @@ describe('KubernetesProxy', () => {
it('should return a ERROR_NOT_FOUND if no clusters are found', async () => {
clusterSupplier.getClusters.mockResolvedValue([]);
permissionApi.authorize.mockReturnValue(
Promise.resolve([{ result: AuthorizeResult.ALLOW }]),
);
const req = buildMockRequest('test', 'api');
const { res, next } = getMockRes();
await expect(proxy.createRequestHandler()(req, res, next)).rejects.toThrow(
NotFoundError,
);
await expect(
proxy.createRequestHandler({ permissionApi })(req, res, next),
).rejects.toThrow(NotFoundError);
});
it('should pass the exact response from Kubernetes', async () => {
@@ -100,7 +112,13 @@ describe('KubernetesProxy', () => {
authProvider: 'serviceAccount',
},
] as ClusterDetails[]);
const app = express().use('/mountpath', proxy.createRequestHandler());
const app = express().use(
'/mountpath',
proxy.createRequestHandler({ permissionApi }),
);
permissionApi.authorize.mockReturnValue(
Promise.resolve([{ result: AuthorizeResult.ALLOW }]),
);
const requestPromise = request(app)
.get('/mountpath/api')
.set(HEADER_KUBERNETES_CLUSTER, 'cluster1');
@@ -18,9 +18,16 @@ import {
ErrorResponseBody,
ForwardedError,
InputError,
NotAllowedError,
NotFoundError,
serializeError,
} from '@backstage/errors';
import { getBearerTokenFromAuthorizationHeader } from '@backstage/plugin-auth-node';
import { kubernetesProxyPermission } from '@backstage/plugin-kubernetes-common';
import {
PermissionEvaluator,
AuthorizeResult,
} from '@backstage/plugin-permission-common';
import { bufferFromFileOrString } from '@kubernetes/client-node';
import type { Request, RequestHandler } from 'express';
import { createProxyMiddleware } from 'http-proxy-middleware';
@@ -34,7 +41,24 @@ export const APPLICATION_JSON: string = 'application/json';
*
* @public
*/
export const HEADER_KUBERNETES_CLUSTER: string = 'X-Kubernetes-Cluster';
export const HEADER_KUBERNETES_CLUSTER: string = 'Backstage-Kubernetes-Cluster';
/**
* The header that is used to specify the Authentication Authorities token.
* e.x if using the google auth provider as your authentication authority then this field would be the google provided bearer token.
* @public
*/
export const HEADER_KUBERNETES_AUTH: string =
'Backstage-Kubernetes-Authorization';
/**
* The options object expected to be passed as a parameter to KubernetesProxy.createRequestHandler().
*
* @public
*/
export type KubernetesProxyCreateRequestHandlerOptions = {
permissionApi: PermissionEvaluator;
};
/**
* A proxy that routes requests to the Kubernetes API.
@@ -49,8 +73,29 @@ export class KubernetesProxy {
private readonly clusterSupplier: KubernetesClustersSupplier,
) {}
public createRequestHandler(): RequestHandler {
public createRequestHandler(
options: KubernetesProxyCreateRequestHandlerOptions,
): RequestHandler {
const { permissionApi } = options;
return async (req, res, next) => {
const token = getBearerTokenFromAuthorizationHeader(
req.header('authorization'),
);
const authorizeResponse = (
await permissionApi.authorize(
[{ permission: kubernetesProxyPermission }],
{
token,
},
)
)[0];
if (authorizeResponse.result === AuthorizeResult.DENY) {
res.status(403).json({ error: new NotAllowedError('Unauthorized') });
return;
}
const middleware = await this.getMiddleware(req);
middleware(req, res, next);
};
@@ -104,6 +149,11 @@ export class KubernetesProxy {
res.status(500).json(body);
},
onProxyReq: (proxyReq, req) => {
// the kubernetes proxy endpoint expects a header field labeled `Backstage-Kubernetes-Authorization` that will be used to authenticate with the Kubernetes Api. The token provided as a value should be an bearer token for the target cluster.
const token = req.header(HEADER_KUBERNETES_AUTH) ?? '';
proxyReq.setHeader('Authorization', token);
},
});
this.middlewareForClusterName.set(originalCluster.name, middleware);
@@ -16,5 +16,10 @@
export * from './KubernetesBuilder';
export { DEFAULT_OBJECTS } from './KubernetesFanOutHandler';
export { HEADER_KUBERNETES_CLUSTER, KubernetesProxy } from './KubernetesProxy';
export {
HEADER_KUBERNETES_CLUSTER,
KubernetesProxy,
HEADER_KUBERNETES_AUTH,
} from './KubernetesProxy';
export type { KubernetesProxyCreateRequestHandlerOptions } from './KubernetesProxy';
export * from './router';
@@ -21,6 +21,7 @@ import express from 'express';
import { KubernetesBuilder } from './KubernetesBuilder';
import { PluginEndpointDiscovery } from '@backstage/backend-common';
import { CatalogApi } from '@backstage/catalog-client';
import { PermissionEvaluator } from '@backstage/plugin-permission-common';
/**
*
@@ -32,6 +33,7 @@ export interface RouterOptions {
catalogApi: CatalogApi;
clusterSupplier?: KubernetesClustersSupplier;
discovery: PluginEndpointDiscovery;
permissions: PermissionEvaluator;
}
/**
@@ -28,6 +28,7 @@ import { Logger } from 'winston';
import { createRouter } from './router';
import { ConfigReader } from '@backstage/config';
import { CatalogClient } from '@backstage/catalog-client';
import { PermissionEvaluator } from '@backstage/plugin-permission-common';
export interface ApplicationOptions {
enableCors: boolean;
@@ -47,6 +48,8 @@ export async function createStandaloneApplication(
discoveryApi: SingleHostDiscovery.fromConfig(config),
});
const permissions = {} as PermissionEvaluator;
app.use(helmet());
if (enableCors) {
app.use(cors());
@@ -54,7 +57,10 @@ export async function createStandaloneApplication(
app.use(compression());
app.use(express.json());
app.use(requestLoggingHandler());
app.use('/', await createRouter({ logger, config, discovery, catalogApi }));
app.use(
'/',
await createRouter({ logger, config, discovery, catalogApi, permissions }),
);
app.use(notFoundHandler());
app.use(errorHandler());
+7
View File
@@ -3,6 +3,7 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { BasicPermission } from '@backstage/plugin-permission-common';
import { Entity } from '@backstage/catalog-model';
import type { JsonObject } from '@backstage/types';
import { PodStatus } from '@kubernetes/client-node';
@@ -217,6 +218,12 @@ export type KubernetesErrorTypes =
// @public (undocumented)
export type KubernetesFetchError = StatusError | RawFetchError;
// @public
export const kubernetesPermissions: BasicPermission[];
// @public
export const kubernetesProxyPermission: BasicPermission;
// @public (undocumented)
export interface KubernetesRequestAuth {
// (undocumented)
+1
View File
@@ -39,6 +39,7 @@
},
"dependencies": {
"@backstage/catalog-model": "workspace:^",
"@backstage/plugin-permission-common": "workspace:^",
"@kubernetes/client-node": "0.18.1"
},
"devDependencies": {
+4
View File
@@ -22,3 +22,7 @@
export * from './types';
export * from './catalog-entity-constants';
export {
kubernetesProxyPermission,
kubernetesPermissions,
} from './permissions';
@@ -0,0 +1,31 @@
/*
* Copyright 2023 The Backstage Authors
*
* 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 { createPermission } from '@backstage/plugin-permission-common';
/** This permission is used to check access to the proxy endpoint
* @public
*/
export const kubernetesProxyPermission = createPermission({
name: 'kubernetes.proxy',
attributes: {},
});
/**
* List of all Kubernetes permissions.
* @public
*/
export const kubernetesPermissions = [kubernetesProxyPermission];