28 июл. 2025 г.·7 мин

Схема базы данных для B2B организаций и команд, которая остаётся понятной

Схема базы данных для B2B организаций и команд: практический реляционный шаблон для приглашений, состояний членства, наследования ролей и аудируемых изменений.

Схема базы данных для B2B организаций и команд, которая остаётся понятной

Какая проблема решает этот шаблон схемы

Большинство B2B-приложений — это не просто «учётные записи пользователей». Это совместные рабочие пространства, где люди принадлежат организации, разбиваются на команды и получают разные права в зависимости от роли. Отделы продаж, поддержки, бухгалтерии и админы требуют разных доступов, и эти права меняются со временем.

Слишком простая модель быстро ломается. Если держать одну таблицу users с единым столбцом role, вы не сможете выразить «тот же человек — Admin в одной организации, но Viewer в другой». Невозможно также корректно работать с распространёнными случаями вроде подрядчиков, которые должны видеть только одну команду, или сотрудника, который ушёл из проекта, но всё ещё принадлежит компании.

Приглашения — ещё один частый источник ошибок. Если приглашение — просто строка с email, неясно, «вошёл» ли человек в организацию, в какую команду его планируют добавить и что произойдёт, если он зарегистрируется с другим email. Небольшие несогласованности здесь часто превращаются в проблемы безопасности.

Этот шаблон преследует четыре цели:

  • Безопасность: права исходят из явного членства, а не из предположений.
  • Ясность: у org, команд и ролей — по одному источнику правды.
  • Последовательность: приглашения и членства следуют предсказуемому жизненному циклу.
  • История: можно объяснить, кто выдал доступ, сменил роль или удалил кого-то.

Обещание — единая реляционная модель, которая остаётся понятной по мере роста функционала: несколько org на пользователя, несколько команд в рамках org, предсказуемое наследование ролей и удобная для аудита история изменений. Это структура, которую можно внедрить сейчас и расширять позже без переписывания всего проекта.

Ключевые понятия: orgs, teams, users и memberships

Если вы хотите, чтобы схема оставалась читаемой через полгода, начните с согласования нескольких терминов. Большая часть путаницы возникает от смешения «кто это» и «что он может делать».

Organization (org) — верхняя граница арендатора. Это учётная запись клиента или бизнес-аккаунт, владеющий данными. Если два пользователя в разных org, они по умолчанию не должны видеть данные друг друга. Это простое правило предотвращает множество случайных кросс-арендных утечек.

Team — меньшая группа внутри org. Команды моделируют реальные рабочие юниты: Sales, Support, Finance или «Проект A». Команды существуют внутри org и не заменяют границу арендатора.

User — идентичность. Это логин и профиль человека: email, имя, пароль или SSO ID и, возможно, настройки MFA. Пользователь может существовать без доступа ни к чему.

Membership — запись доступа. Она отвечает на вопрос: «Этот пользователь принадлежит этой организации (и опционально этой команде) с таким-то статусом и такими ролями». Разделение идентичности (User) и доступа (Membership) упрощает моделирование подрядчиков, оффбординга и доступа в несколько org.

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

  • Member: пользователь с активным membership в org или команде.
  • Role: именованный набор прав (например, Org Admin, Team Manager).
  • Permission: одно разрешённое действие (например, «просмотр счетов»).
  • Tenant boundary: правило, что данные ограничены рамками org.

Рассматривайте membership как небольшой автомат состояний, а не булево значение. Типичные состояния: invited, active, suspended и removed. Это делает приглашения, утверждения и оффбординг последовательными и аудируемыми.

Единая реляционная модель: основные таблицы и связи

Хорошая многоарендная схема начинается с одной идеи: храните «кто где принадлежит» в одном месте, а всё остальное — как вспомогательные таблицы. Так вы сможете ответить на базовые вопросы (кто в org, кто в команде, что они могут) без прыжков по несвязанным моделям.

