diff --git a/.changeset/olive-pumpkins-burn.md b/.changeset/olive-pumpkins-burn.md new file mode 100644 index 0000000000..3cd9c46267 --- /dev/null +++ b/.changeset/olive-pumpkins-burn.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-permission-backend': minor +--- + +New package containing the backend for authorization and permissions. For more information, see the [authorization PRFC](https://github.com/backstage/backstage/pull/7761). 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..4857c8b044 --- /dev/null +++ b/plugins/permission-backend/api-report.md @@ -0,0 +1,26 @@ +## 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 express from 'express'; +import { IdentityClient } from '@backstage/plugin-auth-backend'; +import { Logger as Logger_2 } from 'winston'; +import { PermissionPolicy } from '@backstage/plugin-permission-node'; +import { PluginEndpointDiscovery } from '@backstage/backend-common'; + +// @public +export function createRouter(options: RouterOptions): Promise; + +// @public +export interface RouterOptions { + // (undocumented) + discovery: PluginEndpointDiscovery; + // (undocumented) + identity: IdentityClient; + // (undocumented) + logger: Logger_2; + // (undocumented) + policy: PermissionPolicy; +} +``` diff --git a/plugins/permission-backend/package.json b/plugins/permission-backend/package.json new file mode 100644 index 0000000000..f30c73f8eb --- /dev/null +++ b/plugins/permission-backend/package.json @@ -0,0 +1,44 @@ +{ + "name": "@backstage/plugin-permission-backend", + "version": "0.0.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/plugin-auth-backend": "^0.4.7", + "@backstage/plugin-permission-common": "^0.1.0", + "@backstage/plugin-permission-node": "^0.0.0", + "@types/express": "*", + "express": "^4.17.1", + "express-promise-router": "^4.1.0", + "node-fetch": "^2.6.1", + "winston": "^3.2.1", + "yn": "^4.0.0", + "zod": "^3.11.6" + }, + "devDependencies": { + "@backstage/cli": "^0.9.0", + "@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/service/PermissionIntegrationClient.test.ts b/plugins/permission-backend/src/service/PermissionIntegrationClient.test.ts new file mode 100644 index 0000000000..6a35488117 --- /dev/null +++ b/plugins/permission-backend/src/service/PermissionIntegrationClient.test.ts @@ -0,0 +1,292 @@ +/* + * 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 { AddressInfo } from 'net'; +import { Server } from 'http'; +import express, { Router } from 'express'; +import { RestContext, rest } from 'msw'; +import { setupServer, SetupServerApi } from 'msw/node'; +import { PluginEndpointDiscovery } from '@backstage/backend-common'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; +import { createPermissionIntegrationRouter } from '@backstage/plugin-permission-node'; +import { PermissionIntegrationClient } from './PermissionIntegrationClient'; + +describe('PermissionIntegrationClient', () => { + describe('applyConditions', () => { + let server: SetupServerApi; + + const mockConditions = { + not: { + allOf: [ + { rule: 'RULE_1', params: [] }, + { rule: 'RULE_2', params: ['abc'] }, + ], + }, + }; + + const mockApplyConditionsHandler = jest.fn( + (_req, res, { json }: RestContext) => { + return res(json({ result: AuthorizeResult.ALLOW })); + }, + ); + + const mockBaseUrl = 'http://backstage:9191/i-am-a-mock-base'; + const discovery: PluginEndpointDiscovery = { + async getBaseUrl() { + return mockBaseUrl; + }, + async getExternalBaseUrl() { + throw new Error('Not implemented.'); + }, + }; + + const client: PermissionIntegrationClient = new PermissionIntegrationClient( + { + discovery, + }, + ); + + beforeAll(() => { + server = setupServer(); + server.listen({ onUnhandledRequest: 'error' }); + server.use( + rest.post( + `${mockBaseUrl}/permissions/apply-conditions`, + mockApplyConditionsHandler, + ), + ); + }); + + afterAll(() => server.close()); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should make a POST request to the correct endpoint', async () => { + await client.applyConditions({ + pluginId: 'test-plugin', + resourceRef: 'testResource1', + resourceType: 'test-resource', + conditions: mockConditions, + }); + + expect(mockApplyConditionsHandler).toHaveBeenCalled(); + }); + + it('should include a request body', async () => { + await client.applyConditions({ + pluginId: 'test-plugin', + resourceRef: 'testResource1', + resourceType: 'test-resource', + conditions: mockConditions, + }); + + expect(mockApplyConditionsHandler).toHaveBeenCalledWith( + expect.objectContaining({ + body: { + resourceRef: 'testResource1', + resourceType: 'test-resource', + conditions: mockConditions, + }, + }), + expect.anything(), + expect.anything(), + ); + }); + + it('should return the response from the fetch request', async () => { + const response = await client.applyConditions({ + pluginId: 'test-plugin', + resourceRef: 'testResource1', + resourceType: 'test-resource', + conditions: mockConditions, + }); + + expect(response).toEqual( + expect.objectContaining({ result: AuthorizeResult.ALLOW }), + ); + }); + + it('should not include authorization headers if no token is supplied', async () => { + await client.applyConditions({ + pluginId: 'test-plugin', + resourceRef: 'testResource1', + resourceType: 'test-resource', + conditions: mockConditions, + }); + + const request = mockApplyConditionsHandler.mock.calls[0][0]; + expect(request.headers.has('authorization')).toEqual(false); + }); + + it('should include correctly-constructed authorization header if token is supplied', async () => { + await client.applyConditions( + { + pluginId: 'test-plugin', + resourceRef: 'testResource1', + resourceType: 'test-resource', + conditions: mockConditions, + }, + 'Bearer fake-token', + ); + + const request = mockApplyConditionsHandler.mock.calls[0][0]; + expect(request.headers.get('authorization')).toEqual('Bearer fake-token'); + }); + + it('should forward response errors', async () => { + mockApplyConditionsHandler.mockImplementationOnce( + (_req, res, { status }: RestContext) => { + return res(status(401)); + }, + ); + + await expect( + client.applyConditions({ + pluginId: 'test-plugin', + resourceRef: 'testResource1', + resourceType: 'test-resource', + conditions: mockConditions, + }), + ).rejects.toThrowError(/401/i); + }); + + it('should reject invalid responses', async () => { + mockApplyConditionsHandler.mockImplementationOnce( + (_req, res, { json }: RestContext) => { + return res(json({ outcome: AuthorizeResult.ALLOW })); + }, + ); + + await expect( + client.applyConditions({ + pluginId: 'test-plugin', + resourceRef: 'testResource1', + resourceType: 'test-resource', + conditions: mockConditions, + }), + ).rejects.toThrowError(/invalid input/i); + }); + }); + + describe('integration with @backstage/plugin-permission-node', () => { + let server: Server; + let client: PermissionIntegrationClient; + + beforeAll(async () => { + const router = Router(); + + router.use( + createPermissionIntegrationRouter({ + resourceType: 'test-resource', + getResource: async resourceRef => ({ id: resourceRef }), + rules: [ + { + name: 'RULE_1', + description: 'Test rule 1', + apply: (_resource: any, input: 'yes' | 'no') => input === 'yes', + toQuery: () => { + throw new Error('Not implemented'); + }, + }, + { + name: 'RULE_2', + description: 'Test rule 2', + apply: (_resource: any, input: 'yes' | 'no') => input === 'yes', + toQuery: () => { + throw new Error('Not implemented'); + }, + }, + ], + }), + ); + + const app = express(); + + app.use('/test-plugin', router); + + await new Promise(resolve => { + server = app.listen(resolve); + }); + + const discovery: PluginEndpointDiscovery = { + async getBaseUrl(pluginId: string) { + const listenPort = (server.address()! as AddressInfo).port; + + return `http://0.0.0.0:${listenPort}/${pluginId}`; + }, + async getExternalBaseUrl() { + throw new Error('Not implemented.'); + }, + }; + + client = new PermissionIntegrationClient({ + discovery, + }); + }); + + afterAll( + async () => + new Promise((resolve, reject) => + server.close(err => (err ? reject(err) : resolve())), + ), + ); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('works for simple conditions', async () => { + await expect( + client.applyConditions({ + pluginId: 'test-plugin', + resourceRef: 'testResource1', + resourceType: 'test-resource', + conditions: { rule: 'RULE_1', params: ['no'] }, + }), + ).resolves.toEqual({ result: AuthorizeResult.DENY }); + }); + + it('works for complex criteria', async () => { + await expect( + client.applyConditions({ + pluginId: 'test-plugin', + resourceRef: 'testResource1', + resourceType: 'test-resource', + conditions: { + allOf: [ + { + allOf: [ + { rule: 'RULE_1', params: ['yes'] }, + { not: { rule: 'RULE_2', params: ['no'] } }, + ], + }, + { + not: { + allOf: [ + { rule: 'RULE_1', params: ['no'] }, + { rule: 'RULE_2', params: ['yes'] }, + ], + }, + }, + ], + }, + }), + ).resolves.toEqual({ result: AuthorizeResult.ALLOW }); + }); + }); +}); diff --git a/plugins/permission-backend/src/service/PermissionIntegrationClient.ts b/plugins/permission-backend/src/service/PermissionIntegrationClient.ts new file mode 100644 index 0000000000..0d6017cfd3 --- /dev/null +++ b/plugins/permission-backend/src/service/PermissionIntegrationClient.ts @@ -0,0 +1,82 @@ +/* + * 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 fetch from 'node-fetch'; +import { z } from 'zod'; +import { PluginEndpointDiscovery } from '@backstage/backend-common'; +import { + AuthorizeResult, + PermissionCondition, + PermissionCriteria, +} from '@backstage/plugin-permission-common'; +import { + ApplyConditionsRequest, + ApplyConditionsResponse, +} from '@backstage/plugin-permission-node'; + +const responseSchema = z.object({ + result: z.literal(AuthorizeResult.ALLOW).or(z.literal(AuthorizeResult.DENY)), +}); + +export class PermissionIntegrationClient { + private readonly discovery: PluginEndpointDiscovery; + + constructor(options: { discovery: PluginEndpointDiscovery }) { + this.discovery = options.discovery; + } + + async applyConditions( + { + pluginId, + resourceRef, + resourceType, + conditions, + }: { + resourceRef: string; + pluginId: string; + resourceType: string; + conditions: PermissionCriteria; + }, + authHeader?: string, + ): Promise { + const endpoint = `${await this.discovery.getBaseUrl( + pluginId, + )}/permissions/apply-conditions`; + + const request: ApplyConditionsRequest = { + resourceRef, + resourceType, + conditions, + }; + + const response = await fetch(endpoint, { + method: 'POST', + body: JSON.stringify(request), + headers: { + ...(authHeader ? { authorization: authHeader } : {}), + 'content-type': 'application/json', + }, + }); + + if (!response.ok) { + throw new Error( + `Unexpected response from plugin upstream when applying conditions. Expected 200 but got ${response.status} - ${response.statusText}`, + ); + } + + return responseSchema.parse(await response.json()); + } +} diff --git a/plugins/permission-backend/src/service/index.ts b/plugins/permission-backend/src/service/index.ts new file mode 100644 index 0000000000..3ce8cdbcc4 --- /dev/null +++ b/plugins/permission-backend/src/service/index.ts @@ -0,0 +1,18 @@ +/* + * 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 { createRouter } from './router'; +export type { RouterOptions } 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..78e7121da6 --- /dev/null +++ b/plugins/permission-backend/src/service/router.test.ts @@ -0,0 +1,298 @@ +/* + * 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 { IdentityClient } from '@backstage/plugin-auth-backend'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; +import { ApplyConditionsResponse } from '@backstage/plugin-permission-node'; +import { PermissionIntegrationClient } from './PermissionIntegrationClient'; + +import { createRouter } from './router'; + +const mockApplyConditions: jest.MockedFunction< + InstanceType['applyConditions'] +> = jest.fn(); +jest.mock('./PermissionIntegrationClient', () => ({ + PermissionIntegrationClient: jest.fn(() => ({ + applyConditions: mockApplyConditions, + })), +})); + +const policy = { + handle: jest.fn().mockImplementation((_req, identity) => { + if (identity) { + return { result: AuthorizeResult.ALLOW }; + } + return { result: AuthorizeResult.DENY }; + }), +}; + +describe('createRouter', () => { + let app: express.Express; + + beforeAll(async () => { + const router = await createRouter({ + logger: getVoidLogger(), + discovery: { + getBaseUrl: jest.fn(), + getExternalBaseUrl: jest.fn(), + }, + identity: { + authenticate: jest.fn(token => { + if (!token) { + throw new Error('No token supplied!'); + } + + return Promise.resolve({ + id: 'test-user', + token, + }); + }), + } as unknown as IdentityClient, + policy, + }); + + 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: { + name: 'test.permission1', + attributes: {}, + }, + }, + { + id: '234', + permission: { + name: 'test.permission2', + attributes: {}, + }, + }, + ]); + + expect(response.status).toEqual(200); + + expect(policy.handle).toHaveBeenCalledWith( + { + permission: { + name: 'test.permission1', + attributes: {}, + }, + }, + undefined, + ); + expect(policy.handle).toHaveBeenCalledWith( + { + permission: { + name: 'test.permission2', + attributes: {}, + }, + }, + undefined, + ); + + expect(response.body).toEqual([ + { id: '123', result: AuthorizeResult.DENY }, + { id: '234', result: AuthorizeResult.DENY }, + ]); + }); + + it('resolves identity from the Authorization header', async () => { + const token = 'test-token'; + const response = await request(app) + .post('/authorize') + .auth(token, { type: 'bearer' }) + .send([ + { + id: '123', + permission: { + name: 'test.permission', + attributes: {}, + }, + }, + ]); + + expect(response.status).toEqual(200); + expect(policy.handle).toHaveBeenCalledWith( + { + permission: { + name: 'test.permission', + attributes: {}, + }, + }, + { id: 'test-user', token: 'test-token' }, + ); + expect(response.body).toEqual([ + { id: '123', result: AuthorizeResult.ALLOW }, + ]); + }); + + describe('conditional policy result', () => { + beforeEach(() => { + policy.handle.mockReturnValueOnce({ + result: AuthorizeResult.CONDITIONAL, + conditions: { + pluginId: 'test-plugin', + resourceType: 'test-resource-1', + conditions: { + anyOf: [{ rule: 'test-rule', params: ['abc'] }], + }, + }, + }); + }); + + it('returns conditions if no resourceRef is supplied', async () => { + const response = await request(app) + .post('/authorize') + .send([ + { + id: '123', + permission: { + name: 'test.permission', + resourceType: 'test-resource-1', + attributes: {}, + }, + }, + ]); + + expect(response.status).toEqual(200); + expect(response.body).toEqual([ + { + id: '123', + result: AuthorizeResult.CONDITIONAL, + conditions: { anyOf: [{ rule: 'test-rule', params: ['abc'] }] }, + }, + ]); + }); + + it.each([ + AuthorizeResult.ALLOW, + AuthorizeResult.DENY, + ])( + 'applies conditions and returns %s if resourceRef is supplied', + async result => { + mockApplyConditions.mockResolvedValueOnce({ + result, + }); + + const response = await request(app) + .post('/authorize') + .auth('test-token', { type: 'bearer' }) + .send([ + { + id: '123', + resourceRef: 'test/resource', + permission: { + name: 'test.permission', + resourceType: 'test-resource-1', + attributes: {}, + }, + }, + ]); + + expect(mockApplyConditions).toHaveBeenCalledWith( + { + pluginId: 'test-plugin', + resourceType: 'test-resource-1', + resourceRef: 'test/resource', + conditions: { anyOf: [{ rule: 'test-rule', params: ['abc'] }] }, + }, + 'Bearer test-token', + ); + + expect(response.status).toEqual(200); + expect(response.body).toEqual([ + { + id: '123', + result, + }, + ]); + }, + ); + }); + + it.each([ + undefined, + '', + {}, + [{ permission: { name: 'test.permission', attributes: {} } }], + [{ id: '123' }], + [{ id: '123', permission: { name: 'test.permission' } }], + [{ id: '123', permission: { attributes: { invalid: 'attribute' } } }], + ])('returns a 500 error for invalid request %#', async requestBody => { + const response = await request(app).post('/authorize').send(requestBody); + + expect(response.status).toEqual(500); + expect(response.body).toEqual( + expect.objectContaining({ + error: expect.objectContaining({ + message: expect.stringMatching(/invalid/i), + }), + }), + ); + }); + + it('returns a 500 error if the policy returns a different resourceType', async () => { + policy.handle.mockReturnValueOnce({ + result: AuthorizeResult.CONDITIONAL, + conditions: { + pluginId: 'test-plugin', + resourceType: 'test-resource-2', + conditions: {}, + }, + }); + + const response = await request(app) + .post('/authorize') + .send([ + { + id: '123', + permission: { + name: 'test.permission', + resourceType: 'test-resource-1', + attributes: {}, + }, + }, + ]); + + expect(response.status).toEqual(500); + expect(response.body).toEqual( + expect.objectContaining({ + error: expect.objectContaining({ + message: expect.stringMatching(/invalid resource conditions/i), + }), + }), + ); + }); + }); +}); diff --git a/plugins/permission-backend/src/service/router.ts b/plugins/permission-backend/src/service/router.ts new file mode 100644 index 0000000000..8caba23b50 --- /dev/null +++ b/plugins/permission-backend/src/service/router.ts @@ -0,0 +1,166 @@ +/* + * 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 { z } from 'zod'; +import express, { Request, Response } from 'express'; +import Router from 'express-promise-router'; +import { Logger } from 'winston'; +import { + errorHandler, + PluginEndpointDiscovery, +} from '@backstage/backend-common'; +import { + BackstageIdentity, + IdentityClient, +} from '@backstage/plugin-auth-backend'; +import { + AuthorizeResult, + AuthorizeResponse, + AuthorizeRequest, + Identified, +} from '@backstage/plugin-permission-common'; +import { PermissionPolicy } from '@backstage/plugin-permission-node'; +import { PermissionIntegrationClient } from './PermissionIntegrationClient'; + +const requestSchema: z.ZodSchema[]> = z.array( + z.object({ + id: z.string(), + resourceRef: z.string().optional(), + permission: z.object({ + name: z.string(), + resourceType: z.string().optional(), + attributes: z.object({ + action: z + .union([ + z.literal('create'), + z.literal('read'), + z.literal('update'), + z.literal('delete'), + ]) + .optional(), + }), + }), + }), +); + +/** + * Options required when constructing a new {@link express#Router} using + * {@link createRouter}. + * + * @public + */ +export interface RouterOptions { + logger: Logger; + discovery: PluginEndpointDiscovery; + policy: PermissionPolicy; + identity: IdentityClient; +} + +const handleRequest = async ( + { id, resourceRef, ...request }: Identified, + user: BackstageIdentity | undefined, + policy: PermissionPolicy, + permissionIntegrationClient: PermissionIntegrationClient, + 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 Error( + `Invalid resource conditions returned from permission policy for permission ${request.permission.name}`, + ); + } + + if (resourceRef) { + return { + id, + ...(await permissionIntegrationClient.applyConditions( + { + resourceRef, + ...response.conditions, + }, + authHeader, + )), + }; + } + + return { + id, + result: AuthorizeResult.CONDITIONAL, + // TODO(mtlewis): this .conditions.conditions situation is a bit awkward. I think it's + // worth exploring a bit of reorganization of the ConditionalPolicyResult type so that + // the naming of property chains like this makes a bit more sense. + conditions: response.conditions.conditions, + }; + } + + return { id, ...response }; +}; + +/** + * Creates a new {@link express#Router} which provides the backend API + * for the permission system. + * + * @public + */ +export async function createRouter( + options: RouterOptions, +): Promise { + const { policy, discovery, identity } = options; + + const permissionIntegrationClient = new PermissionIntegrationClient({ + discovery, + }); + + 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 = requestSchema.parse(req.body); + + res.json( + await Promise.all( + body.map(request => + handleRequest( + request, + user, + policy, + permissionIntegrationClient, + req.header('authorization'), + ), + ), + ), + ); + }, + ); + + router.use(errorHandler()); + return router; +} 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 {};