Architecture First: как небольшой командой с ИИ сделать альтернативу Jira — Часть 16.
В предыдущей статье мы написали первые интеграционные тесты. Теперь с ними можно было браться за организации — изменение, которое затронуло почти каждый запрос к API.
Краткое содержание
Как мы сделали Boards мультитенантными:
- Над спейсами появился новый уровень — организация. Иерархия стала такой: Организация → Спейс → Доска → Issue.
- Все организации живут в одной базе: спейс хранит ссылку на свою организацию, и через неё фильтруются все данные.
- У каждого пользователя есть личная организация. Она создаётся при регистрации, её нельзя удалить, и в ней нельзя управлять участниками.
- В обычную организацию вступают по коду приглашения из восьми символов. Код можно перевыпустить, участник может выйти сам, а администратор может отозвать у него доступ.
- Для работы внутри организации пользователь получает отдельный токен: в нём, кроме самого пользователя, записана организация, в которую он вошёл.
- Около двадцати миграций, которые накопились за время работы, перед релизом объединили в одну, чтобы история миграций оставалась понятной.
Зачем понадобились организации
Когда появились спейсы, личные дела и работа над 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. Систему прав мы переписывали несколько раз, пытаясь найти баланс между гибкостью и простотой, — об этом следующая статья.