Это история не про инцидент и не про сложную архитектуру. Решение, к которому мы в итоге пришли, вполне стандартное: RBAC для ролей и ABAC для конкретных объектов. Интереснее то, как мы к нему пришли, хочу поделиться этой историей.
На старте брифинга у нас в голове была совсем другая модель. Простая, понятная и рабочая на MVP. Поменяли её несколько вопросов заказчику про то, как он видит систему после MVP.
Оговоримся сразу: домен и детали упрощены, модель показываем в виде, который можно обсуждать публично. Разговор и ход мыслей передаю как было)
Первое решение
Созвон. Заказчику нужен внутренний портал: документы, задачи, вики‑страницы. Документы относятся к разным бизнес‑направлениям компании, у них есть категории и проекты. Пользователями являютя сотрудники.
Дальше прозвучала фраза, с которой всё и началось:
Нам нужны роли, которые будут разграничивать доступ к документам.
Первая реакция: ага, ок. Справочник ролей. Пользователю назначаются роли. Таблица связки «документ - роль». Документ виден, если у пользователя есть хотя бы одна роль, с которой он связан.

Проверка доступа = пара JOIN. Реализуется быстро, понятно и заказчику, и разработке 🤷♀️
Если бы следующих вопросов не было, так бы и сделали. И на MVP это бы работало… Но потом у нас бы появился весомый технический долг.
Как бы эта модель жила дальше 🔮
Прежде чем рассказать, что было на созвоне дальше, покажу, как развивалась бы эта модель, если бы на этом всё закончилось. Каждый шаг ниже это ответ, который заказчик дал нам на том же звонке, просто разнесённый во времени.
Третий месяц: Запустили вики. Приходит задача: «У вики тоже должны быть ограничения по ролям». Логично. Делаем WIKI_PAGE_ROLE, копируем логику проверки. Немного неприятно, но терпимо.
Пятый месяц: «Нам нужно дать Пете доступ к документам, но не давать ему роль. Роль даёт слишком много». Появляется DOCUMENT_USER. Проверка превращается в «есть связь через роль ИЛИ есть прямая связь». Через неделю то же самое просят для вики, и появляется WIKI_PAGE_USER.
Седьмой месяц: Завели новую роль «Аналитик закупок». Ей нужен доступ ко всем отчётам по закупкам. Их четыре тысячи. Пишем скрипт, который перепривязывает. Через месяц появляется ещё сотня отчётов, и половину из них автор забыл привязать к новой роли. Аналитики закупок их не видят и пишут в поддержку.
Девятый месяц: Кто‑то создал документ и не привязал его ни к одной роли. Дальше всё зависит от поведения по умолчанию. Если «не видит никто», документ потерялся. Если «видят все», его видят и те, кому не надо.
В итоге четыре таблицы связей на две сущности и одинаковая логика в двух местах. Связка ссылается на конкретную таблицу сущности внешним ключом (document_id, wiki_page_id), поэтому переиспользовать её для другой сущности нельзя. Значит, у каждой следующей сущности, например FAQ, появятся свои две: FAQ_ROLE для доступа по роли и FAQ_USER для доступа человеку. А доступ описан списком. Список растёт вместе с данными…
Вопрос про целевой проект
Где‑то в середине звонка мы спросили:
А только ли у документов должны быть ограничения по ролям? Если говорить не про MVP, а про целевой проект?
Хм. Наверное, нет. Для вики‑страниц тоже надо. А задачи вообще должны показываться только те, что назначены на человека, который зашёл на портал.
Для вики связку можно скопировать, некрасиво, но можно 🙃. А вот задачи в неё не ложились никак. «Видны только мои задачи» вообще не про роль. Это правило, которое зависит от свойства самой задачи.
Значит, как минимум два разных способа решать, кто что видит. А может, и больше.
«Да, конечно»
Следующий вопрос мы задали уже осознанно:
А может быть так, что доступ захотят выдать не роли, а конкретному человеку?
Да, это очень надо, конечно
Для заказчика это было настолько очевидно, что он не посчитал нужным это упомянуть. Он не скрывал требование и не забыл его, оно просто подразумевалось)
А для модели это означало, что первый вариант уже не спасти даже костылями. Можно было бы достраивать:
Сущность | Доступ по роли | Доступ человеку |
|---|---|---|
Документ |
|
|
Вики |
|
|
FAQ |
|
|
… | +1 таблица | +1 таблица |
Но это ровно тот путь, который мы описали выше и он выглядит уже неприятно.
Два вопроса в одной фразе
Когда мы после звонка сели перерисовывать схему, стало видно, что во фразе «роли, которые разграничивают доступ к документам» спрятаны два совершенно разных вопроса:
1. Может ли этот человек вообще работать с документами? Читать, создавать, редактировать, удалять.
2. Какие именно документы он видит?
Первый вопрос про тип ресурса и действие. Он одинаковый для документов, вики, задач и админки. Это классический RBAC (Role‑Based Access Control): роль определяет, какие действия разрешены над какими типами ресурсов.
Второй вопрос про конкретные экземпляры. И у каждой сущности он свой: документы режутся по направлениям и категориям, задачи по исполнителю, вики по разделам. Это ABAC (Attribute‑Based Access Control): доступ определяется правилом над атрибутами пользователя и ресурса. У нас получился упрощённый ABAC вариант: атрибуты документа сверяются с разрешёнными значениями пользователя.
Первая модель отвечала только на второй вопрос, да и то списком. Про действия в ней не было ничего: видеть документ и редактировать его было одним и тем же. Как только понадобилось бы разделить чтение и редактирование, в каждую таблицу связей пришлось бы добавлять действие, и для каждой новой сущности копировать всё целиком: и общую часть, и частную.
Вопрос про атрибуты
Дальше мы спросили:
Должны ли свойства документа (направление, категория, проект) определять, кому он доступен?
Да, должны.
В тот момент это уже было ожидаемо, хотя на старте совсем не очевидно) Так появился ещё один уровень: доступ к документу описывается не списком «кому показать», а областью по атрибутам.
Аналитик видит отчёты и регламенты направления «Рога и копыта», сколько бы их ни было. Новый отчёт «Рогов и копыт» попадает в его область сам, в момент создания, без привязок. Если направление и категория обязательны при создании документа, сценарий с забытой привязкой просто исчезает: забыть заполнить обязательное поле нельзя.
Что получилось

