Мультитенантный SaaS: роли участников и права отдельных операций
Членство позволило отделить свои компании от чужих. Но участники одной компании тоже делают разные действия. Наблюдатель должен читать проекты, редактор — менять содержимое, администратор — приглашать людей. Владелец дополнительно отвечает за существование организации и назначение другого владельца.
В этом уроке зададим матрицу ролей «Пространства» и применим её к операциям. Анна остаётся владельцем «Севера» и наблюдателем «Юга», поэтому её пример показывает разницу между глобальной личностью и локальным разрешением. Полный код Permissions.cs и методов членства входит в архив. Приведённые сценарии являются ожидаемыми; приложение не запускалось.
Роль обозначает набор действий
Названия ролей не нужно сравнивать по алфавиту или номеру. Роль admin не становится выше editor потому, что раньше стоит в перечислении. Мы явно связываем действие с разрешёнными ролями. Так новое право можно добавить, не меняя смысл всех существующих сравнений.
| Операция | owner | admin | editor | viewer |
|---|---|---|---|---|
| Читать проекты | да | да | да | да |
| Создавать и менять проекты | да | да | да | нет |
| Приглашать editor или viewer | да | да | нет | нет |
| Менять обычные роли | да | да, с ограничением | нет | нет |
| Назначать owner и менять состояние компании | да | нет | нет | нет |
Матрица является контрактом примера, а не универсальным словарём SaaS. В другом продукте администратор может не иметь доступа к содержимому документов, а финансовый участник — читать счета без права приглашать людей. Начинать полезно с действий, которые действительно существуют, и только затем объединять их в удобные роли.
Ниже — завершённый фрагмент метода Can из Permissions.cs. role приходит из повторно проверенного членства, а permission задаётся серверным обработчиком. Клиент не передаёт название желаемого разрешения для самопроверки.
return permission switch
{
"read" => role is "owner" or "admin" or "editor" or "viewer",
"write" => role is "owner" or "admin" or "editor",
"manage_members" => role is "owner" or "admin",
"lifecycle" => role == "owner",
_ => false
};
Неизвестное разрешение получает отказ. Это безопасное значение по умолчанию: опечатка в новой операции не должна разрешать её всем. При разработке такую опечатку желательно обнаружить статически через отдельный тип или перечисление. В небольшом примере строки оставлены видимыми, чтобы каждый метод показывал своё требуемое действие.
Проверка относится к конкретной компании
Получив роль Анны для «Юга», метод создания видит viewer и возвращает 403. Роль owner из «Севера» не участвует в этом решении. Именно поэтому встроенный глобальный RequireRole("owner") без области компании не выражает наш контракт. Общая аутентификация и локальная проверка прав выполняют разные части задачи.
Для чтения проверка роли может казаться избыточной, поскольку все четыре роли читают. Но явное разрешение помогает при будущем появлении приостановленного членства или финансовой роли. Не стоит превращать условие «найдена любая строка членства» в вечную гарантию доступа ко всем данным. Смысл строки задаётся политикой текущего проекта.
Проверка делается рядом с началом транзакции. Если фильтр уже нашёл admin, а владелец понизил его до viewer до начала операции, транзакция перечитывает роль. Присланное клиентом старое значение роли не помогает. Если понижение конфликтует с уже начавшимся действием, блокировки определяют порядок, а не случайное время выполнения C#.
В ответе 403 можно вернуть предметный код permission_denied. Не нужно перечислять клиенту внутренние SQL-права или все возможные способы получить привилегию. Пользователь уже знает, что состоит в компании; интерфейс может показать понятное действие «обратитесь к владельцу» по собственному контракту.
Последний владелец не исчезает
Изменение ролей отличается от записи обычного проекта. Если владелец понизит единственного владельца до viewer, компания потеряет участника, который может назначить нового владельца или завершить её жизненный цикл. Поэтому транзакция перед изменением берёт блокировку строки организации и проверяет количество владельцев.
Эта общая блокировка важна для двух параллельных действий. Представьте двух владельцев, каждый из которых пытается понизить другого. Без сериализации обе транзакции увидят «владелец ещё есть», а затем сохранят изменения. В зависимости от выбранных целей компания может остаться без владельца. Блокировка родительской строки заставляет второй запрос прочитать состав после первого.
В полном примере admin не может назначать owner, менять владельца или повышать себя до owner. Ограничение проверяется по целевой роли и текущей роли изменяемого участника. Одна проверка «инициатор admin» слишком широка: она дала бы администратору возможность отобрать владельцу полномочия через обычный маршрут управления составом.
Удаление членства использует то же правило последнего владельца. Не добавляйте для удаления отдельный упрощённый путь только потому, что он назван «выйти из компании». Если единственный владелец выходит, продукт должен сначала предложить передать владение либо завершить существование организации. Название кнопки не меняет инвариант.
Интерфейс показывает права, сервер сохраняет их
Клиенту удобно получить текущую роль вместе с выбранной компанией и скрыть недоступные кнопки. Это уменьшает число бессмысленных отказов, но сервер повторяет проверку независимо от интерфейса. Пользователь может держать старую вкладку после изменения роли, вручную отправить запрос или использовать другую версию клиента.
Для самостоятельного наблюдения сначала попробуйте изменение имени проекта от Анны в «Юге», затем назначьте ей editor владельцем Борисом и повторите тот же запрос с актуальной версией. Ожидается переход от 403 к успешному изменению. При этом проекты «Севера» остаются в прежней области, а личность Анны не меняется.
Аудит изменения роли сохраняет инициатора, целевого участника и прежнее с новым значением. Он не должен записывать фиктивное событие при отказе последнему владельцу, как будто изменение состоялось. Отказы можно хранить в техническом журнале, а подтверждённую историю — вместе с commit. Это поможет позже различать попытку и завершённое действие.
Матрица ролей теперь связана с реальными операциями. Следующий урок вводит приглашения в компанию: приглашение переносит предложение доступа во времени, поэтому в нём понадобится срок действия, связь с подтверждённой личностью и защита от повторного принятия.