Назад к статьям
Статья

Права доступа в таск-трекере без ролей — альтернатива RBAC, четыре версии модели и права на уровне спейса

Architecture First: как небольшой командой с ИИ сделать альтернативу Jira — Часть 17.
В предыдущей статье мы добавили организации: в них можно пригласить других людей. Теперь нужно было решить, что каждый из них может делать.

Краткое содержание

Как мы сделали права доступа в Boards:

  1. Ролей нет: каждому участнику организации права выдаются напрямую. Для текущих задач этого хватает, а роли можно будет добавить позже, если о них попросят пользователи.
  2. Права делятся на две части: права на объекты (спейсы, доски, issues) и административные (управлять участниками, переименовать организацию и так далее).
  3. Права на объекты можно выдать на всю организацию или на конкретный спейс. Итоговые права — объединение двух уровней.
  4. У модели было четыре версии. После каждой мы придумывали случаи, которые она не могла выразить, и меняли её.
  5. От прав на отдельную доску отказались: запросы становились слишком сложными, а настройка — запутанной.
  6. Флаги в одной колонке заменили отдельными bool-колонками, потому что по флагам база не может использовать индекс.

Почему без ролей

Самый известный подход к правам — RBAC (role based access control, управление доступом на основе ролей). Пользователю назначается роль, например «администратор», «участник» или «наблюдатель», а права принадлежат роли. Это удобно, когда пользователей много, а наборов прав мало.

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

Дальше — о том, как устроен этот набор и сколько раз мы его меняли.

Первая версия: один набор флагов

Первая версия появилась через пару дней после организаций. У участника был один набор прав в виде флагов:

[Flags]
public enum AccessLevel : byte
{
    Read = 1,
    Create = 2,
    Update = 4,
    Delete = 8,
    Manage = 16,
}

Те же флаги можно было выдать на конкретный спейс или конкретную доску. Если ссылка на спейс или доску была пустой, права действовали на все.

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

Вторая версия: административные права отдельно

Во второй версии административные права вынесли в отдельный набор, а у прав на объекты остались только чтение, создание, изменение и удаление:

[Flags]
public enum AdminAccessLevel : byte
{
    None = 0,
    CreateSpaces = 1,
    ManagePermissions = 2,
    UpdateOrganization = 4,
    DeleteOrganization = 8,
    All = CreateSpaces | ManagePermissions | DeleteOrganization | UpdateOrganization,
}

Но и во второй версии нашлись случаи, которые она не могла выразить. Например, участник должен видеть спейс и создавать в нём issues, но не переименовывать сам спейс. Одного набора флагов на спейс для этого мало: право «создать» относится к тому, что внутри спейса, а «изменить» и «удалить» — к самому спейсу.

Третья версия: права на сущность и на её детей

В третьей версии права разделились на две группы. У участника появились уровни на весь тип сущностей: на все спейсы, все доски и все issues организации. А прямые права на спейс или доску стали состоять из прав на саму сущность и прав на её детей:

public class DirectSpacePermission
{
    public long SpaceId { get; set; }
    public long OrganizationUserId { get; set; }

    public ChildrenAccessLevel ChildrenIssuesAccessLevel { get; set; }
    public ChildrenAccessLevel ChildrenEpicsAccessLevel { get; set; }
    public EntityAccessLevel EntityAccessLevel { get; set; }
}

[Flags]
public enum EntityAccessLevel : byte
{
    None = 0,
    Read = 1,
    Update = 4,
    Delete = 8,
    All = Read | Update | Delete
}

У EntityAccessLevel нет Create: создать можно только что-то внутри сущности, поэтому это право есть только в ChildrenAccessLevel. Такая же таблица была и для досок.

Модель стала выражать почти любой случай, но за это пришлось платить. Чтобы понять, может ли пользователь изменить issue, нужно было проверить его уровень в организации, прямые права на спейс и прямые права на доску. Запрос «какие issues видит пользователь» превратился в объединение нескольких источников прав.

Четвёртая версия: права только до уровня спейса

В четвёртой версии мы сделали два изменения.

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

