diff --git a/packages/permission-common/.eslintrc.js b/packages/permission-common/.eslintrc.js new file mode 100644 index 0000000000..16a033dbc6 --- /dev/null +++ b/packages/permission-common/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint.backend')], +}; diff --git a/packages/permission-common/README.md b/packages/permission-common/README.md new file mode 100644 index 0000000000..21452748cf --- /dev/null +++ b/packages/permission-common/README.md @@ -0,0 +1,7 @@ +# @backstage/permission-common + +> NOTE: THIS PACKAGE IS EXPERIMENTAL, HERE BE DRAGONS + +Isomorphic types and client for Backstage permissions and authorization. For +more information, see the [authorization +PRFC](https://github.com/backstage/backstage/pull/7761). diff --git a/packages/permission-common/api-report.md b/packages/permission-common/api-report.md new file mode 100644 index 0000000000..a88d50b083 --- /dev/null +++ b/packages/permission-common/api-report.md @@ -0,0 +1,116 @@ +## API Report File for "@backstage/permission-common" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +// @public +export type AuthorizeRequest = { + permission: Permission; + resourceRef?: string; +}; + +// @public +export type AuthorizeRequestOptions = { + token?: string; +}; + +// @public +export type AuthorizeResponse = + | { + result: AuthorizeResult.ALLOW | AuthorizeResult.DENY; + } + | { + result: AuthorizeResult.CONDITIONAL; + conditions: PermissionCriteria; + }; + +// @public +export enum AuthorizeResult { + ALLOW = 'ALLOW', + CONDITIONAL = 'CONDITIONAL', + DENY = 'DENY', +} + +// @public +export type DiscoveryApi = { + getBaseUrl(pluginId: string): Promise; +}; + +// @public +export class Permission { + constructor( + name: string, + attributes: PermissionAttributes, + resourceType?: string | undefined, + ); + // (undocumented) + readonly attributes: PermissionAttributes; + // (undocumented) + static create({ name, attributes, resourceType }: PermissionJSON): Permission; + // (undocumented) + is(permission: Permission): boolean; + // (undocumented) + get isCreate(): boolean; + // (undocumented) + get isDelete(): boolean; + // (undocumented) + get isRead(): boolean; + // (undocumented) + get isUpdate(): boolean; + // (undocumented) + readonly name: string; + // (undocumented) + readonly resourceType?: string | undefined; + // (undocumented) + toJSON(): PermissionJSON; +} + +// @public +export enum PermissionAction { + // (undocumented) + Create = 'create', + // (undocumented) + Delete = 'delete', + // (undocumented) + Read = 'read', + // (undocumented) + Update = 'update', +} + +// @public +export type PermissionAttributes = { + action?: PermissionAction; +}; + +// @public +export class PermissionClient { + constructor(options: { discoveryApi: DiscoveryApi }); + authorize( + requests: AuthorizeRequest[], + options?: AuthorizeRequestOptions, + ): Promise; +} + +// @public +export type PermissionCondition = { + rule: string; + params: TParams; +}; + +// @public +export type PermissionCriteria = + | { + allOf: PermissionCriteria[]; + } + | { + anyOf: PermissionCriteria[]; + } + | PermissionCondition; + +// @public +export type PermissionJSON = { + name: string; + attributes: PermissionAttributes; + resourceType?: string; +}; +``` diff --git a/packages/permission-common/package.json b/packages/permission-common/package.json new file mode 100644 index 0000000000..7fb89519c2 --- /dev/null +++ b/packages/permission-common/package.json @@ -0,0 +1,48 @@ +{ + "name": "@backstage/permission-common", + "description": "Isomorphic types and client for Backstage permissions and authorization", + "version": "0.1.0", + "main": "src/index.ts", + "types": "src/index.ts", + "private": true, + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "packages/permission-common" + }, + "keywords": [ + "backstage", + "permissions" + ], + "license": "Apache-2.0", + "files": [ + "dist" + ], + "scripts": { + "build": "backstage-cli build --outputs cjs,types", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "bugs": { + "url": "https://github.com/backstage/backstage/issues" + }, + "dependencies": { + "@backstage/errors": "^0.1.2", + "cross-fetch": "^3.0.6", + "uuid": "^8.0.0" + }, + "devDependencies": { + "@backstage/cli": "^0.8.0", + "@types/jest": "^26.0.7", + "msw": "^0.35.0" + } +} diff --git a/packages/permission-common/src/Permission.ts b/packages/permission-common/src/Permission.ts new file mode 100644 index 0000000000..83473c8beb --- /dev/null +++ b/packages/permission-common/src/Permission.ts @@ -0,0 +1,76 @@ +/* + * 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 { + PermissionAction, + PermissionAttributes, + PermissionJSON, +} from './types/permission'; + +/** + * A permission that can be checked through authorization. + * + * Permissions are the "what" part of authorization, the action to be performed. This may be reading + * an entity from the catalog, executing a software template, or any other action a plugin author + * may wish to protect. + * + * To evaluate authorization, a permission is paired with a Backstage identity (the "who") and + * evaluated using an authorization policy. + * @public + */ +export class Permission { + constructor( + readonly name: string, + readonly attributes: PermissionAttributes, + readonly resourceType?: string, + ) {} + + is(permission: Permission) { + return this.name === permission.name; + } + + get isCreate() { + return this.attributes.action === PermissionAction.Create; + } + + get isRead() { + return this.attributes.action === PermissionAction.Read; + } + + get isUpdate() { + return this.attributes.action === PermissionAction.Update; + } + + get isDelete() { + return this.attributes.action === PermissionAction.Delete; + } + + toJSON(): PermissionJSON { + return { + name: this.name, + attributes: this.attributes, + resourceType: this.resourceType, + }; + } + + static create({ + name, + attributes, + resourceType, + }: PermissionJSON): Permission { + return new Permission(name, attributes, resourceType); + } +} diff --git a/packages/permission-common/src/PermissionClient.test.ts b/packages/permission-common/src/PermissionClient.test.ts new file mode 100644 index 0000000000..d709cca28d --- /dev/null +++ b/packages/permission-common/src/PermissionClient.test.ts @@ -0,0 +1,129 @@ +/* + * 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 { RestContext, rest } from 'msw'; +import { setupServer } from 'msw/node'; +import { PermissionClient } from './PermissionClient'; +import { AuthorizeResult, Identified, AuthorizeRequestJSON } from './types/api'; +import { DiscoveryApi } from './types/discovery'; +import { Permission } from './Permission'; + +const server = setupServer(); +const token = 'fake-token'; + +const mockBaseUrl = 'http://backstage:9191/i-am-a-mock-base'; +const discoveryApi: DiscoveryApi = { + async getBaseUrl() { + return mockBaseUrl; + }, +}; +const client: PermissionClient = new PermissionClient({ discoveryApi }); + +const mockPermission = Permission.create({ + name: 'test.permission', + attributes: {}, + resourceType: 'test-resource', +}); + +const mockAuthorizeRequest = { + permission: mockPermission, + resourceRef: 'foo', +}; + +describe('PermissionClient', () => { + beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); + afterAll(() => server.close()); + afterEach(() => server.resetHandlers()); + + describe('authorize', () => { + const mockAuthorizeHandler = jest.fn((req, res, { json }: RestContext) => { + const responses = req.body.map((a: Identified) => ({ + id: a.id, + result: AuthorizeResult.ALLOW, + })); + + return res(json(responses)); + }); + + beforeEach(() => { + server.use(rest.post(`${mockBaseUrl}/authorize`, mockAuthorizeHandler)); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should fetch entities from correct endpoint', async () => { + await client.authorize([mockAuthorizeRequest]); + expect(mockAuthorizeHandler).toHaveBeenCalled(); + }); + + it('should include a request body', async () => { + await client.authorize([mockAuthorizeRequest]); + + const request = mockAuthorizeHandler.mock.calls[0][0]; + expect(request.body[0]).toEqual( + expect.objectContaining({ + permission: mockPermission, + resourceRef: 'foo', + }), + ); + }); + + it('should return the response from the fetch request', async () => { + const response = await client.authorize([mockAuthorizeRequest]); + expect(response[0]).toEqual( + expect.objectContaining({ result: AuthorizeResult.ALLOW }), + ); + }); + + it('should not include authorization headers if no token is supplied', async () => { + await client.authorize([mockAuthorizeRequest]); + + const request = mockAuthorizeHandler.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.authorize([mockAuthorizeRequest], { token }); + + const request = mockAuthorizeHandler.mock.calls[0][0]; + expect(request.headers.get('authorization')).toEqual('Bearer fake-token'); + }); + + it('should forward response errors', async () => { + mockAuthorizeHandler.mockImplementationOnce( + (_req, res, { status }: RestContext) => { + return res(status(401)); + }, + ); + await expect( + client.authorize([mockAuthorizeRequest], { token }), + ).rejects.toThrowError(/request failed with 401/i); + }); + + it('should reject invalid responses', async () => { + mockAuthorizeHandler.mockImplementationOnce( + (_req, res, { json }: RestContext) => { + return res(json([{ id: 'wrong-id', result: AuthorizeResult.ALLOW }])); + }, + ); + await expect( + client.authorize([mockAuthorizeRequest], { token }), + ).rejects.toThrowError(/Unexpected authorization response/i); + }); + }); +}); diff --git a/packages/permission-common/src/PermissionClient.ts b/packages/permission-common/src/PermissionClient.ts new file mode 100644 index 0000000000..acb23f3345 --- /dev/null +++ b/packages/permission-common/src/PermissionClient.ts @@ -0,0 +1,124 @@ +/* + * 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 { ResponseError } from '@backstage/errors'; +import fetch from 'cross-fetch'; +import * as uuid from 'uuid'; +import { + AuthorizeResult, + AuthorizeRequest, + AuthorizeResponse, + AuthorizeRequestJSON, + Identified, +} from './types/api'; +import { DiscoveryApi } from './types/discovery'; + +/** + * Options for authorization requests; currently only an optional auth token. + * @public + */ +export type AuthorizeRequestOptions = { + token?: string; +}; + +/** + * An isomorphic client for requesting authorization for Backstage permissions. + * @public + */ +export class PermissionClient { + private readonly discoveryApi: DiscoveryApi; + + constructor(options: { discoveryApi: DiscoveryApi }) { + this.discoveryApi = options.discoveryApi; + } + + /** + * Request authorization from the permission-backend for the given set of permissions. + * + * Authorization requests check that a given Backstage user can perform a protected operation, + * potentially for a specific resource (such as a catalog entity). The Backstage identity token + * should be included in the `options` if available. + * + * Permissions can be imported from plugins exposing them, such as `catalogEntityReadPermission`. + * + * The response will be either ALLOW or DENY when either the permission has no resourceType, or a + * resourceRef is provided in the request. For permissions with a resourceType, CONDITIONAL may be + * returned if no resourceRef is provided in the request. Conditional responses are intended only + * for backends which have access to the data source for permissioned resources, so that filters + * can be applied when loading collections of resources. + * @public + */ + async authorize( + requests: AuthorizeRequest[], + options?: AuthorizeRequestOptions, + ): Promise { + const identifiedRequests: Identified[] = requests.map( + request => ({ + id: uuid.v4(), + permission: request.permission.toJSON(), + resourceRef: request.resourceRef, + }), + ); + + const permissionApi = await this.discoveryApi.getBaseUrl('permission'); + const response = await fetch(`${permissionApi}/authorize`, { + method: 'POST', + body: JSON.stringify(identifiedRequests), + headers: { + ...this.getAuthorizationHeader(options?.token), + 'content-type': 'application/json', + }, + }); + if (!response.ok) { + throw await ResponseError.fromResponse(response); + } + + const identifiedResponses = await response.json(); + this.assertValidResponses(identifiedRequests, identifiedResponses); + + const responsesById = identifiedResponses.reduce((acc, r) => { + acc[r.id] = r; + return acc; + }, {} as Record>); + + return identifiedRequests.map(request => responsesById[request.id]); + } + + private getAuthorizationHeader(token?: string): Record { + return token ? { Authorization: `Bearer ${token}` } : {}; + } + + private assertValidResponses( + requests: Identified[], + json: any, + ): asserts json is Identified[] { + const responses = Array.isArray(json) ? json : []; + const authorizedResponses: Identified[] = + responses.filter( + (r: any): r is Identified => + typeof r === 'object' && + typeof r.id === 'string' && + r.result in AuthorizeResult, + ); + const responseIds = authorizedResponses.map(r => r.id); + const hasAllRequestIds = requests.every(r => responseIds.includes(r.id)); + if (!hasAllRequestIds) { + throw new Error( + 'Unexpected authorization response from permission-backend', + ); + } + } +} diff --git a/packages/permission-common/src/index.ts b/packages/permission-common/src/index.ts new file mode 100644 index 0000000000..ca48218a9e --- /dev/null +++ b/packages/permission-common/src/index.ts @@ -0,0 +1,24 @@ +/* + * 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. + */ + +/** + * Isomorphic types and client for Backstage permissions and authorization + * + * @packageDocumentation + */ +export * from './types'; +export * from './Permission'; +export * from './PermissionClient'; diff --git a/packages/permission-common/src/types/api.ts b/packages/permission-common/src/types/api.ts new file mode 100644 index 0000000000..95d505bc16 --- /dev/null +++ b/packages/permission-common/src/types/api.ts @@ -0,0 +1,86 @@ +/* + * 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 { Permission } from '../Permission'; +import { PermissionJSON } from './permission'; + +export type Identified = T & { id: string }; + +/** + * The result of an authorization request. + * @public + */ +export enum AuthorizeResult { + /** + * The authorization request is denied. + */ + DENY = 'DENY', + /** + * The authorization request is allowed. + */ + ALLOW = 'ALLOW', + /** + * The authorization request is allowed if the provided conditions are met. + */ + CONDITIONAL = 'CONDITIONAL', +} + +/** + * An authorization request for {@link PermissionClient#authorize}. + * @public + */ +export type AuthorizeRequest = { + permission: Permission; + resourceRef?: string; +}; + +export type AuthorizeRequestJSON = { + permission: PermissionJSON; + resourceRef?: string; +}; + +/** + * A condition returned with a CONDITIONAL authorization response. + * + * Conditions are a reference to a rule defined by a plugin, and parameters to apply the rule. For + * example, a rule might be `isOwner` from the catalog-backend, and params may be a list of entity + * claims from a identity token. + * @public + */ +export type PermissionCondition = { + rule: string; + params: TParams; +}; + +/** + * Composes several {@link PermissionCondition}s as criteria with a nested AND/OR structure. + * @public + */ +export type PermissionCriteria = + | { allOf: PermissionCriteria[] } + | { anyOf: PermissionCriteria[] } + | PermissionCondition; + +/** + * An authorization response from {@link PermissionClient#authorize}. + * @public + */ +export type AuthorizeResponse = + | { result: AuthorizeResult.ALLOW | AuthorizeResult.DENY } + | { + result: AuthorizeResult.CONDITIONAL; + conditions: PermissionCriteria; + }; diff --git a/packages/permission-common/src/types/discovery.ts b/packages/permission-common/src/types/discovery.ts new file mode 100644 index 0000000000..19ee5ed19c --- /dev/null +++ b/packages/permission-common/src/types/discovery.ts @@ -0,0 +1,24 @@ +/* + * 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. + */ + +/** + * This is a copy of the core DiscoveryApi, to avoid importing core. + * + * @public + */ +export type DiscoveryApi = { + getBaseUrl(pluginId: string): Promise; +}; diff --git a/packages/permission-common/src/types/index.ts b/packages/permission-common/src/types/index.ts new file mode 100644 index 0000000000..bea3de4672 --- /dev/null +++ b/packages/permission-common/src/types/index.ts @@ -0,0 +1,26 @@ +/* + * 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 type { + AuthorizeRequest, + AuthorizeResponse, + AuthorizeResult, + PermissionCondition, + PermissionCriteria, +} from './api'; +export type { DiscoveryApi } from './discovery'; +export { PermissionAction } from './permission'; +export type { PermissionAttributes, PermissionJSON } from './permission'; diff --git a/packages/permission-common/src/types/permission.ts b/packages/permission-common/src/types/permission.ts new file mode 100644 index 0000000000..97a6842fb7 --- /dev/null +++ b/packages/permission-common/src/types/permission.ts @@ -0,0 +1,45 @@ +/* + * 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. + */ + +/** + * The basic operation being performed in relation to this permission, expressed as a CRUD action. + * @public + */ +export enum PermissionAction { + Create = 'create', + Read = 'read', + Update = 'update', + Delete = 'delete', +} + +/** + * The attributes related to a given permission; these should be generic and widely applicable to + * all permissions in the system. + * @public + */ +export type PermissionAttributes = { + action?: PermissionAction; +}; + +/** + * JSON serializable representation of a {@link Permission}. + * @public + */ +export type PermissionJSON = { + name: string; + attributes: PermissionAttributes; + resourceType?: string; +};