fix(tenancy): SuperAdmin override должен применять tenant filter выбранной орги
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 46s
CI / Web (React + Vite) (push) Successful in 39s
Docker API / Build + push API (push) Successful in 45s
Docker API / Deploy API on stage (push) Successful in 17s

🔴 КРИТИЧНЫЙ БАГ ИЗОЛЯЦИИ. 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) <noreply@anthropic.com>
This commit is contained in:
nns 2026-04-26 15:55:04 +05:00
parent 7a21c83d3e
commit 2b9623d5cc
4 changed files with 34 additions and 12 deletions

View file

@ -63,18 +63,30 @@ public bool IsSuperAdmin
get get
{ {
if (_override.Value is { OrgId: var o }) return o; if (_override.Value is { OrgId: var o }) return o;
var ctx = _accessor.HttpContext; if (TryGetHttpOverrideOrg(out var http)) return http;
// SuperAdmin может прислать X-Org-Override чтобы посмотреть данные var claim = _accessor.HttpContext?.User?.FindFirst(OrganizationClaim)?.Value;
// конкретной организации в режиме «открыть как…». Блокируем мутации
// через 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;
return Guid.TryParse(claim, out var id) ? id : null; 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);
}
} }

View file

@ -5,4 +5,8 @@ public interface ITenantContext
Guid? OrganizationId { get; } Guid? OrganizationId { get; }
bool IsAuthenticated { get; } bool IsAuthenticated { get; }
bool IsSuperAdmin { get; } bool IsSuperAdmin { get; }
/// <summary>SuperAdmin зашёл в режим «открыто как…» через X-Org-Override.
/// В этом режиме query-filter ОБЯЗАН применяться (по выбранной orgId),
/// иначе SuperAdmin'у возвращаются записи всех орг — нарушение изоляции.</summary>
bool IsTenantOverride { get; }
} }

View file

@ -116,8 +116,13 @@ protected override void OnModelCreating(ModelBuilder builder)
private void ApplyTenantFilter<T>(ModelBuilder builder) where T : class, ITenantEntity private void ApplyTenantFilter<T>(ModelBuilder builder) where T : class, ITenantEntity
{ {
// SuperAdmin обходит фильтр ТОЛЬКО когда не в режиме «открыть как…».
// В override-режиме (X-Org-Override header активен) он работает в
// контексте конкретной орги — фильтр обязан применяться, иначе
// возвращаются записи всех орг и нарушается tenant isolation.
builder.Entity<T>().HasQueryFilter(e => builder.Entity<T>().HasQueryFilter(e =>
_tenant.IsSuperAdmin || e.OrganizationId == _tenant.OrganizationId); (_tenant.IsSuperAdmin && !_tenant.IsTenantOverride)
|| e.OrganizationId == _tenant.OrganizationId);
} }
public override int SaveChanges() public override int SaveChanges()

View file

@ -20,5 +20,6 @@ private sealed class DesignTimeTenantContext : ITenantContext
public Guid? OrganizationId => null; public Guid? OrganizationId => null;
public bool IsAuthenticated => false; public bool IsAuthenticated => false;
public bool IsSuperAdmin => true; public bool IsSuperAdmin => true;
public bool IsTenantOverride => false;
} }
} }