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

22 KiB
Raw Permalink 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-сущность»

Расширенная версия с 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 + миграция)

  1. Добавить 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.
  2. Создать миграцию руками в Persistence/Migrations/:

    • Атрибуты [DbContext(typeof(AppDbContext))] + [Migration("YYYYMMDDHHMMSS_NameHere")]. Без них Migrate() миграцию не подхватит (см. memory feedback_ef_migrations).
    • В Up()CreateTable с колонкой OrganizationId uuid NOT NULL.
    • Индексы (минимум один на OrganizationId).
    • Не использовать dotnet ef migrations add — снапшот в репо не синхронизируется с моделью.
  3. Добавить DbSet<TEntity> в AppDbContext.

Permission (RolePermissions)

  1. Добавить булевый флаг в RolePermissions.cs: public bool MyEntityEdit { get; set; } + соответствующая запись в All() фабрике (для системной роли Admin).
  2. Миграции для role_permissions не нужно — это JSONB-колонка на EmployeeRole.
  3. Все Admin-роли уже получат новый permission через RolePermissions.All().

Validation

  1. Для простой валидации — DataAnnotations на input-record'е: public record Input([Required, MaxLength(200)] string Name, …);
  2. Для сложной — 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'а.
  1. Бизнес-валидация (требует БД — «существует ли поставщик»): в начале action-метода вернуть BadRequest(new { error, field }).

Controller

  1. Контроллер использует _db.MyEntities.Where(...) — query filter подключится автоматически. StampTenant в SaveChangesAsync выставит OrganizationId в Add().
  2. Защитить mutating endpoint'ы атрибутом [RequiresPermission("MyEntityEdit")] (резолвится в policy perm:MyEntityEdit → проверяет флаг на RolePermissions).
  3. Для concurrency-чувствительных мутаций (Post документа): await using var tx = await _db.Database.BeginTransactionAsync( IsolationLevel.Serializable, ct) — защита от race на остатке.

Audit (Sprint 13)

  1. CRUD автоматически логируется OrgAuditInterceptor'ом в org_audit_log (JSON diff).
  2. Для sensitive-операций (смена пароля, выдача роли, изменение permissions) — дополнительно через SensitiveOpsAudit.LogAsync() — она пишет в org_audit_log + Serilog с типизированным action-name.

Tests

  1. Интеграционный тест на изоляцию (TenantIsolationTests): org A создаёт, org B делает GET — список пустой, GET-by-id → 404.
  2. Если есть concurrency-критика (Post под Serializable): RetailOversellingTests-pattern — два параллельных VU гарантированно дают 409 на одном из них.
  3. Для бизнес-инварианта (типа «Sum(movements) ≡ Stock»): property-based test (см. StockServicePropertyTests, Sprint 15).