# 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).