Основные таблицы, которые обычно нужны:

  • organizations: одна строка на клиента (tenant). Хранит имя, статус, биллинг-поля и неизменяемый id.
  • teams: группы внутри организации (Support, Sales, Admin). Всегда принадлежат одной организации.
  • users: одна строка на человека. Глобальная, не per-organization.
  • memberships: мост, который говорит «этот пользователь принадлежит этой организации» и опционально «также этой команде».
  • role_grants (или role_assignments): какие роли у membership на уровне org, команды или обоих.

Держите ключи и ограничения строгими. Используйте суррогатные первичные ключи (UUID или bigint) для каждой таблицы. Добавьте внешние ключи, такие как teams.organization_id -> organizations.id и memberships.user_id -> users.id. Затем добавьте несколько уникальных ограничений, чтобы остановить дубли до попадания в прод.

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

  • Один slug или внешний ключ у org: unique(organizations.slug)
  • Имена команд внутри org: unique(teams.organization_id, teams.name)
  • Нет дублирующего членства в org: unique(memberships.organization_id, memberships.user_id)
  • Нет дублирующего членства в команде (если вы моделируете team membership отдельно): unique(team_memberships.team_id, team_memberships.user_id)

Решите, что у вас append-only, а что можно обновлять. Organizations, teams и users обычно обновляемы. Memberships часто обновляются по текущему состоянию (active, suspended), но изменения стоит также писать в добавочный журнал доступа, чтобы позже аудит был простым.

Приглашения и состояния членства, которые остаются согласованными

Проще всего держать доступ чистым, если рассматривать приглашение как отдельную запись, а не как наполовину созданное membership. Membership означает «этот пользователь сейчас принадлежит». Приглашение означает «нам предложили доступ, но он ещё не реален». Разделение избегает призрачных участников, полупереписанных прав и загадок «кто пригласил этого человека?».

Простая и надёжная модель состояний

Для memberships используйте небольшой набор состояний, который можно объяснить любому:

  • active: пользователь может заходить в org (и в команды, членом которых он является)
  • suspended: временно заблокирован, но история остаётся
  • removed: больше не участник, хранится для аудита и отчётности

Многие команды избегают состояния membership «invited» и вместо этого хранят «invited» строго в таблице invitations. Это чище: строки membership существуют только для пользователей, у которых фактически есть доступ (active) или у которых был доступ ранее (suspended/removed).

Email-приглашения до создания аккаунта

B2B-приложения часто приглашают по email, когда учётной записи ещё нет. Храните email в записи приглашения вместе с областью (org или team), предполагаемой ролью и тем, кто отправил приглашение. Если человек позже зарегистрируется с этим email, вы можете сопоставить отложенные приглашения и позволить ему принять их.

Когда приглашение принимают, обрабатывайте это в одной транзакции: пометьте invitation как accepted, создайте membership и запишите запись в аудит (кто принял, когда и какой email использовался).

Определите понятные конечные состояния приглашения:

  • expired: вышел срок и принять нельзя
  • revoked: отозван админом и принять нельзя
  • accepted: конвертировано в membership

Предотвращайте дублирование приглашений, обеспечив «только одно ожидающее приглашение на org или team на email». Если вы поддерживаете повторные приглашения, либо продлите срок существующего pending-приглашения, либо отзывайте старое и создавайте новый токен.

Роли и наследование без запутанности

Постройте эту схему визуально
Смоделируйте orgs, teams и memberships в PostgreSQL визуально, затем сгенерируйте рабочий код.
Попробовать AppMaster

Большинству B2B нужно два уровня доступа: что человек может делать в организации в целом и что он может делать внутри конкретной команды. Смешивание этого в одном поле role — источник путаницы.

Org-уровневые роли отвечают на вопросы: может ли человек управлять биллингом, приглашать людей или видеть все команды? Team-уровневые роли отвечают: может ли он редактировать элементы в Команде A, утверждать запросы в Команде B или только смотреть?

Наследование ролей проще всего воспринимать, если соблюдать одно правило: роль org применяется везде, если явно не указано иначе на уровне команды. Это делает поведение предсказуемым и сокращает дублирование данных.

Чистый способ моделирования — хранить назначение ролей с областью:

  • role_assignments: user_id, org_id, опционально team_id (NULL означает org-wide), role_id, created_at, created_by

