fix(tenancy): SuperAdmin override должен применять tenant filter выбранной орги
🔴 КРИТИЧНЫЙ БАГ ИЗОЛЯЦИИ. 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:
parent
21c2ca89fe
commit
61cb97a1b7
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,4 +5,8 @@ public interface ITenantContext
|
|||
Guid? OrganizationId { get; }
|
||||
bool IsAuthenticated { get; }
|
||||
bool IsSuperAdmin { get; }
|
||||
/// <summary>SuperAdmin зашёл в режим «открыто как…» через X-Org-Override.
|
||||
/// В этом режиме query-filter ОБЯЗАН применяться (по выбранной orgId),
|
||||
/// иначе SuperAdmin'у возвращаются записи всех орг — нарушение изоляции.</summary>
|
||||
bool IsTenantOverride { get; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -116,8 +116,13 @@ protected override void OnModelCreating(ModelBuilder builder)
|
|||
|
||||
private void ApplyTenantFilter<T>(ModelBuilder builder) where T : class, ITenantEntity
|
||||
{
|
||||
// SuperAdmin обходит фильтр ТОЛЬКО когда не в режиме «открыть как…».
|
||||
// В override-режиме (X-Org-Override header активен) он работает в
|
||||
// контексте конкретной орги — фильтр обязан применяться, иначе
|
||||
// возвращаются записи всех орг и нарушается tenant isolation.
|
||||
builder.Entity<T>().HasQueryFilter(e =>
|
||||
_tenant.IsSuperAdmin || e.OrganizationId == _tenant.OrganizationId);
|
||||
(_tenant.IsSuperAdmin && !_tenant.IsTenantOverride)
|
||||
|| e.OrganizationId == _tenant.OrganizationId);
|
||||
}
|
||||
|
||||
public override int SaveChanges()
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue