Frontend authorization docs for plugin authors (#11034)
* Frontend authorization docs for plugin authors Signed-off-by: Joon Park <joonp@spotify.com> * Wording changes Signed-off-by: Joon Park <joonp@spotify.com> * Clarify name of example create permission variable Signed-off-by: Joon Park <joonp@spotify.com> * Refactor todo list frontend code to reduce diff Signed-off-by: Joon Park <joonp@spotify.com> * API reports Signed-off-by: Joon Park <joonp@spotify.com> Signed-off-by: Joon Park <joonp@spotify.com>
This commit is contained in:
@@ -20,15 +20,18 @@ Let's navigate to the file `plugins/todo-list-common/src/permissions.ts` and add
|
||||
- export const tempExamplePermission = createPermission({
|
||||
- name: 'temp.example.noop',
|
||||
- attributes: {},
|
||||
+ export const todoListCreate = createPermission({
|
||||
+ export const todoListCreatePermission = createPermission({
|
||||
+ name: 'todo.list.create',
|
||||
+ attributes: { action: 'create' },
|
||||
});
|
||||
|
||||
- export const todoListPermissions = [tempExamplePermission];
|
||||
+ export const todoListPermissions = [todoListCreatePermission];
|
||||
```
|
||||
|
||||
For this tutorial, we've automatically exported all permissions from this file (see `plugins/todo-list-common/src/index.ts`).
|
||||
|
||||
> Note: All permissions authorized by your plugin should be exported from a ["common-library" package](https://backstage.io/docs/local-dev/cli-build-system#package-roles). This allows Backstage integrators to reference them in frontend components and permission policies.
|
||||
> Note: We use a separate `todo-list-common` package since all permissions authorized by your plugin should be exported from a ["common-library" package](https://backstage.io/docs/local-dev/cli-build-system#package-roles). This allows Backstage integrators to reference them in frontend components and permission policies.
|
||||
|
||||
## Authorizing using the new permission
|
||||
|
||||
@@ -47,7 +50,7 @@ Edit `plugins/todo-list-backend/src/service/router.ts`:
|
||||
- import { InputError } from '@backstage/errors';
|
||||
+ import { InputError, NotAllowedError } from '@backstage/errors';
|
||||
+ import { PermissionEvaluator, AuthorizeResult } from '@backstage/plugin-permission-common';
|
||||
+ import { todoListCreate } from '@internal/plugin-todo-list-common';
|
||||
+ import { todoListCreatePermission } from '@internal/plugin-todo-list-common';
|
||||
|
||||
...
|
||||
|
||||
@@ -72,7 +75,7 @@ Edit `plugins/todo-list-backend/src/service/router.ts`:
|
||||
const user = token ? await identity.authenticate(token) : undefined;
|
||||
author = user?.identity.userEntityRef;
|
||||
+ const decision = (
|
||||
+ await permissions.authorize([{ permission: todoListCreate }], {
|
||||
+ await permissions.authorize([{ permission: todoListCreatePermission }], {
|
||||
+ token,
|
||||
+ })
|
||||
+ )[0];
|
||||
@@ -135,7 +138,7 @@ In order to test the logic above, the integrators of your backstage instance nee
|
||||
+ PolicyQuery,
|
||||
} from '@backstage/plugin-permission-node';
|
||||
+ import { isPermission } from '@backstage/plugin-permission-common';
|
||||
+ import { todoListCreate } from '@internal/plugin-todo-list-common';
|
||||
+ import { todoListCreatePermission } from '@internal/plugin-todo-list-common';
|
||||
|
||||
class TestPermissionPolicy implements PermissionPolicy {
|
||||
- async handle(): Promise<PolicyDecision> {
|
||||
@@ -143,7 +146,7 @@ In order to test the logic above, the integrators of your backstage instance nee
|
||||
+ request: PolicyQuery,
|
||||
+ user?: BackstageIdentityResponse,
|
||||
+ ): Promise<PolicyDecision> {
|
||||
+ if (isPermission(request.permission, todoListCreate)) {
|
||||
+ if (isPermission(request.permission, todoListCreatePermission)) {
|
||||
+ return {
|
||||
+ result: AuthorizeResult.DENY,
|
||||
+ };
|
||||
@@ -160,7 +163,7 @@ Now the frontend should show an error whenever you try to create a new Todo item
|
||||
Let's flip the result back to `ALLOW` before moving on.
|
||||
|
||||
```diff
|
||||
if (isPermission(request.permission, todoListCreate)) {
|
||||
if (isPermission(request.permission, todoListCreatePermission)) {
|
||||
return {
|
||||
- result: AuthorizeResult.DENY,
|
||||
+ result: AuthorizeResult.ALLOW,
|
||||
|
||||
@@ -15,27 +15,30 @@ Let's add a new permission to the file `plugins/todo-list-common/src/permissions
|
||||
|
||||
+ export const TODO_LIST_RESOURCE_TYPE = 'todo-item';
|
||||
+
|
||||
export const todoListCreate = createPermission({
|
||||
export const todoListCreatePermission = createPermission({
|
||||
name: 'todo.list.create',
|
||||
attributes: { action: 'create' },
|
||||
});
|
||||
+
|
||||
+ export const todoListUpdate = createPermission({
|
||||
+ export const todoListUpdatePermission = createPermission({
|
||||
+ name: 'todo.list.update',
|
||||
+ attributes: { action: 'update' },
|
||||
+ resourceType: TODO_LIST_RESOURCE_TYPE,
|
||||
+ });
|
||||
|
||||
- export const todoListPermissions = [todoListCreatePermission];
|
||||
+ export const todoListPermissions = [todoListCreatePermission, todoListUpdatePermission];
|
||||
```
|
||||
|
||||
Notice that unlike `todoListCreate`, the `todoListUpdate` permission contains a `resourceType` field. This field indicates to the permission framework that this permission is intended to be authorized in the context of a resource with type `'todo-item'`. You can use whatever string you like as the resource type, as long as you use the same value consistently for each type of resource.
|
||||
Notice that unlike `todoListCreatePermission`, the `todoListUpdatePermission` permission contains a `resourceType` field. This field indicates to the permission framework that this permission is intended to be authorized in the context of a resource with type `'todo-item'`. You can use whatever string you like as the resource type, as long as you use the same value consistently for each type of resource.
|
||||
|
||||
## Setting up authorization for the update permission
|
||||
|
||||
To start, let's edit `plugins/todo-list-backend/src/service/router.ts` in the same manner as we did in the previous section:
|
||||
|
||||
```diff
|
||||
- import { todoListCreate } from '@internal/plugin-todo-list-common';
|
||||
+ import { todoListCreate, todoListUpdate } from '@internal/plugin-todo-list-common';
|
||||
- import { todoListCreatePermission } from '@internal/plugin-todo-list-common';
|
||||
+ import { todoListCreatePermission, todoListUpdatePermission } from '@internal/plugin-todo-list-common';
|
||||
|
||||
...
|
||||
|
||||
@@ -49,7 +52,7 @@ To start, let's edit `plugins/todo-list-backend/src/service/router.ts` in the sa
|
||||
}
|
||||
+ const decision = (
|
||||
+ await permissions.authorize(
|
||||
+ [{ permission: todoListUpdate, resourceRef: req.body.id }],
|
||||
+ [{ permission: todoListUpdatePermission, resourceRef: req.body.id }],
|
||||
+ {
|
||||
+ token,
|
||||
+ },
|
||||
@@ -119,6 +122,7 @@ Now, let's create the new endpoint by editing `plugins/todo-list-backend/src/ser
|
||||
|
||||
- `getResources`: a function that accepts an array of `resourceRefs` in the same format you expect to be passed to `authorize`, and returns an array of the corresponding resources.
|
||||
- `resourceType`: the same value used in the permission rule above.
|
||||
- `permissions`: the list of permissions that your plugin accepts.
|
||||
- `rules`: an array of all the permission rules you want to support in conditional decisions.
|
||||
|
||||
```diff
|
||||
@@ -127,7 +131,7 @@ Now, let's create the new endpoint by editing `plugins/todo-list-backend/src/ser
|
||||
- import { add, getAll, update } from './todos';
|
||||
+ import { add, getAll, getTodo, update } from './todos';
|
||||
+ import { createPermissionIntegrationRouter } from '@backstage/plugin-permission-node';
|
||||
+ import { TODO_LIST_RESOURCE_TYPE } from '@internal/plugin-todo-list-common';
|
||||
+ import { TODO_LIST_RESOURCE_TYPE, todoListPermissions } from '@internal/plugin-todo-list-common';
|
||||
+ import { rules } from './rules';
|
||||
|
||||
export async function createRouter(
|
||||
@@ -140,6 +144,7 @@ Now, let's create the new endpoint by editing `plugins/todo-list-backend/src/ser
|
||||
+ return resourceRefs.map(getTodo);
|
||||
+ },
|
||||
+ resourceType: TODO_LIST_RESOURCE_TYPE,
|
||||
+ permissions: todoListPermissions,
|
||||
+ rules: Object.values(rules),
|
||||
+ });
|
||||
|
||||
@@ -196,10 +201,10 @@ Let's go back to the permission policy's handle function and try to authorize ou
|
||||
PolicyQuery,
|
||||
} from '@backstage/plugin-permission-node';
|
||||
import { isPermission } from '@backstage/plugin-permission-common';
|
||||
- import { todoListCreate } from '@internal/plugin-todo-list-common';
|
||||
- import { todoListCreatePermission } from '@internal/plugin-todo-list-common';
|
||||
+ import {
|
||||
+ todoListCreate,
|
||||
+ todoListUpdate,
|
||||
+ todoListCreatePermission,
|
||||
+ todoListUpdatePermission,
|
||||
+ TODO_LIST_RESOURCE_TYPE,
|
||||
+ } from '@internal/plugin-todo-list-common';
|
||||
+ import {
|
||||
@@ -209,13 +214,13 @@ Let's go back to the permission policy's handle function and try to authorize ou
|
||||
|
||||
...
|
||||
|
||||
if (isPermission(request.permission, todoListCreate)) {
|
||||
if (isPermission(request.permission, todoListCreatePermission)) {
|
||||
return {
|
||||
result: AuthorizeResult.ALLOW,
|
||||
};
|
||||
}
|
||||
|
||||
+ if (isPermission(request.permission, todoListUpdate)) {
|
||||
+ if (isPermission(request.permission, todoListUpdatePermission)) {
|
||||
+ return createTodoListConditionalDecision(
|
||||
+ request.permission,
|
||||
+ todoListConditions.isOwner(user?.identity.userEntityRef),
|
||||
|
||||
@@ -42,12 +42,12 @@ Let's add another permission to the plugin.
|
||||
|
||||
export const TODO_LIST_RESOURCE_TYPE = 'todo-item';
|
||||
|
||||
export const todoListCreate = createPermission({
|
||||
export const todoListCreatePermission = createPermission({
|
||||
name: 'todo.list.create',
|
||||
attributes: { action: 'create' },
|
||||
});
|
||||
|
||||
export const todoListUpdate = createPermission({
|
||||
export const todoListUpdatePermission = createPermission({
|
||||
name: 'todo.list.update',
|
||||
attributes: { action: 'update' },
|
||||
resourceType: TODO_LIST_RESOURCE_TYPE,
|
||||
@@ -58,6 +58,9 @@ Let's add another permission to the plugin.
|
||||
+ attributes: { action: 'read' },
|
||||
+ resourceType: TODO_LIST_RESOURCE_TYPE,
|
||||
+ });
|
||||
|
||||
- export const todoListPermissions = [todoListCreatePermission, todoListUpdatePermission];
|
||||
+ export const todoListPermissions = [todoListCreatePermission, todoListUpdatePermission, todoListReadPermission];
|
||||
```
|
||||
|
||||
## Using conditional policy decisions
|
||||
@@ -126,18 +129,18 @@ Let's update our permission policy to return a conditional result whenever a `to
|
||||
...
|
||||
|
||||
import {
|
||||
todoListCreate,
|
||||
todoListUpdate,
|
||||
+ todoListRead,
|
||||
todoListCreatePermission,
|
||||
todoListUpdatePermission,
|
||||
+ todoListReadPermission,
|
||||
TODO_LIST_RESOURCE_TYPE,
|
||||
} from '@internal/plugin-todo-list-common';
|
||||
|
||||
...
|
||||
|
||||
- if (isPermission(request.permission, todoListUpdate)) {
|
||||
- if (isPermission(request.permission, todoListUpdatePermission)) {
|
||||
+ if (
|
||||
+ isPermission(request.permission, todoListUpdate) ||
|
||||
+ isPermission(request.permission, todoListRead)
|
||||
+ isPermission(request.permission, todoListUpdatePermission) ||
|
||||
+ isPermission(request.permission, todoListReadPermission)
|
||||
+ ) {
|
||||
return createTodoListConditionalDecision(
|
||||
request.permission,
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
---
|
||||
id: 05-frontend-authorization
|
||||
title: 5. Frontend Components with Authorization
|
||||
description: Placing frontend components behind authorization
|
||||
---
|
||||
|
||||
In the previous sections, we learned how to protect our plugin's backend API routes with the permission framework. Most routes that return some data to be displayed (such as our `GET /todos` route) need no additional changes on the frontend, as the backend will simply return an empty list or a `404`. However, for UI elements that trigger a mutative action, it's common practice to hide or disable them when a user doesn't have permission.
|
||||
|
||||
Take, for example, the "Add" button in our todo list application. When a user clicks this button, the frontend makes a `POST` request to the `/todos` route of our backend. If a user tries to add a todo but is not authorized, they will have no way of knowing this until they perform the action and are faced with an error. This is a poor user experience. We can do better by disabling the add button.
|
||||
|
||||
> Note: Placing frontend components behind authorization cannot take the place of placing your backend routes behind authorization. Authorization checks on the frontend should be used in _addition_ to the corresponding backend authorization, as an improvement to the user experience. If you do not place your backend route behind authorization, a malicious actor can still send a request to the route even if you disabled the corresponding frontend component.
|
||||
|
||||
## Using `usePermission`
|
||||
|
||||
Let's start by adding the packages we will need:
|
||||
|
||||
```
|
||||
$ yarn workspace @internal/plugin-todo-list \
|
||||
add @backstage/plugin-permission-react @internal/plugin-todo-list-common
|
||||
```
|
||||
|
||||
Let's make the following changes in `plugins/todo-list/src/components/TodoListPage/TodoListPage.tsx`:
|
||||
|
||||
```diff
|
||||
...
|
||||
|
||||
import {
|
||||
alertApiRef,
|
||||
discoveryApiRef,
|
||||
fetchApiRef,
|
||||
useApi,
|
||||
} from '@backstage/core-plugin-api';
|
||||
+ import { usePermission } from '@backstage/plugin-permission-react';
|
||||
+ import { todoListCreatePermission } from '@internal/plugin-todo-list-common';
|
||||
|
||||
...
|
||||
|
||||
function AddTodo({ onAdd }: { onAdd: (title: string) => any }) {
|
||||
const title = useRef('');
|
||||
+ const { loading: loadingPermission, allowed: canAddTodo } = usePermission({ permission: todoListCreatePermission });
|
||||
|
||||
return (
|
||||
<>
|
||||
<Typography variant="body1">Add todo</Typography>
|
||||
<Box
|
||||
component="span"
|
||||
alignItems="flex-end"
|
||||
display="flex"
|
||||
flexDirection="row"
|
||||
>
|
||||
<TextField
|
||||
placeholder="Write something here..."
|
||||
onChange={e => (title.current = e.target.value)}
|
||||
/>
|
||||
- <Button variant="contained" onClick={handleAdd}>
|
||||
- Add
|
||||
- </Button>
|
||||
+ {!loadingPermission && (
|
||||
+ <Button disabled={!canAddTodo} variant="contained" onClick={handleAdd}>
|
||||
+ Add
|
||||
+ </Button>
|
||||
+ )}
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
...
|
||||
```
|
||||
|
||||
Here we are using the [`usePermission` hook](https://backstage.io/docs/reference/plugin-permission-react.usepermission) to communicate with the permission policy and receive a decision on whether this user is authorized to create a todo list item.
|
||||
|
||||
It's really that simple! Let's change our policy to test the disabled button:
|
||||
|
||||
```diff
|
||||
// packages/backend/src/plugins/permission.ts
|
||||
|
||||
...
|
||||
|
||||
if (isPermission(request.permission, todoListCreatePermission)) {
|
||||
return {
|
||||
- result: AuthorizeResult.ALLOW,
|
||||
+ result: AuthorizeResult.DENY,
|
||||
};
|
||||
}
|
||||
|
||||
...
|
||||
```
|
||||
|
||||
And now you should see that you are not able to create a todo item from the frontend!
|
||||
|
||||
## Using `RequirePermission`
|
||||
|
||||
Providing a disabled state can be a helpful signal to users, but there may be cases where hiding the element is preferred. For such cases, you can use the provided [`RequirePermission` component](https://backstage.io/docs/reference/plugin-permission-react.requirepermission):
|
||||
|
||||
```diff
|
||||
// plugins/todo-list/src/components/TodoListPage/TodoListPage.tsx
|
||||
|
||||
...
|
||||
|
||||
import {
|
||||
alertApiRef,
|
||||
discoveryApiRef,
|
||||
fetchApiRef,
|
||||
useApi,
|
||||
} from '@backstage/core-plugin-api';
|
||||
- import { usePermission } from '@backstage/plugin-permission-react';
|
||||
+ import { RequirePermission } from '@backstage/plugin-permission-react';
|
||||
import { todoListCreatePermission } from '@internal/plugin-todo-list-common';
|
||||
|
||||
...
|
||||
|
||||
export const TodoListPage = () => {
|
||||
|
||||
...
|
||||
|
||||
<Grid container spacing={3} direction="column">
|
||||
- <Grid item>
|
||||
- <AddTodo onAdd={handleAdd} />
|
||||
- </Grid>
|
||||
+ <RequirePermission permission={todoListCreatePermission}>
|
||||
+ <Grid item>
|
||||
+ <AddTodo onAdd={handleAdd} />
|
||||
+ </Grid>
|
||||
+ </RequirePermission>
|
||||
<Grid item>
|
||||
<TodoList key={key} onEdit={setEdit} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
...
|
||||
|
||||
|
||||
function AddTodo({ onAdd }: { onAdd: (title: string) => any }) {
|
||||
const title = useRef('');
|
||||
- const { loading: loadingPermission, allowed: canAddTodo } = usePermission({ permission: todoListCreatePermission });
|
||||
|
||||
return (
|
||||
<>
|
||||
<Typography variant="body1">Add todo</Typography>
|
||||
<Box
|
||||
component="span"
|
||||
alignItems="flex-end"
|
||||
display="flex"
|
||||
flexDirection="row"
|
||||
>
|
||||
<TextField
|
||||
placeholder="Write something here..."
|
||||
onChange={e => (title.current = e.target.value)}
|
||||
/>
|
||||
- {!loadingPermission && (
|
||||
- <Button disabled={!canAddTodo} variant="contained" onClick={handleAdd}>
|
||||
- Add
|
||||
- </Button>
|
||||
- )}
|
||||
+ <Button variant="contained" onClick={handleAdd}>
|
||||
+ Add
|
||||
+ </Button>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
...
|
||||
```
|
||||
|
||||
Now you should find that the component for adding a todo list item does not render at all. Success!
|
||||
Reference in New Issue
Block a user