food-market/docs/MULTI-TENANCY.md
nns 97e26a65d5 docs(s12): ARCHITECTURE/MULTI-TENANCY/RUNBOOK/DEVELOPER-GUIDE + k6 baseline + stage-verify CI
Документация для следующего разработчика (4 файла, ~1500 строк по
существу), реальный нагрузочный baseline на stage, и автоматический
smoke на каждый push.

Доки:
- docs/ARCHITECTURE.md — карта слоёв, модулей, Program.cs composition
  root, полный поток signup→post с трассировщиком ASP.NET pipeline.
- docs/MULTI-TENANCY.md — ITenantEntity + reflection query-filter,
  stamping в SaveChanges, SuperAdmin override (read-only + edit-mode
  с reason), 8 подводных камней, чеклист «как добавить tenant-сущность».
- docs/RUNBOOK.md — health-чеки, backup/restore с примером, смена SDK,
  disaster-recovery на новый сервер, 6 описанных инцидентов
  (включая docker-compose project name), БД-troubleshooting.
- docs/DEVELOPER-GUIDE.md — локальный setup, гочи integration-тестов,
  полные паттерны (controller с permission + tenant-сущность с
  RowVersion + 5 шагов миграции), валидация, structured-логирование,
  «НЕ делать» список.

k6 baseline:
- tests/load/ — 3 скрипта (signup-burst, retail-sales-parallel,
  sales-report-heavy) + README с инструкциями.
- docs/performance-baseline.md — реальные цифры на stage:
  * signup p95 446ms @ 50 RPM (IP-лимит 60/мин держит);
  * retail-sale sequential — 17/sec, p95 71ms;
  * retail-sale @ VU>1 — 53% failure из-за race в
    GenerateNumberAsync (unique-violation 23505 не ловится в
    SaveOrFkErrorAsync) — P0 для следующего рефакторинга;
  * reports на 1500 чеков — p95 50-114ms до VU=5.

CI:
- .forgejo/workflows/stage-verify.yml — on workflow_run после Docker
  API/Web, wait-for-ready → tests/stage-smoke.sh → Telegram пинг.
- tests/stage-smoke.sh — 7-секундный bash-смок (curl+jq+python3),
  5 этапов: health, signup, token, multi-tenant изоляция (B → 404
  на product A, B → пустой список), полный документ-цикл
  (supplier+supply.post → stock=100 → sale.post → stock=99).
  Локальный прогон против stage — все этапы зелёные.

Build чистый, локальный прогон smoke зелёный. Sprint 12 закрывает
автономно-безопасный цикл — дальше нужен вход от user'а.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 03:19:25 +05:00

339 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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<T>(ModelBuilder builder) where T : class, ITenantEntity
{
// SuperAdmin обходит фильтр ТОЛЬКО когда не в режиме «открыть как…».
// В override-режиме (X-Org-Override header активен) он работает в
// контексте конкретной орги — фильтр обязан применяться.
builder.Entity<T>().HasQueryFilter(e =>
(_tenant.IsSuperAdmin && !_tenant.IsTenantOverride)
|| e.OrganizationId == _tenant.OrganizationId);
}
private void ApplyOptionalTenantFilter<T>(ModelBuilder builder) where T : class, IOptionalTenantEntity
{
builder.Entity<T>().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: <orgId>`. Без этого хедера 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<AppDbContext>()`
внутри `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`).