feat(s29): [RequiresAdvancedMode] + гейтинг скрытых модулей (шаг 3/8)

- RequiresAdvancedModeAttribute (TypeFilterAttribute → IAsyncAuthorizationFilter):
  читает Organization.IsAdvancedMode по org_id-claim через AppDbContext,
  кэш 30 с, инвалидация после переключения. SuperAdmin bypass. На отказ
  возвращает 403 ProblemDetails с code=advanced-mode-required.
- Атрибут навешен на контроллеры:
  Sales/DemandsController, Inventory/TransfersController,
  Loyalty/LoyaltyCardsController + LoyaltyProgramsController,
  Promotions/PromotionsController, Reports/AbcReportController,
  Admin/OrgAuditLogController, Telegram/TelegramBindingController,
  Common/UserPresetsController.
- И на отдельные действия:
  Catalog/PriceTypesController POST/PUT/DELETE (GET доступен — товары
  используют retail-прайс),
  Catalog/ProductsController POST /bulk-update.
- SuperAdmin toggle (шаг 2) инвалидирует кэш фильтра после SaveChangesAsync.
- SSO (Auth/ExternalAuthController) гейтится UI, а не атрибутом: endpoint
  инициируется анонимом без org_id в токене.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
nns 2026-10-04 11:38:10 +05:00
parent 202763e0d0
commit 921bca076c
14 changed files with 157 additions and 5 deletions

View file

@ -11,7 +11,7 @@ SuperAdmin и только для конкретной организации.
- [x] 1. Domain + миграция `Organization.IsAdvancedMode` (bool, default false, backfill false)
- [x] 2. API `PUT /api/super-admin/organizations/{id}/advanced-mode` + аудит + `/api/me.advancedMode`; org-admin → 403
- [ ] 3. Серверная защита: `[RequiresAdvancedMode]` → 403 ProblemDetails на скрытых модулях
- [x] 3. Серверная защита: `[RequiresAdvancedMode]` → 403 ProblemDetails на скрытых модулях
- [ ] 4. Web: меню, маршруты, дашборд фильтруются; прямой переход → страница «Недоступно»
- [ ] 5. Упрощённые формы (товар / контрагент / документы) — скрыть «не нужные» поля, значения сохраняются
- [ ] 6. SuperAdmin UI — бейдж «Простой/Расширенный» + переключатель с подтверждением
@ -63,3 +63,26 @@ SuperAdmin и только для конкретной организации.
- `OrgRow` / `OrgDetail` в SuperAdmin list / detail — добавлено `IsAdvancedMode`.
- Маршрут: `/api/super-admin/...` (с дефисом), так как существующий контроллер уже использует этот стиль; задача упоминала `superadmin` без дефиса — выбрал существующий стиль.
- `dotnet build food-market.api` — ✓ (0 error).
### 2026-10-04 — Шаг 3 ✓
- `src/food-market.api/Infrastructure/Authorization/RequiresAdvancedModeAttribute.cs`:
- `[RequiresAdvancedMode]` — `TypeFilterAttribute` вокруг `AdvancedModeFilter : IAsyncAuthorizationFilter`.
- Читает `IsAdvancedMode` по `org_id`-claim через `AppDbContext` (без доверия к клиентским заголовкам). Кэш in-memory 30 с, инвалидация после переключения.
- SuperAdmin bypass (чтобы он мог проверять скрытые модули через «войти как»).
- Отказ — `403 ProblemDetails` с `type`, `title`, `detail` на русском и `extensions.code = "advanced-mode-required"` — фронт различает это 403 от обычного «нет прав».
- Атрибут навешен на (контроллеры):
- `Sales/DemandsController` (оптовые отгрузки)
- `Inventory/TransfersController` (перемещения)
- `Loyalty/LoyaltyCardsController`, `Loyalty/LoyaltyProgramsController`
- `Promotions/PromotionsController`
- `Reports/AbcReportController`
- `Admin/OrgAuditLogController`
- `Telegram/TelegramBindingController`
- `Common/UserPresetsController` (пресеты фильтров)
- И на отдельные действия:
- `Catalog/PriceTypesController` — POST / PUT / DELETE (чтение остаётся — товары используют системный retail-прайс)
- `Catalog/ProductsController.BulkUpdate` (POST `/products/bulk-update`)
- `Auth/ExternalAuthController` (SSO) — атрибут НЕ навешен: endpoint инициирует OAuth-челлендж для анонима, `org_id`-claim ещё не выставлен. Контроль — на уровне UI (не показывать кнопку «Войти через Google/Microsoft» в простом режиме). Также SSO в простом режиме не ломается при включении — просто кнопка появляется.
- `SuperAdminOrganizationsController.SetAdvancedMode` вызывает `AdvancedModeFilter.InvalidateCache(orgId)` после `SaveChangesAsync`, чтобы следующий запрос этой org сразу увидел новое значение.
- `dotnet build food-market.api` — ✓ (0 error).

