Документация для следующего разработчика (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>
17 KiB
Multi-tenancy в food-market
Один процесс API, одна БД, много организаций (тенантов). Каждый запрос видит только данные своей организации. Изоляция держится на двух вещах:
- EF Core query filter на каждой
ITenantEntity(auto-инжектится вWHEREкаждого SQL-запроса). - 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/).
Источники данных в порядке приоритета:
- AsyncLocal-override (
UseOverride(orgId, isSuper)) — для background-tasks (Hangfire, импорт MoySklad, фоновые сидеры). Когда нет HttpContext, нужно явно задать tenant передDbContext-вызовом. - HTTP-заголовок
X-Org-Override— режим «открыть как…» (только если у юзера роль SuperAdmin). - 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: режим «открыть как…»
Конкретный поток с фронта:
- SuperAdmin заходит в «Системная консоль → Организации».
- Кликает «Открыть как…» на какой-то orgRow.
- Фронт начинает слать каждый запрос с заголовком
X-Org-Override: <orgId>. Без этого хедера SuperAdmin видит «своё» (а у SuperAdmin'а часто нет своей орги, поэтому все списки пусты — у супер-админа в админке тренировочный режим). - По умолчанию режим read-only (
ReadonlyOverrideMiddleware): GET/HEAD/OPTIONS — пропускаются; PUT/POST/DELETE/PATCH —403. Исключение:/api/super-admin/*и/connect/*(refresh-token). - Чтобы что-то поменять, фронт показывает «Войти в edit-mode»,
запрашивает причину (≥ 10 символов), отправляет её в каждом запросе
как
X-Org-Override-Reason: <текст>. Тогда middleware пропускает мутации, а action-filter (SuperAdminEditAuditFilter) после успешного ответа пишет строку вsuper_admin_audit_logс reason'ом и запросом/ответом. - Фронт ограничивает 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-сущность»
- Унаследовать от
TenantEntity(или реализоватьITenantEntity). - Добавить EF Configuration в
food-market.infrastructure/Persistence/Configurations/:b.ToTable("...");b.HasIndex(x => new { x.OrganizationId, x.SomeField });— индекс с OrganizationId первым полем (для скорости query filter'а).- Если есть уникальность в рамках org:
.IsUnique()на индексе с OrganizationId первым полем.
- Создать миграцию руками в
Persistence/Migrations/:- Атрибуты
[DbContext(typeof(AppDbContext))]+[Migration("YYYYMMDD…")]. - В
Up()—CreateTableс колонкойOrganizationId uuid NOT NULL. - Индекс на OrganizationId.
- Атрибуты
- Добавить
DbSetвAppDbContext. - Контроллер использует
_db.MyEntities.Where(...)— query filter подключится автоматически. Stamping выставитOrganizationIdвAdd(). - Интеграционный тест на изоляцию (см.
TenantIsolationTests).