«Каждый уважающий себя блогер должен написать статью о RBAC. Каждый уважающий себя читатель должен написать в комментарии, что всё равно ничего не понял». С этой фразы началось мое знакомство с RBAC. И я имел прекрасную возможность узнать, что всё так и есть.

Но теперь я разобрался с тем, что RBAC такое, и готов рассказать вам. Более того, я создал на основе RBAC собственный продукт, позволяющий использовать RBAC-паттерн в Angular-приложениях. Но обо всём по порядку.

Краткий экскурс в RBAC: что это такое

RBAC расшифровывается как role-based access control, то есть «Контроль доступа, основанный на ролях».

Разберём основные понятия:

Access control — контроль доступа. Механизм, позволяющий прозрачно для разработчика контролировать доступ к ресурсам. В случае фронтенд подконтрольными ресурсами могут быть маршрутизация внутри приложения, элементы интерфейса или реакции на действия пользователя.

Role — роль в RBAC. Должность или название, которое определяется на уровне авторизации.

Permission —  разрешение. Определяет, имеет ли роль доступ к определённому ресурсу.

Rule — правило. Rule является дополнительным ограничением для permission. Оно приходит на помощь, когда недостаточно просто дать или не дать доступ к ресурсу. Приведу наглядный пример: отношение доктора к карте пациента. Лечащий врач просматривает карту конкретно своего пациента, а не любого. В данном случае на просмотр карт наложены дополнительные ограничения. 

Плюсы и минусы RBAC

Преимущество RBAC-паттерна в том, что он позволяет вам заранее сформулировать все разрешения и на их основе описать роли. А также контролировать уровни доступа в понятном для человека виде, то есть в файле конфигурации, который используют как проектную документацию. Еще один плюс — подход позволяет вынести из кода в файл конфигурации всю логику контроля доступа к ресурсам. Это позволит любому сотруднику гибко управлять доступом персонала к ресурсам системы. Всё это нужно, чтобы разрабатывать системы с  управляемым, гибким контролем доступа. 

Минус — не в каждой системе можно явно выделить роли и разрешения. Тогда RBAC будет попросту не применим.

Почему я считаю, что RBAC бывает полезен во фронтенд?

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

  1. Отправить запрос на бэкенд и по коду ошибки показать пользователю, что он чего-то не может в системе, то есть у него нет прав; 

  2. Определить по роли, что пользователю недоступен какой-то функционал, и далее скрыть функционал от него или запретить навигацию. 

Но на практике взаимодействие может быть сложнее и интереснее. К примеру, с помощью RBAC можно отобразить кнопку для редактирования своего комментария, не отсылая лишних запросов на бэкенд. Или в разных разделах системы задать разное поведение по отображению/скрытию кнопок одному и тому же пользователю в одних и тех же интерфейсах.

Почему мне не подошли готовые библиотеки с RBAC?

Я провёл анализ существующего продукта. Рассмотрим ngx-permissions:

  • Нет pipe. В ngx-permissions реализованы директивы и компоненты, но нет pipe. Pipe позволил бы пользоваться обычными ngIf, SwitchCase, а также использовать их в интерполяциях, чего не могут позволить директивы.

  • Нет наследования ролей. Довольно частая ситуация, когда более высокий уровень роли имеет под управлением все permissions нижестоящей роли. В такой ситуации хотелось бы иметь на вооружении наследование ролей, чего нет в рассматриваемом продукте.

  • Нет локального внедрения правил и ролей посредством механизма элементного наследования инъекций. Функционал может показаться непонятным или излишним, но о том, для чего он нужен, я расскажу в разделе «Элементное наследование инъекций: его плюсы для RBAC».

  • Нет динамического контроля доступа внутри компонента или сервиса. Например, как быть, если нужно контролировать реакцию на действие пользователя? А что, если нужно её контролировать в ситуации, когда в одном и том же интерфейсе может измениться role? В этой ситуации удобнее работать с реактивными событиями на базе Observable из стандартного пакета rxjs.

  • Отсутствует возможность инверсии rule. Что, если один и тот же rule в разных интерфейсах должен выполнять разные проверки? На самом деле, это важная функция, которую в ngx-permissions не настроить. Я понимаю, что пока это звучит непонятно и даже как антипаттерн, но я всё объясню подробно ниже в разделе «Инвертирование rule».

