diff --git a/docs/simple-mode-progress.md b/docs/simple-mode-progress.md index 229c2b1..7f2de2f 100644 --- a/docs/simple-mode-progress.md +++ b/docs/simple-mode-progress.md @@ -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). diff --git a/src/food-market.api/Controllers/Admin/OrgAuditLogController.cs b/src/food-market.api/Controllers/Admin/OrgAuditLogController.cs index 92616ec..098d705 100644 --- a/src/food-market.api/Controllers/Admin/OrgAuditLogController.cs +++ b/src/food-market.api/Controllers/Admin/OrgAuditLogController.cs @@ -13,6 +13,7 @@ namespace foodmarket.Api.Controllers.Admin; /// видит всё (через query-filter bypass + IsSuperAdmin). [ApiController] [Authorize] +[RequiresAdvancedMode] [Route("api/admin/audit-log")] public class OrgAuditLogController : ControllerBase { diff --git a/src/food-market.api/Controllers/Catalog/PriceTypesController.cs b/src/food-market.api/Controllers/Catalog/PriceTypesController.cs index 795c3c4..59dea01 100644 --- a/src/food-market.api/Controllers/Catalog/PriceTypesController.cs +++ b/src/food-market.api/Controllers/Catalog/PriceTypesController.cs @@ -50,7 +50,7 @@ public async Task> 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> 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 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 Delete(Guid id, CancellationToken ct) { var e = await _db.PriceTypes.FirstOrDefaultAsync(x => x.Id == id, ct); diff --git a/src/food-market.api/Controllers/Catalog/ProductsController.cs b/src/food-market.api/Controllers/Catalog/ProductsController.cs index ca0d42a..0b8b97e 100644 --- a/src/food-market.api/Controllers/Catalog/ProductsController.cs +++ b/src/food-market.api/Controllers/Catalog/ProductsController.cs @@ -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> BulkUpdate( [FromBody] BulkUpdateRequest req, CancellationToken ct) { diff --git a/src/food-market.api/Controllers/Common/UserPresetsController.cs b/src/food-market.api/Controllers/Common/UserPresetsController.cs index d82bec9..089ad62 100644 --- a/src/food-market.api/Controllers/Common/UserPresetsController.cs +++ b/src/food-market.api/Controllers/Common/UserPresetsController.cs @@ -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. [ApiController] [Authorize] +[RequiresAdvancedMode] [Route("api/user/presets")] public class UserPresetsController : ControllerBase { diff --git a/src/food-market.api/Controllers/Inventory/TransfersController.cs b/src/food-market.api/Controllers/Inventory/TransfersController.cs index d820cd7..2cd9f6a 100644 --- a/src/food-market.api/Controllers/Inventory/TransfersController.cs +++ b/src/food-market.api/Controllers/Inventory/TransfersController.cs @@ -18,6 +18,7 @@ namespace foodmarket.Api.Controllers.Inventory; /// транзакции. [ApiController] [Authorize] +[RequiresAdvancedMode] [Route("api/inventory/transfers")] public class TransfersController : ControllerBase { diff --git a/src/food-market.api/Controllers/Loyalty/LoyaltyCardsController.cs b/src/food-market.api/Controllers/Loyalty/LoyaltyCardsController.cs index 54749b8..c3471f9 100644 --- a/src/food-market.api/Controllers/Loyalty/LoyaltyCardsController.cs +++ b/src/food-market.api/Controllers/Loyalty/LoyaltyCardsController.cs @@ -14,6 +14,7 @@ namespace foodmarket.Api.Controllers.Loyalty; /// кассир сканирует, мы возвращаем привязанную программу и Counterparty. [ApiController] [Authorize] +[RequiresAdvancedMode] [Route("api/loyalty/cards")] public class LoyaltyCardsController : ControllerBase { diff --git a/src/food-market.api/Controllers/Loyalty/LoyaltyProgramsController.cs b/src/food-market.api/Controllers/Loyalty/LoyaltyProgramsController.cs index 50f25af..da0eb54 100644 --- a/src/food-market.api/Controllers/Loyalty/LoyaltyProgramsController.cs +++ b/src/food-market.api/Controllers/Loyalty/LoyaltyProgramsController.cs @@ -14,6 +14,7 @@ namespace foodmarket.Api.Controllers.Loyalty; /// orphan-карты и сломало history. [ApiController] [Authorize] +[RequiresAdvancedMode] [Route("api/loyalty/programs")] public class LoyaltyProgramsController : ControllerBase { diff --git a/src/food-market.api/Controllers/Promotions/PromotionsController.cs b/src/food-market.api/Controllers/Promotions/PromotionsController.cs index 6633fd5..837957e 100644 --- a/src/food-market.api/Controllers/Promotions/PromotionsController.cs +++ b/src/food-market.api/Controllers/Promotions/PromotionsController.cs @@ -13,6 +13,7 @@ namespace foodmarket.Api.Controllers.Promotions; /// контроллере отдаём 409 с понятным текстом если дубль. [ApiController] [Authorize] +[RequiresAdvancedMode] [Route("api/promotions")] public class PromotionsController : ControllerBase { diff --git a/src/food-market.api/Controllers/Reports/AbcReportController.cs b/src/food-market.api/Controllers/Reports/AbcReportController.cs index 6dfb74c..d39e649 100644 --- a/src/food-market.api/Controllers/Reports/AbcReportController.cs +++ b/src/food-market.api/Controllers/Reports/AbcReportController.cs @@ -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-метрика для периода. [ApiController] [Authorize] +[RequiresAdvancedMode] [Route("api/reports/abc")] public class AbcReportController : ControllerBase { diff --git a/src/food-market.api/Controllers/Sales/DemandsController.cs b/src/food-market.api/Controllers/Sales/DemandsController.cs index 5510267..fc3531f 100644 --- a/src/food-market.api/Controllers/Sales/DemandsController.cs +++ b/src/food-market.api/Controllers/Sales/DemandsController.cs @@ -17,6 +17,7 @@ namespace foodmarket.Api.Controllers.Sales; /// Множ. возвратов нет в MVP — учётный шаг без подтверждения дебиторки. [ApiController] [Authorize] +[RequiresAdvancedMode] [Route("api/sales/demands")] public class DemandsController : ControllerBase { diff --git a/src/food-market.api/Controllers/SuperAdmin/SuperAdminOrganizationsController.cs b/src/food-market.api/Controllers/SuperAdmin/SuperAdminOrganizationsController.cs index ad1fbb8..cbc0309 100644 --- a/src/food-market.api/Controllers/SuperAdmin/SuperAdminOrganizationsController.cs +++ b/src/food-market.api/Controllers/SuperAdmin/SuperAdminOrganizationsController.cs @@ -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 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 diff --git a/src/food-market.api/Controllers/Telegram/TelegramBindingController.cs b/src/food-market.api/Controllers/Telegram/TelegramBindingController.cs index d38f9ba..bc2db58 100644 --- a/src/food-market.api/Controllers/Telegram/TelegramBindingController.cs +++ b/src/food-market.api/Controllers/Telegram/TelegramBindingController.cs @@ -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; /// [ApiController] [Authorize(Policy = "AdminAccess")] +[RequiresAdvancedMode] [Route("api/organization/telegram")] public class TelegramBindingController : ControllerBase { diff --git a/src/food-market.api/Infrastructure/Authorization/RequiresAdvancedModeAttribute.cs b/src/food-market.api/Infrastructure/Authorization/RequiresAdvancedModeAttribute.cs new file mode 100644 index 0000000..9aa1b23 --- /dev/null +++ b/src/food-market.api/Infrastructure/Authorization/RequiresAdvancedModeAttribute.cs @@ -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; + +/// Sprint 29 — гейтит endpoint на «расширенный режим» организации. +/// Если Organization.IsAdvancedMode = false, возвращается +/// 403 ProblemDetails с кодом advanced-mode-required. SuperAdmin +/// bypass'ит проверку (он работает через /api/super-admin/* + режим +/// «войти как», и ему нужно иметь возможность читать скрытые модули). +/// Платформенные SuperAdmin-эндпоинты вовсе не навешивают атрибут. +/// +/// Применяется: +/// +/// На контроллер — закрывает ВСЕ действия контроллера (Оптовые +/// отгрузки, Перемещения, Лояльность, Промокоды/акции, ABC, Аудит, +/// SSO, Telegram-бот, Пресеты, PriceTypes). +/// На отдельное действие — например, ProductsController.BulkUpdate +/// / BulkDelete когда контроллер в целом «простой». +/// +/// +/// Работает как , чтобы фильтр мог +/// внедрять . Фильтр читает флаг напрямую из БД +/// по org_id-claim'у (один дешёвый Select + можно позже +/// добавить кэш) — специально не доверяем клиентскому заголовку. +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)] +public sealed class RequiresAdvancedModeAttribute : TypeFilterAttribute +{ + public RequiresAdvancedModeAttribute() : base(typeof(AdvancedModeFilter)) { } +} + +/// Внутренняя реализация . +/// Не используется напрямую — вешай на контроллер атрибут. +public sealed class AdvancedModeFilter : IAsyncAuthorizationFilter +{ + // Легковесный in-memory snapshot: org -> IsAdvancedMode. Flag меняет + // только SuperAdmin и редко; гонка между переключением и действием — + // в худшем случае 1-2 секунды «неправильного ответа», что приемлемо. + private static readonly ConcurrentDictionary 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 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; + } + + /// Инвалидирует кэш для организации — вызывает + /// SuperAdminOrganizationsController после переключения, + /// чтобы следующий запрос сразу увидел новое значение, а не ждал TTL. + 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" }, + }; + } +}