Adding permission framework integration to kubernetes proxy endpoint
Signed-off-by: Ruben Vallejo <rvallejo@vmware.com>
This commit is contained in:
@@ -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());
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/catalog-model": "workspace:^",
|
||||
"@backstage/plugin-permission-common": "workspace:^",
|
||||
"@kubernetes/client-node": "0.18.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
@@ -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:^"
|
||||
|
||||
Reference in New Issue
Block a user