По факту здесь два слоя, ровно по двум вопросам.
Верхний слой, RBAC, отвечает на вопрос «может ли»
RESOURCE: каталог типов ресурсов:DOCUMENT,TASK,WIKI,FAQ,DICTIONARY,ADMIN_PANELACTION: каталог действий:READ,CREATE,UPDATE,DELETEROLE_PERMISSION: матрица, какая роль что можетUSER_ROLE: какие роли есть у человека
Отдельно про RESOURCE_ACTION. В первой версии матрица ролей ссылалась напрямую на ресурс и действие, и эта таблица казалась лишней. Потом мы представили админку ролей, в которой можно поставить галочку «удаление админ‑панели». Не каждое действие применимо к каждому ресурсу: у задач наверняка появится ASSIGN, которого нет у документов, а у админки есть только READ. RESOURCE_ACTION это по факту список допустимых пар с уникальностью по паре (resource_id, action_id), и внешний ключ сам не даёт выдать роли бессмыслицу. На диаграмме оба поля помечены UK, но Mermaid не умеет показывать составной ключ, так что имеется в виду именно уникальность пары)
Нижний слой, ABAC, отвечает на вопрос «что именно» DOCUMENT_ACCESS_SCOPE: область доступа к документам. К ней цепляются три измерения: направления, категории и проекты. У каждого свой справочник.
Главное здесь то, кому принадлежит область. У неё два возможных владельца: роль (role_id) или конкретный человек (user_id). Заполнено ровно одно из двух полей.
Область роли = типовой доступ. Все аналитики видят отчёты и регламенты своего направления. Это ответ на исходный запрос «роли, которые разграничивают доступ»
Область человека = персональное дополнение поверх роли. Это ответ на то самое «да, конечно»
Обе области устроены одинаково и проверяются одним и тем же запросом. Вместо двух параллельных таблиц связей, как в первой модели, получился один механизм с двумя владельцами. Итоговый доступ человека это объединение областей всех его ролей и его личных областей.
Сама таблица документов на схеме не показана, чтобы не перегружать её. Для проверки важно, что у документа одно направление (business_unit_id), одна категория (category_id) и сколько угодно проектов через связку DOCUMENT_TAG.
Как это работает на примере
Схему проще понять на конкретном человеке. Возьмём Анну, она аналитик.
Утро. Анна заходит на портал Первым делом система проверяет верхний слой: есть ли у Анны роль с правом DOCUMENT:READ? Есть, роль ANALYST даёт ей READ, CREATE и UPDATE для документов. Раздел документов открывается.
SELECT EXISTS ( SELECT 1 FROM user_role ur JOIN role r ON r.id = ur.role_id AND r.status = 'ACTIVE' JOIN role_permission rp ON rp.role_id = r.id JOIN resource_action ra ON ra.id = rp.resource_action_id JOIN resource res ON res.id = ra.resource_id JOIN action a ON a.id = ra.action_id WHERE ur.user_id = :user_id AND res.code = 'DOCUMENT' AND a.code = 'READ' );
Дальше нижний слой: какие именно документы? Личных областей у Анны пока нет, но есть область её роли ANALYST: направление «Рога и копыта», категории «Отчёты» и «Регламенты», проекты не заданы. Эту область ей никто отдельно не выдавал, она пришла вместе с ролью.
Документ | Направление | Категория | Видит? |
|---|---|---|---|
Отчёт по продажам за II квартал | Рога и копыта | Отчёты | да |
Отчёт по складским остаткам | Шарашкина контора | Отчёты | нет, чужое направление |
Бюджет на следующий год | Рога и копыта | Финансы | нет, чужая категория |
Регламент согласования заявок | Рога и копыта | Регламенты | да |
Правило простое: внутри измерения значения объединяются через ИЛИ, между измерениями через И. Для проектов это значит, что достаточно совпадения хотя бы одного проекта документа с проектами области.
Обед. В «Рогах и копытах» подготовили новый отчёт. Коллега загружает его на портал, указывает направление и категорию. Анна и все остальные аналитики видят его сразу. Никто не привязывал его ни к Анне, ни к роли, ни к чему. Документ попал в область роли, потому что подошёл по атрибутам.
После обеда. Анну просят помочь с отчётами направления «Шарашкина контора». Только с отчётами, остальные документы «Шарашкиной конторы» ей не нужны.
Первая мысль: добавить «Шарашкину контору» в область роли ANALYST. Но тогда её увидят все аналитики, а не только Анна. Да ещё и вместе с регламентами: одна область это все комбинации её измерений сразу, и выразить «Рога и копыта: отчёты и регламенты, Шарашкина контора: только отчёты» одной областью нельзя.
Поэтому Анне выдают личную область: «Шарашкина контора» × «Отчёты». Она складывается с областью роли через ИЛИ. Роль не меняется, других аналитиков это не затрагивает, документы никто не трогает.
SELECT d.* FROM document d WHERE EXISTS ( SELECT 1 FROM document_access_scope s WHERE ( s.user_id = :user_id -- личные области OR s.role_id IN ( -- области ролей пользователя SELECT ur.role_id FROM user_role ur JOIN role r ON r.id = ur.role_id AND r.status = 'ACTIVE' WHERE ur.user_id = :user_id) ) AND EXISTS (SELECT 1 FROM document_scope_business_unit x WHERE x.scope_id = s.id AND x.business_unit_id = d.business_unit_id) AND EXISTS (SELECT 1 FROM document_scope_category x WHERE x.scope_id = s.id AND x.category_id = d.category_id) AND ( NOT EXISTS (SELECT 1 FROM document_scope_tag x WHERE x.scope_id = s.id) OR EXISTS (SELECT 1 FROM document_tag dt JOIN document_scope_tag x ON x.tag_id = dt.tag_id WHERE x.scope_id = s.id AND dt.document_id = d.id) ) ) ORDER BY d.updated_at DESC LIMIT 50;
Вечер. Анна открывает задачи. Здесь никаких областей: RBAC пускает её в раздел, а дальше работает правило task.assignee_id = :user_id. Она видит только свои задачи. Тот же двухслойный механизм, просто нижний слой у задач выглядит иначе. Строго говоря, это уже не столько ABAC, сколько доступ по связи пользователя с объектом, но для двухслойной схемы разницы нет: верхний слой общий, нижний у каждой сущности свой.
Через месяц Анна переходит в другой отдел, и роль ANALYST у неё забирают. Вместе с ролью уходит и её область: отчёты и регламенты «Рогов и копыт» Анна больше не видит, и никому не нужно ничего чистить руками. Это главный плюс областей роли.
А вот личная область «Шарашкина контора» × «Отчёты» остаётся. Но пока у Анны нет ни одной роли с DOCUMENT:READ, она ничего не даёт: верхний слой отсекает раньше. Но стоит Анне получить в новом отделе любую роль с правом чтения документов, и личная область снова заработает. Поэтому смена отдела должна запускать пересмотр личных областей. Схемой это не решается, это процесс, и его надо проговорить с заказчиком заранее.
А когда дойдут руки до вики, в каталог добавится WIKI, в матрицу несколько галочек, а для самих страниц появится своя область WIKI_ACCESS_SCOPE с разделами вместо направлений. Ни одна существующая таблица не поменяется.
Соблазн сделать одну таблицу на всё
Когда смотришь на три почти одинаковые таблицы измерений, руки тянутся их схлопнуть. Одна универсальная таблица:
ACCESS_SCOPE_RULE(scope_id, resource_code, attribute_code, value)
Новая сущность или атрибут просто новые строки, без миграций, разве не прекрасно?
Мы этот вариант рассматривали и отказались. value превращается в строку, которая может быть id направления, id категории или чем угодно. Внешних ключей нет. Удалили направление и в правилах осталась ссылка в никуда, и никто об этом не узнает. Проверка превращается в SQL, который собирается из метаданных на лету, и разбирать его, когда «аналитик не видит отчёт», неудобно)
Цена типизированного подхода, что новая сущность приносит свои таблицы области. Но это ровно та часть, которая у разных сущностей и так отличается. Общую мы уже вынесли наверх.
Что всплыло, когда прогнали сценарии
Когда модель кажется готовой, полезно прогнать по ней несколько жизненных ситуаций. Вот что всплыло у нас:
На странице 12 документов вместо 50. Разработчику хочется сделать просто: достать из базы 50 последних документов, а потом выкинуть те, к которым у Пети нет доступа. Петя открывает страницу и видит 12 документов, хотя пагинация обещала 50. Сверху счётчик «Найдено: 3к+», хотя Пете доступны от силы сотня. А в поиске по слову «бюджет» Петя видит сниппет документа, который открыть не может, но текст из него уже прочитал.
Что с этим делать: фильтр доступа должен быть частью запроса к базе, а не проверкой после. И тот же фильтр должны применять поиск, уведомления и выгрузки.Страница открывается пять секунд. Документов стало пятьдесят тысяч, и проверка «подходит ли этот документ под область» выполняется для каждого из них. Без индексов база перебирает всё подряд.
Что с этим делать: индекс на документах по направлению и категории, индекс на связке документов с проектами. Таблицы областей уже покрыты своими первичными ключами.Анна правит то, что должна только читать. Анне дали личную область, чтобы она посмотрела отчёты «Шарашкиной конторы». Но её роль даёт право не только читать, но и редактировать документы. Область не различает действия, поэтому Анна может отредактировать любой отчёт «Шарашкиной конторы», хотя её просили только посмотреть.
Выразить «читает „Шарашкину контору“, а редактирует только „Рога и копыта“» эта модель не может. Для этого область пришлось бы привязывать ещё и к действию. Мы решили, что на старте это не нужно, но заказчик должен об этом знать 😁Анна создала документ и потеряла его. Анна загружает регламент и по ошибке выбирает категорию «Финансы», которой нет в её области. Документ создан, и Анна его больше не видит. Ни исправить, ни удалить.
Что с этим делать: при создании документа проверять, что выбранные направление и категория входят в область автора, и не давать выбрать остальные.Хотели дать доступ Пете, а дали всем аналитикам. Пете нужны отчёты «Шарашкиной конторы». Олег открывает админку, видит у Пети роль
ANALYST, заходит в неё и добавляет «Шарашкину контору» в область роли. Задача решена за минуту. Только теперь эти отчёты видят все сорок аналитиков.
Области роли и личные области устроены одинаково, и в интерфейсе их легко перепутать)
Что с этим делать: в админке явно разделять «доступ роли» и «личный доступ», а при правке области роли показывать, скольких людей это затронет. «Изменение применится к 40 пользователям» останавливает лучше любой инструкции, проверено)Личные области расползаются. Через полгода у половины аналитиков одна и та же личная область «Шарашкина контора» × «Отчёты». Каждую выдавали по отдельной заявке, и каждый раз это было разумно. Но по факту это уже не исключение, а новая типовая роль, которую никто не завёл. Новичок, пришедший на ту же работу, этой области не получит и снова пойдёт писать заявку.
Что с этим делать: время от времени смотреть на повторяющиеся личные области. Если одинаковых много, это сигнал завести роль или расширить область существующей.«Дай Пете посмотреть вот этот отчёт до пятницы». Не все отчёты «Рогов и копыт», а один конкретный, и только до пятницы. Области так не умеют: они описывают группы документов, а не отдельные экземпляры. Можно завести категорию ради одного отчёта, но это костыль.
Что с этим делать: отдельная таблица точечных выдач с датой окончания. Мы её сознательно не добавили, потому что в целевой картине заказчика такого сценария не было. Но место для неё понятно, и существующую модель она не ломает.«Всё по „Рогам и копытам“, кроме финансов». Сейчас модель умеет только разрешать, запретов в ней нет, поэтому и спорить правилам между собой не о чем. Такой запрос проще всего закрыть без запрета: выдать область «Рога и копыта» со всеми категориями, кроме «Финансов». Как только появятся настоящие запреты, придётся решать, что сильнее: разрешение или запрет, роль или персональная область. Мы планируем продержаться без запретов как можно дольше)
Так при чём тут брифинг
Если оглянуться на всю историю, то ни один ответ заказчика не был для него неожиданным. Вики тоже надо, конечно. Людям персонально, конечно, очень надо. Атрибуты должны влиять, да.
Он просто описывал MVP, потому что думал первым релизом. И это нормально: у него сроки и понятная ближайшая цель. А модель данных живёт дольше первого релиза, и вопрос «а что будет после» обычно задаёт тот, кто её проектирует. Требования из разряда «конечно» заказчик сам не проговорит: для него они очевидны. Их можно только вытащить вопросом.
После этой истории у нас появился короткий список вопросов, который мы задаём на любом брифинге про доступы:
Какие ещё сущности появятся после MVP, и нужно ли им то же самое?
Доступ выдаётся только ролям или ещё людям, группам, внешним участникам?
От чего зависит доступ: от того, кто человек, или от свойств самого объекта?
Доступ бывает временным?
Кто будет выдавать и отзывать доступ, и как часто?
Нужно ли знать, у кого был доступ в прошлом?
И важная оговорка: это не призыв строить всё сразу. На MVP сделали RBAC и области только для документов. Вики и задачи подключаются позже, но уже без переделки модели. Проектировать под целевое, реализовывать MVP. Это разные вещи, и путать их одинаково дорого в обе стороны 😁
Три вещи, которые мы забрали себе
Вопрос «а как это выглядит после MVP» дешевле миграции. Он занимает пять минут на встрече, а переделка модели доступа на живых данных занимает намного больше.
«Может ли» и «что именно» это два разных вопроса. Первый общий для всех сущностей и решается ролями. Второй у каждой сущности свой и решается атрибутами. Если решать их одной таблицей связей, общую часть придётся копировать в каждую новую сущность.
Доступ, описанный правилом, масштабируется. Доступ, описанный списком, нет. Список растёт вместе с данными и требует рук на каждый новый объект. Правило один раз описывает область, а новые объекты попадают в неё сами.
Больше таких разборов: про интеграции, проектирование и решения, которые казались очевидными в момент принятия, пишy тут.
А пока мне интересно: какое у вас своё последнее «да, конечно»? Требование, которое заказчик считал настолько очевидным, что вы узнали о нём только когда всё уже было построено?)

