Architecture First: как небольшой командой с ИИ сделать альтернативу Jira — Часть 17.
В предыдущей статье мы добавили организации: в них можно пригласить других людей. Теперь нужно было решить, что каждый из них может делать.
Краткое содержание
Как мы сделали права доступа в Boards:
- Ролей нет: каждому участнику организации права выдаются напрямую. Для текущих задач этого хватает, а роли можно будет добавить позже, если о них попросят пользователи.
- Права делятся на две части: права на объекты (спейсы, доски, issues) и административные (управлять участниками, переименовать организацию и так далее).
- Права на объекты можно выдать на всю организацию или на конкретный спейс. Итоговые права — объединение двух уровней.
- У модели было четыре версии. После каждой мы придумывали случаи, которые она не могла выразить, и меняли её.
- От прав на отдельную доску отказались: запросы становились слишком сложными, а настройка — запутанной.
- Флаги в одной колонке заменили отдельными 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.