diff --git a/.changeset/poor-steaks-remain.md b/.changeset/poor-steaks-remain.md new file mode 100644 index 0000000000..b159564483 --- /dev/null +++ b/.changeset/poor-steaks-remain.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-permission-react': minor +--- + +Add @backstage/plugin-permission-react + +@backstage/plugin-permission-react is a library containing utils for implementing permissions in your frontend Backstage plugins. See [the authorization PRFC](https://github.com/backstage/backstage/pull/7761) for more details. diff --git a/plugins/permission-react/.eslintrc.js b/plugins/permission-react/.eslintrc.js new file mode 100644 index 0000000000..13573efa9c --- /dev/null +++ b/plugins/permission-react/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/plugins/permission-react/README.md b/plugins/permission-react/README.md new file mode 100644 index 0000000000..72383f024d --- /dev/null +++ b/plugins/permission-react/README.md @@ -0,0 +1,5 @@ +# permission + +**NOTE: THIS PACKAGE IS EXPERIMENTAL!** + +Components and hooks to help implement permissions in Backstage frontend plugins. For more information, see the [authorization PRFC](https://github.com/backstage/backstage/pull/7761). diff --git a/plugins/permission-react/api-report.md b/plugins/permission-react/api-report.md new file mode 100644 index 0000000000..65f93de8a0 --- /dev/null +++ b/plugins/permission-react/api-report.md @@ -0,0 +1,69 @@ +## API Report File for "@backstage/plugin-permission-react" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { ApiRef } from '@backstage/core-plugin-api'; +import { AuthorizeRequest } from '@backstage/plugin-permission-common'; +import { AuthorizeResponse } from '@backstage/plugin-permission-common'; +import { Config } from '@backstage/config'; +import { DiscoveryApi } from '@backstage/core-plugin-api'; +import { IdentityApi } from '@backstage/core-plugin-api'; +import { Permission } from '@backstage/plugin-permission-common'; +import { default as React_2 } from 'react'; +import { RouteProps } from 'react-router'; + +// @public (undocumented) +export type AsyncPermissionResult = { + loading: boolean; + allowed: boolean; + error?: Error; +}; + +// @public +export class IdentityPermissionApi implements PermissionApi { + // (undocumented) + authorize(request: AuthorizeRequest): Promise; + // (undocumented) + static create({ + configApi, + discoveryApi, + identityApi, + }: { + configApi: Config; + discoveryApi: DiscoveryApi; + identityApi: IdentityApi; + }): IdentityPermissionApi; +} + +// @public +export type PermissionApi = { + authorize(request: AuthorizeRequest): Promise; +}; + +// @public +export const permissionApiRef: ApiRef; + +// @public +export const PermissionedRoute: ({ + permission, + resourceRef, + errorComponent, + ...props +}: RouteProps & { + permission: Permission; + resourceRef?: string | undefined; + errorComponent?: + | React_2.ReactElement> + | null + | undefined; +}) => JSX.Element; + +// @public +export const usePermission: ( + permission: Permission, + resourceRef?: string | undefined, +) => AsyncPermissionResult; + +// (No @packageDocumentation comment for this package) +``` diff --git a/plugins/permission-react/package.json b/plugins/permission-react/package.json new file mode 100644 index 0000000000..cd0d315683 --- /dev/null +++ b/plugins/permission-react/package.json @@ -0,0 +1,49 @@ +{ + "name": "@backstage/plugin-permission-react", + "version": "0.0.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "publishConfig": { + "access": "public", + "main": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/permission-react" + }, + "keywords": [ + "backstage" + ], + "scripts": { + "build": "backstage-cli build", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "dependencies": { + "@backstage/config": "^0.1.11", + "@backstage/core-plugin-api": "^0.2.0", + "@backstage/plugin-permission-common": "^0.2.0", + "@types/react": "*", + "cross-fetch": "^3.0.6", + "react": "^16.13.1", + "react-router": "6.0.0-beta.0", + "react-use": "^17.2.4" + }, + "devDependencies": { + "@backstage/cli": "^0.9.0", + "@backstage/test-utils": "^0.1.22", + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/react": "^11.2.5", + "@types/jest": "^26.0.7" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/permission-react/src/apis/IdentityPermissionApi.ts b/plugins/permission-react/src/apis/IdentityPermissionApi.ts new file mode 100644 index 0000000000..a68060a61d --- /dev/null +++ b/plugins/permission-react/src/apis/IdentityPermissionApi.ts @@ -0,0 +1,56 @@ +/* + * 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 { DiscoveryApi, IdentityApi } from '@backstage/core-plugin-api'; +import { PermissionApi } from './PermissionApi'; +import { + AuthorizeRequest, + AuthorizeResponse, + PermissionClient, +} from '@backstage/plugin-permission-common'; +import { Config } from '@backstage/config'; + +/** + * The default implementation of the PermissionApi, which simply calls the authorize method of the given + * {@link @backstage/plugin-permission-common#PermissionClient}. + * @public + */ +export class IdentityPermissionApi implements PermissionApi { + private constructor( + private readonly permissionClient: PermissionClient, + private readonly identityApi: IdentityApi, + ) {} + + static create({ + configApi, + discoveryApi, + identityApi, + }: { + configApi: Config; + discoveryApi: DiscoveryApi; + identityApi: IdentityApi; + }) { + const permissionClient = new PermissionClient({ discoveryApi, configApi }); + return new IdentityPermissionApi(permissionClient, identityApi); + } + + async authorize(request: AuthorizeRequest): Promise { + const response = await this.permissionClient.authorize([request], { + token: await this.identityApi.getIdToken(), + }); + return response[0]; + } +} diff --git a/plugins/permission-react/src/apis/PermissionApi.ts b/plugins/permission-react/src/apis/PermissionApi.ts new file mode 100644 index 0000000000..5c224ff06d --- /dev/null +++ b/plugins/permission-react/src/apis/PermissionApi.ts @@ -0,0 +1,40 @@ +/* + * 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 { + AuthorizeRequest, + AuthorizeResponse, +} from '@backstage/plugin-permission-common'; +import { ApiRef, createApiRef } from '@backstage/core-plugin-api'; + +/** + * This API is used by various frontend utilities that allow developers to implement authorization wihtin their frontend + * plugins. A plugin developer will likely not have to interact with this API or its implementations directly, but + * rather with the aforementioned utility components/hooks. + * @public + */ +export type PermissionApi = { + authorize(request: AuthorizeRequest): Promise; +}; + +/** + * A Backstage ApiRef for the Permission API. See https://backstage.io/docs/api/utility-apis for more information on + * Backstage ApiRefs. + * @public + */ +export const permissionApiRef: ApiRef = createApiRef({ + id: 'plugin.permission.api', +}); diff --git a/plugins/permission-react/src/apis/index.ts b/plugins/permission-react/src/apis/index.ts new file mode 100644 index 0000000000..223339f891 --- /dev/null +++ b/plugins/permission-react/src/apis/index.ts @@ -0,0 +1,19 @@ +/* + * 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 { permissionApiRef } from './PermissionApi'; +export type { PermissionApi } from './PermissionApi'; +export { IdentityPermissionApi } from './IdentityPermissionApi'; diff --git a/plugins/permission-react/src/components/PermissionedRoute.test.tsx b/plugins/permission-react/src/components/PermissionedRoute.test.tsx new file mode 100644 index 0000000000..3d7d1b7af7 --- /dev/null +++ b/plugins/permission-react/src/components/PermissionedRoute.test.tsx @@ -0,0 +1,87 @@ +/* + * 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 React from 'react'; +import { PermissionedRoute } from '.'; +import { usePermission } from '../hooks'; +import { renderInTestApp } from '@backstage/test-utils'; + +jest.mock('../hooks', () => ({ + usePermission: jest.fn(), +})); +const mockUsePermission = usePermission as jest.MockedFunction< + typeof usePermission +>; + +const permission = { + name: 'access.something', + attributes: { action: 'read' as const }, +}; + +describe('PermissionedRoute', () => { + it('Does not render when loading', async () => { + mockUsePermission.mockReturnValue({ loading: true, allowed: false }); + + const { queryByText } = await renderInTestApp( + content} + />, + ); + + expect(queryByText('content')).not.toBeTruthy(); + }); + + it('Renders given element if authorized', async () => { + mockUsePermission.mockReturnValue({ loading: false, allowed: true }); + + const { getByText } = await renderInTestApp( + content} + />, + ); + + expect(getByText('content')).toBeTruthy(); + }); + + it('Renders not found page if not authorized', async () => { + mockUsePermission.mockReturnValue({ loading: false, allowed: false }); + + await expect( + renderInTestApp( + content} + />, + ), + ).rejects.toThrowError('Reached NotFound Page'); + }); + + it('Renders custom error page if not authorized', async () => { + mockUsePermission.mockReturnValue({ loading: false, allowed: false }); + + const { getByText } = await renderInTestApp( + content} + errorComponent={

