diff --git a/.changeset/five-rockets-punch.md b/.changeset/five-rockets-punch.md new file mode 100644 index 0000000000..4b46acb27b --- /dev/null +++ b/.changeset/five-rockets-punch.md @@ -0,0 +1,5 @@ +--- +'@backstage/test-utils': patch +--- + +Add MockPermissionApi diff --git a/.changeset/nice-trains-cross.md b/.changeset/nice-trains-cross.md new file mode 100644 index 0000000000..033e9db466 --- /dev/null +++ b/.changeset/nice-trains-cross.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-catalog': patch +--- + +Add permission check to unregister entity button + +If the permissions framework is disabled, this change should have no effect. If the permission framework is enabled, the unregister entity button will be disabled for those who do not have access to the `catalogEntityDeletePermission` as specified in your permission policy. diff --git a/.changeset/sweet-grapes-argue.md b/.changeset/sweet-grapes-argue.md new file mode 100644 index 0000000000..786cb2ea33 --- /dev/null +++ b/.changeset/sweet-grapes-argue.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-react': patch +--- + +Add useEntityPermission hook diff --git a/packages/app/package.json b/packages/app/package.json index f65b221dbe..b688219d21 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -69,6 +69,7 @@ "zen-observable": "^0.8.15" }, "devDependencies": { + "@backstage/plugin-permission-react": "^0.3.0-next.0", "@backstage/test-utils": "^0.2.3-next.0", "@rjsf/core": "^3.2.1", "@testing-library/cypress": "^8.0.2", diff --git a/packages/app/src/components/catalog/EntityPage.test.tsx b/packages/app/src/components/catalog/EntityPage.test.tsx index a6878cd28b..b68b9e9895 100644 --- a/packages/app/src/components/catalog/EntityPage.test.tsx +++ b/packages/app/src/components/catalog/EntityPage.test.tsx @@ -21,7 +21,9 @@ import { starredEntitiesApiRef, } from '@backstage/plugin-catalog-react'; import { githubActionsApiRef } from '@backstage/plugin-github-actions'; +import { permissionApiRef } from '@backstage/plugin-permission-react'; import { + MockPermissionApi, MockStorageApi, renderInTestApp, TestApiProvider, @@ -49,6 +51,7 @@ describe('EntityPage Test', () => { const mockedApi = { listWorkflowRuns: jest.fn().mockResolvedValue([]), }; + const mockPermissionApi = new MockPermissionApi(); describe('cicdContent', () => { it('Should render GitHub Actions View', async () => { @@ -62,6 +65,7 @@ describe('EntityPage Test', () => { storageApi: MockStorageApi.create(), }), ], + [permissionApiRef, mockPermissionApi], ]} > diff --git a/packages/test-utils/api-report.md b/packages/test-utils/api-report.md index 6a3813e49f..2039bc60b5 100644 --- a/packages/test-utils/api-report.md +++ b/packages/test-utils/api-report.md @@ -7,6 +7,9 @@ import { AnalyticsApi } from '@backstage/core-plugin-api'; import { AnalyticsEvent } from '@backstage/core-plugin-api'; import { ApiHolder } from '@backstage/core-plugin-api'; import { ApiRef } from '@backstage/core-plugin-api'; +import { AuthorizeDecision } from '@backstage/plugin-permission-common'; +import { AuthorizeQuery } from '@backstage/plugin-permission-common'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; import { ComponentType } from 'react'; import { Config } from '@backstage/config'; import { ConfigApi } from '@backstage/core-plugin-api'; @@ -17,6 +20,7 @@ import { ExternalRouteRef } from '@backstage/core-plugin-api'; import { JsonObject } from '@backstage/types'; import { JsonValue } from '@backstage/types'; import { Observable } from '@backstage/types'; +import { PermissionApi } from '@backstage/plugin-permission-react'; import { ReactElement } from 'react'; import { ReactNode } from 'react'; import { RenderResult } from '@testing-library/react'; @@ -113,6 +117,17 @@ export type MockErrorApiOptions = { collect?: boolean; }; +// @public +export class MockPermissionApi implements PermissionApi { + constructor( + requestHandler?: ( + request: AuthorizeQuery, + ) => AuthorizeResult.ALLOW | AuthorizeResult.DENY, + ); + // (undocumented) + authorize(request: AuthorizeQuery): Promise; +} + // @public export class MockStorageApi implements StorageApi { // (undocumented) diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index bfa9f5502e..32d7010f53 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -32,6 +32,8 @@ "@backstage/config": "^0.1.13-next.0", "@backstage/core-app-api": "^0.5.0-next.0", "@backstage/core-plugin-api": "^0.6.0-next.0", + "@backstage/plugin-permission-common": "^0.4.0-next.0", + "@backstage/plugin-permission-react": "^0.3.0-next.0", "@backstage/theme": "^0.2.14", "@backstage/types": "^0.1.1", "@material-ui/core": "^4.12.2", diff --git a/packages/test-utils/src/testUtils/apis/PermissionApi/MockPermissionApi.test.ts b/packages/test-utils/src/testUtils/apis/PermissionApi/MockPermissionApi.test.ts new file mode 100644 index 0000000000..88e7c995f5 --- /dev/null +++ b/packages/test-utils/src/testUtils/apis/PermissionApi/MockPermissionApi.test.ts @@ -0,0 +1,43 @@ +/* + * 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 { + AuthorizeResult, + Permission, +} from '@backstage/plugin-permission-common'; +import { MockPermissionApi } from './MockPermissionApi'; + +describe('MockPermissionApi', () => { + it('returns ALLOW by default', async () => { + const api = new MockPermissionApi(); + + await expect( + api.authorize({ permission: { name: 'permission.1' } as Permission }), + ).resolves.toEqual({ result: AuthorizeResult.ALLOW }); + }); + + it('allows passing a handler to customize the result', async () => { + const api = new MockPermissionApi(request => + request.permission.name === 'permission.2' + ? AuthorizeResult.DENY + : AuthorizeResult.ALLOW, + ); + + await expect( + api.authorize({ permission: { name: 'permission.2' } as Permission }), + ).resolves.toEqual({ result: AuthorizeResult.DENY }); + }); +}); diff --git a/packages/test-utils/src/testUtils/apis/PermissionApi/MockPermissionApi.ts b/packages/test-utils/src/testUtils/apis/PermissionApi/MockPermissionApi.ts new file mode 100644 index 0000000000..3dcc67dd3e --- /dev/null +++ b/packages/test-utils/src/testUtils/apis/PermissionApi/MockPermissionApi.ts @@ -0,0 +1,41 @@ +/* + * Copyright 2022 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 { PermissionApi } from '@backstage/plugin-permission-react'; +import { + AuthorizeDecision, + AuthorizeQuery, + AuthorizeResult, +} from '@backstage/plugin-permission-common'; + +/** + * Mock implementation of {@link core-plugin-api#PermissionApi}. Supply a + * requestHandler function to override the mock result returned for a given + * request. + * @public + */ +export class MockPermissionApi implements PermissionApi { + constructor( + private readonly requestHandler: ( + request: AuthorizeQuery, + ) => AuthorizeResult.ALLOW | AuthorizeResult.DENY = () => + AuthorizeResult.ALLOW, + ) {} + + async authorize(request: AuthorizeQuery): Promise { + return { result: this.requestHandler(request) }; + } +} diff --git a/packages/test-utils/src/testUtils/apis/PermissionApi/index.ts b/packages/test-utils/src/testUtils/apis/PermissionApi/index.ts new file mode 100644 index 0000000000..f69316b811 --- /dev/null +++ b/packages/test-utils/src/testUtils/apis/PermissionApi/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2022 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 { MockPermissionApi } from './MockPermissionApi'; diff --git a/packages/test-utils/src/testUtils/apis/index.ts b/packages/test-utils/src/testUtils/apis/index.ts index 8f6bb06f9e..e59608d2b0 100644 --- a/packages/test-utils/src/testUtils/apis/index.ts +++ b/packages/test-utils/src/testUtils/apis/index.ts @@ -17,4 +17,5 @@ export * from './AnalyticsApi'; export * from './ConfigApi'; export * from './ErrorApi'; +export * from './PermissionApi'; export * from './StorageApi'; diff --git a/plugins/catalog-react/api-report.md b/plugins/catalog-react/api-report.md index 90fa193bfd..129f7b36af 100644 --- a/plugins/catalog-react/api-report.md +++ b/plugins/catalog-react/api-report.md @@ -20,6 +20,7 @@ import { IdentityApi } from '@backstage/core-plugin-api'; import { LinkProps } from '@backstage/core-components'; import { Observable } from '@backstage/types'; import { Overrides } from '@material-ui/core/styles/overrides'; +import { Permission } from '@backstage/plugin-permission-common'; import { PropsWithChildren } from 'react'; import { default as React_2 } from 'react'; import { ReactNode } from 'react'; @@ -879,6 +880,13 @@ export function useEntityOwnership(): { isOwnedEntity: (entity: Entity | EntityName) => boolean; }; +// @public +export function useEntityPermission(permission: Permission): { + loading: boolean; + allowed: boolean; + error?: Error; +}; + // Warning: (ae-forgotten-export) The symbol "EntityTypeReturn" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "useEntityTypeFilter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // diff --git a/plugins/catalog-react/package.json b/plugins/catalog-react/package.json index dc90619253..87d6f7ada8 100644 --- a/plugins/catalog-react/package.json +++ b/plugins/catalog-react/package.json @@ -35,6 +35,8 @@ "@backstage/core-plugin-api": "^0.6.0-next.0", "@backstage/errors": "^0.2.0", "@backstage/integration": "^0.7.2-next.0", + "@backstage/plugin-permission-common": "^0.4.0-next.0", + "@backstage/plugin-permission-react": "^0.3.0-next.0", "@backstage/types": "^0.1.1", "@backstage/version-bridge": "^0.1.1", "@material-ui/core": "^4.12.2", @@ -54,6 +56,7 @@ "devDependencies": { "@backstage/cli": "^0.12.0-next.0", "@backstage/core-app-api": "^0.5.0-next.0", + "@backstage/plugin-catalog-common": "^0.1.1-next.0", "@backstage/test-utils": "^0.2.3-next.0", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", diff --git a/plugins/catalog-react/src/hooks/index.ts b/plugins/catalog-react/src/hooks/index.ts index a6684732ce..06791f5706 100644 --- a/plugins/catalog-react/src/hooks/index.ts +++ b/plugins/catalog-react/src/hooks/index.ts @@ -43,3 +43,4 @@ export { loadIdentityOwnerRefs, } from './useEntityOwnership'; export { useOwnedEntities } from './useOwnedEntities'; +export { useEntityPermission } from './useEntityPermission'; diff --git a/plugins/catalog-react/src/hooks/useEntityPermission.test.tsx b/plugins/catalog-react/src/hooks/useEntityPermission.test.tsx new file mode 100644 index 0000000000..553d999b61 --- /dev/null +++ b/plugins/catalog-react/src/hooks/useEntityPermission.test.tsx @@ -0,0 +1,97 @@ +/* + * Copyright 2022 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 { catalogEntityDeletePermission } from '@backstage/plugin-catalog-common'; +import { renderHook } from '@testing-library/react-hooks'; +import { useEntityPermission } from './useEntityPermission'; +import { useEntity } from './useEntity'; +import { usePermission } from '@backstage/plugin-permission-react'; + +jest.mock('./useEntity', () => ({ + ...jest.requireActual('./useEntity'), + useEntity: jest.fn(), +})); +jest.mock('@backstage/plugin-permission-react', () => ({ + ...jest.requireActual('@backstage/plugin-permission-react'), + usePermission: jest.fn(), +})); +const useEntityMock = useEntity as jest.Mock; +const usePermissionMock = usePermission as jest.Mock; + +describe('useEntityPermission', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + it('returns loading when entity is loading', () => { + useEntityMock.mockReturnValue({ loading: true, entity: undefined }); + usePermissionMock.mockReturnValue({ loading: false, allowed: false }); + const { result } = renderHook(() => + useEntityPermission(catalogEntityDeletePermission), + ); + + expect(result.current.loading).toBe(true); + }); + + it('returns loading when permission is loading', () => { + useEntityMock.mockReturnValue({ + loading: false, + entity: { + apiVersion: 'a', + kind: 'b', + metadata: { name: 'c' }, + }, + }); + usePermissionMock.mockReturnValue({ loading: true, allowed: false }); + const { result } = renderHook(() => + useEntityPermission(catalogEntityDeletePermission), + ); + + expect(result.current.loading).toBe(true); + }); + + it('does not authorize when there is an entity error', () => { + useEntityMock.mockReturnValue({ + loading: false, + entity: undefined, + error: new Error(), + }); + usePermissionMock.mockReturnValue({ loading: false, allowed: false }); + const { result } = renderHook(() => + useEntityPermission(catalogEntityDeletePermission), + ); + + expect(result.current.error).toBeInstanceOf(Error); + expect(result.current.allowed).toBe(false); + }); + + it('returns authorization result', () => { + useEntityMock.mockReturnValue({ + loading: false, + entity: { + apiVersion: 'a', + kind: 'b', + metadata: { name: 'c' }, + }, + }); + usePermissionMock.mockReturnValue({ loading: false, allowed: true }); + const { result } = renderHook(() => + useEntityPermission(catalogEntityDeletePermission), + ); + + expect(result.current.allowed).toBe(true); + }); +}); diff --git a/plugins/catalog-react/src/hooks/useEntityPermission.ts b/plugins/catalog-react/src/hooks/useEntityPermission.ts new file mode 100644 index 0000000000..c1f62cfc39 --- /dev/null +++ b/plugins/catalog-react/src/hooks/useEntityPermission.ts @@ -0,0 +1,55 @@ +/* + * Copyright 2022 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 { stringifyEntityRef } from '@backstage/catalog-model'; +import { Permission } from '@backstage/plugin-permission-common'; +import { usePermission } from '@backstage/plugin-permission-react'; +import { useEntity } from './useEntity'; + +/** + * A thin wrapper around the + * {@link @backstage/plugin-permission-react#usePermission} hook which uses the + * current entity in context to make an authorization request for the given + * permission. + * + * Note: this hook blocks the permission request until the entity has loaded in + * context. If you have the entityRef and need concurrent requests, use the + * `usePermission` hook directly. + * @public + */ +export function useEntityPermission(permission: Permission): { + loading: boolean; + allowed: boolean; + error?: Error; +} { + const { entity, loading: loadingEntity, error: entityError } = useEntity(); + const { + allowed, + loading: loadingPermission, + error: permissionError, + } = usePermission( + permission, + entity ? stringifyEntityRef(entity) : undefined, + ); + + if (loadingEntity || loadingPermission) { + return { loading: true, allowed: false }; + } + if (entityError) { + return { loading: false, allowed: false, error: entityError }; + } + return { loading: false, allowed, error: permissionError }; +} diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json index 66b87718be..2ac01025cf 100644 --- a/plugins/catalog/package.json +++ b/plugins/catalog/package.json @@ -37,6 +37,7 @@ "@backstage/core-plugin-api": "^0.6.0-next.0", "@backstage/errors": "^0.2.0", "@backstage/integration-react": "^0.1.19-next.0", + "@backstage/plugin-catalog-common": "^0.1.1-next.0", "@backstage/plugin-catalog-react": "^0.6.12-next.0", "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", @@ -56,6 +57,7 @@ "@backstage/cli": "^0.12.0-next.0", "@backstage/core-app-api": "^0.5.0-next.0", "@backstage/dev-utils": "^0.2.18-next.0", + "@backstage/plugin-permission-react": "^0.3.0-next.0", "@backstage/test-utils": "^0.2.3-next.0", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", diff --git a/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.test.tsx b/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.test.tsx index 49a2479faf..f16a17bc1d 100644 --- a/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.test.tsx +++ b/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.test.tsx @@ -14,19 +14,36 @@ * limitations under the License. */ -import { renderInTestApp } from '@backstage/test-utils'; +import { EntityProvider } from '@backstage/plugin-catalog-react'; +import { permissionApiRef } from '@backstage/plugin-permission-react'; +import { + MockPermissionApi, + renderInTestApp, + TestApiProvider, +} from '@backstage/test-utils'; import SearchIcon from '@material-ui/icons/Search'; import { fireEvent, screen } from '@testing-library/react'; import * as React from 'react'; import { EntityContextMenu } from './EntityContextMenu'; +const mockPermissionApi = new MockPermissionApi(); + +function render(children: React.ReactNode) { + return renderInTestApp( + + + , + ); +} + describe('ComponentContextMenu', () => { it('should call onUnregisterEntity on button click', async () => { const mockCallback = jest.fn(); - await renderInTestApp( - , - ); + await render(); const button = await screen.findByTestId('menu-button'); expect(button).toBeInTheDocument(); @@ -46,7 +63,7 @@ describe('ComponentContextMenu', () => { onClick: jest.fn(), }; - await renderInTestApp( + await render( { const [anchorEl, setAnchorEl] = useState(); const classes = useStyles(); + const unregisterPermission = useEntityPermission( + catalogEntityDeletePermission, + ); const onOpen = (event: React.SyntheticEvent) => { setAnchorEl(event.currentTarget); @@ -90,7 +95,9 @@ export const EntityContextMenu = ({ ]; const disableUnregister = - UNSTABLE_contextMenuOptions?.disableUnregister ?? false; + (!unregisterPermission.allowed || + UNSTABLE_contextMenuOptions?.disableUnregister) ?? + false; return ( <> diff --git a/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx b/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx index a45afe6e02..78dcf855e8 100644 --- a/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx +++ b/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx @@ -26,7 +26,9 @@ import { entityRouteRef, starredEntitiesApiRef, } from '@backstage/plugin-catalog-react'; +import { permissionApiRef } from '@backstage/plugin-permission-react'; import { + MockPermissionApi, MockStorageApi, renderInTestApp, TestApiRegistry, @@ -50,6 +52,7 @@ const mockApis = TestApiRegistry.from( starredEntitiesApiRef, new DefaultStarredEntitiesApi({ storageApi: MockStorageApi.create() }), ], + [permissionApiRef, new MockPermissionApi()], ); describe('EntityLayout', () => {