@backstage/plugin-permission-react (#8215)

* Add @backstage/plugin-permission-react

Signed-off-by: Joon Park <joonp@spotify.com>
This commit is contained in:
Joon Park
2021-12-02 03:30:56 -06:00
committed by GitHub
parent 57f15a25c7
commit 6ed24445a9
16 changed files with 605 additions and 0 deletions
+7
View File
@@ -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.
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint')],
};
+5
View File
@@ -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).
+69
View File
@@ -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<AuthorizeResponse>;
// (undocumented)
static create({
configApi,
discoveryApi,
identityApi,
}: {
configApi: Config;
discoveryApi: DiscoveryApi;
identityApi: IdentityApi;
}): IdentityPermissionApi;
}
// @public
export type PermissionApi = {
authorize(request: AuthorizeRequest): Promise<AuthorizeResponse>;
};
// @public
export const permissionApiRef: ApiRef<PermissionApi>;
// @public
export const PermissionedRoute: ({
permission,
resourceRef,
errorComponent,
...props
}: RouteProps & {
permission: Permission;
resourceRef?: string | undefined;
errorComponent?:
| React_2.ReactElement<any, string | React_2.JSXElementConstructor<any>>
| null
| undefined;
}) => JSX.Element;
// @public
export const usePermission: (
permission: Permission,
resourceRef?: string | undefined,
) => AsyncPermissionResult;
// (No @packageDocumentation comment for this package)
```
+49
View File
@@ -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"
]
}
@@ -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<AuthorizeResponse> {
const response = await this.permissionClient.authorize([request], {
token: await this.identityApi.getIdToken(),
});
return response[0];
}
}
@@ -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<AuthorizeResponse>;
};
/**
* 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<PermissionApi> = createApiRef({
id: 'plugin.permission.api',
});
@@ -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';
@@ -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(
<PermissionedRoute
permission={permission}
element={<div>content</div>}
/>,
);
expect(queryByText('content')).not.toBeTruthy();
});
it('Renders given element if authorized', async () => {
mockUsePermission.mockReturnValue({ loading: false, allowed: true });
const { getByText } = await renderInTestApp(
<PermissionedRoute
permission={permission}
element={<div>content</div>}
/>,
);
expect(getByText('content')).toBeTruthy();
});
it('Renders not found page if not authorized', async () => {
mockUsePermission.mockReturnValue({ loading: false, allowed: false });
await expect(
renderInTestApp(
<PermissionedRoute
permission={permission}
element={<div>content</div>}
/>,
),
).rejects.toThrowError('Reached NotFound Page');
});
it('Renders custom error page if not authorized', async () => {
mockUsePermission.mockReturnValue({ loading: false, allowed: false });
const { getByText } = await renderInTestApp(
<PermissionedRoute
permission={permission}
element={<div>content</div>}
errorComponent={<h1>Custom Error</h1>}
/>,
);
expect(getByText('Custom Error')).toBeTruthy();
});
});
@@ -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<typeof Route> & {
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 ? <NotFoundErrorPage /> : errorComponent;
if (permissionResult.loading) {
shownElement = null;
} else if (permissionResult.allowed) {
shownElement = props.element;
}
return <Route {...props} element={shownElement} />;
};
@@ -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';
@@ -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';
@@ -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 (
<div>
{loading && 'loading'}
{error && 'error'}
{allowed ? 'content' : null}
</div>
);
};
describe('usePermission', () => {
it('Returns loading when permissionApi has not yet responded.', () => {
mockAuthorize.mockReturnValueOnce(new Promise(() => {}));
const { getByText } = render(
<TestApiProvider
apis={[[permissionApiRef, { authorize: mockAuthorize }]]}
>
<TestComponent />
</TestApiProvider>,
);
expect(mockAuthorize).toHaveBeenCalledWith({ permission });
expect(getByText('loading')).toBeTruthy();
});
it('Returns allowed when permissionApi allows authorization.', async () => {
mockAuthorize.mockResolvedValueOnce({ result: AuthorizeResult.ALLOW });
const { findByText } = render(
<TestApiProvider
apis={[[permissionApiRef, { authorize: mockAuthorize }]]}
>
<TestComponent />
</TestApiProvider>,
);
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(
<TestApiProvider
apis={[[permissionApiRef, { authorize: mockAuthorize }]]}
>
<TestComponent />
</TestApiProvider>,
);
expect(mockAuthorize).toHaveBeenCalledWith({ permission });
await expect(findByText('content')).rejects.toThrowError();
});
});
@@ -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 };
};
+19
View File
@@ -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';
@@ -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';