View file

@ -13,6 +13,7 @@ namespace foodmarket.Api.Controllers.Admin;
/// видит всё (через query-filter bypass + IsSuperAdmin).</summary>
[ApiController]
[Authorize]
[RequiresAdvancedMode]
[Route("api/admin/audit-log")]
public class OrgAuditLogController : ControllerBase
{

View file

@ -50,7 +50,7 @@ public async Task<ActionResult<PriceTypeDto>> Get(Guid id, CancellationToken ct)
return p is null ? NotFound() : new PriceTypeDto(p.Id, p.Name, p.IsRequired, p.IsSystem, p.IsRetail, p.SortOrder);
}
[HttpPost, RequiresPermission("PriceTypesManage")]
[HttpPost, RequiresPermission("PriceTypesManage"), RequiresAdvancedMode]
public async Task<ActionResult<PriceTypeDto>> Create([FromBody] PriceTypeInput input, CancellationToken ct)
{
if (input.IsRetail)
@ -73,7 +73,7 @@ await _db.PriceTypes.Where(p => p.IsRetail)
new PriceTypeDto(e.Id, e.Name, e.IsRequired, e.IsSystem, e.IsRetail, e.SortOrder));
}
[HttpPut("{id:guid}"), RequiresPermission("PriceTypesManage")]
[HttpPut("{id:guid}"), RequiresPermission("PriceTypesManage"), RequiresAdvancedMode]
public async Task<IActionResult> Update(Guid id, [FromBody] PriceTypeInput input, CancellationToken ct)
{
var e = await _db.PriceTypes.FirstOrDefaultAsync(x => x.Id == id, ct);
@ -93,7 +93,7 @@ await _db.PriceTypes.Where(p => p.IsRetail && p.Id != id)
return NoContent();
}
[HttpDelete("{id:guid}"), RequiresPermission("PriceTypesManage")]
[HttpDelete("{id:guid}"), RequiresPermission("PriceTypesManage"), RequiresAdvancedMode]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var e = await _db.PriceTypes.FirstOrDefaultAsync(x => x.Id == id, ct);

View file

