Skip to main content

Формирование доступов

Permission - это доступ к функционалу, обусловленный бизнес требованиями.

Как выделять permission

Требование является permission, если:

  • Требование указывает на то, что функционал приложения должен быть ограничен на основе данных об аккаунте (роль, организация, оплата...)
  • Описанное ограничение может быть снято при изменении данных пользователя

Permission не является:

  • Временный Feature Toggle на функционал
  • Перманентное условие на блокировку функционала, без возможности открытия доступа (даже при изменении роли пользователя)
  • Условия (if) в коде, реализующие рядовые бизнес требования

Примеры требований

Кнопка "Создать документ" доступна только администратору - это permission потому, что ограничение основывается на данных пользователя и доступ к функционалу можно открыть, если изменить роль пользователя.

Кнопка "Редактировать документ" доступна только для пользователей с платным аккаунтом и добавленной организацией - это permission потому, что ограничение основывается на данных пользователя и доступ к функционалу можно открыть, если оплатить аккаунт и добавить организацию.

Кнопка "Создать документ" заблокирована до 20.05.2024 - это не permission, а feature toggle. Требование не зависит от данных пользователя.

При этом:

Кнопка "Создать документ" для пользователей с тарифом "Базовый" заблокирована до 20.05.2024 - это уже permission потому, что основывается на данных пользователя.

Кнопка "Отправить" заблокирована, если чекбокс "Ознакомлен с требованиями" не активен - это не permission. Это требование для формы.

Создание permissions посредством @astral/permissions

Permissions создаются только внутри policy с помощью метода policy.createPermission:

modules/permissions/domain/stores/PermissionsStore/policies/AdministrationPolicyStore

import { makeAutoObservable } from 'mobx';

import type { UserRepository } from '@example/data';

import { PermissionDenialReason } from '../../../../enums';

// @astral/permissions в реальном коде должен реэкспортироваться через shared
import { PolicyManagerStore, Policy } from '@astral/permissions';

export class AdministrationPolicyStore {
private readonly policy: Policy;

constructor(
private readonly policyManager: PolicyManagerStore,
private readonly userRepo: UserRepository,
) {
makeAutoObservable(this, {}, { autoBind: true });

this.policy = this.policyManager.createPolicy({
name: 'administration',
prepareData: async (): Promise<void> => {
await Promise.all([this.userRepo.getRolesQuery().async()]);
},
});
}

/**
* Доступ к действиям администратора
*/
public get administrationActions() {
return this.policy.createPermission((allow, deny) => {
if (this.userRepo.getRolesQuery().data?.isAdmin) {
// разрешает доступ
return allow();
}

// запрещает доступ с конкретной причиной
deny(PermissionDenialReason.NoAdmin);
});
}
}

API Permission

createPermission возвращает объект вида:

type Permission = {
isAllowed: boolean;
/**
* Причина отказа в доступе
*/
reason?: string;
/**
* @example permission.hasReason(DenialReason.NoAdmin)
*/
hasReason: (reason: string) => boolean;
};

О причинах отказа читайте далее.

Пример реализации и использования permission

Требования

Кнопка "Создать книгу" в Sidebar отображается только если пользователь является администратором.

Решение

modules/permissions/domain/stores/PermissionsStore/policies/AdministrationPolicyStore

class AdministrationPolicyStore {
constructor(
private readonly policyManager: PolicyManagerStore,
private readonly userRepo: UserRepository,
) {
makeAutoObservable(this, {}, { autoBind: true });

this.policyManager.createPolicy({
name: 'administration',
prepareData: async (): Promise<void> => {
await Promise.all([this.userRepo.getRolesQuery().async()]);
},
});
}

/**
* Доступ к действиям администратора
*/
public get administrationActions() {
return this.policyManager.createPermission((allow, deny) => {
if (this.userRepo.getRolesQuery().data?.isAdmin) {
return allow();
}

deny(PermissionDenialReason.NoAdmin);
});
}
}

В features необходимо избегать разрешения доступов через абстрактные компоненты вида:

