Проектирование продукта

Роли и права доступа в веб-продукте: как не открыть пользователю чужие данные

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

Пользователь входит в свой кабинет, открывает проект:

/project/1842

потом вручную меняет адрес:

/project/1843

и неожиданно получает проект другого клиента.

Интерфейс может быть красивым.

Пароли могут надёжно хешироваться.

HTTPS работает.

JWT корректный.

Форма входа защищена.

Но приложение всё равно раскрывает чужие данные.

Причина проста: система успешно ответила на вопрос:

Кто этот пользователь?

но не ответила на второй:

Имеет ли именно этот пользователь право работать именно с этим объектом?

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

Особенно быстро проблема становится заметной в SaaS, CRM, клиентских кабинетах и B2B-системах, где одновременно существуют пользователи, организации, проекты, документы, сообщения, файлы, администраторы и десятки операций с разными уровнями доступа.

Разберём, как проектировать эту модель так, чтобы права оставались понятными разработчикам и при этом пользователь не смог получить чужой проект просто потому, что знает его ID.


Сначала нужно разделить authentication и authorization

Эти понятия часто смешивают.

Authentication отвечает:

Кто вы?

Например:

email + password
        ↓
user_id = 482

Authorization отвечает:

Что 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.role

ADMIN?

Тогда в компании «Бета» он тоже случайно получит административные права.

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_READ
MANAGER
├── PROJECT_READ
├── PROJECT_CREATE
├── PROJECT_UPDATE
├── FILE_READ
└── FILE_UPLOAD
OWNER
└── *

Теперь бизнес-правило звучит не:

ADMIN может нажать эту кнопку.

А:

Для операции требуется PROJECT_UPDATE.

Это намного устойчивее.


Но RBAC всё равно не решает главную проблему

Допустим, у пользователя есть:

PROJECT_READ

Означает ли это, что он может читать любой проект в системе?

Конечно нет.

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

object-level authorization.


Permission отвечает «что», ownership отвечает «с чем»

Представим:

User 482
↓
Organization 27
↓
Project 1842

Пользователь имеет разрешение:

PROJECT_READ

Но запрашивает:

Project 9041

который принадлежит:

Organization 91

Permission формально есть.

Но объект чужой.

Правильная проверка должна учитывать сразу две вещи:

Есть 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 полезно буквально составить таблицу.

Например:

ОперацияOwnerAdminManagerMemberViewer
Просмотр проекта✓✓✓✓✓
Редактирование✓✓✓△✗
Удаление✓✗✗✗✗
Загрузка файлов✓✓✓✓✗
Удаление файлов✓✓✓△✗
Приглашение сотрудников✓✓✗✗✗
Управление оплатой✓✗✗✗✗

Даже такая простая таблица часто выявляет не технические, а продуктовые вопросы.

Например:

Может ли 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. Проект ещё допускает изменение?

Только после этого:

ALLOW

Multi-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?

Только после:

ALLOW

socket попадает в 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 status

Finance role дополнительно:

invoice
payment
contract amount

Platform 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 = OWNER

MANAGER фактически способен создать OWNER.

То есть косвенно повысить собственные возможности.

Такие escalation paths нужно специально искать.


Самое интересное начинается при комбинации функций

Отдельно всё безопасно:

создать API key

и:

назначить integration

Но вместе может оказаться, что пользователь с ограниченной ролью создаёт ключ, имеющий более широкие права, чем он сам.

Или:

экспортировать данные

позволяет получить поля, скрытые в UI.

Или:

пригласить участника

позволяет выдать чрезмерную роль.

Authorization нужно проверять не только по функциям, но и по возможным цепочкам повышения привилегий.


Как тестировать права доступа

Тест:

ADMIN может открыть страницу

недостаточен.

Гораздо важнее негативные сценарии.

Например:

VIEWER не может обновить project
User tenant A
не видит project tenant B
User tenant A
не скачивает file tenant B
MANAGER
не может назначить OWNER
removed 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 API

tenant 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 или клиентского кабинета это не дополнительная функция безопасности.

Это одна из основных частей архитектуры продукта.

Есть похожая задача?

Опишите продукт, интеграции и ограничения. До разработки зафиксируем объём, риски и критерии приёмки.

Обсудить проект →