Adding permission framework integration to kubernetes proxy endpoint

Signed-off-by: Ruben Vallejo <rvallejo@vmware.com>
This commit is contained in:
Ruben Vallejo
2023-02-07 12:44:08 -05:00
parent 2bf94911a4
commit 1fef8423ce
10 changed files with 211 additions and 20 deletions
@@ -27,6 +27,7 @@ export default async function createPlugin(
logger: env.logger,
config: env.config,
catalogApi,
permissions: env.permissions,
}).build();
return router;
}
@@ -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)
@@ -278,20 +293,26 @@ describe('KubernetesBuilder', () => {
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 +321,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,17 +338,22 @@ 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
metadata:
name: new-ns
`;
kind: Namespace
apiVersion: v1
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 +362,31 @@ 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);
});
});
});
@@ -15,6 +15,7 @@
*/
import { CatalogApi } from '@backstage/catalog-client';
import { Config } from '@backstage/config';
import { PermissionEvaluator } from '@backstage/plugin-permission-common';
import express from 'express';
import Router from 'express-promise-router';
import { Duration } from 'luxon';
@@ -49,6 +50,7 @@ export interface KubernetesEnvironment {
logger: Logger;
config: Config;
catalogApi: CatalogApi;
permissions: PermissionEvaluator;
}
/**
@@ -89,6 +91,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 +129,7 @@ export class KubernetesBuilder {
clusterSupplier,
this.env.catalogApi,
proxy,
permissions,
);
return {
@@ -262,10 +266,11 @@ 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());
// @deprecated
@@ -18,9 +18,19 @@ import {
ErrorResponseBody,
ForwardedError,
InputError,
NotAllowedError,
NotFoundError,
serializeError,
} from '@backstage/errors';
import { getBearerTokenFromAuthorizationHeader } from '@backstage/plugin-auth-node';
import {
kubernetesProxyReadPermission,
kubernetesProxyCreatePermission,
} 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';
@@ -36,6 +46,13 @@ export const APPLICATION_JSON: string = 'application/json';
*/
export const HEADER_KUBERNETES_CLUSTER: string = 'X-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.
* @alpha
*/
export const HEADER_KUBERNETES_AUTH: string = 'X-Kubernetes-Authorization';
/**
* A proxy that routes requests to the Kubernetes API.
*
@@ -49,8 +66,33 @@ export class KubernetesProxy {
private readonly clusterSupplier: KubernetesClustersSupplier,
) {}
public createRequestHandler(): RequestHandler {
public createRequestHandler(
permissionApi: PermissionEvaluator,
): RequestHandler {
return async (req, res, next) => {
const token = getBearerTokenFromAuthorizationHeader(
req.header('authorization'),
);
const authorizeResponse = (
await permissionApi.authorize(
[
{ permission: kubernetesProxyReadPermission },
{ permission: kubernetesProxyCreatePermission },
],
{
token,
},
)
)[0];
if (authorizeResponse.result === AuthorizeResult.DENY) {
res
.status(403)
.json({ error: new NotAllowedError('Unauthorized').message });
return;
}
const middleware = await this.getMiddleware(req);
middleware(req, res, next);
};
@@ -104,6 +146,11 @@ export class KubernetesProxy {
res.status(500).json(body);
},
onProxyReq: (proxyReq, req) => {
// the kubernetes proxy endpoint expects a header field labeled `X-Kubernetes-Authorization` that will be used to authenticate with the Kubernetes Api. The token provided as a value should be an Authentication Providers bearer token.
const token = req.header('X-Kubernetes-Authorization') ?? '';
proxyReq.setHeader('Authorization', token);
},
});
this.middlewareForClusterName.set(originalCluster.name, middleware);
@@ -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());
+1
View File
@@ -39,6 +39,7 @@
},
"dependencies": {
"@backstage/catalog-model": "workspace:^",
"@backstage/plugin-permission-common": "workspace:^",
"@kubernetes/client-node": "0.18.1"
},
"devDependencies": {
+5
View File
@@ -22,3 +22,8 @@
export * from './types';
export * from './catalog-entity-constants';
export {
kubernetesProxyReadPermission,
kubernetesProxyCreatePermission,
kubernetesClusterPermissions,
} from './permissions';
@@ -0,0 +1,39 @@
/*
* 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 authorize actions that involve using the kubernetes Proxy Endpoint /proxy
* @alpha
*/
export const kubernetesProxyReadPermission = createPermission({
name: 'kubernetes.proxy.read',
attributes: { action: 'read' },
});
export const kubernetesProxyCreatePermission = createPermission({
name: 'kubernetes.proxy.create',
attributes: { action: 'create' },
});
/**
* List of all cluster permissions.
* @alpha
*/
export const kubernetesClusterPermissions = [
kubernetesProxyReadPermission,
kubernetesProxyCreatePermission,
];
+28
View File
@@ -6843,6 +6843,7 @@ __metadata:
dependencies:
"@backstage/catalog-model": "workspace:^"
"@backstage/cli": "workspace:^"
"@backstage/plugin-permission-common": "workspace:^"
"@kubernetes/client-node": 0.18.1
languageName: unknown
linkType: soft
@@ -7462,6 +7463,32 @@ __metadata:
languageName: unknown
linkType: soft
"@backstage/plugin-proxy-restrict@^0.0.0, @backstage/plugin-proxy-restrict@workspace:plugins/proxy-restrict":
version: 0.0.0-use.local
resolution: "@backstage/plugin-proxy-restrict@workspace:plugins/proxy-restrict"
dependencies:
"@backstage/cli": "workspace:^"
"@backstage/core-app-api": "workspace:^"
"@backstage/core-components": "workspace:^"
"@backstage/core-plugin-api": "workspace:^"
"@backstage/dev-utils": "workspace:^"
"@backstage/test-utils": "workspace:^"
"@backstage/theme": "workspace:^"
"@material-ui/core": ^4.12.2
"@material-ui/icons": ^4.9.1
"@material-ui/lab": ^4.0.0-alpha.57
"@testing-library/jest-dom": ^5.10.1
"@testing-library/react": ^12.1.3
"@testing-library/user-event": ^14.0.0
"@types/node": "*"
cross-fetch: ^3.1.5
msw: ^0.49.0
react-use: ^17.2.4
peerDependencies:
react: ^16.13.1 || ^17.0.0
languageName: unknown
linkType: soft
"@backstage/plugin-rollbar-backend@workspace:^, @backstage/plugin-rollbar-backend@workspace:plugins/rollbar-backend":
version: 0.0.0-use.local
resolution: "@backstage/plugin-rollbar-backend@workspace:plugins/rollbar-backend"
@@ -22469,6 +22496,7 @@ __metadata:
"@backstage/plugin-pagerduty": "workspace:^"
"@backstage/plugin-permission-react": "workspace:^"
"@backstage/plugin-playlist": "workspace:^"
"@backstage/plugin-proxy-restrict": ^0.0.0
"@backstage/plugin-rollbar": "workspace:^"
"@backstage/plugin-scaffolder": "workspace:^"
"@backstage/plugin-scaffolder-react": "workspace:^"