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>
22 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-сущность»
Расширенная версия с RowVersion + permission + validation паттернами (Sprint 15). Минимальный «список из 6 пунктов» оставлен ниже как краткая форма.
Domain
- Унаследовать от
TenantEntity(или реализоватьITenantEntity). - Для документов (Supply, RetailSale, Loss…) — добавить
IVersionedEntity+uint Xmin { get; set; }для оптимистической блокировки через PG xmin. EF переведёт concurrency-конфликт вDbUpdateConcurrencyException, контроллер вернёт 409.
Infrastructure (EF Config + миграция)
-
Добавить 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.
-
Создать миграцию руками в
Persistence/Migrations/:- Атрибуты
[DbContext(typeof(AppDbContext))]+[Migration("YYYYMMDDHHMMSS_NameHere")]. Без нихMigrate()миграцию не подхватит (см. memoryfeedback_ef_migrations). - В
Up()—CreateTableс колонкойOrganizationId uuid NOT NULL. - Индексы (минимум один на OrganizationId).
- Не использовать
dotnet ef migrations add— снапшот в репо не синхронизируется с моделью.
- Атрибуты
-
Добавить
DbSet<TEntity>вAppDbContext.
Permission (RolePermissions)
- Добавить булевый флаг в
RolePermissions.cs:public bool MyEntityEdit { get; set; }+ соответствующая запись вAll()фабрике (для системной роли Admin). - Миграции для
role_permissionsне нужно — это JSONB-колонка наEmployeeRole. - Все Admin-роли уже получат новый permission через
RolePermissions.All().
Validation
- Для простой валидации — DataAnnotations на input-record'е:
public record Input([Required, MaxLength(200)] string Name, …); - Для сложной —
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'а.
- Бизнес-валидация (требует БД — «существует ли поставщик»): в начале
action-метода вернуть
BadRequest(new { error, field }).
Controller
- Контроллер использует
_db.MyEntities.Where(...)— query filter подключится автоматически.StampTenantвSaveChangesAsyncвыставитOrganizationIdвAdd(). - Защитить mutating endpoint'ы атрибутом
[RequiresPermission("MyEntityEdit")](резолвится в policyperm:MyEntityEdit→ проверяет флаг наRolePermissions). - Для concurrency-чувствительных мутаций (Post документа):
await using var tx = await _db.Database.BeginTransactionAsync( IsolationLevel.Serializable, ct)— защита от race на остатке.
Audit (Sprint 13)
- CRUD автоматически логируется
OrgAuditInterceptor'ом вorg_audit_log(JSON diff). - Для sensitive-операций (смена пароля, выдача роли, изменение
permissions) — дополнительно через
SensitiveOpsAudit.LogAsync()— она пишет вorg_audit_log+ Serilog с типизированным action-name.
Tests
- Интеграционный тест на изоляцию (
TenantIsolationTests): org A создаёт, org B делает GET — список пустой, GET-by-id → 404. - Если есть concurrency-критика (Post под Serializable):
RetailOversellingTests-pattern — два параллельных VU гарантированно дают 409 на одном из них. - Для бизнес-инварианта (типа «Sum(movements) ≡ Stock»):
property-based test (см.
StockServicePropertyTests, Sprint 15).