food-market/docs/MULTI-TENANCY.md
nns 9588d03bf4 test(s15): axe a11y + focus traps + unit coverage 80% + property tests + backup drill
Sprint 15 финальный — реальные axe + coverage + pg_restore numbers.

Ключевые цифры:
- axe-core: critical=0 on 10 страниц stage'а; serious 12→9
  после фиксов (sidebar contrast + 8 icon-only back-arrow aria-labels).
- Unit coverage: Application 56%→83%, Domain 11%→79%, combined
  60%→80%. Тестов 68→147 (+79).
- Backup recovery drill: RTO ~25 секунд end-to-end
  (pg_dump 2s + pg_restore 4s + dotnet startup 19s).

Что сделано:
1. @axe-core/playwright + stage-ui-15 (10 страниц) + stage-ui-16
   (SR smoke на login: getByLabel, role=alert, aria-describedby,
   keyboard nav).
2. useFocusTrap hook (WCAG 2.4.3 + 2.1.2): return-focus, mount-focus,
   Tab cycle. Подключён к Modal + ConfirmDialog с opt-in
   defaultFocus='cancel'|'confirm'. ConfirmDialog по дефолту фокусит
   Cancel для destructive actions (safer чем Enter→Delete).
3. A11y фиксы:
   • text-slate-400→text-slate-500 в sidebar (contrast 2.63→4.61).
   • 8 страниц edit с back-arrow Link — aria-label + aria-hidden
     на иконке + текст-slate-500 цвет.
   • Modal close button — то же.
   • LoginPage — aria-invalid/aria-describedby/role=alert на
     ошибках валидации.
   • Field component — role="alert" на error span (announce'ит SR).
4. 8 файлов unit-тестов: PhoneNormalization, PagedRequest,
   RequiredGuid, RolePermissions (Domain), DomainPocoSmoke,
   DomainFullPropertyTouch, CatalogDtosSmoke, StockServiceProperty
   (4 seeds × 4 size + batch + 2-product isolation).
5. Backup-drill: pg_dump со stage'а → fresh postgres:16-alpine →
   pg_restore → dotnet run против восстановленной БД → /health/ready
   Healthy. Команды и timing в RUNBOOK.md.
6. Docs review:
   • MULTI-TENANCY чеклист «добавить tenant-сущность» расширен с 6
     до 19 шагов (Domain → EF Config → Migration с Xmin →
     RolePermissions → Validation → Controller + RequiresPermission →
     Audit + SensitiveOpsAudit → property tests).
   • ARCHITECTURE.md — Sprint 13-15 changes таблица.
   • DEVELOPER-GUIDE.md — «что добавилось после первого guide'а» +
     a11y pitfalls в «что НЕ делать».

Stage smoke ✓. Это финальный автономно-безопасный спринт. Дальше
нужен вход от user'а (ОФД keys, MoySklad tokens, Windows для POS,
прод-деплой план, kz-перевод, реальный SMTP).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 14:53:38 +05:00