@ -857,7 +857,7 @@ int Idx(params string[] aliases)
return new CsvImportResponse(createdIds.Count, [], createdIds);
}
[HttpPost("bulk-update"), RequiresPermission("ProductsEdit")]
[HttpPost("bulk-update"), RequiresPermission("ProductsEdit"), RequiresAdvancedMode]
public async Task<ActionResult<BulkUpdateResponse>> BulkUpdate(
[FromBody] BulkUpdateRequest req, CancellationToken ct)
{

View file

@ -1,4 +1,5 @@
using System.Security.Claims;
using foodmarket.Api.Infrastructure.Authorization;
using foodmarket.Domain.Common;
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
@ -13,6 +14,7 @@ namespace foodmarket.Api.Controllers.Common;
/// контроллера через WHERE UserId = currentUserId.</summary>
[ApiController]
[Authorize]
[RequiresAdvancedMode]
[Route("api/user/presets")]
public class UserPresetsController : ControllerBase
{

View file

@ -18,6 +18,7 @@ namespace foodmarket.Api.Controllers.Inventory;
/// транзакции.</summary>
[ApiController]
[Authorize]
[RequiresAdvancedMode]
[Route("api/inventory/transfers")]
public class TransfersController : ControllerBase
{

View file

@ -14,6 +14,7 @@ namespace foodmarket.Api.Controllers.Loyalty;
/// кассир сканирует, мы возвращаем привязанную программу и Counterparty.</summary>
[ApiController]
[Authorize]
[RequiresAdvancedMode]
[Route("api/loyalty/cards")]
public class LoyaltyCardsController : ControllerBase
{

View file

@ -14,6 +14,7 @@ namespace foodmarket.Api.Controllers.Loyalty;
/// orphan-карты и сломало history.</summary>
[ApiController]
[Authorize]
[RequiresAdvancedMode]
[Route("api/loyalty/programs")]
public class LoyaltyProgramsController : ControllerBase
{

View file

@ -13,6 +13,7 @@ namespace foodmarket.Api.Controllers.Promotions;
/// контроллере отдаём 409 с понятным текстом если дубль.</summary>
[ApiController]
[Authorize]
[RequiresAdvancedMode]
[Route("api/promotions")]
public class PromotionsController : ControllerBase
{

View file

@ -1,3 +1,4 @@
using foodmarket.Api.Infrastructure.Authorization;
using foodmarket.Domain.Sales;
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
@ -23,6 +24,7 @@ namespace foodmarket.Api.Controllers.Reports;
/// Возвраты учтены со знаком: net-метрика для периода.</summary>
[ApiController]
[Authorize]
[RequiresAdvancedMode]
[Route("api/reports/abc")]
public class AbcReportController : ControllerBase
{

View file

@ -17,6 +17,7 @@ namespace foodmarket.Api.Controllers.Sales;
/// Множ. возвратов нет в MVP — учётный шаг без подтверждения дебиторки.</summary>
[ApiController]
[Authorize]
[RequiresAdvancedMode]
[Route("api/sales/demands")]
public class DemandsController : ControllerBase
{

View file

@ -1,3 +1,4 @@
using foodmarket.Api.Infrastructure.Authorization;
using foodmarket.Api.Seed;
using foodmarket.Application.Common;
using foodmarket.Domain.Organizations;
@ -252,6 +253,9 @@ public async Task<IActionResult> SetAdvancedMode(Guid id, [FromBody] AdvancedMod
var before = o.IsAdvancedMode;
o.IsAdvancedMode = req.Enabled;
await _db.SaveChangesAsync(ct);
// Инвалидируем кэш фильтра, чтобы следующий запрос этой org сразу
// увидел новое значение, а не ждал TTL (30 сек).
AdvancedModeFilter.InvalidateCache(o.Id);
var actionType = req.Enabled ? "EnableAdvancedMode" : "DisableAdvancedMode";
var description = req.Enabled

View file

@ -1,4 +1,5 @@
using System.Security.Cryptography;
using foodmarket.Api.Infrastructure.Authorization;
using foodmarket.Api.Integrations.Telegram;
using foodmarket.Application.Common.Tenancy;
using foodmarket.Infrastructure.Persistence;
@ -28,6 +29,7 @@ namespace foodmarket.Api.Controllers.Telegram;
/// </summary>
[ApiController]
[Authorize(Policy = "AdminAccess")]
[RequiresAdvancedMode]
[Route("api/organization/telegram")]
public class TelegramBindingController : ControllerBase
{

View file

@ -0,0 +1,113 @@
using System.Collections.Concurrent;
using foodmarket.Api.Infrastructure.Tenancy;
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Infrastructure.Authorization;
/// <summary>Sprint 29 — гейтит endpoint на «расширенный режим» организации.
/// Если <c>Organization.IsAdvancedMode = false</c>, возвращается
/// <c>403 ProblemDetails</c> с кодом <c>advanced-mode-required</c>. SuperAdmin
/// bypass'ит проверку (он работает через <c>/api/super-admin/*</c> + режим
/// «войти как», и ему нужно иметь возможность читать скрытые модули).
/// Платформенные SuperAdmin-эндпоинты вовсе не навешивают атрибут.
///
/// Применяется:
/// <list type="bullet">
/// <item>На контроллер — закрывает ВСЕ действия контроллера (Оптовые
/// отгрузки, Перемещения, Лояльность, Промокоды/акции, ABC, Аудит,
/// SSO, Telegram-бот, Пресеты, PriceTypes).</item>
/// <item>На отдельное действие — например, <c>ProductsController.BulkUpdate</c>
/// / <c>BulkDelete</c> когда контроллер в целом «простой».</item>
/// </list>
///
/// Работает как <see cref="TypeFilterAttribute"/>, чтобы фильтр мог
/// внедрять <see cref="AppDbContext"/>. Фильтр читает флаг напрямую из БД
/// по <c>org_id</c>-claim'у (один дешёвый <c>Select</c> + можно позже
/// добавить кэш) — специально не доверяем клиентскому заголовку.</summary>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)]
public sealed class RequiresAdvancedModeAttribute : TypeFilterAttribute
{
public RequiresAdvancedModeAttribute() : base(typeof(AdvancedModeFilter)) { }
}
/// <summary>Внутренняя реализация <see cref="RequiresAdvancedModeAttribute"/>.
/// Не используется напрямую — вешай на контроллер атрибут.</summary>
public sealed class AdvancedModeFilter : IAsyncAuthorizationFilter
{
// Легковесный in-memory snapshot: org -> IsAdvancedMode. Flag меняет
// только SuperAdmin и редко; гонка между переключением и действием —
// в худшем случае 1-2 секунды «неправильного ответа», что приемлемо.
private static readonly ConcurrentDictionary<Guid, (bool Advanced, DateTime At)> Cache = new();
private static readonly TimeSpan CacheTtl = TimeSpan.FromSeconds(30);
private readonly AppDbContext _db;
public AdvancedModeFilter(AppDbContext db) => _db = db;
public async Task OnAuthorizationAsync(AuthorizationFilterContext ctx)
{
var user = ctx.HttpContext.User;
// Аноним / не аутентифицирован — пропускаем, пусть выше по пайплайну
// отработает [Authorize]. Атрибут гейтит ТОЛЬКО по режиму.
if (user?.Identity?.IsAuthenticated != true) return;
// SuperAdmin видит всё.
if (user.IsInRole(HttpContextTenantContext.SuperAdminRole)) return;
var orgRaw = user.FindFirst(HttpContextTenantContext.OrganizationClaim)?.Value;
if (!Guid.TryParse(orgRaw, out var orgId))
{
// Нет org в токене — не можем сказать «расширено ли» → отказ,
// чтобы не было silent-прохода. [Authorize] уже должен был это
// отловить, но fail-closed.
ctx.Result = Forbidden(ctx);
return;
}
var advanced = await ReadAdvancedAsync(orgId, ctx.HttpContext.RequestAborted);
if (!advanced)
{
ctx.Result = Forbidden(ctx);
}
}
private async Task<bool> ReadAdvancedAsync(Guid orgId, CancellationToken ct)
{
var now = DateTime.UtcNow;
if (Cache.TryGetValue(orgId, out var entry) && now - entry.At < CacheTtl)
return entry.Advanced;
var advanced = await _db.Organizations.IgnoreQueryFilters()
.Where(o => o.Id == orgId)
.Select(o => (bool?)o.IsAdvancedMode)
.FirstOrDefaultAsync(ct) ?? false;
Cache[orgId] = (advanced, now);
return advanced;
}
/// <summary>Инвалидирует кэш для организации — вызывает
/// <c>SuperAdminOrganizationsController</c> после переключения,
/// чтобы следующий запрос сразу увидел новое значение, а не ждал TTL.</summary>
public static void InvalidateCache(Guid orgId) => Cache.TryRemove(orgId, out _);
private static ObjectResult Forbidden(AuthorizationFilterContext ctx)
{
var pd = new ProblemDetails
{
Type = "https://food-market.kz/errors/advanced-mode-required",
Title = "Функция доступна в расширенном режиме.",
Detail = "Этот раздел недоступен в простом режиме магазина. Обратитесь к администратору платформы, чтобы включить расширенный режим.",
Status = StatusCodes.Status403Forbidden,
Instance = ctx.HttpContext.Request.Path,
};
pd.Extensions["code"] = "advanced-mode-required";
return new ObjectResult(pd)
{
StatusCode = StatusCodes.Status403Forbidden,
ContentTypes = { "application/problem+json" },
};
}
}