Итак, мне не хватило в существующих продуктах функционала и гибкости его использования. Поэтому пришлось создать свой, в котором я учёл все недочёты. И дальше я расскажу о своей разработке и задачах, которые она решает.

Моя собственная библиотека: что в ней нового и для чего она нужна

Я разработал библиотеку @doce/ngx-rbac, с помощью которой можно реализовать контроль доступа в двух паттернах: rbac и rule-bac (подробнее — в разделе «Использование @doce/ngx-rbac без ролей»). 

Библиотека решает проблему излишней запутанности и «рассеянности» правил, регулирующих контроль доступа в больших приложениях. Её преимущества в сравнении с конкурентами:

  • Наличие полезных утилит: pipe, контроль доступа в компонентах, динамический контроль доступа в компонентах и утилиты для отладки;

  • Гибкое наследование ролей;

  • Инструментарий для создания как RBAC-паттерна, так и ABAC-паттерна;

  • Наличие возможности переопределить правило в подмодулях;

  • Различные области видимости для ролей и правил на разных уровнях.

@doce/ngx-rbac позволяет уменьшить количество повторяющегося кода, а также сделать многие бизнес-правила более понятными. Всё это благодаря тому, что есть возможность дать правилам имя, а сами правила можно вынести в отдельную конфигурацию

Сначала настрой, потом пользуйся

