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

17 KiB
Raw Blame History

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

Модель

Базовые интерфейсы

// 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 слой) — единственный источник правды о том, кто сейчас делает запрос:

// 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-юзер.
// background-job пример
using (HttpContextTenantContext.UseOverride(orgId, isSuperAdmin: false))
{
    // здесь _db применит фильтр на orgId
    var products = await _db.Products.ToListAsync();
}

Query filter

AppDbContext.OnModelCreating после регистрации всех сущностей рефлексией обходит модель и вешает фильтр на каждую ITenantEntity:

// 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:

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:

{
  "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).