Заменили флаги отдельными колонками. Пока права лежат флагами в одной колонке, проверка «может ли создавать issues» превращается в битовую операцию (issues_access_level & 2) <> 0. Обычный индекс по такому условию не работает, и с ростом таблиц это стало бы проблемой. Отдельная колонка can_create_issues фильтруется обычным сравнением:

public class OrganizationUser
{
    public long Id { get; set; }
    public long OrganizationId { get; set; }
    public Guid UserId { get; set; }
    public AdminAccessLevel AdminAccessLevel { get; set; }

    public bool CanRead { get; set; }
    public bool CanCreateSpaces { get; set; }
    public bool CanUpdateSpaces { get; set; }
    public bool CanDeleteSpaces { get; set; }
    public bool CanCreateEpics { get; set; }
    public bool CanUpdateEpics { get; set; }
    public bool CanDeleteEpics { get; set; }
    public bool CanCreateIssues { get; set; }
    public bool CanUpdateIssues { get; set; }
    public bool CanDeleteIssues { get; set; }
}

У прямых прав на спейс такой же набор колонок, только без создания спейсов. Административные права остались флагами: их проверяют для одного участника, а не используют для фильтрации списков.

Заодно четыре сервиса проверки прав (для спейсов, досок, статусов и issues) объединили в один AccessService.

Как работает проверка сейчас

У участника два уровня прав: на всю организацию и на конкретные спейсы. Итоговые права на спейс — их объединение: если право есть хотя бы на одном уровне, оно есть.

public AccessLevels Merge(AccessLevels? space)
{
    if (space is null)
        return this;

    return new AccessLevels
    {
        CanRead = CanRead | space.CanRead,
        CanCreateEpic = CanCreateEpic | space.CanCreateEpic,
        CanUpdateIssue = CanUpdateIssue | space.CanUpdateIssue,
        // ... и так для каждого права
    };
}

Список спейсов, которые видит пользователь, строится так же: если он может читать всё в организации, это один запрос, иначе — спейсы, на которые у него есть прямое право чтения.

public async Task<T> GetAvailableSpaces<T>(
    OrganizationAuthData authData,
    Func<IQueryable<Space>, Task<T>> map,
    CancellationToken cancellationToken)
{
    var canGloballyRead = await GetUserData(authData, x => x.CanRead, cancellationToken);
    if (canGloballyRead)
        return await map(GetAllSpacesQuery(authData));

    var query = context.DirectSpacePermissions
        .Where(sos => sos.OrganizationUser!.OrganizationId == authData.OrganizationId)
        .Where(sos => sos.OrganizationUser!.UserId == authData.UserId)
        .Where(sos => sos.CanRead)
        .Select(sos => sos.Space!);

    return await map(query);
}

Сервис возвращает не готовый список, а запрос, к которому вызывающий код добавляет свои условия. Поэтому фильтр по правам и фильтр по данным выполняются в базе одним SQL-запросом.

Кроме прав, действуют правила самой модели. Спейс и доску по умолчанию нельзя удалить, даже если у участника есть право на удаление, а выдать право на их удаление нельзя. Каждый такой случай закреплён интеграционным тестом.

Административные права с тех пор только добавлялись: массовый перенос досок, управление атрибутами, привязка Telegram-чатов, просмотр оплаты. Для каждого нового действия достаточно добавить флаг.

Итоги

  • Права без ролей. Каждому участнику права выдаются напрямую. Роли можно будет добавить позже, если они понадобятся.
  • Два вида прав. Права на объекты и административные права хранятся и проверяются отдельно.
  • Два уровня. Права выдаются на организацию или на спейс и объединяются через «или».
  • Граница доступа — спейс. От прав на отдельную доску отказались ради простых запросов и понятной настройки.
  • Колонки вместо флагов. Права, по которым фильтруются списки, хранятся отдельными bool-колонками.
  • Четыре версии модели. Каждая следующая версия появлялась после того, как предыдущая не смогла выразить новый случай.

Что дальше

Когда в одной организации работает несколько человек, issues нужно как-то называть в разговоре. Внутренний id трудно произнести и запомнить. В следующей статье — о том, как у спейсов появились короткие ключи, а у issues — номера вида BRD-42.