Один из плюсов RBAC в том, что всё, касающееся доступа к ресурсам, можно настроить отдельно от основного кода приложения. При использовании библиотеки (https://www.npmjs.com/package/@doce/ngx-rbac) настроить доступ можно в любой момент:

// roles.ts

export const addArticlePermission  = doCreatePermission('add-article');
export const removeArticlePermission  = doCreatePermission('remove-article');
 
export const moderator = createRole('moderator');
 
moderator.addPermissionsOf(addArticlePermission);
moderator.addPermissionsOf(removeArticlePermission);

Сначала я создал разрешения, потом роль, а в конце назначил разрешения роли. Роли и разрешения наследуют один и тот же класс. Думать о них во многом нужно как о родственных сущностях. Подробнее о том, почему две, казалось бы разные, сущности наследуют один и тот же класс, я расскажу в разделе «Наследование ролей».

Почему я использую pipe?

Как я уже говорил, есть продукты, использующие компоненты и директивы. Но мне хотелось использовать pipe, потому что он даёт больше возможностей. Его можно использовать со встроенными инструментами — например, с ngIf switchCase или тернарными операторами внутри, скажем, интерполяций. Это позволит писать меньше кода, а также не создавать лишних обёрток. 

Вот так я использую pipe на практике:

<div *ngIf="'add-article' | doCan"></div>

Также в pipe можно передать аргументы, но об этом подробнее в разделе «Сила и мощь rule».

Данный в примере pipe является imPure, так как роли или разрешения в системе могут меняться динамически, и pipe должен уметь сам за этим следить. В pipe организована система кэширования результата, и, если ничего не меняется, он не будет создавать дополнительной нагрузки на компоненты.

Наследование ролей

Главный плюс RBAC, из-за которого я стал им пользоваться, — наследование ролей. Это позволяет писать меньше кода и делать его более осмысленным. 

Приведём пример:

// roles.ts

export const admin = createRole('admin');
admin.addPermissionsOf(moderator);
 
export const blockModeratorPermission = createPermission('block-moderator');
admin.addPermissionsOf(blockModeratorPermission);

В результате роль admin будет наделена всеми правами модератора, а также получит собственное разрешение, позволяющее блокировать модераторов. И role, и permission наследуют один и тот же класс и реализуют общий интерфейс, вследствие чего  addPermissionsOf можно получить как от permission, так и от role.

Вот так в @doce/ngx-rbac происходит внедрение глобальных ролей:

// app.component.ts

import { moderator } from './roles';

@Component({
  selector: 'app-root',
  template: '',
})
export class AppComponent {
  constructor(public doGlobalRulesService: DoGlobalRulesService) {
    doGlobalRulesService.changeRoles([moderator]);
  }
}

Как видите, для этого необходимо обратиться к методу changeRole в сервисе DoGlobalRulesSerivce и передать ему роли. Роли передаются массивом, так как у пользователя может быть одновременно и параллельно несколько ролей в системе. Также глобальный набор ролей может быть только один, и новый набор заменит старый. Метод changeRoles заменит роли на переданные в аргументе.

Элементное наследование инъекций: его плюсы для RBAC

Элементное наследование в Angular — это механизм, позволяющий компонентам получить инъекции, внедрённые на компонентах, являющихся их предками. 

В отличие от привычной модульной инъекции, элементное наследование не является глобальным. Поэтому, если мы таким образом подключаем сервисы, они не становятся синглтонами. 

Опишем, как работает механизм инъекции. В момент создания экземпляра компонент Angular ищет инъекции выше по дереву элементов. Если не находит, использует merge-инъектор, который забирает инъекции из модулей. И если инъекция не будет найдена, использует NullInjector, возвращающий ошибку. 

Хорошая статья на тему механизма инъекции: https://medium.com/angular-in-depth/angular-dependency-injection-and-tree-shakeable-tokens-4588a8f70d5d

В @doce/ngx-rbac внедрение permission происходит через Angular-инъектор. Причем @doce/ngx-rbac использует оба механизма инъекции — и элементный, и модульный. Это позволяет создавать «локальные роли» и инвертировать rule.

Так происходит внедрение локальных ролей и связанных с ними разрешений:

// login.component.ts

import { guest } from './roles';

@Component({
  selector: 'app-login',
  templateUrl: './login.component.html',
})
export class LoginComponent {
  guest = guest;
}
<!-- login.component.html -->

<do-provide-rules [roles]="[guest]">
  <router-outlet></router-outlet>
</do-provide-rules>

Реализован специальный компонент — провайдер. Через него внедряют как локальные роли, так и правила. Встроенный pipe будет искать вверх по дереву элементов именно его. Также провайдеры объединяются с провайдерами-предками:

<do-provide-rules [roles]="[moderator]">
   <do-provide-rules [roles]="[admin]">
  		<router-outlet></router-outlet>
	 </do-provide-rules>
</do-provide-rules>

Все страницы, подключаемые в router-outlet, будут иметь две роли — moderator и admin.

Динамические разрешения

Строго говоря, в классическом понимании RBAC не должно быть никаких динамических разрешений. Разрешения в данном паттерне связаны с ролями. 

Динамические разрешения подразумевают под собой формирование динамической роли. Несмотря на то, что это может показаться антипаттерном, в мире фронтенда без динамических разрешений невозможно построить контроль доступа. Поскольку фронтенд отвязан от хранилища данных, данные поступают из строго определённых источников. Поэтому мы должны считать в большинстве случаев, что фронтенд ничего не знает о разрешениях до момента обращения к этим источникам. А это полностью соответствует логике динамического формирования разрешений. 

Далее рассмотрим, как @doce/ngx-rbac позволяет создавать и управлять динамическими ролями:

// api.service.ts

getPermissions(): void {
  this.httpClient.get('http://domain.com/some-permissions-url')
    .pipe(tap(permissions => {
        this.doGlobalRulesService.addGlobalRules(
           doCreateSimpleRuleSet(permissions), 
           'some-permissions'
        )
    })
}

// response: {
//  permissions: ['some-dynamic-permission', 'can-edit']
// }

Тут мы добавляем глобальные правила динамически, опираясь на ответ от сервера. 

doCreateSimpleRuleSet создаст набор правил, которые всегда возвращают true при их наличии. То есть они будут работать точно так же, как работает permission. Дело в том, что permission связан с ролью, rule же не связан с ролью, поэтому мы можем внедрять их в любой момент, даже после того, как все роли уже сформированы. 

Второй ключ опционален и может потребоваться, чтобы выгружать роли, а также если мы ещё раз обратимся к методу getPermissions. То есть сервис будет знать, какие правила нужно сначала удалить, чтобы записать новые на их место. Если ключ опущен, правила будут только добавляться.

Проверить, что новые разрешения добавились в систему, можно так же, как и наличие разрешения, связанного с определённой ролью:

<div *ngIf="'some-dynamic-permission' | doCan"></div>

Управление контролем доступа с помощью интерфейса

В данном разделе необходимо ответить на два вопроса: как нам поможет с этим ngx-rbac и как его применить в данном случае.

Как я уже отмечал выше, ngx-rbac позволяет создать конфигурационный файл, описывающий все правила и взаимосвязи с ролями. Часто бывает так, что необходимо разработать интерфейс, позволяющий создавать и настраивать все политики безопасности. В такой ситуации правила хранятся в базе данных, а в фронтенд приходят так, как описано в предыдущем разделе. По сути говоря то, как в такой ситуации использовать ngx-rbac, описано в предыдущем разделе «Динамические разрешения». Все разрешения мы забираем из бэкенда и сохраняем как глобальные разрешения.

Осталось ответить только на вопрос, как нам поможет ngx-rbac. А поможет он в двух аспектах:

  1. До того момента, как было принято решение хранить все разрешения в базе данных, если мы использовали ngx-rbac с конфигурационным файлом, нам понадобится лишь удалить этот файл и организовать получение глобальных разрешений. Если мы не использовали этот инструмент, тогда нам придётся править каждый файл, содержащий условия, связанные с разрешениями. 

  2. Библиотека предоставляет удобный инструментарий, также она является абстракцией над условиями, связанными с разрешениями. Её внедрение сделает код более читабельным.

Сила и мощь rule

Permissions можно считать частным случаем правила — rule. Если есть permissions, система делает проверку валидной, если нет — проверка фейлится. Если rule отсутствует, то система ведёт себя так же, как и в случае отсутствия разрешения, но при его наличии могут быть совершены дополнительные проверки. То есть, условно, мы можем рассмотреть permissions как частный случай rule: permissions — это rule, который всегда возвращает true.

Rule может принять дополнительные аргументы, так как является по сути функцией:

const editMyOwnArticle = doCreatRule(
  'can-edit-article', 
  [(article, user) => article.authorId === user.id]
);

Вот как может проходить проверка в шаблоне:

<div *ngIf="can-edit-article' | doCan : article : user"></div>

Приведу более развёрнутый пример того, как реализовать проверку редактирования статей:

// roles.ts

// Guest:
export const readArticlePermission = createPermission('can-read-article');
export const guest = createRole('guest');
guest.addPermissionsOf(readArticlePermission);

// Author:
const editMyOwnArticle = doCreatRule(
   'can-edit-article',
   [(article, user) => article.authorId === user.id]
);
export const author = createRole('author');
author.addRule(editMyOwnArticle);
author.addPermissionsOf(guest);

// Moderator:
export const editArticlePermission  = createPermission('can-edit-article');
export const moderator = createRole('moderator');
moderator.addPermissionsOf(editArticlePermission);
moderator.addPermissionsOf(author);
<!-- article.component.html -->

<div *ngIf="'can-read-article' | doCan">
   You can read article!
   <div *ngIf="'can-edit-article' | doCan : article : user">
      You can edit article!
   </div>
</div>

В результате, в зависимости от текущей роли, мы можем редактировать любую статью или только текущую. Читать любые статьи смогут гости, авторы и модераторы.

Использование @doce/ngx-rbac без ролей или rule-based access control

В @doce/ngx-rbac реализован более сложный механизм создания и управления правилами. Это позволяет создать систему, основанную только на правилах, без использования ролей:

// rules.ts

const rules = doCreatRuleSet({
  RULE1: [
    ([arg1, arg2]) => {
      return arg1 === arg2;
    },
  ],
  RULE2: [
    ([arg1, arg2]) => {
      return arg1 !== arg2;
    },
  ],
});

Внедрение правил глобально:

// app.component.ts

import { rules } from './rules;

@Component({
  selector: 'app-root',
  template: '',
})
export class AppComponent {
  constructor(public doGlobalRulesService: DoGlobalRulesService) {
    doGlobalRulesService.addGlobalRules(rules);
  }
}

Внедрение правил локально:

// some.component.ts

import { rules } from './rules;

@Component({
  selector: 'app-some,
  templateUrl: './some.component.html',
})
export class SomeComponent {
   rules = rules;
}
<!-- some.component.html -->

<do-provide-rules [rules]="[rules]">
  <router-outlet></router-outlet>
</do-provide-rules>

Правила можно переиспользовать:

export const rule1 = doCreatRule(
  'rule1', 
  [(parameter1) => parameter1.id === 10]
);
export const rule2 = doCreatRule(
  'rule2', 
  [rule1, (parameter1, parameter2) => parameter2.id === 5]
);

Правило rule1 пройдёт, только если первый аргумент имеет id === 10.

Правило rule2 пройдёт, только если и первый аргумент имеет id === 10, и второй аргумент  имеет id === 5.

Правила объединяются с использованием логики И. Но в системе реализованы также логические операторы для использования логики НЕТ и ИЛИ:

doNot и doOr:

export const rule3 = doCreatRule('rule3', [
  doOr([rule1, (parameter1, parameter2) => parameter2.id === 5])
]);

Правило rule3 пройдёт, только если или первый аргумент имеет id === 10, или  второй аргумент имеет id === 5.

Инвертирование rule

Правила в @doce/nx-rbac можно переиспользовать не только по ссылке, но и по имени:

const rule4 = doCreatRule('rule4', [‘rule1’, rule2]);

Это позволяет создавать инверсии правил. Если правило во время проверки чекером обратится по имени в поисках другого правила, механизм ngx-rbac запустит поиск по инъекциям, начиная с элементных, и пройдёт вверх до глобальных правил.

Используя этот механизм, можно в отдельном куске UI внедрить свою особую проверку для правила:

// rules.ts

export const overlappedRule1 = doCreatRule(
  'rule1', 
  [(parameter1) => parameter1.id === 70]
);
// some.component.ts

import { rule1, overlappedRule1 } from './rules';

@Component({
  selector: 'app-some,
  templateUrl: './some.component.html',
})
export class SomeComponent {
   rule1 = rule1;
   overlappedRule1 = overlappedRule1;
}
<!-- some.component.html -->

<do-provide-rules [rules]="[rule1]">
  {{ 'rule4' | doCan : { id: 70 } : { id: 5 } | json }}
  <do-provide-rules [rules]="[overlappedRule1]">
     {{ 'rule4' | doCan : { id: 70 } : { id: 5 } | json }}
  </do-provide-rules>
</do-provide-rules>

В результате, в первом случае мы увидим false, так как мы ожидали первый параметр с id === 10. Во втором случае мы инвертировали правило 1 по имени и теперь получим true, так как ждём теперь объект с id === 70. 

Поскольку элементное наследование работает сквозь роутинг, инвертировать правила можно на разных маршрутах отдельно. Это позволяет обрабатывать очень сложные случаи, в том числе такие, когда мы не можем выделить в системе какие-либо роли.

Проверки в компонентах

Я уже приводил примеры того, как проходят проверки в шаблонах посредством pipe. А что, если нужно провести проверку в компонентах? Например, для управления реакциями на действия пользователя. Легко:

// app.component.ts

@Component({
  selector: 'app-root',
  template: '',
})
export class AppComponent {
  constructor(
    public provideRulesComponent: DoProvideRulesComponent,
  ) {}

  public editArticle() {
   if(this.provideRulesComponent.provideRulesService
     .can('can-edit-article')) {
      // ...
   }
  }
}

Но что, если мы пользуемся динамическими правилами либо локально внедряем правила или роль? Тогда нам нужно делать динамические проверки! Что с @doce/ngx-rbac тоже просто:

// app.component.ts

@Component({
  selector: 'app-root',
  template: '',
})
export class AppComponent {
  constructor(
    public provideRulesComponent: DoProvideRulesComponent,
  ) {}

  public editArticle() {
    this.provideRulesComponent.provideRulesService.can$
     .pipe(take(1))
     .subscribe(({ can }) => {
       if(can('editArticle')) {
         // ...
       }
    });
  }
}

Guards — контроль доступа

В @doce/ngx-rbac встроен guards (DoCanGuard) для контроля навигации. Использовать его легко:

// app-routing.module.ts

const routes: Routes = [
  {
    path: 'route1',
    data: {
      rules: ['can-read-article']
    },
    canActivate: [DoCanGuard],
    component: ReadArticleComponent
  },
]

В объекте data.rules может быть указано имя роли или разрешение.

Но имейте в виду, что guards имеет доступ только к глобальным правилам и разрешениям.

Заключение

Я уже проделал большую работу над библиотекой, впереди её предстоит ещё больше. Сейчас библиотека находится в стадии активной разработки. 

Приглашаю вас воспользоваться продуктом, чтобы оценить, насколько он проще и функциональней остальных. Также буду рад любой обратной связи: отзыву о работе, совету по доработке софта или рекомендации. Спасибо!

Демо приложение