Custom Error

} + />, + ); + + expect(getByText('Custom Error')).toBeTruthy(); + }); +}); diff --git a/plugins/permission-react/src/components/PermissionedRoute.tsx b/plugins/permission-react/src/components/PermissionedRoute.tsx new file mode 100644 index 0000000000..1a3668205b --- /dev/null +++ b/plugins/permission-react/src/components/PermissionedRoute.tsx @@ -0,0 +1,52 @@ +/* + * 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 React, { ComponentProps, ReactElement } from 'react'; +import { Route } from 'react-router'; +import { useApp } from '@backstage/core-plugin-api'; +import { usePermission } from '../hooks'; +import { Permission } from '@backstage/plugin-permission-common'; + +/** + * Returns a React Router Route which only renders the element when authorized. If unathorized, the Route will render a + * NotFoundErrorPage (see {@link @backstage/core-app-api#AppComponents}). + * @public + */ +export const PermissionedRoute = ({ + permission, + resourceRef, + errorComponent, + ...props +}: ComponentProps & { + permission: Permission; + resourceRef?: string; + errorComponent?: ReactElement | null; +}) => { + const permissionResult = usePermission(permission, resourceRef); + const app = useApp(); + const { NotFoundErrorPage } = app.getComponents(); + + let shownElement: ReactElement | null | undefined = + errorComponent === undefined ? : errorComponent; + + if (permissionResult.loading) { + shownElement = null; + } else if (permissionResult.allowed) { + shownElement = props.element; + } + + return ; +}; diff --git a/plugins/permission-react/src/components/index.ts b/plugins/permission-react/src/components/index.ts new file mode 100644 index 0000000000..05f13bca81 --- /dev/null +++ b/plugins/permission-react/src/components/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 { PermissionedRoute } from './PermissionedRoute'; diff --git a/plugins/permission-react/src/hooks/index.ts b/plugins/permission-react/src/hooks/index.ts new file mode 100644 index 0000000000..5fca80b89e --- /dev/null +++ b/plugins/permission-react/src/hooks/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { usePermission } from './usePermission'; +export type { AsyncPermissionResult } from './usePermission'; diff --git a/plugins/permission-react/src/hooks/usePermission.test.tsx b/plugins/permission-react/src/hooks/usePermission.test.tsx new file mode 100644 index 0000000000..4cf0bc6530 --- /dev/null +++ b/plugins/permission-react/src/hooks/usePermission.test.tsx @@ -0,0 +1,87 @@ +/* + * 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 React, { FC } from 'react'; +import { render } from '@testing-library/react'; +import { usePermission } from './usePermission'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; +import { TestApiProvider } from '@backstage/test-utils'; +import { permissionApiRef } from '../apis'; + +const mockAuthorize = jest.fn(); + +const permission = { + name: 'access.something', + attributes: { action: 'read' as const }, +}; + +const TestComponent: FC = () => { + const { loading, allowed, error } = usePermission(permission); + return ( +
+ {loading && 'loading'} + {error && 'error'} + {allowed ? 'content' : null} +
+ ); +}; + +describe('usePermission', () => { + it('Returns loading when permissionApi has not yet responded.', () => { + mockAuthorize.mockReturnValueOnce(new Promise(() => {})); + + const { getByText } = render( + + + , + ); + + expect(mockAuthorize).toHaveBeenCalledWith({ permission }); + expect(getByText('loading')).toBeTruthy(); + }); + + it('Returns allowed when permissionApi allows authorization.', async () => { + mockAuthorize.mockResolvedValueOnce({ result: AuthorizeResult.ALLOW }); + + const { findByText } = render( + + + , + ); + + expect(mockAuthorize).toHaveBeenCalledWith({ permission }); + expect(await findByText('content')).toBeTruthy(); + }); + + it('Returns not allowed when permissionApi denies authorization.', async () => { + mockAuthorize.mockResolvedValueOnce({ result: AuthorizeResult.DENY }); + + const { findByText } = render( + + + , + ); + + expect(mockAuthorize).toHaveBeenCalledWith({ permission }); + await expect(findByText('content')).rejects.toThrowError(); + }); +}); diff --git a/plugins/permission-react/src/hooks/usePermission.ts b/plugins/permission-react/src/hooks/usePermission.ts new file mode 100644 index 0000000000..129904d7df --- /dev/null +++ b/plugins/permission-react/src/hooks/usePermission.ts @@ -0,0 +1,60 @@ +/* + * 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 { useAsync } from 'react-use'; +import { useApi } from '@backstage/core-plugin-api'; +import { permissionApiRef } from '../apis'; +import { + AuthorizeResult, + Permission, +} from '@backstage/plugin-permission-common'; + +/** @public */ +export type AsyncPermissionResult = { + loading: boolean; + allowed: boolean; + error?: Error; +}; + +/** + * React hook utlity for authorization. Given a {@link @backstage/plugin-permission-common#Permission} and an optional + * resourceRef, it will return whether or not access is allowed (for the given resource, if resourceRef is provided). See + * {@link @backstage/plugin-permission-common/PermissionClient#authorize} for more details. + * @public + */ +export const usePermission = ( + permission: Permission, + resourceRef?: string, +): AsyncPermissionResult => { + const permissionApi = useApi(permissionApiRef); + + const { loading, error, value } = useAsync(async () => { + const { result } = await permissionApi.authorize({ + permission, + resourceRef, + }); + + return result; + }, [permissionApi, permission, resourceRef]); + + if (loading) { + return { loading: true, allowed: false }; + } + if (error) { + return { error, loading: false, allowed: false }; + } + return { loading: false, allowed: value === AuthorizeResult.ALLOW }; +}; diff --git a/plugins/permission-react/src/index.ts b/plugins/permission-react/src/index.ts new file mode 100644 index 0000000000..57352c5cdd --- /dev/null +++ b/plugins/permission-react/src/index.ts @@ -0,0 +1,19 @@ +/* + * 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 './components'; +export * from './hooks'; +export * from './apis'; diff --git a/plugins/permission-react/src/setupTests.ts b/plugins/permission-react/src/setupTests.ts new file mode 100644 index 0000000000..992b60d3a4 --- /dev/null +++ b/plugins/permission-react/src/setupTests.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import '@testing-library/jest-dom';