Если вы хотите «одну роль на область», добавьте уникальное ограничение на (user_id, org_id, team_id).

Тогда эффективный доступ для команды вычисляется так:

  1. Ищите назначение конкретной команды (team_id = X). Если есть — используйте его.

  2. Иначе — откатывайтесь к org-назначению (team_id IS NULL).

Для принципа наименьших привилегий выберите минимальную org-роль (часто «Member») и не давайте ей скрытых прав администратора. Новым пользователям не следует автоматически давать доступ к командам, если продукт этого явно не требует. Если вы всё же автоназначаете команды, делайте это созданием явных team-membership, а не тихим расширением org-роли.

Переопределения должны быть редкими и очевидными. Пример: Мария — org «Manager» (может приглашать, смотреть отчёты), но в команде Finance она должна быть «Viewer». Храните одно org-назначение и одно team-назначение-исключение для Finance. Никакого копирования прав — исключение видно.

Имен ролей хватает для типичных сценариев. Явные permissions используйте только для единичных случаев (например, «может экспортировать, но не может редактировать») или при требованиях соответствия. Даже тогда придерживайтесь одной идеи области действия, чтобы модель была понятна.

Аудитируемые изменения: кто менял доступ

Если приложение хранит только текущую роль в строке membership, вы теряете историю. Когда вас спросят «Кто дал Алексу admin’а во вторник?» — ответа может не быть. Нужна история изменений, а не только текущее состояние.

Самый простой подход — отдельная таблица аудита, которая фиксирует события доступа. Рассматривайте её как добавочный журнал: старые строки не редактируются, только добавляются новые.

Практичная таблица аудита обычно содержит:

  • actor_user_id (кто сделал изменение)
  • subject_type и subject_id (membership, team, org)
  • action (invite_sent, role_changed, membership_suspended, team_deleted)
  • occurred_at (когда произошло)
  • reason (опциональный свободный текст, например «offboarding подрядчика»)

Чтобы захватить «до» и «после», храните небольшой снимок интересующих полей. Ограничьте объём: храните только данные контроля доступа, а не полный профиль пользователя. Пример: before_role, after_role, before_state, after_state, before_team_id, after_team_id. Если хотите гибкости, используйте два JSON-столбца (before, after), но держите полезную нагрузку небольшой и последовательной.

Для memberships и teams мягкое удаление обычно лучше, чем жёсткое. Вместо удаления строки помечайте её как отключённую полями вроде deleted_at и deleted_by. Это сохраняет внешние ключи и упрощает объяснение прошлого доступа. Жёсткое удаление может подходить для временных записей (например, просроченные invites), но только если вы уверены, что они не понадобятся.

С этим вы сможете быстро отвечать на вопросы соответствия:

  • Кто дал или отнял доступ и когда?
  • Что именно поменялось (роль, команда, состояние)?
  • Был ли доступ снят в рамках стандартного оффбординга?

Шаг за шагом: проектирование схемы в реляционной БД

Централизуйте управление доступом
Замените разбросанные скрипты единым приложением, которое управляет приглашениями, ролями и изменениями доступа аккуратно.
Попробовать AppMaster

Начните просто: одно место, чтобы сказать, кто принадлежит чему, и почему. Стройте поэтапно и добавляйте правила, чтобы данные не деградировали до «почти правильных».

Практичный порядок действий, хорошо работающий в PostgreSQL и других РБД:

  1. Создайте organizations и teams, каждый с устойчивым первичным ключом (UUID или bigint). Добавьте teams.organization_id как внешний ключ и решите, должны ли имена команд быть уникальны внутри org.

  2. Держите users отдельно от membership. Поместите поля идентичности в users (email, status, created_at). «Принадлежность к org/team» храните в таблице memberships с user_id, organization_id, опциональным team_id (если так моделируете) и столбцом state (active, suspended, removed).

  3. Добавьте invitations как отдельную таблицу, а не как столбец в membership. Храните organization_id, опционально team_id, email, token, expires_at и accepted_at. Обеспечьте уникальность для «одного открытого приглашения на org + email + team», чтобы не создавать дубликаты.

  4. Смоделируйте роли явными таблицами. Простой подход — roles (admin, member и т.д.) плюс role_assignments, указывающие либо на область org (без team_id), либо на область команды (team_id задан).

  5. С первого дня ведите журнал аудита. Используйте access_events с actor_user_id, target_user_id (или email для приглашений), action (invite_sent, role_changed, removed), scope (org/team) и created_at.