417 lines
22 KiB
Markdown
Raw Permalink 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-сущность»
Расширенная версия с RowVersion + permission + validation паттернами
(Sprint 15). Минимальный «список из 6 пунктов» оставлен ниже как краткая
форма.
### Domain
1. Унаследовать от `TenantEntity` (или реализовать `ITenantEntity`).
2. Для **документов** (Supply, RetailSale, Loss…) — добавить
`IVersionedEntity` + `uint Xmin { get; set; }` для оптимистической
блокировки через PG xmin. EF переведёт concurrency-конфликт в
`DbUpdateConcurrencyException`, контроллер вернёт 409.
### Infrastructure (EF Config + миграция)
3. Добавить EF Configuration в
`food-market.infrastructure/Persistence/Configurations/`:
- `b.ToTable("snake_case");`
- Для документа: `b.UseXminAsConcurrencyToken(); b.Ignore(x => x.Xmin);`
- `b.Property(x => x.Number).HasMaxLength(50).IsRequired();`
явные ограничения вместо EF-defaults.
- `b.Property(x => x.SomeDecimal).HasPrecision(18, 4);` — иначе EF
warning'и про missing precision.
- **Индекс с `OrganizationId` первым полем**:
`b.HasIndex(x => new { x.OrganizationId, x.SomeField });`.
- Уникальность в рамках org: `.IsUnique()` на том же composite-индексе.
- Sprint 14: для статусов-документов, по которым строятся отчёты —
ещё один composite `(OrganizationId, Status, Date)` или partial
индекс `WHERE Status = X AND NOT Y` с `INCLUDE` для covering.
4. Создать миграцию руками в `Persistence/Migrations/`:
- Атрибуты `[DbContext(typeof(AppDbContext))]` + `[Migration("YYYYMMDDHHMMSS_NameHere")]`.
**Без них `Migrate()` миграцию не подхватит** (см. memory
`feedback_ef_migrations`).
- В `Up()``CreateTable` с колонкой `OrganizationId uuid NOT NULL`.
- Индексы (минимум один на OrganizationId).
- Не использовать `dotnet ef migrations add` — снапшот в репо
не синхронизируется с моделью.
5. Добавить `DbSet<TEntity>` в `AppDbContext`.
### Permission (RolePermissions)
6. Добавить булевый флаг в `RolePermissions.cs`:
`public bool MyEntityEdit { get; set; }` + соответствующая запись в
`All()` фабрике (для системной роли Admin).
7. Миграции для `role_permissions` не нужно — это JSONB-колонка
на `EmployeeRole`.
8. Все Admin-роли уже получат новый permission через `RolePermissions.All()`.
### Validation
9. Для простой валидации — DataAnnotations на input-record'е:
`public record Input([Required, MaxLength(200)] string Name, …);`
10. Для сложной — `FluentValidation` в
`food-market.api/Infrastructure/Validation/Validators.cs`:
- `public sealed class MyInputValidator : AbstractValidator<MyInput>`
с `RuleFor`/`RuleForEach`.
- Регистрируется автоматически (assembly-scan на старте).
- `ValidationFilter` в pipeline'е вызовет валидатор и вернёт 400
ProblemDetails (RFC 7807) до Action'а.
11. Бизнес-валидация (требует БД — «существует ли поставщик»): в начале
action-метода вернуть `BadRequest(new { error, field })`.
### Controller
12. Контроллер использует `_db.MyEntities.Where(...)` — query filter
подключится автоматически. `StampTenant` в `SaveChangesAsync`
выставит `OrganizationId` в `Add()`.
13. Защитить mutating endpoint'ы атрибутом
`[RequiresPermission("MyEntityEdit")]` (резолвится в policy
`perm:MyEntityEdit` → проверяет флаг на `RolePermissions`).
14. Для concurrency-чувствительных мутаций (Post документа):
`await using var tx = await _db.Database.BeginTransactionAsync(
IsolationLevel.Serializable, ct)` — защита от race на остатке.
### Audit (Sprint 13)
15. CRUD автоматически логируется `OrgAuditInterceptor`'ом в
`org_audit_log` (JSON diff).
16. Для sensitive-операций (смена пароля, выдача роли, изменение
permissions) — дополнительно через
`SensitiveOpsAudit.LogAsync()` — она пишет в `org_audit_log` +
Serilog с типизированным action-name.
### Tests
17. **Интеграционный тест на изоляцию** (`TenantIsolationTests`):
org A создаёт, org B делает GET — список пустой, GET-by-id → 404.
18. Если есть concurrency-критика (Post под Serializable):
`RetailOversellingTests`-pattern — два параллельных VU гарантированно
дают 409 на одном из них.
19. Для бизнес-инварианта (типа «Sum(movements) ≡ Stock»):
property-based test (см. `StockServicePropertyTests`, Sprint 15).