Merge pull request #8972 from backstage/catalog-permission-integration/unregister-entity

Disable unregister entity button in catalog if unauthorized
This commit is contained in:
Fredrik Adelöw
2022-01-20 10:23:15 +01:00
committed by GitHub
20 changed files with 340 additions and 6 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/test-utils': patch
---
Add MockPermissionApi
+7
View File
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-react': patch
---
Add useEntityPermission hook
+1
View File
@@ -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",
@@ -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],
]}
>
<EntityProvider entity={entity}>
+15
View File
@@ -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<AuthorizeDecision>;
}
// @public
export class MockStorageApi implements StorageApi {
// (undocumented)
+2
View File
@@ -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",
@@ -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 });
});
});
@@ -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<AuthorizeDecision> {
return { result: this.requestHandler(request) };
}
}
@@ -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';
@@ -17,4 +17,5 @@
export * from './AnalyticsApi';
export * from './ConfigApi';
export * from './ErrorApi';
export * from './PermissionApi';
export * from './StorageApi';
+8
View File
@@ -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)
//
+3
View File
@@ -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",
+1
View File
@@ -43,3 +43,4 @@ export {
loadIdentityOwnerRefs,
} from './useEntityOwnership';
export { useOwnedEntities } from './useOwnedEntities';
export { useEntityPermission } from './useEntityPermission';
@@ -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);
});
});
@@ -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 };
}
+2
View File
@@ -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",
@@ -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(
<TestApiProvider apis={[[permissionApiRef, mockPermissionApi]]}>
<EntityProvider
entity={{ apiVersion: 'a', kind: 'b', metadata: { name: 'c' } }}
children={children}
/>
</TestApiProvider>,
);
}
describe('ComponentContextMenu', () => {
it('should call onUnregisterEntity on button click', async () => {
const mockCallback = jest.fn();
await renderInTestApp(
<EntityContextMenu onUnregisterEntity={mockCallback} />,
);
await render(<EntityContextMenu onUnregisterEntity={mockCallback} />);
const button = await screen.findByTestId('menu-button');
expect(button).toBeInTheDocument();
@@ -46,7 +63,7 @@ describe('ComponentContextMenu', () => {
onClick: jest.fn(),
};
await renderInTestApp(
await render(
<EntityContextMenu
onUnregisterEntity={jest.fn()}
UNSTABLE_extraContextMenuItems={[extra]}
@@ -28,6 +28,8 @@ import Cancel from '@material-ui/icons/Cancel';
import MoreVert from '@material-ui/icons/MoreVert';
import React, { useState } from 'react';
import { IconComponent } from '@backstage/core-plugin-api';
import { useEntityPermission } from '@backstage/plugin-catalog-react';
import { catalogEntityDeletePermission } from '@backstage/plugin-catalog-common';
// TODO(freben): It should probably instead be the case that Header sets the theme text color to white inside itself unconditionally instead
const useStyles = makeStyles({
@@ -62,6 +64,9 @@ export const EntityContextMenu = ({
}: Props) => {
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement>();
const classes = useStyles();
const unregisterPermission = useEntityPermission(
catalogEntityDeletePermission,
);
const onOpen = (event: React.SyntheticEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
@@ -90,7 +95,9 @@ export const EntityContextMenu = ({
];
const disableUnregister =
UNSTABLE_contextMenuOptions?.disableUnregister ?? false;
(!unregisterPermission.allowed ||
UNSTABLE_contextMenuOptions?.disableUnregister) ??
false;
return (
<>
@@ -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', () => {