diff --git a/docs/permissions/plugin-authors/02-adding-a-basic-permission-check.md b/docs/permissions/plugin-authors/02-adding-a-basic-permission-check.md index c73fe800b4..124605cb66 100644 --- a/docs/permissions/plugin-authors/02-adding-a-basic-permission-check.md +++ b/docs/permissions/plugin-authors/02-adding-a-basic-permission-check.md @@ -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 { @@ -143,7 +146,7 @@ In order to test the logic above, the integrators of your backstage instance nee + request: PolicyQuery, + user?: BackstageIdentityResponse, + ): Promise { -+ 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, diff --git a/docs/permissions/plugin-authors/03-adding-a-resource-permission-check.md b/docs/permissions/plugin-authors/03-adding-a-resource-permission-check.md index a76518b202..5bace9ad7b 100644 --- a/docs/permissions/plugin-authors/03-adding-a-resource-permission-check.md +++ b/docs/permissions/plugin-authors/03-adding-a-resource-permission-check.md @@ -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), diff --git a/docs/permissions/plugin-authors/04-authorizing-access-to-paginated-data.md b/docs/permissions/plugin-authors/04-authorizing-access-to-paginated-data.md index 582553832b..c813ba594c 100644 --- a/docs/permissions/plugin-authors/04-authorizing-access-to-paginated-data.md +++ b/docs/permissions/plugin-authors/04-authorizing-access-to-paginated-data.md @@ -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, diff --git a/docs/permissions/plugin-authors/05-frontend-authorization.md b/docs/permissions/plugin-authors/05-frontend-authorization.md new file mode 100644 index 0000000000..dc4df7839c --- /dev/null +++ b/docs/permissions/plugin-authors/05-frontend-authorization.md @@ -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 ( + <> + Add todo + + (title.current = e.target.value)} + /> +- ++ {!loadingPermission && ( ++ ++ )} + + + ); + } + +... +``` + +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 = () => { + +... + + +- +- +- ++ ++ ++ ++ ++ + + + + + +... + + + function AddTodo({ onAdd }: { onAdd: (title: string) => any }) { + const title = useRef(''); +- const { loading: loadingPermission, allowed: canAddTodo } = usePermission({ permission: todoListCreatePermission }); + + return ( + <> + Add todo + + (title.current = e.target.value)} + /> +- {!loadingPermission && ( +- +- )} ++ + + + ); + } + +... +``` + +Now you should find that the component for adding a todo list item does not render at all. Success! diff --git a/microsite/sidebars.json b/microsite/sidebars.json index 8e77594376..41e54ccb29 100644 --- a/microsite/sidebars.json +++ b/microsite/sidebars.json @@ -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" ] } ], diff --git a/plugins/example-todo-list-common/api-report.md b/plugins/example-todo-list-common/api-report.md index 15ffb7b139..1e51e560c8 100644 --- a/plugins/example-todo-list-common/api-report.md +++ b/plugins/example-todo-list-common/api-report.md @@ -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) ``` diff --git a/plugins/example-todo-list-common/src/permissions.ts b/plugins/example-todo-list-common/src/permissions.ts index 439bf5e8b9..ea0b097376 100644 --- a/plugins/example-todo-list-common/src/permissions.ts +++ b/plugins/example-todo-list-common/src/permissions.ts @@ -25,3 +25,10 @@ export const tempExamplePermission = createPermission({ name: 'temp.example.noop', attributes: {}, }); + +/** + * List of all todo list permissions. + * + * @public + */ +export const todoListPermissions = [tempExamplePermission]; diff --git a/plugins/example-todo-list/src/components/TodoListPage/TodoListPage.tsx b/plugins/example-todo-list/src/components/TodoListPage/TodoListPage.tsx index 7ebb517532..6071502fdb 100644 --- a/plugins/example-todo-list/src/components/TodoListPage/TodoListPage.tsx +++ b/plugins/example-todo-list/src/components/TodoListPage/TodoListPage.tsx @@ -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(); - 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 = () => { - Add todo - - (title.current = e.target.value)} - /> - - + @@ -149,6 +134,30 @@ export const TodoListPage = () => { ); }; +function AddTodo({ onAdd }: { onAdd: (title: string) => any }) { + const title = useRef(''); + + return ( + <> + Add todo + + (title.current = e.target.value)} + /> + + + + ); +} + function EditModal({ todo, onCancel,