Назад к статьям
Статья

Мультитенантность в SaaS-таск-трекере — организации, личная организация пользователя и отдельный JWT для организации

Architecture First: как небольшой командой с ИИ сделать альтернативу Jira — Часть 16.
В предыдущей статье мы написали первые интеграционные тесты. Теперь с ними можно было браться за организации — изменение, которое затронуло почти каждый запрос к API.

Краткое содержание

Как мы сделали Boards мультитенантными:

  1. Над спейсами появился новый уровень — организация. Иерархия стала такой: Организация → Спейс → Доска → Issue.
  2. Все организации живут в одной базе: спейс хранит ссылку на свою организацию, и через неё фильтруются все данные.
  3. У каждого пользователя есть личная организация. Она создаётся при регистрации, её нельзя удалить, и в ней нельзя управлять участниками.
  4. В обычную организацию вступают по коду приглашения из восьми символов. Код можно перевыпустить, участник может выйти сам, а администратор может отозвать у него доступ.
  5. Для работы внутри организации пользователь получает отдельный токен: в нём, кроме самого пользователя, записана организация, в которую он вошёл.
  6. Около двадцати миграций, которые накопились за время работы, перед релизом объединили в одну, чтобы история миграций оставалась понятной.

Зачем понадобились организации

Когда появились спейсы, личные дела и работа над Laraue оказались в разных спейсах, но по-прежнему у одного пользователя. Мы хотели пригласить в проект друга, фронтенд-разработчика, чтобы он помог с интерфейсом, но не показывать ему личные задачи. Для этого нужна была сущность, которой принадлежат спейсы, в которую можно пригласить другого человека и выдать ему права.

Такую сущность обычно называют тенантом, а приложение, в котором много тенантов делят одну систему, — мультитенантным. В Boards тенант — это организация.

Модель данных: одна база на всех

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

/// <summary>
/// The unit that can has spaces, boards, issues.
/// </summary>
public class Organization
{
    public long Id { get; set; }

    [MaxLength(128)]
    public string Name { get; set; } = string.Empty;

    [MaxLength(7)]
    public required string Color { get; set; }

    public Guid OwnerId { get; set; }
    public User? Owner { get; set; }

    public OrganizationType Type { get; set; }

    [MaxLength(8)]
    public string JoinCode { get; set; } = string.Empty;

    public IList<Space>? Spaces { get; set; }
    public IList<OrganizationUser>? Users { get; set; }
}

public enum OrganizationType
{
    Organization = 0,
    Personal = 1,
}

Ссылку на организацию хранит только спейс. Чтобы узнать, к какой организации относится доска или issue, достаточно дойти до их спейса — так же, как в части 14 issue находила свой спейс через статус и доску.

Участники организации лежат в отдельной таблице OrganizationUser. Каждая строка связывает пользователя с организацией и хранит его права в ней. О правах — в следующей статье.

Личная организация

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

user.Organizations = new List<Organization>
{
    OrganizationDefaults.GetNewOrganizationEntity(
        user.Id,
        "Personal",
        Palette.RandomColor(),
        timestamp,
        OrganizationType.Personal)
};

Вместе с организацией создаются спейс по умолчанию и доска Backlog. Для пользователей, которые зарегистрировались раньше, мы написали миграцию: она создала каждому личную организацию и перенесла в неё все созданные до этого issues. Её SQL мы разбирали в части 14.

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

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

У каждой организации есть код из восьми символов, его генерирует наш StringGenerator. Владелец отправляет код человеку, тот вводит его в приложении и становится участником без прав. Права ему потом выдаёт администратор организации.

Позже мы добавили остальные действия с участниками. Если код попал не к тому человеку, его можно заменить новым. Участник может сам выйти из организации, а администратор — отозвать у него доступ.

Два токена: пользователь и организация

До организаций у пользователя был один JWT с его идентификатором (как его выдаёт Telegram-вход, мы рассказывали в части 13). С организациями у каждого запроса появился вопрос: в какой организации пользователь сейчас работает и состоит ли он в ней?

Если передавать идентификатор организации в каждом запросе, API пришлось бы каждый раз проверять, что пользователь в ней состоит. Чтобы этого избежать, мы проверяем членство один раз — когда пользователь входит в организацию. В ответ он получает второй токен, в котором уже записана организация, и дальше работает с ним.

public string CreateOrganizationToken(long organizationId, Guid userId)
{
    var claims = new List<Claim>
    {
        new("orgId", organizationId.ToString()),
        new("id", userId.ToString()),
    };

    var jwt = new JwtSecurityToken(
        issuer: Issuer,
        audience: OrganizationAudience,
        claims: claims,
        signingCredentials: new SigningCredentials(
            GetSymmetricSecurityKey(options.Value.Key),
            SecurityAlgorithms.HmacSha256));

    return new JwtSecurityTokenHandler().WriteToken(jwt);
}

У двух токенов разные audience, и в ASP.NET Core это две схемы аутентификации: AuthSchemas.User и AuthSchemas.Organization. Контроллер явно указывает, какой токен он принимает:

[Authorize(AuthenticationSchemes = AuthSchemas.User)]
[Route("/api/organizations")]
public class OrganizationsController : ControllerBase
{
    [HttpPost("join/{code}")]
    public Task Join(string code, CancellationToken cancellationToken = default) { ... }

    [HttpPost("login")]
    public Task<string> Login([FromBody] LoginRequest request, CancellationToken cancellationToken = default) { ... }

    [Authorize(AuthenticationSchemes = AuthSchemas.Organization)]
    [HttpGet("current")]
    public Task<OrganizationDto> GetOrganization(CancellationToken cancellationToken = default) { ... }
}

Создать организацию, вступить в неё по коду или войти можно с пользовательским токеном. Всё, что происходит внутри организации — спейсы, доски, issues, участники, — требует токена организации.

Токен не заменяет проверку прав. Каждое действие внутри организации всё равно проверяет права участника по таблице OrganizationUser, поэтому, если у участника отозвали доступ, следующий его запрос не пройдёт, даже с действующим токеном. Если организации нет или у пользователя нет к ней доступа, API отвечает 404, а не 403, чтобы не раскрывать, что такая организация существует.

Одна миграция вместо двадцати

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

У нас есть правило «один pull request — одна миграция» (оно описано в статье про архитектуру бэкенда), чтобы в проекте не копились десятки файлов с промежуточными шагами. Перед релизом мы удалили все промежуточные миграции и сгенерировали вместо них одну, AddOrganizations, а локальную базу просто пересоздали. В эту же миграцию вошёл SQL, который перенёс данные существующих пользователей. Так в истории миграций осталось одно понятное изменение вместо двадцати промежуточных шагов.

Итоги

  • Тенант — организация. Все организации хранятся в общих таблицах, и связь с организацией есть только у спейса.
  • У каждого пользователя есть личная организация. Она создаётся при регистрации, поэтому в коде нет случая «у пользователя нет организации».
  • Вступление по коду приглашения. Код можно заменить новым, участник может выйти сам, а администратор — отозвать доступ.
  • Отдельный токен для организации. Членство проверяется один раз при входе, а права — при каждом действии.
  • Понятная история миграций. Двадцать промежуточных миграций перед релизом превратились в одну.

Что дальше

Организация отвечает на вопрос «чьё это». Следующий вопрос — «кто что может делать»: создавать спейсы, видеть чужие доски, удалять issues. Систему прав мы переписывали несколько раз, пытаясь найти баланс между гибкостью и простотой, — об этом следующая статья.