# Multi-tenancy в food-market Один процесс API, одна БД, много организаций (тенантов). Каждый запрос видит только данные своей организации. Изоляция держится на двух вещах: 1. **EF Core query filter** на каждой `ITenantEntity` (auto-инжектится в `WHERE` каждого SQL-запроса). 2. **Stamping в SaveChanges** — добавляемые сущности получают `OrganizationId` из текущего `ITenantContext`. `SuperAdmin` — отдельная роль с правом обходить фильтр (видеть/менять всё). Чтобы не получить «случайные изменения по всем оргам сразу», есть строгий режим «открыть как…» с двумя ступенями (read-only + edit-mode с reason). ## Модель ### Базовые интерфейсы ```csharp // food-market.domain/Common/TenantEntity.cs public interface ITenantEntity // обязательный orgId { Guid OrganizationId { get; set; } } public abstract class TenantEntity : Entity, ITenantEntity { public Guid OrganizationId { get; set; } } public interface IOptionalTenantEntity // системный справочник { Guid? OrganizationId { get; set; } } ``` ### Когда использовать что | Случай | База | |---|---| | Бизнес-данные тенанта: продукты, чеки, контрагенты, отчёты | `TenantEntity` (обязательный orgId) | | Системные справочники с возможностью per-tenant расширения: `UnitOfMeasure`, `ProductGroup`, `Country` | `IOptionalTenantEntity` (null = системная, оrgId = tenant'овская) | | Корневая сущность тенанта: `Organization` | `Entity` (сама не tenant-scoped) | | Платформенные настройки: `PlatformSettings` (SMTP), OpenIddict-клиенты | `Entity` (singletons / cross-tenant) | | Audit-логи SuperAdmin'а: `SuperAdminAuditLog` | `Entity` (есть optional `OrganizationId` для фильтра, но сама не tenant-scoped) | ## Tenant-контекст `ITenantContext` (Application слой) — единственный источник правды о том, кто сейчас делает запрос: ```csharp // food-market.application/Common/Tenancy/ITenantContext.cs public interface ITenantContext { Guid? OrganizationId { get; } // null = SuperAdmin вне override, или нет JWT bool IsAuthenticated { get; } bool IsSuperAdmin { get; } bool IsTenantOverride { get; } // SuperAdmin в режиме «открыть как…» Guid? UserId { get; } } ``` Реализация — `HttpContextTenantContext` (`food-market.api/Infrastructure/Tenancy/`). Источники данных в порядке приоритета: 1. **AsyncLocal-override** (`UseOverride(orgId, isSuper)`) — для background-tasks (Hangfire, импорт MoySklad, фоновые сидеры). Когда нет HttpContext, нужно явно задать tenant перед `DbContext`-вызовом. 2. **HTTP-заголовок `X-Org-Override`** — режим «открыть как…» (только если у юзера роль SuperAdmin). 3. **JWT claim `org_id`** — обычный tenant-юзер. ```csharp // background-job пример using (HttpContextTenantContext.UseOverride(orgId, isSuperAdmin: false)) { // здесь _db применит фильтр на orgId var products = await _db.Products.ToListAsync(); } ``` ## Query filter `AppDbContext.OnModelCreating` после регистрации всех сущностей рефлексией обходит модель и вешает фильтр на каждую `ITenantEntity`: ```csharp // food-market.infrastructure/Persistence/AppDbContext.cs private void ApplyTenantFilter(ModelBuilder builder) where T : class, ITenantEntity { // SuperAdmin обходит фильтр ТОЛЬКО когда не в режиме «открыть как…». // В override-режиме (X-Org-Override header активен) он работает в // контексте конкретной орги — фильтр обязан применяться. builder.Entity().HasQueryFilter(e => (_tenant.IsSuperAdmin && !_tenant.IsTenantOverride) || e.OrganizationId == _tenant.OrganizationId); } private void ApplyOptionalTenantFilter(ModelBuilder builder) where T : class, IOptionalTenantEntity { builder.Entity().HasQueryFilter(e => (_tenant.IsSuperAdmin && !_tenant.IsTenantOverride) || e.OrganizationId == null || e.OrganizationId == _tenant.OrganizationId); } ``` Результат: - Tenant-юзер: `WHERE OrganizationId = '<его orgId>'`. - SuperAdmin без override: фильтр не применяется (видит всё). - SuperAdmin с `X-Org-Override`: `WHERE OrganizationId = '<выбранная orgId>'`. - `IOptionalTenantEntity`: видит свои + системные (`IS NULL`). ## Stamping в SaveChanges `AppDbContext.SaveChanges/Async` зовут `StampTenant()`, который проходит по `Added`-entries: ```csharp private void StampTenant() { foreach (var entry in ChangeTracker.Entries()) { if (entry.State != EntityState.Added) continue; if (entry.Entity is ITenantEntity tenant && tenant.OrganizationId == Guid.Empty) { if (_tenant.OrganizationId.HasValue) tenant.OrganizationId = _tenant.OrganizationId.Value; } else if (entry.Entity is IOptionalTenantEntity opt && opt.OrganizationId is null) { // SuperAdmin без override: оставляем null (системная запись) // SuperAdmin с override / tenant-юзер: стампим текущий orgId if (_tenant.IsSuperAdmin && !_tenant.IsTenantOverride) { /* null */ } else if (_tenant.OrganizationId.HasValue) opt.OrganizationId = _tenant.OrganizationId.Value; } } } ``` Это значит: контроллер может писать `_db.Products.Add(product)` без явного `product.OrganizationId = ...` — stamping подставит сам. **НО**: если код явно выставил `OrganizationId` (например, чтобы создать запись для другой орги в Hangfire-job), stamping её не перетрёт. ## SuperAdmin override: режим «открыть как…» Конкретный поток с фронта: 1. SuperAdmin заходит в «Системная консоль → Организации». 2. Кликает «Открыть как…» на какой-то orgRow. 3. Фронт начинает слать каждый запрос с заголовком `X-Org-Override: `. Без этого хедера SuperAdmin видит «своё» (а у SuperAdmin'а часто нет своей орги, поэтому все списки пусты — у супер-админа в админке тренировочный режим). 4. По умолчанию режим **read-only** (`ReadonlyOverrideMiddleware`): GET/HEAD/OPTIONS — пропускаются; PUT/POST/DELETE/PATCH — `403`. Исключение: `/api/super-admin/*` и `/connect/*` (refresh-token). 5. Чтобы что-то поменять, фронт показывает «Войти в edit-mode», запрашивает причину (≥ 10 символов), отправляет её в каждом запросе как `X-Org-Override-Reason: <текст>`. Тогда middleware пропускает мутации, а action-filter (`SuperAdminEditAuditFilter`) после успешного ответа пишет строку в `super_admin_audit_log` с reason'ом и запросом/ответом. 6. Фронт ограничивает edit-mode 30 минутами (UI таймер). Сервер не следит за временем — это UX-конвенция, а аудит уже есть. ### ClaimsTransformer для tenant-ролей Тонкость: SuperAdmin сам по себе не имеет ролей `Admin/Storekeeper/Cashier` (они — атрибуты `Employee` тенанта). Контроллер `[Authorize(Roles="Admin")]` отшил бы его 403 даже в edit-mode. Решение: `SuperAdminOverrideClaimsTransformer` (`IClaimsTransformation`, вызывается на каждый authenticated request) — если есть `X-Org-Override`, динамически добавляет SuperAdmin'у `Admin/Storekeeper/Cashier` claim-роли **только на текущий запрос**. Записи в БД не трогает. ## RequiresPermission: тонкая авторизация Для мутаций используется `[RequiresPermission("...")]` вместо `[Authorize(Roles="Admin,Storekeeper")]`. Атрибут резолвится через policy-механизм: ``` [RequiresPermission("ProductsEdit")] → Policy "perm:ProductsEdit" → PermissionAuthorizationPolicyProvider создаёт PermissionRequirement → PermissionAuthorizationHandler проверяет EmployeeRole.RolePermissions.ProductsEdit == true для текущего юзера ``` `RolePermissions` — это POCO с булевыми полями (`ProductsView`, `ProductsEdit`, `RetailSalesOperate`, `RetailSalesRefund`, …). `Employee.EmployeeRoleId` указывает на конкретную роль, у каждой — свой набор флагов. SuperAdmin (с override) проходит всегда. См. `food-market.api/Infrastructure/Authorization/`. ## Audit-trail ### `org_audit_log` Каждая `Add/Update/Delete` в `AppDbContext` (через `OrgAuditInterceptor`) пишет JSONB-diff в `org_audit_log`: ```json { "id": "...", "organizationId": "...", "userId": "...", "entityType": "Product", "entityId": "...", "action": "Update", "changesJson": { "before": {...}, "after": {...} }, "createdAt": "..." } ``` Фоновый Hangfire-job `prune-audit-log` (03:45) чистит записи старше 180 дней. Сидеры/миграции выставляют `_db.SkipAudit = true` чтобы не плодить бессмысленные строки. ### `super_admin_audit_log` Только мутации SuperAdmin'а в edit-mode, плюс изменения платформенных настроек. Без TTL — храним всё. ## Известные подводные камни ### 1. `IgnoreQueryFilters()` — когда нужно знать Тенант-фильтр применяется ко ВСЕМУ — в том числе там, где это «не надо». - При логине: ищем `Organization` по `OrgId` из credentials → нужен `IgnoreQueryFilters()`, потому что фильтр требует OrganizationId, которого ещё нет в контексте. - При проверке «есть ли у юзера эта орг»: `_db.Organizations.IgnoreQueryFilters().AnyAsync(...)`. - При cross-tenant отчётах SuperAdmin'а без override-режима фильтр и так не применится, но при override — применится; чтобы получить cross-tenant данные в этом режиме (редко нужно), вызвать `IgnoreQueryFilters()` явно. ### 2. Stamping не работает, если orgId уже задан Бэкенд-код в нескольких местах принимает `Guid OrganizationId` из payload (например, при импорте). Stamping проверяет `OrganizationId == Guid.Empty` и НЕ перетирает уже выставленное значение. Если кто-то по ошибке прислал чужой orgId в payload — он сохранится. Защита: явно валидировать `OrganizationId == _tenant.OrganizationId` в контроллере (или вообще не принимать поле из payload). ### 3. Background-jobs без HttpContext `HangfireJobsConfigurator` регистрирует методы, исполняющиеся в фоне. `HttpContextAccessor.HttpContext` там null → `OrganizationId` тоже null → query filter возвращает только записи с `OrganizationId == null` (т.е. системные справочники), а tenant-запросы — пустоту. Решение: внутри job, перед `DbContext`-вызовом, обернуть в `HttpContextTenantContext.UseOverride(orgId, isSuperAdmin: false)`. См. `OwnerDailySummaryJob`, `EmailNotificationJobs` — там есть пример. ### 4. SignalR за query-фильтром `NotificationsHub` использует `IServiceProvider.GetRequiredService()` внутри `OnConnectedAsync` для добавления соединения в group. Если в connection нет JWT — нет org_id — нет группы → клиент не получит событий. Web-фронт прокидывает `?access_token=...` query (см. middleware в Program.cs), POS — `Authorization` header. ### 5. EF migrations и наследование от TenantEntity При добавлении новой `TenantEntity` миграция должна включать `OrganizationId` колонку (uuid, NOT NULL) и индекс `(OrganizationId, …)` для фильтрации. **Эта колонка не появляется автоматически в snapshot-выводе `dotnet ef migrations add`** в этом проекте, потому что снапшот не синхронизируется с моделью (миграции пишутся руками, см. memory `feedback_ef_migrations`). Проверка: после `Migrate()` тестовый запрос `SELECT column_name FROM information_schema.columns WHERE table_name='...'` должен показать `OrganizationId`. ### 6. `xmin` concurrency и параллельные посты `UseXminAsConcurrencyToken()` на документах. Если два кассира одновременно постят один и тот же чек (что не должно случаться, но всё же) — второй получает `DbUpdateConcurrencyException`. Контроллер ловит и возвращает `409 Conflict` с сообщением «документ изменён в другой сессии, обновите страницу». Stock-операции — отдельная история: `Serializable` транзакция блокирует параллельный пост на тех же товарах в том же storе. Серверу `PG40001` (serialization_failure) — контроллер не ретраит автоматически, кассир видит 409 «недостаточно остатка» (после ретрая по факту достаточно или нет). ### 7. Тестирование изоляции `TenantIsolationTests` (integration) — обязательный смок: создаём 2 организации, в одной — продукт; в другой делаем `GET /api/catalog/products` → список пустой. На любую новую `ITenantEntity` добавлять такой тест. ### 8. Read-models / ad-hoc raw SQL Если контроллер напишет raw SQL через `_db.Database.SqlQueryRaw(...)`, EF-фильтр НЕ применится. Это используется только для отчётов с тяжёлой агрегацией (`ProfitReportController`); там OrganizationId явно включается в `WHERE` и приходит из `_tenant.OrganizationId`. Правило: никаких raw SQL без явного `WHERE OrganizationId = @org` в середине запроса. ## Чеклист «как добавить новую tenant-сущность» 1. Унаследовать от `TenantEntity` (или реализовать `ITenantEntity`). 2. Добавить EF Configuration в `food-market.infrastructure/Persistence/Configurations/`: - `b.ToTable("...");` - `b.HasIndex(x => new { x.OrganizationId, x.SomeField });` — индекс с OrganizationId первым полем (для скорости query filter'а). - Если есть уникальность в рамках org: `.IsUnique()` на индексе с OrganizationId первым полем. 3. Создать миграцию руками в `Persistence/Migrations/`: - Атрибуты `[DbContext(typeof(AppDbContext))]` + `[Migration("YYYYMMDD…")]`. - В `Up()` — `CreateTable` с колонкой `OrganizationId uuid NOT NULL`. - Индекс на OrganizationId. 4. Добавить `DbSet` в `AppDbContext`. 5. Контроллер использует `_db.MyEntities.Where(...)` — query filter подключится автоматически. Stamping выставит `OrganizationId` в `Add()`. 6. Интеграционный тест на изоляцию (см. `TenantIsolationTests`).