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:
Joon Park
2022-09-22 14:06:23 +01:00
committed by GitHub
parent 310e1b75bd
commit 6c027cdab2
8 changed files with 244 additions and 46 deletions
@@ -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!
+2 -1
View File
@@ -295,7 +295,8 @@
"permissions/plugin-authors/01-setup",
"permissions/plugin-authors/02-adding-a-basic-permission-check",
"permissions/plugin-authors/03-adding-a-resource-permission-check",
"permissions/plugin-authors/04-authorizing-access-to-paginated-data"
"permissions/plugin-authors/04-authorizing-access-to-paginated-data",
"permissions/plugin-authors/05-frontend-authorization"
]
}
],
@@ -8,5 +8,8 @@ import { BasicPermission } from '@backstage/plugin-permission-common';
// @public
export const tempExamplePermission: BasicPermission;
// @public
export const todoListPermissions: BasicPermission[];
// (No @packageDocumentation comment for this package)
```
@@ -25,3 +25,10 @@ export const tempExamplePermission = createPermission({
name: 'temp.example.noop',
attributes: {},
});
/**
* List of all todo list permissions.
*
* @public
*/
export const todoListPermissions = [tempExamplePermission];
@@ -45,17 +45,16 @@ export const TodoListPage = () => {
const discoveryApi = useApi(discoveryApiRef);
const { fetch } = useApi(fetchApiRef);
const alertApi = useApi(alertApiRef);
const title = useRef('');
const [key, refetchTodos] = useReducer(i => i + 1, 0);
const [editElement, setEdit] = useState<Todo | undefined>();
const handleAdd = async () => {
const handleAdd = async (title: string) => {
try {
const response = await fetch(
`${await discoveryApi.getBaseUrl('todolist')}/todos`,
{
method: 'POST',
body: JSON.stringify({ title: title.current }),
body: JSON.stringify({ title }),
headers: {
'Content-Type': 'application/json',
},
@@ -117,21 +116,7 @@ export const TodoListPage = () => {
</ContentHeader>
<Grid container spacing={3} direction="column">
<Grid item>
<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>
</Box>
<AddTodo onAdd={handleAdd} />
</Grid>
<Grid item>
<TodoList key={key} onEdit={setEdit} />
@@ -149,6 +134,30 @@ export const TodoListPage = () => {
);
};
function AddTodo({ onAdd }: { onAdd: (title: string) => any }) {
const title = useRef('');
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={() => onAdd(title.current)}>
Add
</Button>
</Box>
</>
);
}
function EditModal({
todo,
onCancel,