После создания таблиц выполните несколько базовых админ-запросов для валидации реальности: «кто имеет org-wide доступ?», «в каких командах нет админов?» и «какие приглашения просрочены, но ещё открыты?». Эти вопросы обычно быстро выявляют недостающие ограничения.

Правила и ограничения, которые предотвращают грязные данные

Преобразуйте приглашения в рабочие процессы
Создайте приглашения, принятие и оффбординг как понятные бизнес-процессы с аудиторией.
Начать разработку

Схема остаётся здоровой, когда база данных, а не только код, навязывает границы арендатора. Самое простое правило: каждая таблица с областью арендатора содержит org_id, и любой запрос включает его. Даже если в приложении забыли фильтр, БД должна сопротивляться кросс-арендным связям.

Ограждения, которые держат данные в порядке

Начните с внешних ключей, которые всегда указывают «внутри той же org». Например, если вы храните team_memberships отдельно, строка team_memberships должна ссылаться на team_id и user_id, но также содержать org_id. С помощью составных ключей вы сможете заставить СУБД гарантировать, что ссылка на команду принадлежит той же org.

Ограничения, предотвращающие наиболее распространённые проблемы:

  • Одно активное членство в org на пользователя: уникальность по (org_id, user_id) с частичным условием для активных строк (где поддерживается).
  • Одно ожидающее приглашение на email на org или team: уникальность на (org_id, team_id, email) где state = 'pending'.
  • Токены приглашений глобально уникальны и не переиспользуются: unique на invite_token.
  • Команда принадлежит ровно одной org: teams.org_id NOT NULL с внешним ключом к orgs(id).
  • Завершайте членства вместо удаления: храните ended_at (и опционально ended_by) для сохранения истории аудита.

Индексация для часто выполняемых запросов

Индексируйте те запросы, которые приложение выполняет постоянно:

  • (org_id, user_id) для «в каких org состоит этот пользователь?»
  • (org_id, team_id) для «список участников команды»
  • (invite_token) для «принять приглашение»
  • (org_id, state) для «ожидающих приглашений» и «активных участников»

Храните имена org изменяемыми. Используйте неизменяемый orgs.id везде, а orgs.name и slug оставляйте редактируемыми. Переименование тогда затрагивает одну строку.

Перемещать команду между org — обычно политическое решение. Самый безопасный вариант — запретить это (или клонировать команду), потому что memberships, роли и история аудита завязаны на org. Если нужно разрешить перенос, делайте это в одной транзакции и обновляйте все дочерние строки с org_id.

Чтобы избежать осиротевших записей при уходе пользователя, избегайте жёстких удалений. Деактивируйте пользователя, завершите его членства и ограничьте удаления родительских строк (ON DELETE RESTRICT), если не желаете каскадного удаления.

Пример сценария: одна org, две команды, безопасная смена доступа

Представьте компанию Northwind Co с одной org и двумя командами: Sales и Support. Они нанимают подрядчика Мию для работы с тикетами Support на месяц. Модель должна оставаться предсказуемой: один человек, одно org-membership, опциональные team-memberships и понятные состояния.

Орг-админ (Ава) приглашает Мию по email. Система создаёт строку приглашения, привязанную к org, со статусом pending и сроком. Ничего больше не меняется — нет «наполовину пользователя» с неясным доступом.

Когда Мия принимает, invitation помечается как accepted, создаётся org-membership со статусом active. Ава ставит Мии org-роль member (не admin). Затем Ава добавляет Мию в команду Support и назначает роль support_agent.

