Одна из самых опасных ошибок в сложном веб-продукте может выглядеть совершенно безобидно.
Пользователь входит в свой кабинет, открывает проект:
/project/1842потом вручную меняет адрес:
/project/1843и неожиданно получает проект другого клиента.
Интерфейс может быть красивым.
Пароли могут надёжно хешироваться.
HTTPS работает.
JWT корректный.
Форма входа защищена.
Но приложение всё равно раскрывает чужие данные.
Причина проста: система успешно ответила на вопрос:
Кто этот пользователь?
но не ответила на второй:
Имеет ли именно этот пользователь право работать именно с этим объектом?
При разработке сложного веб-продукта авторизация заканчивается не после проверки логина и пароля. На этом она, по сути, только начинается.
Особенно быстро проблема становится заметной в SaaS, CRM, клиентских кабинетах и B2B-системах, где одновременно существуют пользователи, организации, проекты, документы, сообщения, файлы, администраторы и десятки операций с разными уровнями доступа.
Разберём, как проектировать эту модель так, чтобы права оставались понятными разработчикам и при этом пользователь не смог получить чужой проект просто потому, что знает его ID.
Сначала нужно разделить authentication и authorization
Эти понятия часто смешивают.
Authentication отвечает:
Кто вы?
Например:
email + password
↓
user_id = 482Authorization отвечает:
Что user 482 имеет право сделать?
Это уже совершенно другая задача.
Например:
Может ли user 482
просмотреть project 1843?или:
Может ли user 482
удалить document 719?или:
Может ли user 482
пригласить сотрудника
в organization 27?Успешный вход в систему ещё ничего не говорит об этих разрешениях.
Именно поэтому логика:
if (user) {
return project;
}для сложного приложения практически всегда недостаточна.
Самая простая модель — пользователь и роль
Начнём с обычной системы:
User
├── id
├── email
└── roleРоли:
USER
ADMINТогда проверка выглядит понятно:
if (user.role !== 'ADMIN') {
throw forbidden();
}Для небольшой административной панели этого действительно иногда достаточно.
Но теперь представим SaaS.
Один человек состоит сразу в двух компаниях:
Иван
│
├── ООО Альфа
│ └── ADMIN
│
└── ООО Бета
└── VIEWERКакую роль записывать в:
user.roleADMIN?
Тогда в компании «Бета» он тоже случайно получит административные права.
VIEWER?
Тогда потеряет права в «Альфе».
Проблема в самой модели.
Роль принадлежит не пользователю вообще.
Она принадлежит отношению пользователя к конкретной организации.
В multi-tenant системе появляется Membership
Модель становится такой:
User
│
↓
Membership
│
↓
OrganizationНапример:
User 482
+
Organization 27
+
role = OWNERи одновременно:
User 482
+
Organization 91
+
role = VIEWERТеперь права имеют контекст.
В базе это может выглядеть примерно так:
users
id
email
...organizations
id
name
...memberships
user_id
organization_id
roleЭто небольшое изменение схемы данных, но архитектурно оно решает огромную проблему.
Роль — это ещё не разрешение
Представим роли:
OWNER
ADMIN
MANAGER
MEMBER
VIEWERМожно написать по всему backend:
if (
role === 'OWNER' ||
role === 'ADMIN'
) {
...
}Потом появится новое требование:
Менеджер тоже должен редактировать проект, но не должен удалять его.
Появляются конструкции:
if (
role === 'OWNER' ||
role === 'ADMIN' ||
role === 'MANAGER'
) {
updateProject();
}Через год такие условия находятся в десятках endpoint.
Теперь вопрос:
Что именно может MANAGER?
Чтобы ответить, нужно искать код по всему проекту.
Так постепенно роль начинает заменять настоящую модель разрешений.
Практичнее разделить:
ROLEи:
PERMISSIONНапример:
PROJECT_READ
PROJECT_CREATE
PROJECT_UPDATE
PROJECT_DELETE
FILE_READ
FILE_UPLOAD
FILE_DELETE
MEMBER_INVITE
MEMBER_REMOVE
BILLING_READ
BILLING_MANAGEА уже роли получают набор прав.
Например:
VIEWER
├── PROJECT_READ
└── FILE_READMANAGER
├── PROJECT_READ
├── PROJECT_CREATE
├── PROJECT_UPDATE
├── FILE_READ
└── FILE_UPLOADOWNER
└── *Теперь бизнес-правило звучит не:
ADMIN может нажать эту кнопку.
А:
Для операции требуется PROJECT_UPDATE.Это намного устойчивее.
Но RBAC всё равно не решает главную проблему
Допустим, у пользователя есть:
PROJECT_READОзначает ли это, что он может читать любой проект в системе?
Конечно нет.
Вот здесь появляется один из самых важных уровней проверки:
object-level authorization.
Permission отвечает «что», ownership отвечает «с чем»
Представим:
User 482
↓
Organization 27
↓
Project 1842Пользователь имеет разрешение:
PROJECT_READНо запрашивает:
Project 9041который принадлежит:
Organization 91Permission формально есть.
Но объект чужой.
Правильная проверка должна учитывать сразу две вещи:
Есть PROJECT_READ?
+
Project принадлежит доступному tenant?То есть:
role / permission
+
ownership / scopeСамая опасная проверка выглядит вполне логично
Плохой endpoint:
const project =
await db.project.findUnique({
where: {
id: req.params.id
}
});
if (!project) {
throw notFound();
}
return project;Почему он опасен?
Потому что пользователь управляет:
req.params.idЕсли project существует, backend его возвращает.
Frontend здесь уже ничего не исправит.
Проверять ownership лучше прямо в запросе
Надёжнее искать объект сразу внутри разрешённой области:
const project =
await db.project.findFirst({
where: {
id: projectId,
organizationId: membership.organizationId
}
});Теперь запрос означает:
Найди project 1842, если он принадлежит организации пользователя.
Если проект чужой:
result = nullИ приложение вообще не получает объект, который пользователь видеть не должен.
Это намного лучше модели:
1. получить объект;
2. потом попробовать вспомнить,
нужно ли проверить owner.Чем ближе ограничение находится к выборке данных, тем сложнее случайно его обойти.
Именно здесь возникает классическая IDOR/BOLA-проблема
Уязвимость часто выглядит именно как изменение идентификатора.
Например:
/api/projects/1842пользователь заменяет на:
/api/projects/1843или:
/api/files/982/downloadна:
/api/files/983/downloadЕсли сервер проверяет только:
session validэтого недостаточно.
Система должна проверить отношение:
User
↓
Tenant / ownership
↓
Requested objectПричём для каждого объекта.
UUID не является системой авторизации
Иногда проблему пытаются решить так:
Сделаем ID очень длинными и случайными.
Например:
project_2c8416c7-...Это полезнее последовательных:
1
2
3
4Но не заменяет authorization.
Невозможность легко угадать ID снижает вероятность случайного обнаружения.
Она не даёт право доступа.
Ссылка может попасть:
- в историю браузера;
- в лог;
- в письмо;
- в screenshot;
- в analytics;
- в Referer;
- другому сотруднику.
Если человек получил identifier, backend всё равно обязан спросить:
Имеет ли он право на этот объект?
Frontend вообще нельзя считать границей безопасности
Представим интерфейс:
[Изменить]
[Удалить]Для VIEWER кнопки скрываются:
if (!canEdit) {
hideButton();
}Это хороший UX.
Но это не защита.
Пользователь может открыть DevTools и самостоятельно отправить:
DELETE /api/projects/1842Поэтому правило:
кнопка скрытаозначает только:
интерфейс не предлагает операцию.
Настоящее правило:
backend запрещает операциюозначает:
выполнить её действительно невозможно.
Обе проверки полезны.
Но только одна является security boundary.
Проверка должна выполняться на каждой mutation
Представим endpoint:
PATCH /api/projects/1842Сервер проверил:
PROJECT_UPDATEи ownership.
Хорошо.
Но есть ещё:
DELETE /api/projects/1842и:
POST /api/projects/1842/archiveи:
POST /api/projects/1842/filesи:
GET /api/projects/1842/exportКаждая операция имеет собственную модель права.
Нельзя считать:
Раз пользователь видит проект, значит он может всё.
Например:
PROJECT_READ ✓
PROJECT_UPDATE ✓
PROJECT_DELETE ✗
PROJECT_EXPORT ✗Это нормальная ситуация.
Хорошая модель начинается с матрицы разрешений
До написания middleware полезно буквально составить таблицу.
Например:
| Операция | Owner | Admin | Manager | Member | Viewer |
|---|---|---|---|---|---|
| Просмотр проекта | ✓ | ✓ | ✓ | ✓ | ✓ |
| Редактирование | ✓ | ✓ | ✓ | △ | ✗ |
| Удаление | ✓ | ✗ | ✗ | ✗ | ✗ |
| Загрузка файлов | ✓ | ✓ | ✓ | ✓ | ✗ |
| Удаление файлов | ✓ | ✓ | ✓ | △ | ✗ |
| Приглашение сотрудников | ✓ | ✓ | ✗ | ✗ | ✗ |
| Управление оплатой | ✓ | ✗ | ✗ | ✗ | ✗ |
Даже такая простая таблица часто выявляет не технические, а продуктовые вопросы.
Например:
Может ли ADMIN удалить организацию?
Может ли MANAGER удалить файл клиента?
Может ли VIEWER скачать документ или только посмотреть его название?
Если команда не знает ответа, проблема не в коде.
Правило ещё не определено бизнесом.
Deny by default значительно безопаснее
Опасная модель:
Если мы явно не запретили —
разрешаем.По мере появления новых функций она создаёт сюрпризы.
Лучше наоборот:
По умолчанию запрещено.
Операция разрешена только если
существует явное правило.Например:
authorize({
user,
action: 'PROJECT_UPDATE',
resource: project
});Если policy ничего не знает об операции:
DENYТак новая функция не получает доступ случайно.
Policy layer помогает не размазывать authorization по коду
Плохой вариант:
projects.controller.js
→ своя проверка
files.controller.js
→ другая проверка
messages.controller.js
→ третья
admin.controller.js
→ ещё однаЧерез некоторое время одинаковое правило реализовано пятью разными способами.
Лучше выделить единый слой:
HTTP
↓
Authentication
↓
Authorization Policy
↓
Domain Service
↓
RepositoryНапример:
can(user, 'PROJECT_UPDATE', project)или:
requirePermission(
context,
Permission.ProjectUpdate
);Тогда правило существует в одном месте.
Но универсальная функция can() тоже может превратиться в монстра
Есть другая крайность:
can(user, action, resource, context)внутри которой находится две тысячи строк:
если admin...
если owner...
если tenant...
если status...
если тариф...
если special case...Поэтому policy полезно разделять по доменам.
Например:
ProjectPolicy
FilePolicy
BillingPolicy
MemberPolicy
AdminPolicyКаждая отвечает за ограниченную область.
Так разработчику проще понять:
Где находится правило удаления проекта?
Роль иногда недостаточна: появляются атрибуты
Представим:
MANAGERможет редактировать проект.
Но только если:
project.status !== COMPLETEDТеперь право зависит не только от роли.
Оно зависит от состояния объекта.
Другой пример:
SUPPORTможет просматривать аккаунт пользователя, но не его финансовые данные.
Ещё один:
MANAGERможет видеть проекты только своего отдела.
Так появляется модель, похожая на ABAC — Attribute-Based Access Control.
Решение зависит от:
кто пользователь;
какая у него роль;
какие атрибуты объекта;
какой tenant;
какое действие;
в каком состоянии ресурс.Не обязательно внедрять отдельный ABAC framework.
Но сам принцип полезно понимать.
Обычно хороший продукт использует несколько моделей одновременно
Например:
RBAC
→ общие возможности ролиOwnership
→ принадлежность объектаABAC-like rules
→ статус, отдел, план, тип объектаПолучается:
Можно ли редактировать проект?
1. Пользователь аутентифицирован?
2. Состоит в tenant?
3. Имеет PROJECT_UPDATE?
4. Проект принадлежит tenant?
5. Проект ещё допускает изменение?Только после этого:
ALLOWMulti-tenant SaaS требует особенно жёсткой изоляции
Представим две организации:
Tenant A
├── users
├── projects
└── files
Tenant B
├── users
├── projects
└── filesГлавный инвариант:
Tenant A
никогда не читает данные
Tenant B.Это должно быть фундаментальным свойством backend.
Не соглашением frontend-команды.
Опасная ошибка — tenantId из request body
Представим создание проекта:
{
"name": "Новый проект",
"organizationId": 91
}Backend принимает organizationId как есть.
Но пользователь принадлежит организации:
27Он вручную отправляет:
{
"name": "Чужой проект",
"organizationId": 91
}Если backend доверяет клиенту, он только что позволил пользователю создать данные внутри чужого tenant.
Правильнее tenant context брать из подтверждённой сервером membership:
authenticated user
↓
selected membership
↓
organizationIdа не из произвольного поля request.
Клиент не должен назначать себе владельца объекта
То же касается:
{
"ownerId": 1,
"role": "ADMIN",
"createdBy": 1
}Любое sensitive поле, которое сервер способен определить самостоятельно, лучше вообще не принимать как authoritative значение от клиента.
Например:
project.organizationId =
context.organizationId;
project.createdBy =
context.userId;Это уменьшает поверхность для mass assignment.
Mass assignment особенно опасен в generic update
Представим:
db.user.update({
data: req.body
});Frontend обычно отправляет:
{
"name": "Иван"
}Но злоумышленник отправляет:
{
"name": "Иван",
"role": "ADMIN"
}Если ORM разрешит поле, пользователь самостоятельно повысит права.
Поэтому input schema должна явно перечислять разрешённые поля:
name
avatar
timezoneа не означать:
Обновить всё, что пришло.
Отдельная проблема — списки
Один project endpoint можно защитить правильно.
Но затем появляется:
GET /api/projectsИ разработчик пишет:
SELECT *
FROM projects
ORDER BY created_at DESCТеперь пользователь видит все проекты.
Поэтому tenant scope должен применяться не только к:
GET /projects/:idно и к:
lists
search
filters
exports
statistics
countsНапример:
SELECT *
FROM projects
WHERE organization_id = $1Даже COUNT(*) может раскрывать чужие данные
Представим dashboard:
Всего проектов: 742У конкретного клиента их:
4Если запрос статистики забыл tenant condition, пользователь уже получает информацию о чужой системе.
То же относится к:
количеству пользователей;
объёму файлов;
сумме сделок;
графикам;
поисковым подсказкам.Утечка данных — это не обязательно выдача целой записи.
Иногда достаточно metadata.
Поиск — одно из частых мест утечки
Пользователь вводит:
ИванBackend ищет:
WHERE name ILIKE '%Иван%'Но забывает:
AND organization_id = ?В результате autocomplete показывает сотрудника другой компании.
Основные страницы приложения могут быть идеально защищены.
А один search endpoint нарушает tenant boundary.
Именно поэтому authorization лучше проектировать системно, а не исправлять endpoint по одному.
Экспорт данных требует отдельного permission
Очень часто пользователь не может открыть чужой проект в интерфейсе, но может вызвать:
/export.csvили:
/report.xlsxгде отсутствует часть фильтров.
Экспорт особенно опасен, потому что обычно получает много данных сразу.
Для него полезно иметь самостоятельное право:
PROJECT_EXPORTи повторять tenant scope на уровне самого запроса.
Файлы имеют такую же модель прав, как обычные сущности
Представим запись:
file_id = 9021
object_key =
organizations/27/projects/1842/file.pdfПлохой endpoint:
GET /api/files/9021находит metadata и возвращает signed URL.
Если ownership не проверен, пользователь получает чужой файл.
S3 здесь ничего не знает о бизнес-правилах.
Backend сначала должен проверить цепочку:
User
↓
Membership
↓
Organization
↓
Project
↓
Fileи только затем выдавать URL.
Public bucket может уничтожить всю модель authorization
Можно прекрасно защитить:
/api/files/:idа затем положить документы в публичный bucket:
https://storage.example/secret.pdfЕсли URL известен, backend уже не участвует.
Для приватных данных обычно разумнее:
private object
↓
authorization
↓
short-lived signed URLТогда доступ ограничен временем.
Signed URL тоже нельзя выдавать навсегда
Если подписанная ссылка действует:
365 днейона практически становится постоянным доступом.
Для чувствительных файлов лучше ограничивать срок реальной потребностью:
несколько минутили другим разумным небольшим интервалом.
При этом даже короткий signed URL желательно создавать после каждой authorization-проверки.
Сообщения и чат тоже являются объектами доступа
Допустим:
Project
└── Conversation
└── MessagesПроверка:
user authenticatedнедостаточна.
Нужно убедиться:
conversation
принадлежит project
project
принадлежит organization
user
имеет membershipОсобенно это важно для WebSocket.
WebSocket не освобождает от authorization
HTTP endpoint может быть защищён идеально.
А потом frontend подписывается:
project:1842через WebSocket.
Если сервер просто принимает:
socket.join(`project:${projectId}`);любой авторизованный пользователь может попытаться:
project:1843Поэтому подписка должна проходить тот же policy check:
Можно ли user 482
читать project 1843?Только после:
ALLOWsocket попадает в room.
Background worker тоже должен уважать security boundary
Авторизация обычно ассоциируется с HTTP.
Но фоновые процессы тоже работают с пользовательскими объектами.
Например, задача:
generate_project_archiveсодержит:
projectId = 1842Если worker получает только ID и не учитывает tenant context, ошибки в постановке job могут привести к обработке неправильных данных.
Хорошо, когда job содержит достаточный контекст:
tenantId
projectId
initiatedByа worker повторно валидирует отношения между ними.
Уведомление само может раскрыть чужие данные
Представим ошибку маршрутизации:
Пользователь A
получает push:
"Проект клиента Бета готов"Даже если при нажатии страница вернёт 403, данные уже утекли через текст уведомления.
Поэтому authorization важна ещё на этапе формирования notification recipient.
То же относится к:
email;
Telegram;
Web Push;
SMS.Кеширование усложняет permission model
Представим:
GET /api/project/1842Ответ кешируется по ключу:
project:1842Пользователь A вызвал его первым.
Потом пользователь B запрашивает тот же URL.
Если cache layer не учитывает scope, B может получить уже готовый ответ A.
Для пользовательских ресурсов ключ должен учитывать контекст доступа или кеширование должно происходить ниже слоя, где персональные данные уже отделены.
Например:
tenant:27:project:1842Но даже этого недостаточно, если внутри tenant разные роли имеют разные представления.
Security и cache architecture нельзя проектировать независимо.
Изменение роли должно действовать сразу
Представим:
USER 482
role = ADMINОн получает session token.
После этого владелец организации меняет роль:
ADMIN → VIEWERЧто произойдёт со старой session?
Если permission закодированы прямо внутрь долгоживущего JWT:
{
"role": "ADMIN",
"exp": "+30 days"
}человек потенциально останется ADMIN ещё месяц.
Это важный архитектурный выбор.
Есть несколько стратегий
Можно использовать короткоживущий access token.
Например:
15 минути регулярно обновлять authorization context.
Можно проверять membership из БД на критичных операциях.
Можно использовать permission version:
membership.version = 7и инвалидировать старые сессии после изменения прав.
Можно использовать session store.
Универсального решения нет.
Но вопрос:
Через сколько времени отзыв права реально начинает действовать?
должен иметь конкретный ответ.
Удалённый сотрудник не должен сохранять старый доступ
Это особенно важно для B2B.
Сотрудника удалили из организации:
membership deletedПосле этого старый bookmarked URL:
/projects/1842должен перестать работать.
Даже если session пользователя в целом остаётся активной.
То есть удаление membership не обязательно означает:
logout со всего приложенияно должно означать:
доступ к tenant 27
прекращёнOwner — специальная роль
У организации обычно есть пользователь, который имеет максимальные полномочия.
Но даже OWNER не обязательно должен означать:
может абсолютно всё всегдаНапример, может быть запрещено:
удалить самого себя,
если других owners нет.Иначе организация останется без владельца.
Полезный invariant:
organization
должна иметь
минимум одного OWNERТеперь изменение роли становится не простым:
UPDATE memberships
SET role = 'VIEWER'а бизнес-операцией, которая должна проверить:
Не был ли это последний owner?
Системный администратор — отдельный контур
В приложении может существовать:
tenant adminи:
platform adminЭто разные роли.
Tenant admin управляет своей организацией.
Platform admin обслуживает весь сервис.
Смешивать их опасно.
Например:
ADMINможет означать совершенно разное.
Лучше иметь явные пространства:
ORGANIZATION_ADMIN
PLATFORM_ADMINили вообще отдельную модель административной авторизации.
Platform admin тоже не должен иметь бесконтрольный доступ
Иногда разработчики думают:
Это же наш администратор. Можно разрешить всё.
Но административный аккаунт — особенно привлекательная цель.
Практичнее применять:
минимально необходимые permissions;
TOTP;
короткие admin sessions;
audit;
разделение критичных действий.Некоторые данные можно вообще скрывать от обычной поддержки.
Impersonation требует особенно осторожного проектирования
Иногда поддержке нужна функция:
Посмотреть интерфейс глазами клиента.
Это удобно.
Но кнопка:
Войти как пользовательфактически является очень мощным permission.
Нужно как минимум понимать:
кто начал impersonation;
кого открыл;
когда;
что сделал;
когда вышел.А некоторые действия во время impersonation можно полностью запрещать:
изменение пароля;
удаление аккаунта;
финансовые операции.Audit log становится частью permission architecture
Когда роли становятся сложными, недостаточно знать текущее состояние.
Нужно отвечать:
Кто дал этому пользователю ADMIN?
Кто удалил участника?
Кто изменил роль?
Кто скачал экспорт?
Audit entry может выглядеть:
actor:
user 482
action:
MEMBERSHIP_ROLE_CHANGED
target:
user 719
organization:
27
before:
VIEWER
after:
ADMIN
timestamp:
...Это полезно и для расследования security-инцидентов, и для обычной поддержки.
Audit log не должен зависеть от интерфейса
Если действие возможно через:
web;
API;
admin panel;
background process,audit должен происходить на уровне бизнес-операции.
Иначе действия, совершённые вне конкретной UI-кнопки, исчезнут из истории.
Никогда не полагайтесь только на route middleware
Можно написать:
router.use(requireAdmin);и чувствовать себя спокойно.
Но настоящие правила часто находятся глубже.
Например:
ADMIN
может редактировать project,
но только своего tenant.Middleware знает роль.
Он ещё не знает конкретный project.
Поэтому часто полезны два слоя:
route-level capability
+
resource-level policyОдин хороший запрос лучше двух плохо связанных проверок
Рассмотрим:
const project =
await getProject(projectId);
if (
project.organizationId !==
user.organizationId
) {
throw forbidden();
}Это уже неплохо.
Но при сложных запросах легче ошибиться.
Часто безопаснее формировать repository method:
getProjectForOrganization(
projectId,
organizationId
)который физически не способен вернуть чужой объект.
Тогда domain service получает уже ограниченный ресурс.
Scoping repository уменьшает количество мест для ошибки
Вместо:
ProjectRepositoryможно работать через:
TenantProjectRepositoryсозданный с контекстом:
organizationId = 27И любой запрос автоматически получает:
WHERE organization_id = 27Это не единственный вариант архитектуры, но идея полезная:
security invariant лучше кодировать структурно, чем надеяться, что разработчик вспомнит о нём в каждом запросе.
Row Level Security может дать ещё один уровень защиты
PostgreSQL поддерживает Row Level Security.
Можно создавать policy уровня БД, которые ограничивают строки определённым tenant.
Это потенциально добавляет defense in depth.
Но RLS тоже создаёт сложность:
контекст подключения;
миграции;
административные запросы;
background workers;
debugging.Поэтому это не обязательное решение для любого SaaS.
Главное — не наличие конкретной технологии, а гарантия tenant isolation.
Permissions должны быть частью API-контракта
Frontend всё равно должен понимать, какие действия можно показать.
Плохая схема:
if (user.role === 'ADMIN') ...во всех компонентах.
Теперь frontend самостоятельно повторяет бизнес-правила backend.
При изменении policy легко получить рассинхронизацию.
Один из подходов — backend возвращает capabilities.
Например:
{
"project": {
"id": "1842",
"name": "CRM",
"permissions": {
"canEdit": true,
"canDelete": false,
"canUpload": true
}
}
}Frontend использует их для UX.
Но backend всё равно повторно проверяет permission при mutation.
Capabilities помогают интерфейсу.
Они не являются security token.
Не возвращайте данные, которые пользователь не должен видеть
Представим объект:
{
"id": 482,
"name": "Иван",
"email": "...",
"internalNote": "...",
"billingRisk": "...",
"passwordHash": "..."
}Frontend показывает только:
nameНо остальные поля всё равно приехали в браузер.
Это уже утечка.
Правило:
Не отображаем поле
не равно:
Не передаём поле.
API response должен формироваться с учётом разрешений.
Один ресурс может иметь разные представления
Например, сотрудник видит:
Client:
name
company
project statusFinance role дополнительно:
invoice
payment
contract amountPlatform admin:
technical IDs
support metadataЭто не обязательно означает три разных endpoint.
Но serialization layer должен учитывать authorization context.
Статус объекта тоже способен менять права
Представим проект:
DRAFT
↓
APPROVED
↓
IN_PROGRESS
↓
COMPLETEDКлиент может редактировать ТЗ в:
DRAFTпосле подтверждения:
APPROVEDпрямое изменение запрещается.
Теперь условие:
CLIENT
+
PROJECT_UPDATEвсё ещё недостаточно.
Нужен:
project.status === DRAFTИменно поэтому permissions и state machine тесно связаны.
Иногда правильная операция — не UPDATE
Представим завершённый проект.
Клиент хочет изменение.
Плохой вариант:
PATCH projectХорошая бизнес-модель может быть:
Create ChangeRequestТеперь старое согласованное состояние остаётся неизменным.
Администратор оценивает изменение.
Клиент принимает условия.
И только потом появляется новая разрешённая операция.
Authorization помогает не только безопасности.
Она помогает правильно моделировать сам бизнес-процесс.
Архивный объект тоже требует правил
Статус:
COMPLETEDили:
ARCHIVEDне обязательно означает:
Объект больше никому не виден.
Например:
читать ✓
скачивать ✓
редактировать ✗
удалять ✗
писать зависит от настройкиPermissions могут зависеть от lifecycle.
Что возвращать: 403 или 404?
Представим пользователь запрашивает чужой проект:
/project/999Можно вернуть:
403 ForbiddenЭто подтверждает:
project 999 существует, но вам нельзя.
Иногда для чувствительных ресурсов лучше:
404 Not FoundТо есть снаружи система не раскрывает само существование объекта.
Универсального правила нет.
Но поведение должно быть последовательным.
Ошибки authorization не должны раскрывать лишнюю информацию
Плохой ответ:
У вас нет доступа к проекту
компании ACME Corporation.Пользователь уже узнал название чужой компании.
Лучше:
Resource not foundили нейтральное:
Access deniedв зависимости от выбранной модели.
GraphQL не решает проблему автоматически
Иногда кажется, что authorization сложна только в REST.
Но GraphQL способен сделать её даже интереснее.
Один запрос может получить:
organization
└── projects
└── files
└── ownerПрава необходимо учитывать на каждом уровне.
Если resolver files забыл policy, основной resolver организации уже не спасает.
Технология API меняется.
Задача authorization остаётся.
Пагинация тоже должна выполняться после scope
Неправильно:
1. взять первые 20 проектов системы;
2. удалить чужие;Пользователь может увидеть:
3 результатахотя у него 50 проектов.
Правильно:
1. ограничить tenant;
2. применить фильтр;
3. выполнить count;
4. применить pagination.То есть security scope должен участвовать непосредственно в запросе.
Один забытый административный endpoint способен обойти всю систему
Представим основной API тщательно защищён.
Но для внутренней админки существует:
/api/admin/project?id=1842и разработчик считает:
Этим пользуемся только мы.
Если endpoint доступен из интернета, он является частью attack surface.
Административный API должен иметь собственную строгую модель authentication и authorization.
Фича-флаг не является permission
Иногда смешивают:
feature enabledи:
user allowedНапример:
Advanced Reportsдоступны только тарифу PRO.
Это entitlement:
PLAN_PRO
→ feature enabledНо внутри организации отчёт могут видеть только:
OWNER
FINANCEПолучается:
Feature enabled?
+
Permission granted?Оба условия нужны.
SaaS обычно имеет как минимум четыре разных слоя ограничений
Например:
Authentication
→ кто пользователь
Membership
→ к какой организации относится
Permission
→ что может делать
Entitlement
→ разрешает ли это тарифИ дополнительно:
Resource state
→ допустима ли операция сейчасВ коде это может выглядеть сложнее обычного:
if (user) ...Но сложность существует в бизнесе независимо от того, отражена она в архитектуре или нет.
Если её не моделировать явно, она всё равно появится — только в виде разрозненных if.
Как мы бы проектировали новую систему
В упрощённом виде схема выглядела бы так:
Request
↓
Authentication
↓
User context
↓
Tenant / Membership
↓
Permission
↓
Resource scope
↓
Business state
↓
Operation
↓
AuditНе каждый endpoint обязан иметь все уровни.
Но порядок очень полезен как mental model.
Пример: редактирование проекта
Запрос:
PATCH /api/projects/1842Шаг 1
Пользователь авторизован?
нет → 401Шаг 2
Есть активная membership?
нет → denyШаг 3
Есть:
PROJECT_UPDATE?
нет → denyШаг 4
Project 1842 принадлежит текущему tenant?
нет → deny/not foundШаг 5
Состояние допускает изменение?
COMPLETED → denyШаг 6
Разрешённые поля валидируются.
Шаг 7
Изменение выполняется.
Шаг 8
Пишется audit event.
Это и есть полноценная операция.
Пример: скачивание файла
На первый взгляд проще:
GET /api/files/719/downloadНо цепочка:
session?
↓
membership?
↓
FILE_READ?
↓
file exists?
↓
file.organizationId == tenant?
↓
file.project accessible?
↓
object still active?
↓
signed URLПропуск одного уровня способен открыть данные.
Пример: приглашение сотрудника
Запрос:
Пригласить:
employee@example.com
role:
ADMINНужно проверить не только:
MEMBER_INVITEно и:
Имеет ли приглашающий право выдавать именно роль ADMIN?
Иначе MANAGER, которому разрешено добавлять обычных пользователей, сможет создать нового администратора.
Для изменения ролей полезен отдельный permission:
MEMBER_ROLE_MANAGEи ограничения на максимальный уровень выдаваемой роли.
Привилегии не должны повышаться транзитивно
Представим:
MANAGER
может создавать invitation.Если invitation позволяет:
role = OWNERMANAGER фактически способен создать OWNER.
То есть косвенно повысить собственные возможности.
Такие escalation paths нужно специально искать.
Самое интересное начинается при комбинации функций
Отдельно всё безопасно:
создать API keyи:
назначить integrationНо вместе может оказаться, что пользователь с ограниченной ролью создаёт ключ, имеющий более широкие права, чем он сам.
Или:
экспортировать данныепозволяет получить поля, скрытые в UI.
Или:
пригласить участникапозволяет выдать чрезмерную роль.
Authorization нужно проверять не только по функциям, но и по возможным цепочкам повышения привилегий.
Как тестировать права доступа
Тест:
ADMIN может открыть страницунедостаточен.
Гораздо важнее негативные сценарии.
Например:
VIEWER не может обновить projectUser tenant A
не видит project tenant BUser tenant A
не скачивает file tenant BMANAGER
не может назначить OWNERremoved member
теряет доступ немедленноархивный project
нельзя изменитьОсобенно полезен тест с двумя организациями
Мы бы считали обязательным сценарий:
Tenant A
└── user A
└── project A
└── file A
Tenant B
└── user B
└── project B
└── file BПосле этого user A пытается:
GET project B
PATCH project B
DELETE project B
DOWNLOAD file B
SEARCH project B
EXPORT project B
SUBSCRIBE websocket BВезде должен быть отказ.
Такой интеграционный тест способен поймать ошибки, которые сложно увидеть обычным happy-path QA.
Тестировать нужно не только endpoint
Если приложение имеет:
HTTP
WebSocket
background jobs
exports
signed files
notifications
admin APItenant isolation нужно проверять во всех этих каналах.
Security boundary продукта равна самому слабому пути доступа к данным.
Не стоит полагаться на ручное тестирование ID
Проверка:
поменяли 1842 на 1843 —
не открылосьполезна.
Но её нужно автоматизировать.
При появлении нового endpoint человек может просто забыть повторить эксперимент.
Regression suite не забывает.
Что происходит при появлении новой роли
Допустим, продукт несколько лет жил с:
OWNER
MEMBERЗатем бизнес просит:
ACCOUNTANTЕсли authorization построена на строках:
if (role === 'OWNER') ...придётся пересматривать весь код.
Если операции работают через permissions, достаточно определить новый набор:
ACCOUNTANT
├── BILLING_READ
├── INVOICE_READ
└── INVOICE_EXPORTОстальные части системы почти не меняются.
Это один из главных аргументов в пользу permission-based модели.
Не нужно создавать тысячу permissions заранее
Здесь легко переусложнить.
Можно придумать:
PROJECT_TITLE_UPDATE
PROJECT_DESCRIPTION_UPDATE
PROJECT_DATE_UPDATE
PROJECT_OWNER_UPDATE
...и получить систему, которую никто не способен администрировать.
Permission должен отражать реальное бизнес-различие.
Например:
PROJECT_READ
PROJECT_UPDATE
PROJECT_DELETEможет быть вполне достаточно.
Декомпозиция нужна там, где разные операции действительно выдаются разным ролям.
Архитектура permissions должна оставаться объяснимой
Хороший тест:
Можно ли за пять минут объяснить владельцу продукта, почему менеджер может выполнить эту операцию?
Если ответ требует анализа пятнадцати вложенных policy, система, возможно, стала слишком сложной.
Security выигрывает не только от строгости.
Она выигрывает от понятности.
Почему эта задача особенно важна для CRM
CRM почти всегда содержит:
контакты;
сделки;
стоимость;
документы;
переписку;
внутренние комментарии.При этом одному менеджеру иногда нельзя видеть сделки другого отдела.
Руководителю нужно видеть весь отдел.
Финансисту — суммы, но не обязательно переписку.
Администратору — настройки.
Владельцу — всё.
Обычная модель:
USER / ADMINдля такого продукта очень быстро перестаёт работать.
В клиентском кабинете задача не проще
Клиент должен видеть:
свои проекты;
свои файлы;
свои уведомления;
свою переписку.Администратор:
проекты всех клиентов.Но даже администратор может иметь ограниченные роли.
Например:
support
project manager
finance
superadminПоэтому хороший клиентский кабинет нужно проектировать как multi-role system уже на уровне backend.
Самая опасная фраза — «этот URL всё равно никто не знает»
Security, построенная на неизвестности маршрута, недолговечна.
Например:
/admin/internal/export-allЕсли endpoint существует, нужно считать, что его адрес однажды станет известен.
Защищать нужно операцию.
Не тайну URL.
Вторая опасная фраза — «frontend туда не отправляет»
Пользователь не обязан пользоваться вашим frontend.
Он может использовать:
curl;
Postman;
DevTools;
собственный script.Backend должен считать любой входящий параметр недоверенным.
Даже если официальный UI никогда его не создаёт.
Третья опасная фраза — «у нас UUID, его невозможно угадать»
UUID полезен.
Но authorization он не заменяет.
И четвёртая — «это же только внутренняя админка»
Если система доступна по сети и содержит важные данные, её нужно защищать независимо от маркетингового определения «внутренняя».
Как понять, что модель доступа спроектирована хорошо
В хорошо спроектированной системе можно быстро ответить:
Почему этот пользователь видит этот проект?
Например:
User 482
↓
Membership in organization 27
↓
role MANAGER
↓
permission PROJECT_READ
↓
Project 1842 belongs to organization 27
↓
ALLOWИ так же быстро:
Почему он не может удалить его?
MANAGER
↓
PROJECT_DELETE absent
↓
DENYЕсли ответ:
Где-то в компоненте есть условие,
модель слишком неявная.
Что мы считаем главным принципом
При проектировании доступа мы стараемся не задавать вопрос:
Как спрятать лишнее от пользователя?
Это неправильная постановка.
Правильный:
Какой минимальный набор данных и операций этому пользователю вообще разрешён?
Отсюда естественно следуют:
deny by default;
server-side authorization;
tenant scoping;
object-level checks;
explicit permissions;
limited serialization;
audit;
negative tests.Вместо вывода
Права доступа становятся сложными не потому, что разработчики любят сложность.
Они становятся сложными потому, что реальный бизнес уже содержит её.
У сотрудника есть роль.
Но роль действует внутри организации.
Организация владеет проектами.
Проект имеет состояние.
Файл принадлежит проекту.
Тариф ограничивает функцию.
Некоторые операции доступны только владельцу.
Другие — администратору.
Третьи запрещены после завершения проекта.
Если всю эту модель попытаться заменить одним:
isAdmin = trueсложность никуда не исчезнет.
Она просто превратится в ошибки.
Поэтому хорошая архитектура доступа строится слоями:
Authentication
↓
Membership
↓
Permission
↓
Ownership / tenant scope
↓
Resource state
↓
Operation
↓
AuditА frontend используется только для того, чтобы удобно показать пользователю то, что backend уже разрешил.
Самый важный вывод здесь довольно простой:
пользователь не должен получать доступ к объекту только потому, что сумел назвать его ID.
Система должна каждый раз доказать сама себе, почему конкретному человеку разрешено конкретное действие над конкретными данными.
Для сложного SaaS, CRM или клиентского кабинета это не дополнительная функция безопасности.
Это одна из основных частей архитектуры продукта.