From 7bba7a89987b1538cc175215ab05d999169e7c08 Mon Sep 17 00:00:00 2001 From: Tim Hansen Date: Wed, 10 Nov 2021 11:58:05 -0700 Subject: [PATCH 01/14] Add permission-common package Signed-off-by: Tim Hansen --- packages/permission-common/.eslintrc.js | 3 + packages/permission-common/README.md | 7 + packages/permission-common/api-report.md | 116 ++++++++++++++++ packages/permission-common/package.json | 48 +++++++ packages/permission-common/src/Permission.ts | 76 +++++++++++ .../src/PermissionClient.test.ts | 129 ++++++++++++++++++ .../permission-common/src/PermissionClient.ts | 124 +++++++++++++++++ packages/permission-common/src/index.ts | 24 ++++ packages/permission-common/src/types/api.ts | 86 ++++++++++++ .../permission-common/src/types/discovery.ts | 24 ++++ packages/permission-common/src/types/index.ts | 26 ++++ .../permission-common/src/types/permission.ts | 45 ++++++ 12 files changed, 708 insertions(+) create mode 100644 packages/permission-common/.eslintrc.js create mode 100644 packages/permission-common/README.md create mode 100644 packages/permission-common/api-report.md create mode 100644 packages/permission-common/package.json create mode 100644 packages/permission-common/src/Permission.ts create mode 100644 packages/permission-common/src/PermissionClient.test.ts create mode 100644 packages/permission-common/src/PermissionClient.ts create mode 100644 packages/permission-common/src/index.ts create mode 100644 packages/permission-common/src/types/api.ts create mode 100644 packages/permission-common/src/types/discovery.ts create mode 100644 packages/permission-common/src/types/index.ts create mode 100644 packages/permission-common/src/types/permission.ts 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; +}; From e6805978d8a8e2d8bf8b682b7b4f48c0f83ae7d6 Mon Sep 17 00:00:00 2001 From: Tim Hansen Date: Wed, 10 Nov 2021 13:26:34 -0700 Subject: [PATCH 02/14] Remove JSON types, add not criteria Signed-off-by: Tim Hansen --- packages/permission-common/api-report.md | 54 +++++-------- packages/permission-common/src/Permission.ts | 76 ------------------- .../src/PermissionClient.test.ts | 10 +-- .../permission-common/src/PermissionClient.ts | 8 +- packages/permission-common/src/index.ts | 2 +- .../src/permissions/index.ts | 17 +++++ .../permission-common/src/permissions/util.ts | 49 ++++++++++++ packages/permission-common/src/types/api.ts | 9 +-- packages/permission-common/src/types/index.ts | 2 +- .../permission-common/src/types/permission.ts | 11 ++- 10 files changed, 107 insertions(+), 131 deletions(-) delete mode 100644 packages/permission-common/src/Permission.ts create mode 100644 packages/permission-common/src/permissions/index.ts create mode 100644 packages/permission-common/src/permissions/util.ts diff --git a/packages/permission-common/api-report.md b/packages/permission-common/api-report.md index a88d50b083..159d61d4c8 100644 --- a/packages/permission-common/api-report.md +++ b/packages/permission-common/api-report.md @@ -37,33 +37,23 @@ export type DiscoveryApi = { }; // @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; -} +export function isCreatePermission(permission: Permission): boolean; + +// @public +export function isDeletePermission(permission: Permission): boolean; + +// @public +export function isReadPermission(permission: Permission): boolean; + +// @public +export function isUpdatePermission(permission: Permission): boolean; + +// @public +export type Permission = { + name: string; + attributes: PermissionAttributes; + resourceType?: string; +}; // @public export enum PermissionAction { @@ -105,12 +95,8 @@ export type PermissionCriteria = | { anyOf: PermissionCriteria[]; } + | { + not: PermissionCriteria; + } | PermissionCondition; - -// @public -export type PermissionJSON = { - name: string; - attributes: PermissionAttributes; - resourceType?: string; -}; ``` diff --git a/packages/permission-common/src/Permission.ts b/packages/permission-common/src/Permission.ts deleted file mode 100644 index 83473c8beb..0000000000 --- a/packages/permission-common/src/Permission.ts +++ /dev/null @@ -1,76 +0,0 @@ -/* - * 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 index d709cca28d..91ad16d24e 100644 --- a/packages/permission-common/src/PermissionClient.test.ts +++ b/packages/permission-common/src/PermissionClient.test.ts @@ -17,9 +17,9 @@ import { RestContext, rest } from 'msw'; import { setupServer } from 'msw/node'; import { PermissionClient } from './PermissionClient'; -import { AuthorizeResult, Identified, AuthorizeRequestJSON } from './types/api'; +import { AuthorizeRequest, AuthorizeResult, Identified } from './types/api'; import { DiscoveryApi } from './types/discovery'; -import { Permission } from './Permission'; +import { Permission } from './types/permission'; const server = setupServer(); const token = 'fake-token'; @@ -32,11 +32,11 @@ const discoveryApi: DiscoveryApi = { }; const client: PermissionClient = new PermissionClient({ discoveryApi }); -const mockPermission = Permission.create({ +const mockPermission: Permission = { name: 'test.permission', attributes: {}, resourceType: 'test-resource', -}); +}; const mockAuthorizeRequest = { permission: mockPermission, @@ -50,7 +50,7 @@ describe('PermissionClient', () => { describe('authorize', () => { const mockAuthorizeHandler = jest.fn((req, res, { json }: RestContext) => { - const responses = req.body.map((a: Identified) => ({ + const responses = req.body.map((a: Identified) => ({ id: a.id, result: AuthorizeResult.ALLOW, })); diff --git a/packages/permission-common/src/PermissionClient.ts b/packages/permission-common/src/PermissionClient.ts index acb23f3345..52fb0478ae 100644 --- a/packages/permission-common/src/PermissionClient.ts +++ b/packages/permission-common/src/PermissionClient.ts @@ -21,7 +21,6 @@ import { AuthorizeResult, AuthorizeRequest, AuthorizeResponse, - AuthorizeRequestJSON, Identified, } from './types/api'; import { DiscoveryApi } from './types/discovery'; @@ -65,11 +64,10 @@ export class PermissionClient { requests: AuthorizeRequest[], options?: AuthorizeRequestOptions, ): Promise { - const identifiedRequests: Identified[] = requests.map( + const identifiedRequests: Identified[] = requests.map( request => ({ id: uuid.v4(), - permission: request.permission.toJSON(), - resourceRef: request.resourceRef, + ...request, }), ); @@ -102,7 +100,7 @@ export class PermissionClient { } private assertValidResponses( - requests: Identified[], + requests: Identified[], json: any, ): asserts json is Identified[] { const responses = Array.isArray(json) ? json : []; diff --git a/packages/permission-common/src/index.ts b/packages/permission-common/src/index.ts index ca48218a9e..7c3f6dd613 100644 --- a/packages/permission-common/src/index.ts +++ b/packages/permission-common/src/index.ts @@ -20,5 +20,5 @@ * @packageDocumentation */ export * from './types'; -export * from './Permission'; +export * from './permissions'; export * from './PermissionClient'; diff --git a/packages/permission-common/src/permissions/index.ts b/packages/permission-common/src/permissions/index.ts new file mode 100644 index 0000000000..736d898539 --- /dev/null +++ b/packages/permission-common/src/permissions/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 './util'; diff --git a/packages/permission-common/src/permissions/util.ts b/packages/permission-common/src/permissions/util.ts new file mode 100644 index 0000000000..06253755d5 --- /dev/null +++ b/packages/permission-common/src/permissions/util.ts @@ -0,0 +1,49 @@ +/* + * 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, PermissionAction } from '../types'; + +/** + * Check if a given permission is related to a create action. + * @public + */ +export function isCreatePermission(permission: Permission) { + return permission.attributes.action === PermissionAction.Create; +} + +/** + * Check if a given permission is related to a read action. + * @public + */ +export function isReadPermission(permission: Permission) { + return permission.attributes.action === PermissionAction.Read; +} + +/** + * Check if a given permission is related to an update action. + * @public + */ +export function isUpdatePermission(permission: Permission) { + return permission.attributes.action === PermissionAction.Update; +} + +/** + * Check if a given permission is related to a delete action. + * @public + */ +export function isDeletePermission(permission: Permission) { + return permission.attributes.action === PermissionAction.Delete; +} diff --git a/packages/permission-common/src/types/api.ts b/packages/permission-common/src/types/api.ts index 95d505bc16..9927f798d2 100644 --- a/packages/permission-common/src/types/api.ts +++ b/packages/permission-common/src/types/api.ts @@ -14,8 +14,7 @@ * limitations under the License. */ -import { Permission } from '../Permission'; -import { PermissionJSON } from './permission'; +import { Permission } from './permission'; export type Identified = T & { id: string }; @@ -47,11 +46,6 @@ export type AuthorizeRequest = { resourceRef?: string; }; -export type AuthorizeRequestJSON = { - permission: PermissionJSON; - resourceRef?: string; -}; - /** * A condition returned with a CONDITIONAL authorization response. * @@ -72,6 +66,7 @@ export type PermissionCondition = { export type PermissionCriteria = | { allOf: PermissionCriteria[] } | { anyOf: PermissionCriteria[] } + | { not: PermissionCriteria } | PermissionCondition; /** diff --git a/packages/permission-common/src/types/index.ts b/packages/permission-common/src/types/index.ts index bea3de4672..ef43666fa7 100644 --- a/packages/permission-common/src/types/index.ts +++ b/packages/permission-common/src/types/index.ts @@ -23,4 +23,4 @@ export type { } from './api'; export type { DiscoveryApi } from './discovery'; export { PermissionAction } from './permission'; -export type { PermissionAttributes, PermissionJSON } from './permission'; +export type { PermissionAttributes, Permission } from './permission'; diff --git a/packages/permission-common/src/types/permission.ts b/packages/permission-common/src/types/permission.ts index 97a6842fb7..6929bcd547 100644 --- a/packages/permission-common/src/types/permission.ts +++ b/packages/permission-common/src/types/permission.ts @@ -35,10 +35,17 @@ export type PermissionAttributes = { }; /** - * JSON serializable representation of a {@link 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 type PermissionJSON = { +export type Permission = { name: string; attributes: PermissionAttributes; resourceType?: string; From c00b4d38290ab91e317ac5999a45c74f8ba50272 Mon Sep 17 00:00:00 2001 From: Tim Hansen Date: Wed, 10 Nov 2021 13:38:04 -0700 Subject: [PATCH 03/14] Remove enum, package fixes Signed-off-by: Tim Hansen --- packages/permission-common/api-report.md | 18 +++--------------- packages/permission-common/package.json | 3 +-- .../permission-common/src/permissions/util.ts | 10 +++++----- packages/permission-common/src/types/api.ts | 4 ++-- packages/permission-common/src/types/index.ts | 1 - .../permission-common/src/types/permission.ts | 13 +------------ 6 files changed, 12 insertions(+), 37 deletions(-) diff --git a/packages/permission-common/api-report.md b/packages/permission-common/api-report.md index 159d61d4c8..9d7bc90e9e 100644 --- a/packages/permission-common/api-report.md +++ b/packages/permission-common/api-report.md @@ -55,21 +55,9 @@ export type Permission = { resourceType?: string; }; -// @public -export enum PermissionAction { - // (undocumented) - Create = 'create', - // (undocumented) - Delete = 'delete', - // (undocumented) - Read = 'read', - // (undocumented) - Update = 'update', -} - // @public export type PermissionAttributes = { - action?: PermissionAction; + action?: 'create' | 'read' | 'update' | 'delete'; }; // @public @@ -82,7 +70,7 @@ export class PermissionClient { } // @public -export type PermissionCondition = { +export type PermissionCondition = { rule: string; params: TParams; }; @@ -98,5 +86,5 @@ export type PermissionCriteria = | { not: PermissionCriteria; } - | PermissionCondition; + | PermissionCondition; ``` diff --git a/packages/permission-common/package.json b/packages/permission-common/package.json index 7fb89519c2..376f969b19 100644 --- a/packages/permission-common/package.json +++ b/packages/permission-common/package.json @@ -4,7 +4,6 @@ "version": "0.1.0", "main": "src/index.ts", "types": "src/index.ts", - "private": true, "publishConfig": { "access": "public", "main": "dist/index.cjs.js", @@ -25,7 +24,7 @@ "dist" ], "scripts": { - "build": "backstage-cli build --outputs cjs,types", + "build": "backstage-cli build", "lint": "backstage-cli lint", "test": "backstage-cli test", "prepack": "backstage-cli prepack", diff --git a/packages/permission-common/src/permissions/util.ts b/packages/permission-common/src/permissions/util.ts index 06253755d5..188609f4ae 100644 --- a/packages/permission-common/src/permissions/util.ts +++ b/packages/permission-common/src/permissions/util.ts @@ -14,14 +14,14 @@ * limitations under the License. */ -import { Permission, PermissionAction } from '../types'; +import { Permission } from '../types'; /** * Check if a given permission is related to a create action. * @public */ export function isCreatePermission(permission: Permission) { - return permission.attributes.action === PermissionAction.Create; + return permission.attributes.action === 'create'; } /** @@ -29,7 +29,7 @@ export function isCreatePermission(permission: Permission) { * @public */ export function isReadPermission(permission: Permission) { - return permission.attributes.action === PermissionAction.Read; + return permission.attributes.action === 'read'; } /** @@ -37,7 +37,7 @@ export function isReadPermission(permission: Permission) { * @public */ export function isUpdatePermission(permission: Permission) { - return permission.attributes.action === PermissionAction.Update; + return permission.attributes.action === 'update'; } /** @@ -45,5 +45,5 @@ export function isUpdatePermission(permission: Permission) { * @public */ export function isDeletePermission(permission: Permission) { - return permission.attributes.action === PermissionAction.Delete; + return permission.attributes.action === 'delete'; } diff --git a/packages/permission-common/src/types/api.ts b/packages/permission-common/src/types/api.ts index 9927f798d2..ffe8406f83 100644 --- a/packages/permission-common/src/types/api.ts +++ b/packages/permission-common/src/types/api.ts @@ -54,7 +54,7 @@ export type AuthorizeRequest = { * claims from a identity token. * @public */ -export type PermissionCondition = { +export type PermissionCondition = { rule: string; params: TParams; }; @@ -67,7 +67,7 @@ export type PermissionCriteria = | { allOf: PermissionCriteria[] } | { anyOf: PermissionCriteria[] } | { not: PermissionCriteria } - | PermissionCondition; + | PermissionCondition; /** * An authorization response from {@link PermissionClient#authorize}. diff --git a/packages/permission-common/src/types/index.ts b/packages/permission-common/src/types/index.ts index ef43666fa7..7faecc38e7 100644 --- a/packages/permission-common/src/types/index.ts +++ b/packages/permission-common/src/types/index.ts @@ -22,5 +22,4 @@ export type { PermissionCriteria, } from './api'; export type { DiscoveryApi } from './discovery'; -export { PermissionAction } from './permission'; export type { PermissionAttributes, Permission } from './permission'; diff --git a/packages/permission-common/src/types/permission.ts b/packages/permission-common/src/types/permission.ts index 6929bcd547..181b36065f 100644 --- a/packages/permission-common/src/types/permission.ts +++ b/packages/permission-common/src/types/permission.ts @@ -14,24 +14,13 @@ * 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; + action?: 'create' | 'read' | 'update' | 'delete'; }; /** From dce7237d46e604f5b6681c638693859d27d7488d Mon Sep 17 00:00:00 2001 From: Tim Hansen Date: Wed, 10 Nov 2021 14:42:56 -0700 Subject: [PATCH 04/14] PermissionCriteria generic Signed-off-by: Tim Hansen --- packages/permission-common/api-report.md | 14 +++++++------- packages/permission-common/src/types/api.ts | 14 +++++++------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/permission-common/api-report.md b/packages/permission-common/api-report.md index 9d7bc90e9e..445125a0c7 100644 --- a/packages/permission-common/api-report.md +++ b/packages/permission-common/api-report.md @@ -21,7 +21,7 @@ export type AuthorizeResponse = } | { result: AuthorizeResult.CONDITIONAL; - conditions: PermissionCriteria; + conditions: PermissionCriteria; }; // @public @@ -70,21 +70,21 @@ export class PermissionClient { } // @public -export type PermissionCondition = { +export type PermissionCondition = { rule: string; params: TParams; }; // @public -export type PermissionCriteria = +export type PermissionCriteria = | { - allOf: PermissionCriteria[]; + allOf: PermissionCriteria[]; } | { - anyOf: PermissionCriteria[]; + anyOf: PermissionCriteria[]; } | { - not: PermissionCriteria; + not: PermissionCriteria; } - | PermissionCondition; + | PermissionCondition; ``` diff --git a/packages/permission-common/src/types/api.ts b/packages/permission-common/src/types/api.ts index ffe8406f83..a1ba61ba66 100644 --- a/packages/permission-common/src/types/api.ts +++ b/packages/permission-common/src/types/api.ts @@ -54,7 +54,7 @@ export type AuthorizeRequest = { * claims from a identity token. * @public */ -export type PermissionCondition = { +export type PermissionCondition = { rule: string; params: TParams; }; @@ -63,11 +63,11 @@ export type PermissionCondition = { * Composes several {@link PermissionCondition}s as criteria with a nested AND/OR structure. * @public */ -export type PermissionCriteria = - | { allOf: PermissionCriteria[] } - | { anyOf: PermissionCriteria[] } - | { not: PermissionCriteria } - | PermissionCondition; +export type PermissionCriteria = + | { allOf: PermissionCriteria[] } + | { anyOf: PermissionCriteria[] } + | { not: PermissionCriteria } + | PermissionCondition; /** * An authorization response from {@link PermissionClient#authorize}. @@ -77,5 +77,5 @@ export type AuthorizeResponse = | { result: AuthorizeResult.ALLOW | AuthorizeResult.DENY } | { result: AuthorizeResult.CONDITIONAL; - conditions: PermissionCriteria; + conditions: PermissionCriteria; }; From 59702b2bded6657fc975617390e30e719e3f3563 Mon Sep 17 00:00:00 2001 From: Mike Lewis Date: Thu, 11 Nov 2021 13:26:17 +0000 Subject: [PATCH 05/14] authz: do not export AuthorizeResult as type Since AuthorizeResult is an enum, exporting it via `export type` is incorrect - it means we can refer to the AuthorizeResult type, but not use it or its members as values. Signed-off-by: Mike Lewis --- packages/permission-common/src/types/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/permission-common/src/types/index.ts b/packages/permission-common/src/types/index.ts index 7faecc38e7..95b8fc2a83 100644 --- a/packages/permission-common/src/types/index.ts +++ b/packages/permission-common/src/types/index.ts @@ -14,10 +14,10 @@ * limitations under the License. */ +export { AuthorizeResult } from './api'; export type { AuthorizeRequest, AuthorizeResponse, - AuthorizeResult, PermissionCondition, PermissionCriteria, } from './api'; From 00448f02e68a5a12562bce23fb285b6ad2f60bbd Mon Sep 17 00:00:00 2001 From: Mike Lewis Date: Thu, 11 Nov 2021 13:28:25 +0000 Subject: [PATCH 06/14] authz: fix PermissionCondition type parameter The type parameter for PermissionCondition refers to the type of the condition params, not the type of the leaf entries in a PermissionCriteria object. It should be safe to always set this type parameter to `unknown[]` in this case. Each PermissionCondition is will have different parameters, therefore we just need to enforce that the params is always an array of unknown. Signed-off-by: Mike Lewis --- packages/permission-common/api-report.md | 2 +- packages/permission-common/src/types/api.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/permission-common/api-report.md b/packages/permission-common/api-report.md index 445125a0c7..c859b71465 100644 --- a/packages/permission-common/api-report.md +++ b/packages/permission-common/api-report.md @@ -86,5 +86,5 @@ export type PermissionCriteria = | { not: PermissionCriteria; } - | PermissionCondition; + | PermissionCondition; ``` diff --git a/packages/permission-common/src/types/api.ts b/packages/permission-common/src/types/api.ts index a1ba61ba66..be6267775a 100644 --- a/packages/permission-common/src/types/api.ts +++ b/packages/permission-common/src/types/api.ts @@ -67,7 +67,7 @@ export type PermissionCriteria = | { allOf: PermissionCriteria[] } | { anyOf: PermissionCriteria[] } | { not: PermissionCriteria } - | PermissionCondition; + | PermissionCondition; /** * An authorization response from {@link PermissionClient#authorize}. From ea77555fe33163470d7d1890a699f01396ec3255 Mon Sep 17 00:00:00 2001 From: Mike Lewis Date: Thu, 11 Nov 2021 13:32:18 +0000 Subject: [PATCH 07/14] authz: restrict type of PermissionCondition params We need a guarantee that we'll be able to spread permission condition params when applying the condition via the `apply` or `toQuery` methods on the corresponding rule. As such, we need to enforce that the TParams type parameter is an array. This commit also supplies a default value of unknown[] for this type param to make it a bit more convenient to use the type in situations where the type of the params is not important. Signed-off-by: Mike Lewis --- packages/permission-common/api-report.md | 2 +- packages/permission-common/src/types/api.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/permission-common/api-report.md b/packages/permission-common/api-report.md index c859b71465..766fec220c 100644 --- a/packages/permission-common/api-report.md +++ b/packages/permission-common/api-report.md @@ -70,7 +70,7 @@ export class PermissionClient { } // @public -export type PermissionCondition = { +export type PermissionCondition = { rule: string; params: TParams; }; diff --git a/packages/permission-common/src/types/api.ts b/packages/permission-common/src/types/api.ts index be6267775a..6a8e7ab836 100644 --- a/packages/permission-common/src/types/api.ts +++ b/packages/permission-common/src/types/api.ts @@ -54,7 +54,7 @@ export type AuthorizeRequest = { * claims from a identity token. * @public */ -export type PermissionCondition = { +export type PermissionCondition = { rule: string; params: TParams; }; From 20d3ef45e252b2252ad46feb3be7dfa27c325587 Mon Sep 17 00:00:00 2001 From: Mike Lewis Date: Thu, 11 Nov 2021 16:53:14 +0000 Subject: [PATCH 08/14] authz: further relaxation of PermissionCriteria type The PermissionCriteria type is actually intended to be the type used to represent criteria for arbitrary queries, whether they are expressed as PermissionConditions or plugin-specific query fragments. As such we don't need to refer to the PermissionCondition type at all - we can stick with the TQuery type parameter as the type for the unwrapped query fragment. Signed-off-by: Mike Lewis --- packages/permission-common/api-report.md | 2 +- packages/permission-common/src/types/api.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/permission-common/api-report.md b/packages/permission-common/api-report.md index 766fec220c..423996efc8 100644 --- a/packages/permission-common/api-report.md +++ b/packages/permission-common/api-report.md @@ -86,5 +86,5 @@ export type PermissionCriteria = | { not: PermissionCriteria; } - | PermissionCondition; + | TQuery; ``` diff --git a/packages/permission-common/src/types/api.ts b/packages/permission-common/src/types/api.ts index 6a8e7ab836..3cdf923978 100644 --- a/packages/permission-common/src/types/api.ts +++ b/packages/permission-common/src/types/api.ts @@ -67,7 +67,7 @@ export type PermissionCriteria = | { allOf: PermissionCriteria[] } | { anyOf: PermissionCriteria[] } | { not: PermissionCriteria } - | PermissionCondition; + | TQuery; /** * An authorization response from {@link PermissionClient#authorize}. From 0c0cf0ddaf781dd1cea2efe4d6a8acebb80d1f32 Mon Sep 17 00:00:00 2001 From: Mike Lewis Date: Fri, 12 Nov 2021 11:43:40 +0000 Subject: [PATCH 09/14] build(deps): add dependency on zod in permission-common Signed-off-by: Mike Lewis --- packages/permission-common/package.json | 3 ++- yarn.lock | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/permission-common/package.json b/packages/permission-common/package.json index 376f969b19..e801c27344 100644 --- a/packages/permission-common/package.json +++ b/packages/permission-common/package.json @@ -37,7 +37,8 @@ "dependencies": { "@backstage/errors": "^0.1.2", "cross-fetch": "^3.0.6", - "uuid": "^8.0.0" + "uuid": "^8.0.0", + "zod": "^3.11.6" }, "devDependencies": { "@backstage/cli": "^0.8.0", diff --git a/yarn.lock b/yarn.lock index e18f6fedb2..f96df9c008 100644 --- a/yarn.lock +++ b/yarn.lock @@ -29479,7 +29479,7 @@ zip-stream@^4.1.0: compress-commons "^4.1.0" readable-stream "^3.6.0" -zod@^3.9.5: +zod@^3.11.6, zod@^3.9.5: version "3.11.6" resolved "https://registry.npmjs.org/zod/-/zod-3.11.6.tgz#e43a5e0c213ae2e02aefe7cb2b1a6fa3d7f1f483" integrity sha512-daZ80A81I3/9lIydI44motWe6n59kRBfNzTuS2bfzVh1nAXi667TOTWWtatxyG+fwgNUiagSj/CWZwRRbevJIg== From f0124d29a9e673d102bfbc5f2d08d6ca12461292 Mon Sep 17 00:00:00 2001 From: Mike Lewis Date: Fri, 12 Nov 2021 11:44:24 +0000 Subject: [PATCH 10/14] authz: use zod to validate responses in PermissionClient Signed-off-by: Mike Lewis --- .../src/PermissionClient.test.ts | 18 +++++++- .../permission-common/src/PermissionClient.ts | 42 +++++++++++++++---- 2 files changed, 51 insertions(+), 9 deletions(-) diff --git a/packages/permission-common/src/PermissionClient.test.ts b/packages/permission-common/src/PermissionClient.test.ts index 91ad16d24e..a461078e1a 100644 --- a/packages/permission-common/src/PermissionClient.test.ts +++ b/packages/permission-common/src/PermissionClient.test.ts @@ -115,7 +115,7 @@ describe('PermissionClient', () => { ).rejects.toThrowError(/request failed with 401/i); }); - it('should reject invalid responses', async () => { + it('should reject responses with missing ids', async () => { mockAuthorizeHandler.mockImplementationOnce( (_req, res, { json }: RestContext) => { return res(json([{ id: 'wrong-id', result: AuthorizeResult.ALLOW }])); @@ -125,5 +125,21 @@ describe('PermissionClient', () => { client.authorize([mockAuthorizeRequest], { token }), ).rejects.toThrowError(/Unexpected authorization response/i); }); + + it('should reject invalid responses', async () => { + mockAuthorizeHandler.mockImplementationOnce( + (req, res, { json }: RestContext) => { + const responses = req.body.map((a: Identified) => ({ + id: a.id, + outcome: AuthorizeResult.ALLOW, + })); + + return res(json(responses)); + }, + ); + await expect( + client.authorize([mockAuthorizeRequest], { token }), + ).rejects.toThrowError(/invalid input/i); + }); }); }); diff --git a/packages/permission-common/src/PermissionClient.ts b/packages/permission-common/src/PermissionClient.ts index 52fb0478ae..b8b2a03c44 100644 --- a/packages/permission-common/src/PermissionClient.ts +++ b/packages/permission-common/src/PermissionClient.ts @@ -17,14 +17,47 @@ import { ResponseError } from '@backstage/errors'; import fetch from 'cross-fetch'; import * as uuid from 'uuid'; +import { z } from 'zod'; import { AuthorizeResult, AuthorizeRequest, AuthorizeResponse, Identified, + PermissionCriteria, + PermissionCondition, } from './types/api'; import { DiscoveryApi } from './types/discovery'; +const permissionCriteriaSchema: z.ZodSchema< + PermissionCriteria +> = z.lazy(() => + z + .object({ + rule: z.string(), + params: z.array(z.unknown()), + }) + .or(z.object({ anyOf: z.array(permissionCriteriaSchema) })) + .or(z.object({ allOf: z.array(permissionCriteriaSchema) })) + .or(z.object({ not: permissionCriteriaSchema })), +); + +const responseSchema = z.array( + z + .object({ + id: z.string(), + result: z + .literal(AuthorizeResult.ALLOW) + .or(z.literal(AuthorizeResult.DENY)), + }) + .or( + z.object({ + id: z.string(), + result: z.literal(AuthorizeResult.CONDITIONAL), + conditions: permissionCriteriaSchema, + }), + ), +); + /** * Options for authorization requests; currently only an optional auth token. * @public @@ -103,14 +136,7 @@ export class PermissionClient { 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 authorizedResponses = responseSchema.parse(json); const responseIds = authorizedResponses.map(r => r.id); const hasAllRequestIds = requests.every(r => responseIds.includes(r.id)); if (!hasAllRequestIds) { From f816f68bbedd568486f9ef46dc4d33e8882aee47 Mon Sep 17 00:00:00 2001 From: Mike Lewis Date: Fri, 12 Nov 2021 12:13:48 +0000 Subject: [PATCH 11/14] authz: add PermissionClient TODO for better type-safety Signed-off-by: Mike Lewis --- packages/permission-common/src/PermissionClient.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/permission-common/src/PermissionClient.ts b/packages/permission-common/src/PermissionClient.ts index b8b2a03c44..5c32ecb139 100644 --- a/packages/permission-common/src/PermissionClient.ts +++ b/packages/permission-common/src/PermissionClient.ts @@ -97,6 +97,11 @@ export class PermissionClient { requests: AuthorizeRequest[], options?: AuthorizeRequestOptions, ): Promise { + // TODO(permissions): it would be great to provide some kind of typing guarantee that + // conditional responses will only ever be returned for requests containing a resourceType + // but no resourceRef. That way clients who aren't prepared to handle filtering according + // to conditions can be guaranteed that they won't unexpectedly get a CONDITIONAL response. + const identifiedRequests: Identified[] = requests.map( request => ({ id: uuid.v4(), From e84abaccafcbf2c3d1b163285c8abdf8f310edf3 Mon Sep 17 00:00:00 2001 From: Mike Lewis Date: Fri, 12 Nov 2021 12:16:31 +0000 Subject: [PATCH 12/14] authz: move permission-common to plugins Signed-off-by: Mike Lewis --- {packages => plugins}/permission-common/.eslintrc.js | 0 {packages => plugins}/permission-common/README.md | 2 +- {packages => plugins}/permission-common/api-report.md | 2 +- {packages => plugins}/permission-common/package.json | 4 ++-- .../permission-common/src/PermissionClient.test.ts | 0 .../permission-common/src/PermissionClient.ts | 0 {packages => plugins}/permission-common/src/index.ts | 0 .../permission-common/src/permissions/index.ts | 0 .../permission-common/src/permissions/util.ts | 0 {packages => plugins}/permission-common/src/types/api.ts | 0 .../permission-common/src/types/discovery.ts | 0 {packages => plugins}/permission-common/src/types/index.ts | 0 .../permission-common/src/types/permission.ts | 0 13 files changed, 4 insertions(+), 4 deletions(-) rename {packages => plugins}/permission-common/.eslintrc.js (100%) rename {packages => plugins}/permission-common/README.md (85%) rename {packages => plugins}/permission-common/api-report.md (96%) rename {packages => plugins}/permission-common/package.json (92%) rename {packages => plugins}/permission-common/src/PermissionClient.test.ts (100%) rename {packages => plugins}/permission-common/src/PermissionClient.ts (100%) rename {packages => plugins}/permission-common/src/index.ts (100%) rename {packages => plugins}/permission-common/src/permissions/index.ts (100%) rename {packages => plugins}/permission-common/src/permissions/util.ts (100%) rename {packages => plugins}/permission-common/src/types/api.ts (100%) rename {packages => plugins}/permission-common/src/types/discovery.ts (100%) rename {packages => plugins}/permission-common/src/types/index.ts (100%) rename {packages => plugins}/permission-common/src/types/permission.ts (100%) diff --git a/packages/permission-common/.eslintrc.js b/plugins/permission-common/.eslintrc.js similarity index 100% rename from packages/permission-common/.eslintrc.js rename to plugins/permission-common/.eslintrc.js diff --git a/packages/permission-common/README.md b/plugins/permission-common/README.md similarity index 85% rename from packages/permission-common/README.md rename to plugins/permission-common/README.md index 21452748cf..7ba998bed5 100644 --- a/packages/permission-common/README.md +++ b/plugins/permission-common/README.md @@ -1,4 +1,4 @@ -# @backstage/permission-common +# @backstage/plugin-permission-common > NOTE: THIS PACKAGE IS EXPERIMENTAL, HERE BE DRAGONS diff --git a/packages/permission-common/api-report.md b/plugins/permission-common/api-report.md similarity index 96% rename from packages/permission-common/api-report.md rename to plugins/permission-common/api-report.md index 423996efc8..4f1b84d5be 100644 --- a/packages/permission-common/api-report.md +++ b/plugins/permission-common/api-report.md @@ -1,4 +1,4 @@ -## API Report File for "@backstage/permission-common" +## API Report File for "@backstage/plugin-permission-common" > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). diff --git a/packages/permission-common/package.json b/plugins/permission-common/package.json similarity index 92% rename from packages/permission-common/package.json rename to plugins/permission-common/package.json index e801c27344..f6d66934e2 100644 --- a/packages/permission-common/package.json +++ b/plugins/permission-common/package.json @@ -1,5 +1,5 @@ { - "name": "@backstage/permission-common", + "name": "@backstage/plugin-permission-common", "description": "Isomorphic types and client for Backstage permissions and authorization", "version": "0.1.0", "main": "src/index.ts", @@ -13,7 +13,7 @@ "repository": { "type": "git", "url": "https://github.com/backstage/backstage", - "directory": "packages/permission-common" + "directory": "plugins/permission-common" }, "keywords": [ "backstage", diff --git a/packages/permission-common/src/PermissionClient.test.ts b/plugins/permission-common/src/PermissionClient.test.ts similarity index 100% rename from packages/permission-common/src/PermissionClient.test.ts rename to plugins/permission-common/src/PermissionClient.test.ts diff --git a/packages/permission-common/src/PermissionClient.ts b/plugins/permission-common/src/PermissionClient.ts similarity index 100% rename from packages/permission-common/src/PermissionClient.ts rename to plugins/permission-common/src/PermissionClient.ts diff --git a/packages/permission-common/src/index.ts b/plugins/permission-common/src/index.ts similarity index 100% rename from packages/permission-common/src/index.ts rename to plugins/permission-common/src/index.ts diff --git a/packages/permission-common/src/permissions/index.ts b/plugins/permission-common/src/permissions/index.ts similarity index 100% rename from packages/permission-common/src/permissions/index.ts rename to plugins/permission-common/src/permissions/index.ts diff --git a/packages/permission-common/src/permissions/util.ts b/plugins/permission-common/src/permissions/util.ts similarity index 100% rename from packages/permission-common/src/permissions/util.ts rename to plugins/permission-common/src/permissions/util.ts diff --git a/packages/permission-common/src/types/api.ts b/plugins/permission-common/src/types/api.ts similarity index 100% rename from packages/permission-common/src/types/api.ts rename to plugins/permission-common/src/types/api.ts diff --git a/packages/permission-common/src/types/discovery.ts b/plugins/permission-common/src/types/discovery.ts similarity index 100% rename from packages/permission-common/src/types/discovery.ts rename to plugins/permission-common/src/types/discovery.ts diff --git a/packages/permission-common/src/types/index.ts b/plugins/permission-common/src/types/index.ts similarity index 100% rename from packages/permission-common/src/types/index.ts rename to plugins/permission-common/src/types/index.ts diff --git a/packages/permission-common/src/types/permission.ts b/plugins/permission-common/src/types/permission.ts similarity index 100% rename from packages/permission-common/src/types/permission.ts rename to plugins/permission-common/src/types/permission.ts From db299e05ef8728308edc24268f951eb666ce5080 Mon Sep 17 00:00:00 2001 From: Tim Hansen Date: Tue, 16 Nov 2021 13:21:17 -0700 Subject: [PATCH 13/14] Add config, type fixes Signed-off-by: Tim Hansen --- plugins/permission-common/api-report.md | 9 +++++-- plugins/permission-common/config.d.ts | 27 +++++++++++++++++++ plugins/permission-common/package.json | 4 ++- .../src/PermissionClient.test.ts | 24 ++++++++++++++++- .../permission-common/src/PermissionClient.ts | 8 +++++- plugins/permission-common/src/types/api.ts | 7 ++++- plugins/permission-common/src/types/index.ts | 1 + 7 files changed, 74 insertions(+), 6 deletions(-) create mode 100644 plugins/permission-common/config.d.ts diff --git a/plugins/permission-common/api-report.md b/plugins/permission-common/api-report.md index 4f1b84d5be..cbf1877c75 100644 --- a/plugins/permission-common/api-report.md +++ b/plugins/permission-common/api-report.md @@ -21,7 +21,7 @@ export type AuthorizeResponse = } | { result: AuthorizeResult.CONDITIONAL; - conditions: PermissionCriteria; + conditions: PermissionCriteria; }; // @public @@ -36,6 +36,11 @@ export type DiscoveryApi = { getBaseUrl(pluginId: string): Promise; }; +// @public +export type Identified = T & { + id: string; +}; + // @public export function isCreatePermission(permission: Permission): boolean; @@ -62,7 +67,7 @@ export type PermissionAttributes = { // @public export class PermissionClient { - constructor(options: { discoveryApi: DiscoveryApi }); + constructor(options: { discoveryApi: DiscoveryApi; enabled?: boolean }); authorize( requests: AuthorizeRequest[], options?: AuthorizeRequestOptions, diff --git a/plugins/permission-common/config.d.ts b/plugins/permission-common/config.d.ts new file mode 100644 index 0000000000..2378fa4a71 --- /dev/null +++ b/plugins/permission-common/config.d.ts @@ -0,0 +1,27 @@ +/* + * 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 interface Config { + /** Configuration options for Backstage permissions and authorization */ + permission?: { + /** + * Whether authorization is enabled in Backstage. Defaults to false, which means authorization + * requests will be automatically allowed without invoking the authorization policy. + * @visibility frontend + */ + enabled?: boolean; + }; +} diff --git a/plugins/permission-common/package.json b/plugins/permission-common/package.json index f6d66934e2..d2ebf85b36 100644 --- a/plugins/permission-common/package.json +++ b/plugins/permission-common/package.json @@ -21,8 +21,10 @@ ], "license": "Apache-2.0", "files": [ - "dist" + "dist", + "config.d.ts" ], + "configSchema": "config.d.ts", "scripts": { "build": "backstage-cli build", "lint": "backstage-cli lint", diff --git a/plugins/permission-common/src/PermissionClient.test.ts b/plugins/permission-common/src/PermissionClient.test.ts index a461078e1a..06e2ae0561 100644 --- a/plugins/permission-common/src/PermissionClient.test.ts +++ b/plugins/permission-common/src/PermissionClient.test.ts @@ -30,7 +30,10 @@ const discoveryApi: DiscoveryApi = { return mockBaseUrl; }, }; -const client: PermissionClient = new PermissionClient({ discoveryApi }); +const client: PermissionClient = new PermissionClient({ + discoveryApi, + enabled: true, +}); const mockPermission: Permission = { name: 'test.permission', @@ -141,5 +144,24 @@ describe('PermissionClient', () => { client.authorize([mockAuthorizeRequest], { token }), ).rejects.toThrowError(/invalid input/i); }); + + it('should allow all when authorization is not enabled', async () => { + mockAuthorizeHandler.mockImplementationOnce( + (req, res, { json }: RestContext) => { + const responses = req.body.map((a: Identified) => ({ + id: a.id, + outcome: AuthorizeResult.DENY, + })); + + return res(json(responses)); + }, + ); + const disabled = new PermissionClient({ discoveryApi, enabled: false }); + const response = await disabled.authorize([mockAuthorizeRequest]); + expect(response[0]).toEqual( + expect.objectContaining({ result: AuthorizeResult.ALLOW }), + ); + expect(mockAuthorizeHandler).not.toBeCalled(); + }); }); }); diff --git a/plugins/permission-common/src/PermissionClient.ts b/plugins/permission-common/src/PermissionClient.ts index 5c32ecb139..5b17ccd333 100644 --- a/plugins/permission-common/src/PermissionClient.ts +++ b/plugins/permission-common/src/PermissionClient.ts @@ -71,10 +71,12 @@ export type AuthorizeRequestOptions = { * @public */ export class PermissionClient { + private readonly enabled: boolean; private readonly discoveryApi: DiscoveryApi; - constructor(options: { discoveryApi: DiscoveryApi }) { + constructor(options: { discoveryApi: DiscoveryApi; enabled?: boolean }) { this.discoveryApi = options.discoveryApi; + this.enabled = options.enabled ?? false; } /** @@ -102,6 +104,10 @@ export class PermissionClient { // but no resourceRef. That way clients who aren't prepared to handle filtering according // to conditions can be guaranteed that they won't unexpectedly get a CONDITIONAL response. + if (!this.enabled) { + return requests.map(_ => ({ result: AuthorizeResult.ALLOW })); + } + const identifiedRequests: Identified[] = requests.map( request => ({ id: uuid.v4(), diff --git a/plugins/permission-common/src/types/api.ts b/plugins/permission-common/src/types/api.ts index 3cdf923978..7f4cd0730b 100644 --- a/plugins/permission-common/src/types/api.ts +++ b/plugins/permission-common/src/types/api.ts @@ -16,6 +16,11 @@ import { Permission } from './permission'; +/** + * A request with a UUID identifier, so that batched responses can be matched up with the original + * requests. + * @public + */ export type Identified = T & { id: string }; /** @@ -77,5 +82,5 @@ export type AuthorizeResponse = | { result: AuthorizeResult.ALLOW | AuthorizeResult.DENY } | { result: AuthorizeResult.CONDITIONAL; - conditions: PermissionCriteria; + conditions: PermissionCriteria; }; diff --git a/plugins/permission-common/src/types/index.ts b/plugins/permission-common/src/types/index.ts index 95b8fc2a83..e21fa19e8b 100644 --- a/plugins/permission-common/src/types/index.ts +++ b/plugins/permission-common/src/types/index.ts @@ -18,6 +18,7 @@ export { AuthorizeResult } from './api'; export type { AuthorizeRequest, AuthorizeResponse, + Identified, PermissionCondition, PermissionCriteria, } from './api'; From bd1d8b1757e466883fb3a7cf77d7c0e83f04a59c Mon Sep 17 00:00:00 2001 From: Tim Hansen Date: Wed, 17 Nov 2021 07:55:10 -0700 Subject: [PATCH 14/14] package.json module Signed-off-by: Tim Hansen --- plugins/permission-common/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/permission-common/package.json b/plugins/permission-common/package.json index d2ebf85b36..5e79c1ba7b 100644 --- a/plugins/permission-common/package.json +++ b/plugins/permission-common/package.json @@ -7,6 +7,7 @@ "publishConfig": { "access": "public", "main": "dist/index.cjs.js", + "module": "dist/index.esm.js", "types": "dist/index.d.ts" }, "homepage": "https://backstage.io",