From f028746410a472a63f45c9270e997f450bd23893 Mon Sep 17 00:00:00 2001 From: Tim Hansen Date: Wed, 17 Nov 2021 14:58:09 -0700 Subject: [PATCH] Add permission-backend plugin Signed-off-by: Tim Hansen --- plugins/permission-backend/.eslintrc.js | 3 + plugins/permission-backend/README.md | 6 + plugins/permission-backend/api-report.md | 29 +++ plugins/permission-backend/package.json | 44 +++++ plugins/permission-backend/src/index.ts | 21 +++ plugins/permission-backend/src/run.ts | 33 ++++ .../permission-backend/src/service/index.ts | 17 ++ .../src/service/router.test.ts | 103 ++++++++++ .../permission-backend/src/service/router.ts | 177 ++++++++++++++++++ .../src/service/standaloneServer.ts | 59 ++++++ plugins/permission-backend/src/setupTests.ts | 17 ++ 11 files changed, 509 insertions(+) create mode 100644 plugins/permission-backend/.eslintrc.js create mode 100644 plugins/permission-backend/README.md create mode 100644 plugins/permission-backend/api-report.md create mode 100644 plugins/permission-backend/package.json create mode 100644 plugins/permission-backend/src/index.ts create mode 100644 plugins/permission-backend/src/run.ts create mode 100644 plugins/permission-backend/src/service/index.ts create mode 100644 plugins/permission-backend/src/service/router.test.ts create mode 100644 plugins/permission-backend/src/service/router.ts create mode 100644 plugins/permission-backend/src/service/standaloneServer.ts create mode 100644 plugins/permission-backend/src/setupTests.ts diff --git a/plugins/permission-backend/.eslintrc.js b/plugins/permission-backend/.eslintrc.js new file mode 100644 index 0000000000..16a033dbc6 --- /dev/null +++ b/plugins/permission-backend/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint.backend')], +}; diff --git a/plugins/permission-backend/README.md b/plugins/permission-backend/README.md new file mode 100644 index 0000000000..59fe1730ba --- /dev/null +++ b/plugins/permission-backend/README.md @@ -0,0 +1,6 @@ +# @backstage/plugin-permission-backend + +> NOTE: THIS PACKAGE IS EXPERIMENTAL, HERE BE DRAGONS + +Backend for Backstage authorization and permissions. For more information, see +the [authorization PRFC](https://github.com/backstage/backstage/pull/7761). diff --git a/plugins/permission-backend/api-report.md b/plugins/permission-backend/api-report.md new file mode 100644 index 0000000000..5b7801d0ab --- /dev/null +++ b/plugins/permission-backend/api-report.md @@ -0,0 +1,29 @@ +## API Report File for "@backstage/plugin-permission-backend" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { Config } from '@backstage/config'; +import express from 'express'; +import { Logger as Logger_2 } from 'winston'; +import { PermissionPolicy } from '@backstage/plugin-permission-node'; + +// Warning: (ae-missing-release-tag) "createRouter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export function createRouter(options: RouterOptions): Promise; + +// Warning: (ae-missing-release-tag) "RouterOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface RouterOptions { + // (undocumented) + config: Config; + // (undocumented) + logger: Logger_2; + // (undocumented) + policy: PermissionPolicy; +} + +// (No @packageDocumentation comment for this package) +``` diff --git a/plugins/permission-backend/package.json b/plugins/permission-backend/package.json new file mode 100644 index 0000000000..f6aac0b3ab --- /dev/null +++ b/plugins/permission-backend/package.json @@ -0,0 +1,44 @@ +{ + "name": "@backstage/plugin-permission-backend", + "version": "0.1.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "scripts": { + "start": "backstage-cli backend:dev", + "build": "backstage-cli backend:build", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "dependencies": { + "@backstage/backend-common": "^0.9.9", + "@backstage/config": "^0.1.11", + "@backstage/errors": "^0.1.4", + "@backstage/plugin-auth-backend": "^0.4.7", + "@backstage/plugin-permission-common": "^0.1.0", + "@backstage/plugin-permission-node": "^0.1.0", + "@types/express": "*", + "cross-fetch": "^3.0.6", + "express": "^4.17.1", + "express-promise-router": "^4.1.0", + "winston": "^3.2.1", + "yn": "^4.0.0" + }, + "devDependencies": { + "@backstage/cli": "^0.8.2", + "@types/supertest": "^2.0.8", + "supertest": "^4.0.2", + "msw": "^0.35.0" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/permission-backend/src/index.ts b/plugins/permission-backend/src/index.ts new file mode 100644 index 0000000000..877b9070c3 --- /dev/null +++ b/plugins/permission-backend/src/index.ts @@ -0,0 +1,21 @@ +/* + * Copyright 2021 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. + */ + +/** + * Backend for Backstage authorization and permissions. + * @packageDocumentation + */ +export * from './service'; diff --git a/plugins/permission-backend/src/run.ts b/plugins/permission-backend/src/run.ts new file mode 100644 index 0000000000..addfdfd6d7 --- /dev/null +++ b/plugins/permission-backend/src/run.ts @@ -0,0 +1,33 @@ +/* + * Copyright 2021 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 { getRootLogger } from '@backstage/backend-common'; +import yn from 'yn'; +import { startStandaloneServer } from './service/standaloneServer'; + +const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7000; +const enableCors = yn(process.env.PLUGIN_CORS, { default: false }); +const logger = getRootLogger(); + +startStandaloneServer({ port, enableCors, logger }).catch(err => { + logger.error(err); + process.exit(1); +}); + +process.on('SIGINT', () => { + logger.info('CTRL+C pressed; exiting.'); + process.exit(0); +}); diff --git a/plugins/permission-backend/src/service/index.ts b/plugins/permission-backend/src/service/index.ts new file mode 100644 index 0000000000..b26567988c --- /dev/null +++ b/plugins/permission-backend/src/service/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 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. + */ + +export * from './router'; diff --git a/plugins/permission-backend/src/service/router.test.ts b/plugins/permission-backend/src/service/router.test.ts new file mode 100644 index 0000000000..f0fc889e8b --- /dev/null +++ b/plugins/permission-backend/src/service/router.test.ts @@ -0,0 +1,103 @@ +/* + * Copyright 2021 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 express from 'express'; +import request from 'supertest'; +import { getVoidLogger } from '@backstage/backend-common'; +import { ConfigReader } from '@backstage/config'; +import { IdentityClient } from '@backstage/plugin-auth-backend'; +import { + AuthorizeResult, + Permission, +} from '@backstage/plugin-permission-common'; +import { PermissionPolicy } from '@backstage/plugin-permission-node'; + +import { createRouter } from './router'; + +const identityApi: Partial = { + authenticate: jest.fn().mockImplementation(_ => ({ id: 'test-user' })), +}; + +const policy: PermissionPolicy = { + handle: jest.fn().mockImplementation((_req, identity) => { + if (identity) { + return { result: AuthorizeResult.ALLOW }; + } + return { result: AuthorizeResult.DENY }; + }), +}; + +const permission: Permission = { + name: 'test.permission', + attributes: { action: 'read' }, +}; + +describe('createRouter', () => { + let app: express.Express; + + beforeAll(async () => { + const router = await createRouter({ + logger: getVoidLogger(), + config: new ConfigReader({ + backend: { + baseUrl: 'http://localhost', + listen: { port: 7007 }, + }, + }), + policy, + identity: identityApi as IdentityClient, + }); + app = express().use(router); + }); + + describe('GET /health', () => { + it('returns ok', async () => { + const response = await request(app).get('/health'); + + expect(response.status).toEqual(200); + expect(response.body).toEqual({ status: 'ok' }); + }); + }); + + describe('POST /authorize', () => { + it('calls the permission policy', async () => { + const response = await request(app) + .post('/authorize') + .send([{ id: 123, permission }]); + + expect(response.status).toEqual(200); + expect(policy.handle).toHaveBeenCalledWith({ permission }, undefined); + expect(response.body).toEqual([ + { id: 123, result: AuthorizeResult.DENY }, + ]); + }); + + it('resolves identity from the Authorization header', async () => { + const token = 'token'; + const response = await request(app) + .post('/authorize') + .auth(token, { type: 'bearer' }) + .send([{ id: 123, permission }]); + + expect(response.status).toEqual(200); + expect(identityApi.authenticate).toHaveBeenCalledWith('token'); + expect(policy.handle).toHaveBeenCalledWith( + { permission }, + { id: 'test-user' }, + ); + }); + }); +}); diff --git a/plugins/permission-backend/src/service/router.ts b/plugins/permission-backend/src/service/router.ts new file mode 100644 index 0000000000..2019d13d2a --- /dev/null +++ b/plugins/permission-backend/src/service/router.ts @@ -0,0 +1,177 @@ +/* + * Copyright 2021 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 { + errorHandler, + SingleHostDiscovery, + PluginEndpointDiscovery, +} from '@backstage/backend-common'; +import fetch from 'cross-fetch'; +import express, { Request, Response } from 'express'; +import Router from 'express-promise-router'; +import { Logger } from 'winston'; +import { + BackstageIdentity, + IdentityClient, +} from '@backstage/plugin-auth-backend'; +import { Config } from '@backstage/config'; +import { ConflictError, ResponseError } from '@backstage/errors'; +import { + AuthorizeResult, + AuthorizeResponse, + AuthorizeRequest, + Identified, + PermissionCondition, + PermissionCriteria, +} from '@backstage/plugin-permission-common'; +import { + ApplyConditionsRequest, + PermissionPolicy, +} from '@backstage/plugin-permission-node'; + +export interface RouterOptions { + logger: Logger; + config: Config; + policy: PermissionPolicy; + identity?: IdentityClient; +} + +// TODO(permission-backend): probably move this to a separate client +const applyConditions = async ( + resourceRef: string, + conditions: { + pluginId: string; + resourceType: string; + conditions: PermissionCriteria; + }, + discoveryApi: PluginEndpointDiscovery, + authHeader?: string, +): Promise<{ result: AuthorizeResult.ALLOW | AuthorizeResult.DENY }> => { + const endpoint = `${await discoveryApi.getBaseUrl( + conditions.pluginId, + )}/permissions/apply-conditions`; + + const request: ApplyConditionsRequest = { + resourceRef, + resourceType: conditions.resourceType, + conditions: conditions.conditions, + }; + + const response = await fetch(endpoint, { + method: 'POST', + body: JSON.stringify(request), + headers: { + ...(authHeader ? { authorization: authHeader } : {}), + 'content-type': 'application/json', + }, + }); + + if (!response.ok) { + throw await ResponseError.fromResponse(response); + } + + // TODO(permission-backend): validate response + return await response.json(); +}; + +const handleRequest = async ( + { id, resourceRef, ...request }: Identified, + user: BackstageIdentity | undefined, + policy: PermissionPolicy, + discoveryApi: PluginEndpointDiscovery, + authHeader?: string, +): Promise> => { + const response = await policy.handle(request, user); + + if (response.result === AuthorizeResult.CONDITIONAL) { + // Sanity check that any resource provided matches the one expected by the permission + if (request.permission.resourceType !== response.conditions.resourceType) { + throw new ConflictError( + `Invalid resource conditions returned from permission policy for permission ${request.permission.name}`, + ); + } + + if (resourceRef) { + return { + id, + ...(await applyConditions( + resourceRef, + response.conditions, + discoveryApi, + authHeader, + )), + }; + } + + return { + id, + result: AuthorizeResult.CONDITIONAL, + conditions: response.conditions.conditions, + }; + } + + return { id, ...response }; +}; + +export async function createRouter( + options: RouterOptions, +): Promise { + const { config, policy } = options; + const discovery = SingleHostDiscovery.fromConfig(config); + const identity = + options.identity ?? + new IdentityClient({ + discovery, + issuer: await discovery.getExternalBaseUrl('auth'), + }); + + const router = Router(); + router.use(express.json()); + + router.get('/health', (_, response) => { + response.send({ status: 'ok' }); + }); + + router.post( + '/authorize', + async ( + req: Request[]>, + res: Response[]>, + ) => { + const token = IdentityClient.getBearerToken(req.header('authorization')); + const user = token ? await identity.authenticate(token) : undefined; + + const body: Identified[] = req.body; + + res.json( + await Promise.all( + body.map(request => + handleRequest( + request, + user, + policy, + discovery, + req.header('authorization'), + ), + ), + ), + ); + }, + ); + + router.use(errorHandler()); + return router; +} diff --git a/plugins/permission-backend/src/service/standaloneServer.ts b/plugins/permission-backend/src/service/standaloneServer.ts new file mode 100644 index 0000000000..6218d22d59 --- /dev/null +++ b/plugins/permission-backend/src/service/standaloneServer.ts @@ -0,0 +1,59 @@ +/* + * Copyright 2021 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 { + createServiceBuilder, + loadBackendConfig, +} from '@backstage/backend-common'; +import { Server } from 'http'; +import { Logger } from 'winston'; +import { createRouter } from './router'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; + +export interface ServerOptions { + port: number; + enableCors: boolean; + logger: Logger; +} + +export async function startStandaloneServer( + options: ServerOptions, +): Promise { + const logger = options.logger.child({ service: 'permission-backend' }); + const config = await loadBackendConfig({ logger, argv: process.argv }); + logger.debug('Starting application server...'); + const router = await createRouter({ + logger, + config, + policy: { + handle: () => Promise.resolve({ result: AuthorizeResult.ALLOW }), + }, + }); + + let service = createServiceBuilder(module) + .setPort(options.port) + .addRouter('/permission', router); + if (options.enableCors) { + service = service.enableCors({ origin: 'http://localhost:3000' }); + } + + return await service.start().catch(err => { + logger.error(err); + process.exit(1); + }); +} + +module.hot?.accept(); diff --git a/plugins/permission-backend/src/setupTests.ts b/plugins/permission-backend/src/setupTests.ts new file mode 100644 index 0000000000..a330613afb --- /dev/null +++ b/plugins/permission-backend/src/setupTests.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 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. + */ + +export {};