Merge pull request #11017 from backstage/permission-docs

Permission framework documentation
This commit is contained in:
Vincenzo Scamporlino
2022-05-02 17:29:07 +02:00
committed by GitHub
44 changed files with 2213 additions and 0 deletions
+3
View File
@@ -218,6 +218,8 @@ performant
Performant
periskop
Periskop
permissioned
permissioning
plantuml
Platformize
Podman
@@ -309,6 +311,7 @@ Templaters
theia
thumbsup
todo
todos
tolerations
Tolerations
toolchain
Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

+25
View File
@@ -0,0 +1,25 @@
---
id: concepts
title: Concepts
description: A list of important permission framework concepts
---
### Permission
Any action that a user performs within Backstage may be represented as a permission. More complex actions, like executing a software template, may require authorization for multiple permissions throughout the flow. Permissions are identified by a unique name and optionally include a set of attributes that describe the corresponding action. Plugins are responsible for defining and exposing the permissions they enforce.
### Policy
User permissions are authorized by a central, user-defined permission policy. At a high level, a policy is a function that receives a Backstage user and permission, and returns a decision to allow or deny. Policies are expressed as code, which decouples the framework from any particular authorization model, like role-based access control (RBAC) or attribute-based access control (ABAC).
### Policy decision versus enforcement
Two important responsibilities of any authorization system are to decide if a user can do something, and to enforce that decision. In the Backstage permission framework, policies are responsible for decisions and plugins (typically backends) are responsible for enforcing them.
### Resources and rules
In many cases, a permission represents a user's interaction with another object. This object likely has information that policy authors can use to define more granular access. The permission framework introduces two abstractions to account for this: resources and rules. Resources represent the objects that users interact with. Rules are predicate-based controls that tap into a resource's data. For example, the catalog plugin defines a resource for catalog entities and a rule to check if an entity has a given annotation.
### Conditional decisions
Rules need additional data before they can be used in a decision. For example, the catalog plugin's "has annotation" rule needs to know what annotation to look for on a given entity. Once a rule is bound to relevant information it forms a condition. Conditions are then used to return a conditional decision from a policy. Conditional decisions tell the permission framework to delegate evaluation to the plugin that owns the corresponding resource. Permission requests that result in a conditional decision are allowed if all of the provided conditions evaluate to be true. This conditional behavior avoids coupling between policies and resource schemas, and allows plugins to evaluate complex rules in an efficient way. For example, a plugin may convert a conditional decision to a database query instead of loading and filtering objects in memory.
+107
View File
@@ -0,0 +1,107 @@
---
id: custom-rules
title: Defining custom permission rules
description: How to define custom permission rules for existing resources
---
For some use cases, you may want to define custom [rules](./concepts.md#resources-and-rules) in addition to the ones provided by a plugin. In the [previous section](./writing-a-policy.md) we used the `isEntityOwner` rule to control access for catalog entities. Let's extend this policy with a custom rule that checks what [system](https://backstage.io/docs/features/software-catalog/system-model#system) an entity is part of.
## Define a custom rule
Plugins should export a rule factory that provides type-safety that ensures compatibility with the plugin's backend. The catalog plugin exports `createCatalogPermissionRule` from `@backstage/plugin-catalog-backend/alpha` for this purpose. Note: the `/alpha` path segment is temporary until this API is marked as stable. For this example, we'll define the rule in `packages/backend/src/plugins/permission.ts`, but you can put it anywhere that's accessible by your `backend` package.
```typescript
import type { Entity } from '@backstage/plugin-catalog-model';
import { createCatalogPermissionRule } from '@backstage/plugin-catalog-backend';
import { createConditionFactory } from '@backstage/plugin-permission-node';
export const isInSystemRule = createCatalogPermissionRule({
name: 'IS_IN_SYSTEM',
description: 'Checks if an entity is part of the system provided',
resourceType: 'catalog-entity',
apply: (resource: Entity, systemRef: string) => {
if (!resource.relations) {
return false;
}
return resource.relations
.filter(relation => relation.type === 'partOf')
.some(relation => relation.targetRef === systemRef);
},
toQuery: (systemRef: string) => ({
key: 'relations.partOf',
value: systemRef,
}),
});
const isInSystem = createConditionFactory(isInSystemRule);
```
For a more detailed explanation on defining rules, refer to the [documentation for plugin authors](./plugin-authors/03-adding-a-resource-permission-check.md#adding-support-for-conditional-decisions).
## Provide the rule during plugin setup
Now that we have a custom rule defined, we need provide it to the catalog plugin. This step is important because the catalog plugin will use the rule's `toQuery` and `apply` methods while evaluating conditional authorize results. There's no guarantee that the catalog and permission backends are running on the same server, so we must explicitly link the rule to ensure that it's available at runtime.
The api for providing custom rules may differ between plugins, but there should typically be some integration point during the creation of the backend router. For the catalog, this integration point is exposed via `CatalogBuilder.addPermissionRules`.
```typescript
// packages/backend/src/plugins/catalog.ts
import { isInSystemRule } from './permission';
...
export default async function createPlugin(
env: PluginEnvironment,
): Promise<Router> {
const builder = await CatalogBuilder.create(env);
builder.addPermissionRules(isInSystem);
...
return router;
}
```
The new rule is now ready for use in a permission policy!
## Use the rule in a policy
Let's bring this all together by extending the example policy from the previous section.
```diff
// packages/backend/src/plugins/permission.ts
+ import { isInSystem } from './catalog';
...
class TestPermissionPolicy implements PermissionPolicy {
async handle(
request: PolicyQuery,
user?: BackstageIdentityResponse,
): Promise<PolicyDecision> {
if (isResourcePermission(request.permission, 'catalog-entity')) {
return createCatalogConditionalDecision(
request.permission,
- catalogConditions.isEntityOwner(
- user?.identity.ownershipEntityRefs ?? [],
- ),
+ {
+ anyOf: [
+ catalogConditions.isEntityOwner(
+ user?.identity.ownershipEntityRefs ?? []
+ ),
+ isInSystem('interviewing')
+ ]
+ }
);
}
return { result: AuthorizeResult.ALLOW };
}
```
The updated policy will allow catalog entity resource permissions if any of the following are true:
- User owns the target entity
- Target entity is part of the `'interviewing'` system
+157
View File
@@ -0,0 +1,157 @@
---
id: getting-started
title: Getting Started
description: How to get started with the permission framework as an integrator
---
If you prefer to watch a video instead, you can start with this video introduction:
<iframe width="560" height="315" src="https://www.youtube.com/embed/EQr9tFClgG0" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
> Note: This video was recorded in the January 2022 Contributors Session using `@backstage/create-app@0.4.14`. Some aspects of the demo may have changed in later releases.
Backstage integrators control permissions by writing a policy. In general terms, a policy is simply an async function which receives a request to authorize a specific action for a user and (optional) resource, and returns a decision on whether to authorize that permission. Integrators can implement their own policies from scratch, or adopt reusable policies written by others.
## Prerequisites
The permissions framework depends on a few other Backstage systems, which must be set up before we can dive into writing a policy.
### Upgrade to the latest version of Backstage
The permissions framework itself is new to Backstage and still evolving quickly. To ensure your version of Backstage has all the latest permission-related functionality, its important to upgrade to the latest version. The [Backstage upgrade helper](https://backstage.github.io/upgrade-helper/) is a great tool to help ensure that youve made all the necessary changes during the upgrade!
### Enable backend-to-backend authentication
Backend-to-backend authentication allows Backstage backend code to verify that a given request originates from elsewhere in the Backstage backend. This is useful for tasks like collation of catalog entities in the search index. This type of request shouldnt be permissioned, so its important to configure this feature before trying to use the permissions framework.
To set up backend-to-backend authentication, follow the [backend-to-backend authentication docs](../tutorials/backend-to-backend-auth.md).
### Supply an identity resolver to populate group membership on sign in
**Note**: If you are working off of an existing Backstage instance, you likely already have some form of an identity resolver set up.
Like many other parts of Backstage, the permissions framework relies on information about group membership. This simplifies authoring policies through the use of groups, rather than requiring each user to be listed in the configuration. Group membership is also often useful for conditional permissions, for example allowing permissions to act on an entity to be granted when a user is a member of a group that owns that entity.
[The IdentityResolver docs](../auth/identity-resolver.md) describe the process for resolving group membership on sign in.
## Optionally add cookie-based authentication
Asset requests initiated by the browser will not include a token in the `Authorization` header. If these requests check authorization through the permission framework, as done in plugins like TechDocs, then you'll need to set up cookie-based authentication. Refer to the ["Authenticate API requests"](https://github.com/backstage/backstage/blob/master/contrib/docs/tutorials/authenticate-api-requests.md) tutorial for a demonstration on how to implement this behavior.
## Integrating the permission framework with your Backstage instance
### 1. Set up the permission backend
The permissions framework uses a new `permission-backend` plugin to accept authorization requests from other plugins across your Backstage instance. The Backstage backend does not include this permission backend by default, so you will need to add it:
1. Add `@backstage/plugin-permission-backend` as a dependency of your Backstage backend:
```bash
$ yarn workspace backend add @backstage/plugin-permission-backend
```
2. Add the following to a new file, `packages/backend/src/plugins/permission.ts`. This adds the permission-backend router, and configures it with a policy which allows everything.
```typescript
import { IdentityClient } from '@backstage/plugin-auth-node';
import { createRouter } from '@backstage/plugin-permission-backend';
import {
AuthorizeResult,
PolicyDecision,
} from '@backstage/plugin-permission-common';
import { PermissionPolicy } from '@backstage/plugin-permission-node';
import { Router } from 'express';
import { PluginEnvironment } from '../types';
class TestPermissionPolicy implements PermissionPolicy {
async handle(): Promise<PolicyDecision> {
return { result: AuthorizeResult.ALLOW };
}
}
export default async function createPlugin(
env: PluginEnvironment,
): Promise<Router> {
return await createRouter({
config: env.config,
logger: env.logger,
discovery: env.discovery,
policy: new TestPermissionPolicy(),
identity: IdentityClient.create({
discovery: env.discovery,
issuer: await env.discovery.getExternalBaseUrl('auth'),
}),
});
}
```
3. Wire up the permission policy in `packages/backend/src/index.ts`. [The index in the example backend](https://github.com/backstage/backstage/blob/master/packages/backend/src/index.ts) shows how to do this. Youll need to import the module from the previous step, create a plugin environment, and add the router to the express app:
```diff
import proxy from './plugins/proxy';
import techdocs from './plugins/techdocs';
import search from './plugins/search';
+ import permission from './plugins/permission';
...
const techdocsEnv = useHotMemoize(module, () => createEnv('techdocs'));
const searchEnv = useHotMemoize(module, () => createEnv('search'));
const appEnv = useHotMemoize(module, () => createEnv('app'));
+ const permissionEnv = useHotMemoize(module, () => createEnv('permission'));
...
apiRouter.use('/techdocs', await techdocs(techdocsEnv));
apiRouter.use('/proxy', await proxy(proxyEnv));
apiRouter.use('/search', await search(searchEnv));
+ apiRouter.use('/permission', await permission(permissionEnv));
```
### 2. Enable and test the permissions system
Now that the permission backend is running, its time to enable the permissions framework and make sure its working properly.
1. Set the property `permission.enabled` to `true` in `app-config.yaml`.
```yaml
permission:
enabled: true
```
2. Update the PermissionPolicy in `packages/backend/src/plugins/permission.ts` to disable a permission thats easy for us to test. This policy rejects any attempt to delete a catalog entity:
```diff
import { IdentityClient } from '@backstage/plugin-auth-node';
import { createRouter } from '@backstage/plugin-permission-backend';
import {
AuthorizeResult,
PolicyDecision,
} from '@backstage/plugin-permission-common';
- import { PermissionPolicy } from '@backstage/plugin-permission-node';
+ import {
+ PermissionPolicy,
+ PolicyQuery,
+ } from '@backstage/plugin-permission-node';
import { Router } from 'express';
import { PluginEnvironment } from '../types';
class TestPermissionPolicy implements PermissionPolicy {
- async handle(): Promise<PolicyDecision> {
+ async handle(request: PolicyQuery): Promise<PolicyDecision> {
+ if (request.permission.name === 'catalog.entity.delete') {
+ return {
+ result: AuthorizeResult.DENY,
+ };
+ }
+
return { result: AuthorizeResult.ALLOW };
}
}
```
3. Now that youve made this change, you should find that the unregister entity menu option on the catalog entity page is disabled.
![Entity detail page showing disabled unregister entity context menu entry](../assets/permission/disabled-unregister-entity.png)
Now that the framework is fully configured, you can craft a permission policy that works best for your organization by utilizing a provided authorization method or by [writing your own policy](./writing-a-policy.md)!
+41
View File
@@ -0,0 +1,41 @@
---
id: overview
title: Overview
description: A high level overview of the Backstage permission framework
---
[The previous section](../auth/index.md) covered the various _authentication_ methods of Backstage, but Backstage can also _authorize_ specific data, APIs, or interface actions - meaning that Backstage has the ability to enforce rules about what type of access is allowed for a given user of a system.
By default, Backstage endpoints are not protected, and all actions are available to anyone. However, configuring which users can access which resources and actions is a common need for many organizations. The permission framework allows integrators to achieve this through the use of granular permissioning for those resources and actions.
The permission framework was designed with a few key properties in mind:
- Flexibility: the framework allows integrators to configure many different authorization methods. This could include implementations like role-based access control (RBAC), attribute-based access control (ABAC), bespoke logic expressed in code, or integrations with external authorization providers.
- Usability: the permission framework allows integrators to focus on configuring what they care about (the permission policy) by providing all of its moving parts out of the box. It also allows plugin authors to integrate support for permissions in their plugins without having to make any changes in Backstage core.
## How does it work?
- **Plugin authors** can add permission support in their plugins by declaring which resources from their plugins can be placed behind authorization and what types of actions users may take upon those resources.
- **Contributors** can implement and share authorization methods (such as RBAC).
- **Integrators** can author or configure policies that define which users can take certain actions upon which resources.
![](../assets/permission/permission-framework-overview.drawio.svg)
1. The user triggers a request to perform some action. The request specifies the authorization details using the permission specified by the plugin (in this case, a resource read action).
- The action may be triggered by a user interacting with the UI, but it can also be a direct request to the plugin's backend.
2. The plugin backend sends a request to the permission framework's backend with the authorization details.
3. The permission framework's backend delegates the authorization decision to the permission policy, which is specified by the integrator using code, a provided authorization method (such as RBAC), or integrations with external authorization providers.
4. An authorization decision is sent to the plugin from the permission backend.
## How do I get started?
See the "[getting started](./getting-started.md)" permission documentation for Backstage integrators.
If you are a plugin author, see the permission [documentation for plugin authors](plugin-authors/01-setup.md) on how to integrate permissions into your plugin.
+120
View File
@@ -0,0 +1,120 @@
---
id: 01-setup
title: 1. Tutorial setup
description: How to get started with the permission framework as a plugin author
---
The following tutorial is designed to help plugin authors add support for permissions to their plugins. We'll add support for permissions to example `todo-list` and `todo-list-backend` plugins, but the process should be similar for other plugins!
The rest of this page is focused on adding the `todo-list` and `todo-list-backend` plugins to your Backstage instance. If you want to add support for permissions to your own plugin instead, feel free to skip to the [next section](./02-adding-a-basic-permission-check.md).
## Setup for the Tutorial
We will use a "Todo list" feature, composed of the `todo-list` and `todo-list-backend` plugins, as well as their dependency, `todo-list-common`.
The source code is available here:
- [todo-list](https://github.com/backstage/backstage/blob/master/plugins/example-todo-list)
- [todo-list-backend](https://github.com/backstage/backstage/blob/master/plugins/example-todo-list-backend)
- [todo-list-common](https://github.com/backstage/backstage/blob/master/plugins/example-todo-list-common)
1. Copy-paste the three folders into the plugins folder of your backstage application repository (removing the `example-` prefix from each folder) or run the following script from the root of your backstage application:
```bash
$ cd $(mktemp -d)
git clone --depth 1 --quiet --no-checkout --filter=blob:none https://github.com/backstage/backstage.git .
git checkout master -- plugins/example-todo-list/
git checkout master -- plugins/example-todo-list-backend/
git checkout master -- plugins/example-todo-list-common/
for file in plugins/*; do mv "$file" "$OLDPWD/${file/example-todo/todo}"; done
cd -
```
The `plugins` directory of your project should now include `todo-list`, `todo-list-backend`, and `todo-list-common`.
**Important**: if you are on **Windows**, make sure you have WSL and git installed on your machine before executing the script above.
2. Add these packages as dependencies for your Backstage app:
```
$ yarn workspace backend add @internal/plugin-todo-list-backend@^1.0.0 @internal/plugin-todo-list-common@^1.0.0
$ yarn workspace app add @internal/plugin-todo-list@^1.0.0
```
3. Include the backend and frontend plugin in your application:
Create a new `packages/backend/src/plugins/todolist.ts` with the following content:
```javascript
import { IdentityClient } from '@backstage/plugin-auth-node';
import { createRouter } from '@internal/plugin-todo-list-backend';
import { Router } from 'express';
import { PluginEnvironment } from '../types';
export default async function createPlugin({
logger,
discovery,
}: PluginEnvironment): Promise<Router> {
return await createRouter({
logger,
identity: IdentityClient.create({
discovery,
issuer: await discovery.getExternalBaseUrl('auth'),
}),
});
}
```
Apply the following changes to `packages/backend/src/index.ts`:
```diff
import techdocs from './plugins/techdocs';
+ import todoList from './plugins/todolist';
import search from './plugins/search';
...
const searchEnv = useHotMemoize(module, () => createEnv('search'));
const appEnv = useHotMemoize(module, () => createEnv('app'));
+ const todoListEnv = useHotMemoize(module, () => createEnv('todolist'));
...
apiRouter.use('/proxy', await proxy(proxyEnv));
apiRouter.use('/search', await search(searchEnv));
apiRouter.use('/permission', await permission(permissionEnv));
+ apiRouter.use('/todolist', await todoList(todoListEnv));
// Add backends ABOVE this line; this 404 handler is the catch-all fallback
apiRouter.use(notFoundHandler());
```
Apply the following changes to `packages/app/src/App.tsx`:
```diff
+ import { TodoListPage } from '@internal/plugin-todo-list';
...
<Route path="/search" element={<SearchPage />}>
{searchPage}
</Route>
<Route path="/settings" element={<UserSettingsPage />} />
+ <Route path="/todo-list" element={<TodoListPage />} />
</FlatRoutes>
```
Now if you start your application you should be able to reach the `/todo-list` page:
![Todo List plugin page](../../assets/permission/permission-todo-list-page.png)
---
## Integrate the new plugin
If you play with the UI, you will notice that it is possible to perform a few actions:
- create a new todo item (`POST /todos`)
- view todo items (`GET /todos`)
- edit an existing todo item (`PUT /todos`)
Let's try to bring authorization on top of each one of them.
@@ -0,0 +1,169 @@
---
id: 02-adding-a-basic-permission-check
title: 2. Adding a basic permission check
description: Explains how to add a basic permission check to a Backstage plugin
---
If the outcome of a permission check doesn't need to change for different [resources](../concepts.md#resources-and-rules), you can use a _basic permission check_. For this kind of check, we simply need to define a [permission](../concepts.md#resources-and-rules), and call `authorize` with it.
For this tutorial, we'll use a basic permission check to authorize the `create` endpoint in our todo-backend. This will allow Backstage integrators to control whether each of their users is authorized to create todos by adjusting their [permission policy](../concepts.md#policy).
We'll start by creating a new permission, and then we'll use the permission api to call `authorize` with it during todo creation.
## Creating a new permission
Let's navigate to the file `plugins/todo-list-common/src/permissions.ts` and add our first permission:
```diff
import { createPermission } from '@backstage/plugin-permission-common';
- export const tempExamplePermission = createPermission({
- name: 'temp.example.noop',
- attributes: {},
+ export const todoListCreate = createPermission({
+ name: 'todo.list.create',
+ attributes: { action: 'create' },
});
```
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.
## Authorizing using the new permission
Install the following module:
```
$ yarn workspace @internal/plugin-todo-list-backend \
add @backstage/plugin-permission-common @internal/plugin-todo-list-common
```
Edit `plugins/todo-list-backend/src/service/router.ts`:
```diff
...
- 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';
...
export interface RouterOptions {
logger: Logger;
identity: IdentityClient;
+ permissions: PermissionEvaluator;
}
export async function createRouter(
options: RouterOptions,
): Promise<express.Router> {
- const { logger, identity } = options;
+ const { logger, identity, permissions } = options;
...
router.post('/todos', async (req, res) => {
const token = IdentityClient.getBearerToken(req.header('authorization'));
let author: string | undefined = undefined;
const user = token ? await identity.authenticate(token) : undefined;
author = user?.identity.userEntityRef;
+ const decision = (
+ await permissions.authorize([{ permission: todoListCreate }], {
+ token,
+ })
+ )[0];
+ if (decision.result === AuthorizeResult.DENY) {
+ throw new NotAllowedError('Unauthorized');
+ }
if (!isTodoCreateRequest(req.body)) {
throw new InputError('Invalid payload');
}
const todo = add({ title: req.body.title, author });
res.json(todo);
});
```
Pass the `permissions` object to the plugin in `packages/backend/src/plugins/todolist.ts`:
```diff
import { IdentityClient } from '@backstage/plugin-auth-backend';
import { createRouter } from '@internal/plugin-todo-list-backend';
import { Router } from 'express';
import { PluginEnvironment } from '../types';
export default async function createPlugin({
logger,
discovery,
+ permissions,
}: PluginEnvironment): Promise<Router> {
return await createRouter({
logger,
identity: new IdentityClient({
discovery,
issuer: await discovery.getExternalBaseUrl('auth'),
}),
+ permissions,
});
}
```
That's it! Now your plugin is fully configured. Let's try to test the logic by denying the permission.
## Test the authorized create endpoint
Before running this step, please make sure you followed the steps described in [Getting started](../getting-started.md) section.
In order to test the logic above, the integrators of your backstage instance need to change their permission policy to return `DENY` for our newly-created permission:
```diff
// packages/backend/src/plugins/permission.ts
- import { IdentityClient } from '@backstage/plugin-auth-node';
+ import {
+ BackstageIdentityResponse,
+ IdentityClient
+ } from '@backstage/plugin-auth-node';
import {
PermissionPolicy,
+ PolicyQuery,
} from '@backstage/plugin-permission-node';
+ import { isPermission } from '@backstage/plugin-permission-common';
+ import { todoListCreate } from '@internal/plugin-todo-list-common';
class TestPermissionPolicy implements PermissionPolicy {
- async handle(): Promise<PolicyDecision> {
+ async handle(
+ request: PolicyQuery,
+ user?: BackstageIdentityResponse,
+ ): Promise<PolicyDecision> {
+ if (isPermission(request.permission, todoListCreate)) {
+ return {
+ result: AuthorizeResult.DENY,
+ };
+ }
+
return {
result: AuthorizeResult.ALLOW,
};
}
```
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)) {
return {
- result: AuthorizeResult.DENY,
+ result: AuthorizeResult.ALLOW,
};
}
```
@@ -0,0 +1,229 @@
---
id: 03-adding-a-resource-permission-check
title: 3. Adding a resource permission check
description: Explains how to add a resource permission check to a Backstage plugin
---
When performing updates (or other operations) on specific [resources](../concepts.md#resources-and-rules), the permissions framework allows for the decision to be based on characteristics of the resource itself. This means that it's possible to write policies that (for example) allow the operation for users that own a resource, and deny the operation otherwise.
## Creating the update permission
Let's add a new permission to the file `plugins/todo-list-common/src/permissions.ts` from [the previous section](./02-adding-a-basic-permission-check.md).
```diff
import { createPermission } from '@backstage/plugin-permission-common';
+ export const TODO_LIST_RESOURCE_TYPE = 'todo-item';
+
export const todoListCreate = createPermission({
name: 'todo.list.create',
attributes: { action: 'create' },
});
+
+ export const todoListUpdate = createPermission({
+ name: 'todo.list.update',
+ attributes: { action: 'update' },
+ resourceType: TODO_LIST_RESOURCE_TYPE,
+ });
```
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.
## 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';
...
router.put('/todos', async (req, res) => {
+ const token = getBearerTokenFromAuthorizationHeader(
+ req.header('authorization'),
+ );
if (!isTodoUpdateRequest(req.body)) {
throw new InputError('Invalid payload');
}
+ const decision = (
+ await permissions.authorize(
+ [{ permission: todoListUpdate, resourceRef: req.body.id }],
+ {
+ token,
+ },
+ )
+ )[0];
+
+ if (decision.result !== AuthorizeResult.ALLOW) {
+ throw new NotAllowedError('Unauthorized');
+ }
res.json(update(req.body));
});
```
**Important:** Notice that we are passing an extra `resourceRef` field, with the `id` of the todo item as the value.
This enables decisions based on characteristics of the resource, but it's important to note that policy authors will not have access to the resource ref inside of their permission policies. Instead, the policies will return conditional decisions, which we need to now support in our plugin.
## Adding support for conditional decisions
Install the missing module:
```
$ yarn workspace @internal/plugin-todo-list-backend add @backstage/plugin-permission-node
```
Create a new `plugins/todo-list-backend/src/service/rules.ts` file and append the following code:
```typescript
import { makeCreatePermissionRule } from '@backstage/plugin-permission-node';
import { TODO_LIST_RESOURCE_TYPE } from '@internal/plugin-todo-list-common';
import { Todo, TodoFilter } from './todos';
export const createTodoListPermissionRule = makeCreatePermissionRule<
Todo,
TodoFilter
>();
export const isOwner = createTodoListPermissionRule({
name: 'IS_OWNER',
description: 'Should allow only if the todo belongs to the user',
resourceType: TODO_LIST_RESOURCE_TYPE,
apply: (resource: Todo, userId: string) => {
return resource.author === userId;
},
toQuery: (userId: string) => {
return {
property: 'author',
values: [userId],
};
},
});
export const rules = { isOwner };
```
`makeCreatePermissionRule` is a helper used to ensure that rules created for this plugin use consistent types for the resource and query.
> Note: To support custom rules defined by Backstage integrators, you must export `createTodoListPermissionRule` from the backend package and provide some way for custom rules to be passed in before the backend starts, likely via `createRouter`.
We have created a new `isOwner` rule, which is going to be automatically used by the permission framework whenever a conditional response is returned in response to an authorized request with an attached `resourceRef`.
Specifically, the `apply` function is used to understand whether the passed resource should be authorized or not.
Let's skip the `toQuery` function for now, we'll come back to that in the next section.
Now, let's create the new endpoint by editing `plugins/todo-list-backend/src/service/router.ts`. This uses the `createPermissionIntegrationRouter` helper to add the APIs needed by the permission framework to your plugin. You'll need to supply:
- `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.
- `rules`: an array of all the permission rules you want to support in conditional decisions.
```diff
...
- 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 { rules } from './rules';
export async function createRouter(
options: RouterOptions,
): Promise<express.Router> {
const { logger, identity, permissions } = options;
+ const permissionIntegrationRouter = createPermissionIntegrationRouter({
+ getResources: async resourceRefs => {
+ return resourceRefs.map(getTodo);
+ },
+ resourceType: TODO_LIST_RESOURCE_TYPE,
+ rules: Object.values(rules),
+ });
const router = Router();
router.use(express.json());
+ router.use(permissionIntegrationRouter);
router.post('/todos', async (req, res) => {
```
## Provide utilities for policy authors
Now that we have a new resource type and a corresponding rule, we need to export some utilities for policy authors to reference them.
Create a new `plugins/todo-list-backend/src/conditionExports.ts` file and add the following code:
```typescript
import { TODO_LIST_RESOURCE_TYPE } from '@internal/plugin-todo-list-common';
import { createConditionExports } from '@backstage/plugin-permission-node';
import { permissionRules } from './service/rules';
const { conditions, createConditionalDecision } = createConditionExports({
pluginId: 'catalog',
resourceType: RESOURCE_TYPE_CATALOG_ENTITY,
rules: permissionRules,
});
export const todoListConditions = conditions;
export const createTodoListConditionalDecision = createConditionalDecision;
```
Make sure `todoListConditions` and `createTodoListConditionalDecision` are exported from the `todo-list-backend` package.
## Test the authorized update endpoint
Let's go back to the permission policy's handle function and try to authorize our new permission with an `isOwner` condition.
```diff
// packages/backend/src/plugins/permission.ts
import {
BackstageIdentityResponse,
IdentityClient
} from '@backstage/plugin-auth-node';
import {
PermissionPolicy,
PolicyQuery,
} from '@backstage/plugin-permission-node';
import { isPermission } from '@backstage/plugin-permission-common';
- import { todoListCreate } from '@internal/plugin-todo-list-common';
+ import {
+ todoListCreate,
+ todoListUpdate,
+ TODO_LIST_RESOURCE_TYPE,
+ } from '@internal/plugin-todo-list-common';
+ import {
+ todoListConditions,
+ createTodoListConditionalDecision,
+ } from '@internal/plugin-todo-list-backend';
...
if (isPermission(request.permission, todoListCreate)) {
return {
result: AuthorizeResult.ALLOW,
};
}
+ if (isPermission(request.permission, todoListUpdate)) {
+ return createTodoListConditionalDecision(
+ request.permission,
+ todoListConditions.isOwner(user?.identity.userEntityRef),
+ );
+ }
+
return {
result: AuthorizeResult.ALLOW,
};
```
For any incoming update requests, we now return a _Conditional Decision_. We are saying:
> Hey permission framework, I can't make a decision alone. Please go to the plugin with id `todolist` and ask it to apply these conditions.
To check that everything works as expected, you should now see an error in the UI whenever you try to edit an item that wasnt created by you. Success!
@@ -0,0 +1,149 @@
---
id: 04-authorizing-access-to-paginated-data
title: 4. Authorizing access to paginated data
description: Explains how to authorize access to paginated data in a Backstage plugin
---
Authorizing `GET /todos` is similar to the update endpoint, in that it should be possible to authorize access based on the characteristics of each resource. However, we'll need to authorize a list of resources for this endpoint.
One possible solution may leverage the batching functionality to authorize all of the todos, and then returning only the ones for which the decision was `ALLOW`:
```diff
router.get('/todos', async (req, res) => {
+ const token = IdentityClient.getBearerToken(req.header('authorization'));
- res.json(getAll())
+ const items = getAll();
+ const decisions = await permissions.authorize(
+ items.map(({ id }) => ({ permission: todosListRead, resourceRef: id })),
+ );
+ const filteredItems = decisions.filter(
+ decision => decision.result === AuthorizeResult.ALLOW,
+ );
+ res.json(filteredItems);
});
```
This approach will work for simple cases, but it has a downside: it forces us to retrieve all the elements upfront and authorize them one by one. This forces the plugin implementation to handle concerns like pagination, which is currently handled by the data source.
To avoid this situation, the permissions framework has support for filtering items in the data source itself. In this part of the tutorial, we'll describe the steps required to use that behavior.
> Note: in order to perform authorization filtering in this way, the data source must allow filters to be logically combined with AND, OR, and NOT operators. The conditional decisions returned by the permissions framework use a [nested object](https://backstage.io/docs/reference/plugin-permission-common.permissioncriteria) to combine conditions. If you're implementing a filter API from scratch, we recommend using the same shape for ease of interoperability. If not, you'll need to implement a function which transforms the nested object into your own format.
## Creating the read permission
Let's add another permission to the plugin.
```diff
// plugins/todo-list-backend/src/service/permissions.ts
import { createPermission } from '@backstage/plugin-permission-common';
export const TODO_LIST_RESOURCE_TYPE = 'todo-item';
export const todoListCreate = createPermission({
name: 'todo.list.create',
attributes: { action: 'create' },
});
export const todoListUpdate = createPermission({
name: 'todo.list.update',
attributes: { action: 'update' },
resourceType: TODO_LIST_RESOURCE_TYPE,
});
+
+ export const todosListRead = createPermission({
+ name: 'todos.list.read',
+ attributes: { action: 'read' },
+ resourceType: TODO_LIST_RESOURCE_TYPE,
+ });
```
## Using conditional policy decisions
So far we've only used the `PermissionEvaluator.authorize` method, which will evaluate conditional decisions before returning a result. In this step, we want to evaluate conditional decisions within our plugin, so we'll use `PermissionEvaluator.authorizeConditional` instead.
```diff
// plugins/todo-list-backend/src/service/router.ts
- import { createPermissionIntegrationRouter } from '@backstage/plugin-permission-node';
+ import {
+ createPermissionIntegrationRouter,
+ createConditionTransformer,
+ ConditionTransformer,
+ } from '@backstage/plugin-permission-node';
- import { add, getAll, getTodo, update } from './todos';
+ import { add, getAll, getTodo, TodoFilter, update } from './todos';
import {
todosListCreate,
todosListUpdate,
+ todosListRead,
TODO_LIST_RESOURCE_TYPE,
} from './permissions';
+ import { rules } from './rules';
+ const transformConditions: ConditionTransformer<TodoFilter> = createConditionTransformer(Object.values(rules));
- router.get('/todos', async (_req, res) => {
+ router.get('/todos', async (req, res) => {
+ const token = getBearerTokenFromAuthorizationHeader(
+ req.header('authorization'),
+ );
+
+ const decision = (
+ await permissions.authorizeConditional([{ permission: todosListRead }], {
+ token,
+ })
+ )[0];
+
+ if (decision.result === AuthorizeResult.DENY) {
+ throw new NotAllowedError('Unauthorized');
+ }
+
+ if (decision.result === AuthorizeResult.CONDITIONAL) {
+ const filter = transformConditions(decision.conditions);
+ res.json(getAll(filter));
+ } else {
+ res.json(getAll());
+ }
+ }
- res.json(getAll());
});
```
To make the process of handling conditional decisions easier, the permission framework provides a `createConditionTransformer` helper. This function accepts an array of permission rules, and returns a transformer function which converts the conditions to the format needed by the plugin using the `toQuery` method defined on each rule.
Since `TodoFilter` used in our plugin matches the structure of the conditions object, we can directly pass the output of our condition transformer. If the filters were structured differently, we'd need to transform it further before passing it to the api.
## Test the authorized read endpoint
Let's update our permission policy to return a conditional result whenever a `todosListRead` permission is received. In this case, we can reuse the decision returned for the `todosListCreate` permission.
```diff
// packages/backend/src/plugins/permission.ts
...
import {
todoListCreate,
todoListUpdate,
+ todoListRead,
TODO_LIST_RESOURCE_TYPE,
} from '@internal/plugin-todo-list-common';
...
- if (isPermission(request.permission, todoListUpdate)) {
+ if (
+ isPermission(request.permission, todoListUpdate) ||
+ isPermission(request.permission, todoListRead)
+ ) {
return createTodoListConditionalDecision(
request.permission,
todoListConditions.isOwner(user?.identity.userEntityRef),
);
}
```
Once the changes to the permission policy are saved, the UI should show only the todo items you've created.
+134
View File
@@ -0,0 +1,134 @@
---
id: writing-a-policy
title: Writing a permission policy
description: How to write your own permission policy as a Backstage integrator
---
In the [previous section](./getting-started.md), we were able to set up the permission framework and make a simple change to our `TestPermissionPolicy` to confirm that policy is indeed wired up correctly.
That policy looked like this:
```typescript
// packages/backend/src/plugins/permission.ts
class TestPermissionPolicy implements PermissionPolicy {
async handle(request: PolicyQuery): Promise<PolicyDecision> {
if (request.permission.name === 'catalog.entity.delete') {
return {
result: AuthorizeResult.DENY,
};
}
return { result: AuthorizeResult.ALLOW };
}
}
```
## What's in a policy?
Let's break this down a bit further. The request object of type [PolicyQuery](https://backstage.io/docs/reference/plugin-permission-node.policyquery) is a simple wrapper around [the Permission object](https://backstage.io/docs/reference/plugin-permission-common.permission). This permission object encapsulates information about the action that the user is attempting to perform (See [the Concepts page](./concepts.md) for more details).
In the policy above, we are checking to see if the provided action is a catalog entity delete action, which is the permission that the catalog plugin authors have created to represent the action of unregistering a catalog entity. If this is the case, we return a [Definitive Policy Decision](https://backstage.io/docs/reference/plugin-permission-common.definitivepolicydecision) of DENY. In all other cases, we return ALLOW (resulting in an allow-by-default behavior).
As we confirmed in the previous section, we know that this now prevents us from unregistering catalog components. Hooray! But you may notice that this prevents _anyone_ from unregistering a component, which is not a very realistic policy. Let's improve this policy by disabling the unregister action _unless you are the owner of this component_.
## Conditional decisions
Let's change the policy to the following:
```diff
- import { IdentityClient } from '@backstage/plugin-auth-node';
+ import {
+ BackstageIdentityResponse,
+ IdentityClient
+ } from '@backstage/plugin-auth-node';
import {
AuthorizeResult,
PolicyDecision,
+ isPermission,
} from '@backstage/plugin-permission-common';
+ import {
+ catalogConditions,
+ createCatalogConditionalDecision,
+ } from '@backstage/plugin-catalog-backend';
+ import {
+ catalogEntityDeletePermission,
+ } from '@backstage/plugin-catalog-common';
...
class TestPermissionPolicy implements PermissionPolicy {
- async handle(request: PolicyQuery): Promise<PolicyDecision> {
+ async handle(
+ request: PolicyQuery,
+ user?: BackstageIdentityResponse,
+ ): Promise<PolicyDecision> {
- if (request.permission.name === 'catalog.entity.delete') {
+ if (isPermission(request.permission, catalogEntityDeletePermission)) {
- return {
- result: AuthorizeResult.DENY,
- };
+ return createCatalogConditionalDecision(
+ request.permission,
+ catalogConditions.isEntityOwner(
+ user?.identity.ownershipEntityRefs ?? [],
+ ),
+ );
}
return { result: AuthorizeResult.ALLOW };
}
}
```
Let's walk through the new code that we just added.
Instead of returning an Definitive Policy Decision, we use factory methods to construct a [Conditional Policy Decision](https://backstage.io/docs/reference/plugin-permission-common.conditionalpolicydecision) (See the [Concepts page](./concepts.md) for more details). Since the policy doesn't have enough information to determine if `user` is the entity owner, this criteria is encapsulated within the conditional decision. However, `createCatalogConditionalDecision` will not compile unless `request.permission` is a catalog entity [`ResourcePermission`](https://backstage.io/docs/reference/plugin-permission-common.resourcepermission). This type constraint ensures that policies return conditional decisions that are compatible with the requested permission. To address this, we use [`isPermission`](https://backstage.io/docs/reference/plugin-permission-common.ispermission) to ["narrow"](https://www.typescriptlang.org/docs/handbook/2/narrowing.html) the type of `request.permission` to `ResourcePermission<'catalog-entity'>`. This matches the runtime behavior that was in place before, but you'll notice that the type of `request.permission` has changed within the scope of that `if` statement.
The `catalogConditions` object contains all of the rules defined by the catalog plugin. These rules can be combined to form a [`PermissionCriteria`](https://backstage.io/docs/reference/plugin-permission-common.permissioncriteria) object, but for this case we only need to use the `isEntityOwner` rule. This rule accepts a list of entity refs that represent User identity and Group membership used to determine ownership. The second argument to `PermissionPolicy#handle` provides us with a `BackstageIdentityResponse` object, from which we can grab the user's `ownershipEntityRefs`. We provide an empty array as a fallback since the user may be anonymous.
You should now be able to see in your Backstage app that the unregister entity button is enabled for entities that you own, but disabled for all other entities!
## Resource types
Now let's say we want to prevent all actions on catalog entities unless performed by the owner. One way to achieve this may be to simply update the `if` statement and check for each permission. If you choose to write your policy this way, it will certainly work! However, it may be difficult to maintain as the policy grows, and it may not be obvious if certain permissions are left out. We can author this same policy in a more scalable way by checking the resource type of the requested permission.
```diff
import {
AuthorizeResult,
PolicyDecision,
- isPermission,
+ isResourcePermission,
} from '@backstage/plugin-permission-common';
import {
catalogConditions,
createCatalogConditionalDecision,
} from '@backstage/plugin-catalog-backend';
- import {
- catalogEntityDeletePermission,
- } from '@backstage/plugin-catalog-common';
...
class TestPermissionPolicy implements PermissionPolicy {
async handle(
request: PolicyQuery,
user?: BackstageIdentityResponse,
): Promise<PolicyDecision> {
- if (isPermission(request.permission, catalogEntityDeletePermission)) {
+ if (isResourcePermission(request.permission, 'catalog-entity')) {
return createCatalogConditionalDecision(
request.permission,
catalogConditions.isEntityOwner(
user?.identity.ownershipEntityRefs ?? [],
),
);
}
return { result: AuthorizeResult.ALLOW };
}
```
In this example, we use [`isResourcePermission`](https://backstage.io/docs/reference/plugin-permission-common.isresourcepermission) to match all permissions with a resource type of `catalog-entity`. Just like `isPermission`, this helper will "narrow" the type of `request.permission` and enable the use of `createCatalogConditionalDecision`. In addition to the behavior you observed before, you should also see that catalog entities are no longer visible unless you are the owner - success!
_Note:_ Some catalog permissions do not have the `'catalog-entity'` resource type, such as [`catalogEntityCreatePermission`](https://github.com/backstage/backstage/blob/1e5e9fb9de9856a49e60fc70c38a4e4e94c69570/plugins/catalog-common/src/permissions.ts#L49). In those cases, a definitive decision is required because conditions can't be applied to an entity that does not exist yet.
+17
View File
@@ -257,6 +257,23 @@
"auth/troubleshooting",
"auth/glossary"
],
"Permissions": [
"permission/overview",
"permission/concepts",
"permission/getting-started",
"permission/writing-a-policy",
"permission/custom-rules",
{
"type": "subcategory",
"label": "Tutorial: using Permissions in your plugin",
"ids": [
"permission/plugin-authors/01-setup",
"permission/plugin-authors/02-adding-a-basic-permission-check",
"permission/plugin-authors/03-adding-a-resource-permission-check",
"permission/plugin-authors/04-authorizing-access-to-paginated-data"
]
}
],
"Deployment": [
"deployment/index",
"deployment/docker",
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint.backend')],
};
@@ -0,0 +1,3 @@
# todo-list-backend
This package provides a starting point to demonstrate how plugin authors can use the Backstage permission framework. Refer to the [documentation](https://backstage.io/docs/permission/plugin-authors/01-setup) to get started.
@@ -0,0 +1,22 @@
## API Report File for "@internal/plugin-todo-list-backend"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import express from 'express';
import { IdentityClient } from '@backstage/plugin-auth-node';
import { Logger } from 'winston';
// @public
export function createRouter(options: RouterOptions): Promise<express.Router>;
// @public
export interface RouterOptions {
// (undocumented)
identity: IdentityClient;
// (undocumented)
logger: Logger;
}
// (No @packageDocumentation comment for this package)
```
@@ -0,0 +1,45 @@
{
"name": "@internal/plugin-todo-list-backend",
"version": "1.0.0",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": true,
"publishConfig": {
"access": "public",
"main": "dist/index.cjs.js",
"types": "dist/index.d.ts"
},
"scripts": {
"start": "backstage-cli package start",
"build": "backstage-cli package build",
"lint": "backstage-cli package lint",
"test": "backstage-cli package test",
"prepack": "backstage-cli package prepack",
"postpack": "backstage-cli package postpack",
"clean": "backstage-cli package clean"
},
"dependencies": {
"@backstage/backend-common": "^0.13.3-next.0",
"@backstage/config": "^1.0.0",
"@backstage/errors": "^1.0.0",
"@backstage/plugin-auth-node": "^0.2.1-next.0",
"@types/express": "^4.17.6",
"cross-fetch": "^3.1.5",
"express": "^4.17.1",
"express-promise-router": "^4.1.0",
"uuid": "^8.3.2",
"winston": "^3.2.1",
"yn": "^4.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.17.1-next.0",
"@types/supertest": "^2.0.8",
"@types/uuid": "^8.0.0",
"msw": "^0.35.0",
"supertest": "^6.1.6"
},
"files": [
"dist"
]
}
@@ -0,0 +1,17 @@
/*
* Copyright 2020 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 './service/router';
@@ -0,0 +1,33 @@
/*
* Copyright 2020 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 { getRootLogger } from '@backstage/backend-common';
import yn from 'yn';
import { startStandaloneServer } from './service/standaloneServer';
const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7007;
const enableCors = yn(process.env.PLUGIN_CORS, { default: false });
const logger = getRootLogger();
startStandaloneServer({ port, enableCors, logger }).catch(err => {
logger.error(err);
process.exit(1);
});
process.on('SIGINT', () => {
logger.info('CTRL+C pressed; exiting.');
process.exit(0);
});
@@ -0,0 +1,47 @@
/*
* Copyright 2020 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 { getVoidLogger } from '@backstage/backend-common';
import { IdentityClient } from '@backstage/plugin-auth-node';
import express from 'express';
import request from 'supertest';
import { createRouter } from './router';
describe('createRouter', () => {
let app: express.Express;
beforeAll(async () => {
const router = await createRouter({
logger: getVoidLogger(),
identity: {} as IdentityClient,
});
app = express().use(router);
});
beforeEach(() => {
jest.resetAllMocks();
});
describe('GET /health', () => {
it('returns ok', async () => {
const response = await request(app).get('/health');
expect(response.status).toEqual(200);
expect(response.body).toEqual({ status: 'ok' });
});
});
});
@@ -0,0 +1,100 @@
/*
* Copyright 2020 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 { errorHandler } from '@backstage/backend-common';
import express from 'express';
import Router from 'express-promise-router';
import { Logger } from 'winston';
import {
IdentityClient,
getBearerTokenFromAuthorizationHeader,
} from '@backstage/plugin-auth-node';
import { add, getAll, update } from './todos';
import { InputError } from '@backstage/errors';
/**
* Dependencies of the todo-list router
*
* @public
*/
export interface RouterOptions {
logger: Logger;
identity: IdentityClient;
}
/**
* Creates an express.Router with some endpoints
* for creating, editing and deleting todo items.
*
* @public
* @param options - the dependencies of the router
* @returns an express.Router
*
*/
export async function createRouter(
options: RouterOptions,
): Promise<express.Router> {
const { logger, identity } = options;
const router = Router();
router.use(express.json());
router.get('/health', (_, response) => {
logger.info('PONG!');
response.send({ status: 'ok' });
});
router.get('/todos', async (_req, res) => {
res.json(getAll());
});
router.post('/todos', async (req, res) => {
const token = getBearerTokenFromAuthorizationHeader(
req.header('authorization'),
);
let author: string | undefined = undefined;
const user = token ? await identity.authenticate(token) : undefined;
author = user?.identity.userEntityRef;
if (!isTodoCreateRequest(req.body)) {
throw new InputError('Invalid payload');
}
const todo = add({ title: req.body.title, author });
res.json(todo);
});
router.put('/todos', async (req, res) => {
if (!isTodoUpdateRequest(req.body)) {
throw new InputError('Invalid payload');
}
res.json(update(req.body));
});
router.use(errorHandler());
return router;
}
function isTodoCreateRequest(request: any): request is { title: string } {
return typeof request?.title === 'string';
}
function isTodoUpdateRequest(
request: any,
): request is { title: string; id: string } {
return typeof request?.id === 'string' && isTodoCreateRequest(request);
}
@@ -0,0 +1,61 @@
/*
* Copyright 2020 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 {
createServiceBuilder,
loadBackendConfig,
SingleHostDiscovery,
} from '@backstage/backend-common';
import { IdentityClient } from '@backstage/plugin-auth-node';
import { Server } from 'http';
import { Logger } from 'winston';
import { createRouter } from './router';
export interface ServerOptions {
port: number;
enableCors: boolean;
logger: Logger;
}
export async function startStandaloneServer(
options: ServerOptions,
): Promise<Server> {
const logger = options.logger.child({ service: 'todo-list-backend' });
logger.debug('Starting application server...');
const config = await loadBackendConfig({ logger, argv: process.argv });
const discovery = SingleHostDiscovery.fromConfig(config);
const router = await createRouter({
logger,
identity: IdentityClient.create({
discovery,
issuer: await discovery.getExternalBaseUrl('auth'),
}),
});
let service = createServiceBuilder(module)
.setPort(options.port)
.addRouter('/todo-list', router);
if (options.enableCors) {
service = service.enableCors({ origin: 'http://localhost:3000' });
}
return await service.start().catch(err => {
logger.error(err);
process.exit(1);
});
}
module.hot?.accept();
@@ -0,0 +1,92 @@
/*
* 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 { v4 as uuid } from 'uuid';
import { NotFoundError } from '@backstage/errors';
export type Todo = {
title: string;
author?: string;
id: string;
timestamp: number;
};
export type TodoFilter = {
property: Exclude<keyof Todo, 'timestamp'>;
values: Array<string | undefined>;
};
export type TodoFilters =
| {
anyOf: TodoFilters[];
}
| { allOf: TodoFilters[] }
| { not: TodoFilters }
| TodoFilter;
const todos: { [key: string]: Todo } = {};
const matches = (todo: Todo, filters?: TodoFilters): boolean => {
if (!filters) {
return true;
}
if ('allOf' in filters) {
return filters.allOf.every(filter => matches(todo, filter));
}
if ('anyOf' in filters) {
return filters.anyOf.some(filter => matches(todo, filter));
}
if ('not' in filters) {
return !matches(todo, filters.not);
}
return filters.values.includes(todo[filters.property]);
};
export function add(todo: Omit<Todo, 'id' | 'timestamp'>) {
const id = uuid();
const obj: Todo = { ...todo, id, timestamp: Date.now() };
todos[id] = obj;
return obj;
}
export function getTodo(id: string) {
return todos[id];
}
export function update({ id, title }: { id: string; title: string }) {
let todo = todos[id];
if (!todo) {
throw new NotFoundError('Item not found');
}
todo = { ...todo, title, timestamp: Date.now() };
todos[id] = todo;
return todo;
}
export function getAll(filter?: TodoFilters) {
return Object.values(todos)
.filter(value => matches(value, filter))
.sort((a, b) => b.timestamp - a.timestamp);
}
// prepopulate the db
add({ title: 'just a note' });
add({ title: 'another note' });
@@ -0,0 +1,17 @@
/*
* Copyright 2020 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 {};
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
@@ -0,0 +1,3 @@
# todo-list-common
This package provides a starting point to demonstrate how plugin authors can use the Backstage permission framework. Refer to the [documentation](https://backstage.io/docs/permission/plugin-authors/01-setup) to get started.
@@ -0,0 +1,12 @@
## API Report File for "@internal/plugin-todo-list-common"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { BasicPermission } from '@backstage/plugin-permission-common';
// @public
export const tempExamplePermission: BasicPermission;
// (No @packageDocumentation comment for this package)
```
@@ -0,0 +1,40 @@
{
"name": "@internal/plugin-todo-list-common",
"version": "1.0.0",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": true,
"publishConfig": {
"access": "public",
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"backstage": {
"role": "frontend-plugin"
},
"scripts": {
"start": "backstage-cli package start",
"build": "backstage-cli package build",
"lint": "backstage-cli package lint",
"test": "backstage-cli package test",
"clean": "backstage-cli package clean",
"prepack": "backstage-cli package prepack",
"postpack": "backstage-cli package postpack"
},
"dependencies": {
"@backstage/plugin-permission-common": "^0.6.0"
},
"devDependencies": {
"@backstage/cli": "^0.17.1-next.0",
"@backstage/core-app-api": "^1.0.1",
"@backstage/dev-utils": "^1.0.2-next.0",
"@backstage/test-utils": "^1.0.2-next.0",
"@types/node": "^16.11.26",
"msw": "^0.35.0",
"cross-fetch": "^3.1.5"
},
"files": [
"dist"
]
}
@@ -0,0 +1,16 @@
/*
* 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 * from './permissions';
@@ -0,0 +1,27 @@
/*
* 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 { createPermission } from '@backstage/plugin-permission-common';
/**
* An example of a permission.
*
* @public
*/
export const tempExamplePermission = createPermission({
name: 'temp.example.noop',
attributes: {},
});
+14
View File
@@ -0,0 +1,14 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint')],
rules: {
'import/no-extraneous-dependencies': [
'error',
{
devDependencies: true,
optionalDependencies: true,
peerDependencies: true,
bundledDependencies: true,
},
],
},
};
+3
View File
@@ -0,0 +1,3 @@
# todo-list
This package provides a starting point to demonstrate how plugin authors can use the Backstage permission framework. Refer to the [documentation](https://backstage.io/docs/permission/plugin-authors/01-setup) to get started.
+23
View File
@@ -0,0 +1,23 @@
## API Report File for "@internal/plugin-todo-list"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
/// <reference types="react" />
import { BackstagePlugin } from '@backstage/core-plugin-api';
import { RouteRef } from '@backstage/core-plugin-api';
// @public
export const TodoListPage: () => JSX.Element;
// @public
export const todoListPlugin: BackstagePlugin<
{
root: RouteRef<undefined>;
},
{}
>;
// (No @packageDocumentation comment for this package)
```
+50
View File
@@ -0,0 +1,50 @@
{
"name": "@internal/plugin-todo-list",
"version": "1.0.0",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": true,
"publishConfig": {
"access": "public",
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"scripts": {
"build": "backstage-cli package build",
"start": "backstage-cli package start",
"lint": "backstage-cli package lint",
"test": "backstage-cli package test",
"diff": "backstage-cli plugin:diff",
"prepack": "backstage-cli package prepack",
"postpack": "backstage-cli package postpack",
"clean": "backstage-cli package clean"
},
"dependencies": {
"@backstage/core-components": "^0.9.3",
"@backstage/core-plugin-api": "^1.0.1",
"@backstage/theme": "^0.2.15",
"@material-ui/core": "^4.12.2",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.57",
"react-use": "^17.2.4"
},
"peerDependencies": {
"react": "^16.13.1 || ^17.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.17.1-next.0",
"@backstage/core-app-api": "^1.0.1",
"@backstage/dev-utils": "^1.0.2-next.0",
"@backstage/test-utils": "^1.0.2-next.0",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^12.1.3",
"@testing-library/user-event": "^14.0.0",
"@types/jest": "^26.0.7",
"@types/node": "^16.11.26",
"msw": "^0.35.0"
},
"files": [
"dist"
]
}
@@ -0,0 +1,88 @@
/*
* 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 React from 'react';
import { Table, TableColumn, Progress } from '@backstage/core-components';
import Alert from '@material-ui/lab/Alert';
import useAsync from 'react-use/lib/useAsync';
import {
discoveryApiRef,
fetchApiRef,
useApi,
} from '@backstage/core-plugin-api';
import { Button } from '@material-ui/core';
export type Todo = {
title: string;
id: string;
author?: string;
timestamp: number;
};
type TodosTableProps = {
todos: Todo[];
onEdit(todo: Todo): any;
};
export const TodoList = ({ onEdit }: { onEdit(todo: Todo): any }) => {
const discoveryApi = useApi(discoveryApiRef);
const { fetch } = useApi(fetchApiRef);
const { value, loading, error } = useAsync(async (): Promise<Todo[]> => {
const response = await fetch(
`${await discoveryApi.getBaseUrl('todolist')}/todos`,
);
return response.json();
}, []);
if (loading) {
return <Progress />;
} else if (error) {
return <Alert severity="error">{error.message}</Alert>;
}
return <TodosTable todos={value || []} onEdit={onEdit} />;
};
export function TodosTable({ todos, onEdit }: TodosTableProps) {
const columns: TableColumn<Todo>[] = [
{ title: 'Title', field: 'title' },
{ title: 'Author', field: 'author' },
{
title: 'Last edit',
field: 'timestamp',
render: e => new Date(e.timestamp).toLocaleString(),
},
{
title: 'Action',
render: todo => {
return (
<Button variant="contained" onClick={() => onEdit(todo)}>
Edit
</Button>
);
},
},
];
return (
<Table
title="Todos"
options={{ search: false, paging: false }}
columns={columns}
data={todos}
/>
);
}
@@ -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 { TodoList } from './TodoList';
export type { Todo } from './TodoList';
@@ -0,0 +1,187 @@
/*
* 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 React, { useReducer, useRef, useState } from 'react';
import {
Typography,
Grid,
TextField,
Button,
Dialog,
Box,
DialogTitle,
DialogContent,
DialogActions,
} from '@material-ui/core';
import {
Header,
Page,
Content,
ContentHeader,
HeaderLabel,
SupportButton,
} from '@backstage/core-components';
import { Todo, TodoList } from '../TodoList';
import {
alertApiRef,
discoveryApiRef,
fetchApiRef,
useApi,
} from '@backstage/core-plugin-api';
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 () => {
try {
const response = await fetch(
`${await discoveryApi.getBaseUrl('todolist')}/todos`,
{
method: 'POST',
body: JSON.stringify({ title: title.current }),
headers: {
'Content-Type': 'application/json',
},
},
);
if (!response.ok) {
const { error } = await response.json();
alertApi.post({
message: error.message,
severity: 'error',
});
return;
}
refetchTodos();
} catch (e: any) {
alertApi.post({ message: e.message, severity: 'error' });
}
};
const handleEdit = async (todo: Todo) => {
setEdit(undefined);
try {
const response = await fetch(
`${await discoveryApi.getBaseUrl('todolist')}/todos`,
{
method: 'PUT',
body: JSON.stringify({ title: todo.title, id: todo.id }),
headers: {
'Content-Type': 'application/json',
},
},
);
if (!response.ok) {
const { error } = await response.json();
alertApi.post({
message: error.message,
severity: 'error',
});
return;
}
refetchTodos();
} catch (e: any) {
alertApi.post({ message: e.message, severity: 'error' });
}
};
return (
<Page themeId="tool">
<Header
title="Welcome to todo-list!"
subtitle="Just a CRU todo list plugin"
>
<HeaderLabel label="Owner" value="Team X" />
<HeaderLabel label="Lifecycle" value="Alpha" />
</Header>
<Content>
<ContentHeader title="Todo List">
<SupportButton>A description of your plugin goes here.</SupportButton>
</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>
</Grid>
<Grid item>
<TodoList key={key} onEdit={setEdit} />
</Grid>
</Grid>
</Content>
{!!editElement && (
<EditModal
todo={editElement}
onSubmit={handleEdit}
onCancel={() => setEdit(undefined)}
/>
)}
</Page>
);
};
function EditModal({
todo,
onCancel,
onSubmit,
}: {
todo?: Todo;
onSubmit(todo: Todo): any;
onCancel(): any;
}) {
const title = useRef('');
return (
<Dialog open>
<DialogTitle id="form-dialog-title">Edit item</DialogTitle>
<DialogContent>
<TextField
placeholder="Write something here..."
defaultValue={todo?.title || ''}
onChange={e => (title.current = e.target.value)}
margin="dense"
fullWidth
/>
</DialogContent>
<DialogActions>
<Button color="primary" onClick={onCancel}>
Cancel
</Button>
<Button
onClick={() => onSubmit({ ...todo!, title: title.current })}
color="primary"
>
Save
</Button>
</DialogActions>
</Dialog>
);
}
@@ -0,0 +1,16 @@
/*
* 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 { TodoListPage } from './TodoListPage';
+16
View File
@@ -0,0 +1,16 @@
/*
* 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 { todoListPlugin, TodoListPage } from './plugin';
@@ -0,0 +1,22 @@
/*
* 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 { todoListPlugin } from './plugin';
describe('todo-list', () => {
it('should export plugin', () => {
expect(todoListPlugin).toBeDefined();
});
});
+47
View File
@@ -0,0 +1,47 @@
/*
* 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 {
createPlugin,
createRoutableExtension,
} from '@backstage/core-plugin-api';
import { rootRouteRef } from './routes';
/**
* The todo-list plugin instance
*
* @public
*/
export const todoListPlugin = createPlugin({
id: 'todolist',
routes: {
root: rootRouteRef,
},
});
/**
* The Router and main entrypoint to the todo-list plugin.
*
* @public
*/
export const TodoListPage = todoListPlugin.provide(
createRoutableExtension({
name: 'TodoListPage',
component: () =>
import('./components/TodoListPage').then(m => m.TodoListPage),
mountPoint: rootRouteRef,
}),
);
+20
View File
@@ -0,0 +1,20 @@
/*
* 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 { createRouteRef } from '@backstage/core-plugin-api';
export const rootRouteRef = createRouteRef({
id: 'todo-list',
});
@@ -0,0 +1,16 @@
/*
* 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 '@testing-library/jest-dom';