Skip to content

Commit

Permalink
Add docs for user permissions (#2128)
Browse files Browse the repository at this point in the history
Co-authored-by: Johannes Obermair <[email protected]>
Co-authored-by: Thomas Dax <[email protected]>
  • Loading branch information
3 people authored Oct 29, 2024
1 parent 649e7a5 commit 1b7eaf8
Show file tree
Hide file tree
Showing 10 changed files with 373 additions and 69 deletions.
22 changes: 0 additions & 22 deletions docs/docs/auth/index.md

This file was deleted.

2 changes: 1 addition & 1 deletion docs/docs/content-scope/content-website.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,4 @@ You can then use `useContentScope()` to access the currently selected scope, whi

COMET's user permission feature will automatically validate `scope` arguments of GraphQL operations and check if a user has access to the entity's scope. The column must be named `scope` for this to work.

You additionally need `@ScopedEntity` (at entity level) for nested entities. And, for operations without a `scope` argument, you must add `@AffectedEntity` at resolver level.
For nested entities or operations without a `scope` argument please refer to [Evaluate Content Scopes](/docs/content-scope/evaluate-content-scopes) which describes how to decorate the resolvers/controllers properly.
47 changes: 1 addition & 46 deletions docs/docs/content-scope/data-driven-application.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,49 +44,4 @@ const variables = {

### API: User permissions

Data-driven applications don't benefit much from the COMET scope system. But for user permissions it has its value.

First, an overview of user permissions:

- Every user has permissions that give them access to resolvers (e.g., "products") - not covered here
- Every user has access to scopes

(Both are defined by rule in `AccessControlService` or can be overridden manually per user in the Admin)

- Every entity belongs to a scope

(The user permission feature checks for every request if the entity scope and the user's allowed scopes match.)

#### @ScopedEntity

Use this decorator at entity level to return the scope of an entity. You might have to load multiple relations for nested data.

```ts
@ScopedEntity(async (product: Product) => {
return {
dealer: product.dealer.id,
};
})
@Entity()
export class Product extends BaseEntity<Product, "id"> {}
```

#### @AffectedEntity

Use this decorator at the operation level to specify which entity (and thus scope) is affected by the operation.

```ts
@Query(Product)
@AffectedEntity(Product)
async product(@Args("id", { type: () => ID }) id: string): Promise<Product> {
//...
}
```

```ts
@Query([Product])
@AffectedEntity(Dealer, { idArg: "dealer" })
async products(@Args("dealer", { type: () => ID }) dealer: string): Promise<Product[]> {
// Note: you can trust "dealer" being in a valid scope, but you need to make sure that your business code restricts this query to the given dealer
}
```
In data-driven applications, the scope system is primarily used for controlling permissions. Please refer to [Evaluate Content Scopes](/docs/content-scope/evaluate-content-scopes) which is heavily used in data-driven applications.
78 changes: 78 additions & 0 deletions docs/docs/content-scope/evaluate-content-scopes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
---
title: Evaluate Content Scopes
sidebar_position: 3
---

To evaluate the scope there a two technically very distinctive ways depending on the type of operation:

### Operations that create entities or query lists

If an operation does not handle existing entities, the scope has to be passed as an argument. COMET DXP expects the argument to be named `scope` in order to be able to validate it. So do not forget to provide the `scope` argument in your operation.

### Operations that handle specific entities

**@AffectedEntity**

COMET DXP needs information on which entities are being handled in the operation (= which entities are affected). Therefore, every operation of this kind needs to be marked with this decorator.

Use this decorator at the **operation level** to specify which entity (and thus scope) is affected by the operation.

:::info
By default COMET DXP tries to load the affected entity by id with the value of the submitted id-argument. However, the name of the argument can be altered by using the `idArg` setting.
:::

```ts
@Query(Product)
@AffectedEntity(Product)
async product(@Args("id", { type: () => ID }) id: string): Promise<Product> {
//...
}
```

```ts
@Query([Product])
@AffectedEntity(Dealer, { idArg: "dealerId" })
async products(@Args("dealerId", { type: () => ID }) dealerId: string): Promise<Product[]> {
// Note: you can trust "dealerId" being in a valid scope, but you need to make sure that your business code restricts this query to the given dealer
}
```

It's possible to add multiple `@AffectedEntity` decorators to one operation if multiple entities are affected:

```ts
@Query(Product)
@AffectedEntity(Product)
@AffectedEntity(Dealer, { idArg: "dealerId" })
async product(@Args("id", { type: () => ID }) id: string, @Args("dealerId", { type: () => ID }) dealerId: string): Promise<Product> {
//...
}
```

**@ScopedEntity**

Retrieving the affected entity alone is not sufficient, COMET DXP also needs to know the scope of the entity. The simplest case is when the entity has a field named `scope`. If this is true, this decorator is not necessary.

If the scope is stored in a different field or the entity has a relation to another entity that stores the scope, additional information is required. This is where this decorator comes into play.

Use this decorator at the **entity level** to return the scope of an entity.

```ts
@ScopedEntity(async (product: Product) => {
return {
dealer: product.dealer.id,
};
})
@Entity()
export class Product extends BaseEntity<Product, "id"> {}
```

:::info
You might have to load multiple relations for nested data.
:::

It's also possible to pass a function which returns the content scope to the `@ScopedEntity` decorator. This is necessary for documents that get their scope from a `PageTreeNode`. Here you can pass the helper service `PageTreeNodeDocumentEntityScopeService` provided by the library:

```ts
@ScopedEntity(PageTreeNodeDocumentEntityScopeService)
export class PredefinedPage extends BaseEntity<PredefinedPage, "id"> implements DocumentInterface {
```
File renamed without changes.
4 changes: 4 additions & 0 deletions docs/docs/logging/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
title: Logging
sidebar_position: 8
---
83 changes: 83 additions & 0 deletions docs/docs/user-permissions/access-control.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
---
title: Access Control in the API
sidebar_position: 2
---

:::note

The term **operation** stands for the locations in which COMET DXP invokes permission checks:

- Queries/Mutations in GraphQL-resolvers
- Routes in REST-controllers

Normally you want to decorate the methods of these classes, however, decorating the whole class is also possible.
:::

After activating the module, COMET DXP checks every operation for the required permissions and scopes. Therefore it is necessary to decorate the operations to let the system know what to check. COMET DXP then checks if the current user possesses the permission defined in the decorator.

Additionally, the scope of the data in operation will be checked against the scope of the users. To achieve this, the system has to know the scope of the data that is being handled right now.

:::note
You might also want to check the permissions on field resolvers. To do that, you have to add `guards` to `fieldResolverEnhancers` in the configuration of the GraphQL-module. Please be aware that field resolvers are only checked for permissions but not for scopes.
:::

## Permission check

**@RequiredPermission**

This decorator is mandatory for all operations. The first parameter of type `string | string[] | "disablePermissionCheck"` configures which permission is necessary to access the decorated operation.

The core of COMET DXP already defines a list of permissions (e.g. `pageTree`, `dam`, `cronJobs`, `userPermissions`). Permissions are defined as plain strings, in the most basic case they represent the main items of the menu bar in the admin panel.

However, if you need a more fine-grained access control you might want to concatenate strings, e.g. `newsRead` or `newsCreate`. Only create as many permissions as really necessary.

:::info
Future version will support a dot-like notation (e.g. `news` will subsume `news.read` and `news.write`).
:::

## Scope check

The scope check needs to know which scope is used for the current operation. This is described in [Evaluate Content Scopes documentation](/docs/content-scope/evaluate-content-scopes).

:::caution
COMET DXP validates the data relevant for the operation, but cannot check if the validated data is finally used. You are responsible for applying the validated data in your operations.
:::

## Disable permission/scope checks

**skipScopeCheck**

The scope check can be disabled by adding `{skipScopeCheck: true}` as the second argument of the `@RequiredPermission` decorator.

:::caution
Use this option only when you are sure that checking the scope is not necessary (e.g. the current entity does not have a scope). Do not add it just because it seems cumbersome at the moment to add the correct `AffectedEntity`/`ScopedEntity` decorators.
:::

:::note
Also, try to avoid using the `@GetCurrentUser` decorator (which often leads to use `skipScopeCheck`). Instead, you should explicitly send all the data needed in an operation. In the following example, this requires adding `userId` as a scope part as well as passing the data throughout the client. In general, this leads to a cleaner API design.

```diff
- @RequiredPermission("products", {skipScopeCheck: true})
+ @RequiredPermission("products")
+ @AffectedEntity(User, { idArg: "userId" })
- async myProducts(@GetCurrentUser() currentUser: CurrentUser): Promise<Product[]> {
+ async productsForUser(@Args("userId", { type: () => ID }) userId: string): Promise<Product[]> {
//...
}
```

:::

### @DisableCometGuards

`@DisableCometGuards()` disables the global auth guards (`CometAuthGuard`, `UserPermissionsGuard`). This may be used if a different authentication method is desired (e.g., basic authentication) for a specific handler or class in combination with a custom guard.

e.g.:

```typescript
@DisableCometGuards()
@UseGuards(MyCustomGuard)
async handlerThatUsesACustomGuard(): {
...
}
```
91 changes: 91 additions & 0 deletions docs/docs/user-permissions/admin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
---
title: Permissions in Admin
sidebar_position: 3
---

:::caution
Please be aware that there is no access control possible in the admin panel. You have to check every permission and scope in the API. The following functions only assist for showing the correct user interface.
:::

### CurrentUserProvider

The `CurrentUserProvider` loads the current user from the API and provides the following hooks:

**useCurrentUser**

You can use this hook to access the current user.

:::note
The current user object provides `allowedContentScopes` which may be used for the content scope selector.
:::

:::info
Try not to use the permissions field of the current user object directly as this is subject to change in future versions.
:::

**useUserPermissionCheck**

This hook provides a function that behaves like the `isAllowed` function in the API.

```ts
const isAllowed = useUserPermissionCheck();
if (isAllowed("pageTree")) {
// ...
}
```

:::info
Since this function also checks the content scope, it requires the `ContentScopeProvider` in the rendering tree.
:::

### MasterMenuData

The `MasterMenuData` data type provides a unified format for

- the `menu` prop in `MasterMenu`
- the `menu` prop in `MasterMenuRoutes`

Regarding user permissions, `MasterMenuData` also provides a `requiredPermission` field. Both of the mentioned components use this field to filter the data.

```ts
// src/common/MasterMenu.tsx
export const masterMenuData: MasterMenuData = [
{
type: "route",
primary: "Demo",
icon: <Snips />,
route: ...,
requiredPermission: "demo",
},
{
type: "collapsible",
primary: "Project Snips",
icon: <Snips />,
items: [
{
primary: "Main Menu",
route: {
path: "/project-snips/main-menu",
component: MainMenu,
},
},
],
requiredPermission: "pageTree",
},
// ...
];

export const AppMasterMenu = () => <MasterMenu menu={masterMenuData} />;

// src/App.tsx
<Route
render={() => (
<MasterLayout
headerComponent={MasterHeader}
menuComponent={AppMasterMenu}
>
<MasterMenuRoutes menu={masterMenuData} />
</MasterLayout>
)}
/>
```
33 changes: 33 additions & 0 deletions docs/docs/user-permissions/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
title: User Permissions
sidebar_position: 6
---

While COMET DXP does not provide authentication, it handles authorization by providing a User Permissions system.

## Key concepts

The user permissions system

- streamlines authorization throughout the application
- not only checks operations but also the handled data
- relies on external user handling
- allows assigning permissions by code as well as in the admin panel
- offers an admin panel that works out of the box

COMET DXP checks authentication in two dimensions:

**Permissions** are used for access control to specific resolvers or controllers.

**Content Scopes** (also referred to as scopes) are used to control which data is allowed to be handled (see [documentation about content scopes](/docs/content-scope)).

Users in COMET DXP possess permissions and scopes. Every operation is assigned to one or more permissions and handles data that is bound to a scope. The system then tries to match if the requested permissions and scopes are covered by the user's permissions and scopes.

There are no roles as they can easily be represented as a combination of permissions. Furthermore, the ability to check scopes is more powerful than just being assigned a single role.

## Important types

- `User` is provided by COMET DXP as an interface so that it's possible to enhance the type by TypeScript module augmentation. By default, a ` User` object contains the fields `id`, `name` and `email`.
- `CurrentUser` is used as a GraphQL-type and is returned by `@GetCurrentUser`. It's not customizable and enhances the default `User` type with the current permissions and scopes.
- `ContentScope` is provided as an interface and should be augmented in the application.
- There is no custom type for permissions, they are reflected as plain strings.
Loading

0 comments on commit 1b7eaf8

Please sign in to comment.