fix(security): SuperAdmin edit-mode override обходит [Authorize(Roles=Admin)]

Проблема: в режиме «открыть как…» (SuperAdmin + X-Org-Override) с reason ≥10
символов ReadonlyOverrideMiddleware пропускает PUT/POST/DELETE, но затем
контроллер падает 403 на атрибуте [Authorize(Roles="Admin,Storekeeper")] —
у SuperAdmin'а нет роли Admin тенанта. Результат: edit-mode фактически
не работает ни на одном tenant-эндпоинте.

Симптом, обнаруженный E2E:
  step11_superadmin_edit_override_with_reason: PUT → 403 «Forbidden»,
  super_admin_audit_log не растёт.

Фикс: новый SuperAdminOverrideClaimsTransformer (IClaimsTransformation).
При каждом запросе с заголовком X-Org-Override и ролью SuperAdmin
временно добавляет роли Admin/Storekeeper/Cashier в principal — только
для этого запроса. Изоляция и аудит остаются:
  - query filter всё равно скоупится через X-Org-Override (см.
    HttpContextTenantContext.TryGetHttpOverrideOrg).
  - SuperAdminEditAuditFilter пишет SuperAdminAuditLog с reason
    при успешном 2xx ответе.

Проверено E2E multi-tenant-isolation: 12/12 шагов проходят.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
nns 2026-05-23 12:24:52 +05:00
parent a06464baeb
commit ab5c4c970d
2 changed files with 63 additions and 0 deletions

View file

@ -0,0 +1,58 @@
using Microsoft.AspNetCore.Authentication;
using System.Security.Claims;
namespace foodmarket.Api.Infrastructure.Tenancy;
/// <summary>В режиме «открыть как…» (SuperAdmin + X-Org-Override) сам
/// SuperAdmin не имеет роли Admin/Storekeeper/Cashier, потому что эти
/// роли — атрибуты сотрудника тенанта. Без трансформации claim'ов все
/// мутации, защищённые <c>[Authorize(Roles="Admin,Storekeeper")]</c>,
/// отшиваются 403 даже когда <see cref="ReadonlyOverrideMiddleware"/>
/// разрешил мутацию (edit-mode с X-Org-Override-Reason ≥ 10 символов).
///
/// Здесь мы временно (на текущий request) добавляем SuperAdmin'у полный
/// набор tenant-ролей, чтобы он мог пройти атрибуты-гарды контроллеров.
/// Изоляция и аудит остаются: query filter всё равно скоупится на
/// X-Org-Override, а <see cref="SuperAdminEditAuditFilter"/> пишет запись
/// в super_admin_audit_log с reason и diff'ом.
///
/// Срабатывает только если:
/// - User имеет роль SuperAdmin,
/// - запрос содержит заголовок X-Org-Override.
/// На GET-запросах (read-only override) тоже работает — это безопасно,
/// потому что мутации остаются за middleware-гардом.</summary>
public class SuperAdminOverrideClaimsTransformer : IClaimsTransformation
{
private readonly IHttpContextAccessor _accessor;
public SuperAdminOverrideClaimsTransformer(IHttpContextAccessor accessor)
=> _accessor = accessor;
public Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
{
var ctx = _accessor.HttpContext;
if (ctx is null) return Task.FromResult(principal);
var isSuper = principal.IsInRole(HttpContextTenantContext.SuperAdminRole);
var hasOverride = ctx.Request.Headers.ContainsKey(HttpContextTenantContext.OrgOverrideHeader);
if (!isSuper || !hasOverride) return Task.FromResult(principal);
// Не клонируем — IClaimsTransformation вызывается на каждый запрос,
// принципал и так свежий. Мутируем первый identity.
var identity = principal.Identities.FirstOrDefault(i => i.IsAuthenticated);
if (identity is null) return Task.FromResult(principal);
foreach (var role in TenantRolesForOverride)
{
if (!principal.IsInRole(role))
identity.AddClaim(new Claim(identity.RoleClaimType, role));
}
return Task.FromResult(principal);
}
private static readonly string[] TenantRolesForOverride =
[
"Admin", "Storekeeper", "Cashier",
];
}

View file

@ -31,6 +31,11 @@
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ITenantContext, HttpContextTenantContext>();
// ClaimsTransformation для SuperAdmin override: добавляет роли Admin/
// Storekeeper/Cashier при наличии X-Org-Override, чтобы [Authorize(Roles=...)]
// не отшивал edit-mode мутации SuperAdmin'а. См. SuperAdminOverrideClaimsTransformer.
builder.Services.AddScoped<Microsoft.AspNetCore.Authentication.IClaimsTransformation,
foodmarket.Api.Infrastructure.Tenancy.SuperAdminOverrideClaimsTransformer>();
builder.Services.AddDbContext<AppDbContext>(opts =>
{