Добавим поворот: Бен — штатный сотрудник с org-ролью admin, но он не должен видеть данные Support. Это можно решить переопределением на уровне команды: явно понизить его роль в Support, сохранив org-admin-способности для настроек org.

Через неделю Мию приостанавливают. Вместо удаления строк Ава ставит org-membership в состояние suspended. Team-memberships могут оставаться, но становятся неэффективными, потому что org-membership не активен.

История аудита остаётся чистой, потому что каждое изменение — событие:

  • Ава пригласила Мию (кто, что, когда)
  • Мия приняла приглашение
  • Ава добавила Мию в Support и назначила support_agent
  • Ава поставила переопределение для Бена в Support
  • Ава приостановила Мию

С этой моделью UI может показать ясную сводку доступа: статус org (active или suspended), org-роль, список команд с ролями и переопределениями, а также ленту «Недавние изменения доступа», объясняющую, почему кто-то видит или не видит Sales или Support.

Частые ошибки и подводные камни

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

Большинство багов с доступом возникают из «почти правильных» моделей данных. Схема сначала кажется работоспособной, затем накопляются крайние случаи: повторные приглашения, переносы команд, смены ролей и оффбординг.

Распространённая ловушка — смешивать приглашения и членства в одной строке. Если хранить «invited» и «active» в одной записи без чёткого смысла, вы получите вопросы вроде «является ли этот человек членом, если он не принял?». Держите invitations и memberships раздельно или сделайте машину состояний явной и последовательной.

Ещё одна частая ошибка — один столбец role в таблице users и считать, что этого достаточно. Роли почти всегда имеют область действия (org-роль, team-роль, project-роль). Глобальная роль вынуждает к хакам типа «пользователь admin для одного клиента и только чтение для другого», что ломает ожидания многоарендности и создаёт головную боль для поддержки.

Ловушки, которые больно бьют дальше по проекту:

  • Случайное разрешение членства в командах разных org (team_id указывает на org A, membership — на org B).
  • Жёсткие удаления memberships и потеря истории «у кого был доступ прошлой неделе?».
  • Отсутствие уникальностей, в результате чего пользователь получает дублирующий доступ через одинаковые строки.
  • Пассивное накопление наследований (org admin + team member + override), так что никто не может объяснить, почему доступ есть.
  • Рассматривание «принятие приглашения» как UI-событие, а не как факт в базе данных.

Простой пример: подрядчика пригласили в org, он присоединился в Team Sales, затем его удалили и через месяц пригласили снова. Если вы перезаписываете старую строку, теряете историю. Если допускаете дубликаты, можно получить два активных membership. Чёткие состояния, scoped роли и правильные ограничения предотвращают оба случая.

Быстрая проверка и следующие шаги для внедрения в приложение

Прежде чем кодить, пройдитесь по модели на бумаге и проверьте, всё ли по-прежнему логично. Хорошая модель доступа для многоарендного приложения должна ощущаться скучной: одни и те же правила применяются везде, «особые случаи» редки.

Быстрый чеклист для обнаружения распространённых пробелов:

  • Каждое membership указывает ровно на одного user и одну org, с уникальным ограничением, предотвращающим дубликаты.
  • Состояния invitation, membership и removal явны (не подразумеваются null), а переходы ограничены (например, нельзя принять просроченное приглашение).
  • Роли хранятся в одном месте, а эффективный доступ вычисляется последовательно (включая правила наследования, если они есть).
  • Удаление orgs/teams/users не стирает историю (используйте soft delete или поля архивирования там, где нужен аудит).
  • Каждое изменение доступа порождает событие аудита с actor, target, scope, timestamp и причиной/источником.

Протестируйте дизайн на практических вопросах. Если нельзя ответить на них одним запросом и чётким правилом, вероятно, нужно добавить ограничение или дополнительное состояние:

  • Что произойдёт, если пользователя приглашают дважды, затем email меняется?
  • Может ли админ команды удалить владельца org из этой команды?
  • Если org-роль даёт доступ ко всем командам, может ли одна команда её переопределить?
  • Если приглашение принято после смены роли, какая роль применяется?
  • Если поддержка спрашивает «кто убрал доступ», можно ли это доказать быстро?

Запишите, что админы и поддержка должны понимать: состояния membership (и что их меняет), кто может приглашать/удалять, что значит наследование ролей в простых словах и где смотреть события аудита при инциденте.

Сначала реализуйте ограничения (unique, foreign keys, допустимые переходы), затем стройте бизнес-логику вокруг них, чтобы база данных помогала сохранять корректность. Храните политические решения (включение наследования, роли по умолчанию, срок жизни приглашений) в конфигурационных таблицах, а не в кодовых константах.

Если вы хотите реализовать это, не переписывая каждый бэкенд и админский экран вручную, AppMaster (appmaster.io) может помочь: он позволяет смоделировать эти таблицы в PostgreSQL и реализовать переходы приглашений и членств как явные бизнес-процессы, при этом генерируя реальный исходный код для production-развёртываний.

Вопросы и ответы

Почему не хранить один столбец role в таблице users?

Используйте отдельную запись membership, чтобы роли и доступ были привязаны к организации (и опционально к команде), а не к глобальной учетной записи пользователя. Так один и тот же человек может быть Admin в одной организации и Viewer в другой без костылей.

Должно ли приглашение сразу создавать запись membership?

Держите их раздельно: invitation — это предложение с email, областью действия и сроком действия, а membership означает, что пользователь действительно получил доступ. Это предотвращает «призрачных» участников, неясный статус и уязвимости при смене email.

Какие состояния membership мне использовать?

Небольшой набор вроде active, suspended и removed подходит для большинства B2B-приложений. Если состояние «invited» хранится только в таблице invitations, то записи membership остаются однозначными: они отражают текущий или прошлый доступ, а не ожидание.

Как моделировать роли на уровне org и team без путаницы?

Храните org- и team-ролии в назначениях с указанием области (org-wide — когда team_id = NULL, team-specific — когда team_id заполнен). При проверке доступа для команды сначала ищите назначение для конкретной команды, если его нет — откатывайтесь к org-назначению.

Какое простейшее правило для наследования ролей?

Примите простое правило: по умолчанию org-роль применяется везде, а team-роль переопределяет её только если явно задана. Делайте переопределения редкими и прозрачными, чтобы доступ было легко объяснить.

Как предотвратить дублирующие приглашения и чисто их переотправлять?

Ограничьте «только одно ожидающее приглашение на org/команду на email» с помощью уникального ограничения и явного жизненного цикла pending/accepted/revoked/expired. Для повторных приглашений обновляйте существующее pending-приглашение или отзывайте старое перед созданием нового токена.

Как обеспечить границу арендатора на уровне БД?

Каждая запись, зависящая от арендатора, должна содержать org_id, а внешние ключи и ограничения — предотвращать смешение организаций (например, команда, на которую ссылается membership, должна принадлежать той же org). Это уменьшит риски при ошибках в фильтрации на уровне приложения.

Как сделать изменения доступа удобными для аудита?

Ведите добавочный (append-only) журнал событий доступа, где фиксируется кто, что, кому, когда и в каком объёме (org или team) изменил. Записывайте ключевые поля «до» и «после» (роль, состояние, команда), чтобы можно было ответить «кто дал admin во вторник?» достоверно.

Стоит ли жестко удалять memberships и invites?

Избегайте жесткого удаления у memberships и команд; помечайте их завершёнными/отключёнными, чтобы история оставалась доступной и внешние ключи не ломались. Для приглашений тоже можно сохранять записи (даже просроченные) ради полной истории безопасности, но точно не стоит переиспользовать токены.

Какие индексы важны для этой схемы?

Индексируйте горячие пути: (org_id, user_id) для проверок членства в org, (org_id, team_id) для списков участников команды, (invite_token) для принятия приглашений и (org_id, state) для админских экранов вроде «active members» или «pending invites». Делайте индексы по реальным запросам, а не по всем столбцам.

Легко начать
Создай что-то невероятное

Экспериментируйте с AppMaster с бесплатной подпиской.
Как только вы будете готовы, вы сможете выбрать подходящий платный план.

Попробовать AppMaster