import { observer } from 'mobx-react-lite';

import { permissionsStore } from '@example/modules/permissions';

export const Sidebar = observer(() => {
return (
<Sidebar>
<PermissionsGateway
permission={permissionsStore.administration.administrationActions}
allow={
<RouterLink to={APP_ROUTES.createBook.getRedirectPath()}>
Создать книгу
</RouterLink>
}
/>
</Sidebar>
);
});

Использование компонентов вроде PermissionsGateway переносит логику доступов для фичи в UI слой, что нарушает архитектурную концепцию.

Разрешение доступов должно происходить в UIStore:

modules/layout/features/MainLayout/Sidebar/UIStore

export class UIStore {
constructor(private readonly permissions: PermissionsStore) {
makeAutoObservable(this, {}, { autoBind: true });
}

public get isAllowedBookCreation() {
return this.permissions.administration.administrationActions.isAllowed;
}
}

modules/layout/features/MainLayout/Sidebar/Sidebar.tsx

export const Sidebar = observer(() => {
const [{ isAllowedBookCreation }] = useState(createUIStore);

return (
<Sidebar>
<SidebarItem>
{isAllowedBookCreation && (
<RouterLink to={APP_ROUTES.createBook.getRedirectPath()}>
Создать книгу
</RouterLink>
)}
</SidebarItem>
</Sidebar>
);
});

Permission не должен зависеть от UI

Permission не должен напрямую зависеть и указывать на UI, который блокируется. Зависимость от UI приведет к взрывному росту permissions и в последствии к сложной поддержке кода.

Пример

Кнопка "Создать документ" отображается только если пользователь является администратором - в данном требовании присутствует указать на конкретную кнопку.

Неправильное решение

Создать permission showCreationDocButton:

class AdministrationPolicyStore {

...

public get showCreationDocButton() {
return this.policyManager.createPermission((allow, deny) => {
if (this.userRepo.getRolesQuery().data?.isAdmin) {
return allow();
}

deny(PermissionDenialReason.NoAdmin);
});
}

public get allowAdministrationRoute() {
return this.policyManager.createPermission((allow, deny) => {
if (this.userRepo.getRolesQuery().data?.isAdmin) {
return allow();
}

deny(PermissionDenialReason.NoAdmin);
});
}

public get showEditingDocModal() {
return this.policyManager.createPermission((allow, deny) => {
if (this.userRepo.getRolesQuery().data?.isAdmin) {
return allow();
}

deny(PermissionDenialReason.NoAdmin);
});
}
}

Как видно из примера, при связывании permission и UI происходит взрывной рост одинаковых доступов:

  • Доступ к кнопке
  • Доступ к руту
  • Доступ к модалке редактирования

Правильное решение

Создать абстрактный permission administrationActions, который будет закрывать доступ к действиям администратора:

class AdministrationPolicyStore {

...

public get administrationActions() {
return this.policyManager.createPermission((allow, deny) => {
if (this.userRepo.getRolesQuery().data?.isAdmin) {
return allow();
}

deny(PermissionDenialReason.NoAdmin);
});
}
}

Теперь на уровне features необходимо проверять administrationActions и на основе его выполнять необходимые действия.

Нейминг

Название permission должно отвечать на вопрос: "Доступ открыт/закрыт для чего/к чему?".

Примеры

  • Доступ закрыт к действиям администратора - administrationActions
  • Доступ закрыт к чтению книги онлайн - readingBook
  • Доступ закрыт к управлению организацией - organizationManagement

✅ Valid

public get administrationActions() { ... }

public get addingToShelf() { ... }

public get readingBook() { ... }

public get organizationManagement() { ... }

❌ Invalid

public get canReadingBook() { ... }

public get isAddToShelf() { ... }

Нейминг методов для вычисления доступов

Для методов, которые вычисляют доступы, добавляется префикс calc.

✅ Valid

public calcReadingBook = (bookId: string) => { ... }

❌ Invalid

public checkReadingBook = (bookId: string) => { ... }