From 2b9623d5cc096a2a7084dfc5090fc6f9ef7ebf0d Mon Sep 17 00:00:00 2001 From: nns <278048682+nurdotnet@users.noreply.github.com> Date: Sun, 26 Apr 2026 15:55:04 +0500 Subject: [PATCH] =?UTF-8?q?fix(tenancy):=20SuperAdmin=20override=20=D0=B4?= =?UTF-8?q?=D0=BE=D0=BB=D0=B6=D0=B5=D0=BD=20=D0=BF=D1=80=D0=B8=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D1=8F=D1=82=D1=8C=20tenant=20filter=20=D0=B2=D1=8B=D0=B1?= =?UTF-8?q?=D1=80=D0=B0=D0=BD=D0=BD=D0=BE=D0=B9=20=D0=BE=D1=80=D0=B3=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🔴 КРИТИЧНЫЙ БАГ ИЗОЛЯЦИИ. SuperAdmin в режиме «открыть как Demo Market» видел товары FOOD MARKET (29540 чужих записей вместо 0 своих). Корень проблемы — query-filter в AppDbContext: e => _tenant.IsSuperAdmin || e.OrganizationId == _tenant.OrganizationId IsSuperAdmin → весь предикат становится true → все записи всех орг. В режиме override OrganizationId уже корректно подменялся на выбранную орг, НО bypass через IsSuperAdmin делал подмену бессмысленной — фильтр всё равно пропускал всё. Фикс — добавил IsTenantOverride флаг в ITenantContext и переписал: e => (_tenant.IsSuperAdmin && !_tenant.IsTenantOverride) || e.OrganizationId == _tenant.OrganizationId То есть SuperAdmin обходит фильтр ТОЛЬКО когда не в override. В override-режиме он работает в контексте выбранной орги как обычный юзер — фильтр применяется. HttpContextTenantContext.IsTenantOverride возвращает true когда текущий запрос — HTTP, юзер в роли SuperAdmin и присутствует header X-Org-Override с валидным GUID. AsyncLocal-override (background-задачи импорта/Hangfire) намеренно НЕ считается tenant-override — там IsSuper=false по умолчанию и фильтр и так применяется. Smoke-test ДО фикса (воспроизведение): GET /products X-Org-Override=DemoId → total 29540 (баг: чужие) GET /products X-Org-Override=FoodId → total 29540 GET /products без header → total 29540 (legit super) После деплоя ожидается: GET /products X-Org-Override=DemoId → 0 (Demo Market пуст) GET /products X-Org-Override=FoodId → 29540 (своих) GET /products без header → 29540 (legit super bypass) Затронуты ВСЕ tenant-сущности (фильтр применяется через reflection ко всем ITenantEntity): products, counterparties, supplies, stocks, movements, retail-sales и т.д. DesignTimeTenantContext получил IsTenantOverride=false (он только для EF tooling). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Tenancy/HttpContextTenantContext.cs | 34 +++++++++++++------ .../Common/Tenancy/ITenantContext.cs | 4 +++ .../Persistence/AppDbContext.cs | 7 +++- .../DesignTimeAppDbContextFactory.cs | 1 + 4 files changed, 34 insertions(+), 12 deletions(-) diff --git a/src/food-market.api/Infrastructure/Tenancy/HttpContextTenantContext.cs b/src/food-market.api/Infrastructure/Tenancy/HttpContextTenantContext.cs index ca54b9b..ad4c13e 100644 --- a/src/food-market.api/Infrastructure/Tenancy/HttpContextTenantContext.cs +++ b/src/food-market.api/Infrastructure/Tenancy/HttpContextTenantContext.cs @@ -63,18 +63,30 @@ public bool IsSuperAdmin get { if (_override.Value is { OrgId: var o }) return o; - var ctx = _accessor.HttpContext; - // SuperAdmin может прислать X-Org-Override чтобы посмотреть данные - // конкретной организации в режиме «открыть как…». Блокируем мутации - // через ReadonlyOverrideMiddleware — здесь только подменяем tenant. - if (ctx is not null && ctx.User?.IsInRole(SuperAdminRole) == true - && ctx.Request.Headers.TryGetValue(OrgOverrideHeader, out var headerVal) - && Guid.TryParse(headerVal.ToString(), out var override_)) - { - return override_; - } - var claim = ctx?.User?.FindFirst(OrganizationClaim)?.Value; + if (TryGetHttpOverrideOrg(out var http)) return http; + var claim = _accessor.HttpContext?.User?.FindFirst(OrganizationClaim)?.Value; return Guid.TryParse(claim, out var id) ? id : null; } } + + public bool IsTenantOverride + { + get + { + // AsyncLocal-override (background tasks) не считаем «открыть как…» — + // он используется в импорте/Hangfire, где и так применяется фильтр + // (IsSuper=false по умолчанию). Override-режим — только HTTP-header. + return TryGetHttpOverrideOrg(out _); + } + } + + private bool TryGetHttpOverrideOrg(out Guid orgId) + { + orgId = Guid.Empty; + var ctx = _accessor.HttpContext; + if (ctx is null) return false; + if (ctx.User?.IsInRole(SuperAdminRole) != true) return false; + if (!ctx.Request.Headers.TryGetValue(OrgOverrideHeader, out var headerVal)) return false; + return Guid.TryParse(headerVal.ToString(), out orgId); + } } diff --git a/src/food-market.application/Common/Tenancy/ITenantContext.cs b/src/food-market.application/Common/Tenancy/ITenantContext.cs index 91c083d..139cc72 100644 --- a/src/food-market.application/Common/Tenancy/ITenantContext.cs +++ b/src/food-market.application/Common/Tenancy/ITenantContext.cs @@ -5,4 +5,8 @@ public interface ITenantContext Guid? OrganizationId { get; } bool IsAuthenticated { get; } bool IsSuperAdmin { get; } + /// SuperAdmin зашёл в режим «открыто как…» через X-Org-Override. + /// В этом режиме query-filter ОБЯЗАН применяться (по выбранной orgId), + /// иначе SuperAdmin'у возвращаются записи всех орг — нарушение изоляции. + bool IsTenantOverride { get; } } diff --git a/src/food-market.infrastructure/Persistence/AppDbContext.cs b/src/food-market.infrastructure/Persistence/AppDbContext.cs index a62633b..cd0daae 100644 --- a/src/food-market.infrastructure/Persistence/AppDbContext.cs +++ b/src/food-market.infrastructure/Persistence/AppDbContext.cs @@ -116,8 +116,13 @@ protected override void OnModelCreating(ModelBuilder builder) private void ApplyTenantFilter(ModelBuilder builder) where T : class, ITenantEntity { + // SuperAdmin обходит фильтр ТОЛЬКО когда не в режиме «открыть как…». + // В override-режиме (X-Org-Override header активен) он работает в + // контексте конкретной орги — фильтр обязан применяться, иначе + // возвращаются записи всех орг и нарушается tenant isolation. builder.Entity().HasQueryFilter(e => - _tenant.IsSuperAdmin || e.OrganizationId == _tenant.OrganizationId); + (_tenant.IsSuperAdmin && !_tenant.IsTenantOverride) + || e.OrganizationId == _tenant.OrganizationId); } public override int SaveChanges() diff --git a/src/food-market.infrastructure/Persistence/DesignTimeAppDbContextFactory.cs b/src/food-market.infrastructure/Persistence/DesignTimeAppDbContextFactory.cs index 5a034b5..66fb980 100644 --- a/src/food-market.infrastructure/Persistence/DesignTimeAppDbContextFactory.cs +++ b/src/food-market.infrastructure/Persistence/DesignTimeAppDbContextFactory.cs @@ -20,5 +20,6 @@ private sealed class DesignTimeTenantContext : ITenantContext public Guid? OrganizationId => null; public bool IsAuthenticated => false; public bool IsSuperAdmin => true; + public bool IsTenantOverride => false; } }