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:
parent
202763e0d0
commit
921bca076c
|
|
@ -11,7 +11,7 @@ SuperAdmin и только для конкретной организации.
|
||||||
|
|
||||||
- [x] 1. Domain + миграция `Organization.IsAdvancedMode` (bool, default false, backfill false)
|
- [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
|
- [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: меню, маршруты, дашборд фильтруются; прямой переход → страница «Недоступно»
|
- [ ] 4. Web: меню, маршруты, дашборд фильтруются; прямой переход → страница «Недоступно»
|
||||||
- [ ] 5. Упрощённые формы (товар / контрагент / документы) — скрыть «не нужные» поля, значения сохраняются
|
- [ ] 5. Упрощённые формы (товар / контрагент / документы) — скрыть «не нужные» поля, значения сохраняются
|
||||||
- [ ] 6. SuperAdmin UI — бейдж «Простой/Расширенный» + переключатель с подтверждением
|
- [ ] 6. SuperAdmin UI — бейдж «Простой/Расширенный» + переключатель с подтверждением
|
||||||
|
|
@ -63,3 +63,26 @@ SuperAdmin и только для конкретной организации.
|
||||||
- `OrgRow` / `OrgDetail` в SuperAdmin list / detail — добавлено `IsAdvancedMode`.
|
- `OrgRow` / `OrgDetail` в SuperAdmin list / detail — добавлено `IsAdvancedMode`.
|
||||||
- Маршрут: `/api/super-admin/...` (с дефисом), так как существующий контроллер уже использует этот стиль; задача упоминала `superadmin` без дефиса — выбрал существующий стиль.
|
- Маршрут: `/api/super-admin/...` (с дефисом), так как существующий контроллер уже использует этот стиль; задача упоминала `superadmin` без дефиса — выбрал существующий стиль.
|
||||||
- `dotnet build food-market.api` — ✓ (0 error).
|
- `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).
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ namespace foodmarket.Api.Controllers.Admin;
|
||||||
/// видит всё (через query-filter bypass + IsSuperAdmin).</summary>
|
/// видит всё (через query-filter bypass + IsSuperAdmin).</summary>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
|
[RequiresAdvancedMode]
|
||||||
[Route("api/admin/audit-log")]
|
[Route("api/admin/audit-log")]
|
||||||
public class OrgAuditLogController : ControllerBase
|
public class OrgAuditLogController : ControllerBase
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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);
|
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)
|
public async Task<ActionResult<PriceTypeDto>> Create([FromBody] PriceTypeInput input, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (input.IsRetail)
|
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));
|
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)
|
public async Task<IActionResult> Update(Guid id, [FromBody] PriceTypeInput input, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var e = await _db.PriceTypes.FirstOrDefaultAsync(x => x.Id == id, 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();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{id:guid}"), RequiresPermission("PriceTypesManage")]
|
[HttpDelete("{id:guid}"), RequiresPermission("PriceTypesManage"), RequiresAdvancedMode]
|
||||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var e = await _db.PriceTypes.FirstOrDefaultAsync(x => x.Id == id, ct);
|
var e = await _db.PriceTypes.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||||
|
|
|
||||||
|
|
@ -857,7 +857,7 @@ int Idx(params string[] aliases)
|
||||||
return new CsvImportResponse(createdIds.Count, [], createdIds);
|
return new CsvImportResponse(createdIds.Count, [], createdIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("bulk-update"), RequiresPermission("ProductsEdit")]
|
[HttpPost("bulk-update"), RequiresPermission("ProductsEdit"), RequiresAdvancedMode]
|
||||||
public async Task<ActionResult<BulkUpdateResponse>> BulkUpdate(
|
public async Task<ActionResult<BulkUpdateResponse>> BulkUpdate(
|
||||||
[FromBody] BulkUpdateRequest req, CancellationToken ct)
|
[FromBody] BulkUpdateRequest req, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
using foodmarket.Api.Infrastructure.Authorization;
|
||||||
using foodmarket.Domain.Common;
|
using foodmarket.Domain.Common;
|
||||||
using foodmarket.Infrastructure.Persistence;
|
using foodmarket.Infrastructure.Persistence;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
|
@ -13,6 +14,7 @@ namespace foodmarket.Api.Controllers.Common;
|
||||||
/// контроллера через WHERE UserId = currentUserId.</summary>
|
/// контроллера через WHERE UserId = currentUserId.</summary>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
|
[RequiresAdvancedMode]
|
||||||
[Route("api/user/presets")]
|
[Route("api/user/presets")]
|
||||||
public class UserPresetsController : ControllerBase
|
public class UserPresetsController : ControllerBase
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ namespace foodmarket.Api.Controllers.Inventory;
|
||||||
/// транзакции.</summary>
|
/// транзакции.</summary>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
|
[RequiresAdvancedMode]
|
||||||
[Route("api/inventory/transfers")]
|
[Route("api/inventory/transfers")]
|
||||||
public class TransfersController : ControllerBase
|
public class TransfersController : ControllerBase
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ namespace foodmarket.Api.Controllers.Loyalty;
|
||||||
/// кассир сканирует, мы возвращаем привязанную программу и Counterparty.</summary>
|
/// кассир сканирует, мы возвращаем привязанную программу и Counterparty.</summary>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
|
[RequiresAdvancedMode]
|
||||||
[Route("api/loyalty/cards")]
|
[Route("api/loyalty/cards")]
|
||||||
public class LoyaltyCardsController : ControllerBase
|
public class LoyaltyCardsController : ControllerBase
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ namespace foodmarket.Api.Controllers.Loyalty;
|
||||||
/// orphan-карты и сломало history.</summary>
|
/// orphan-карты и сломало history.</summary>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
|
[RequiresAdvancedMode]
|
||||||
[Route("api/loyalty/programs")]
|
[Route("api/loyalty/programs")]
|
||||||
public class LoyaltyProgramsController : ControllerBase
|
public class LoyaltyProgramsController : ControllerBase
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ namespace foodmarket.Api.Controllers.Promotions;
|
||||||
/// контроллере отдаём 409 с понятным текстом если дубль.</summary>
|
/// контроллере отдаём 409 с понятным текстом если дубль.</summary>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
|
[RequiresAdvancedMode]
|
||||||
[Route("api/promotions")]
|
[Route("api/promotions")]
|
||||||
public class PromotionsController : ControllerBase
|
public class PromotionsController : ControllerBase
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using foodmarket.Api.Infrastructure.Authorization;
|
||||||
using foodmarket.Domain.Sales;
|
using foodmarket.Domain.Sales;
|
||||||
using foodmarket.Infrastructure.Persistence;
|
using foodmarket.Infrastructure.Persistence;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
|
@ -23,6 +24,7 @@ namespace foodmarket.Api.Controllers.Reports;
|
||||||
/// Возвраты учтены со знаком: net-метрика для периода.</summary>
|
/// Возвраты учтены со знаком: net-метрика для периода.</summary>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
|
[RequiresAdvancedMode]
|
||||||
[Route("api/reports/abc")]
|
[Route("api/reports/abc")]
|
||||||
public class AbcReportController : ControllerBase
|
public class AbcReportController : ControllerBase
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ namespace foodmarket.Api.Controllers.Sales;
|
||||||
/// Множ. возвратов нет в MVP — учётный шаг без подтверждения дебиторки.</summary>
|
/// Множ. возвратов нет в MVP — учётный шаг без подтверждения дебиторки.</summary>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
|
[RequiresAdvancedMode]
|
||||||
[Route("api/sales/demands")]
|
[Route("api/sales/demands")]
|
||||||
public class DemandsController : ControllerBase
|
public class DemandsController : ControllerBase
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using foodmarket.Api.Infrastructure.Authorization;
|
||||||
using foodmarket.Api.Seed;
|
using foodmarket.Api.Seed;
|
||||||
using foodmarket.Application.Common;
|
using foodmarket.Application.Common;
|
||||||
using foodmarket.Domain.Organizations;
|
using foodmarket.Domain.Organizations;
|
||||||
|
|
@ -252,6 +253,9 @@ public async Task<IActionResult> SetAdvancedMode(Guid id, [FromBody] AdvancedMod
|
||||||
var before = o.IsAdvancedMode;
|
var before = o.IsAdvancedMode;
|
||||||
o.IsAdvancedMode = req.Enabled;
|
o.IsAdvancedMode = req.Enabled;
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
|
// Инвалидируем кэш фильтра, чтобы следующий запрос этой org сразу
|
||||||
|
// увидел новое значение, а не ждал TTL (30 сек).
|
||||||
|
AdvancedModeFilter.InvalidateCache(o.Id);
|
||||||
|
|
||||||
var actionType = req.Enabled ? "EnableAdvancedMode" : "DisableAdvancedMode";
|
var actionType = req.Enabled ? "EnableAdvancedMode" : "DisableAdvancedMode";
|
||||||
var description = req.Enabled
|
var description = req.Enabled
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
|
using foodmarket.Api.Infrastructure.Authorization;
|
||||||
using foodmarket.Api.Integrations.Telegram;
|
using foodmarket.Api.Integrations.Telegram;
|
||||||
using foodmarket.Application.Common.Tenancy;
|
using foodmarket.Application.Common.Tenancy;
|
||||||
using foodmarket.Infrastructure.Persistence;
|
using foodmarket.Infrastructure.Persistence;
|
||||||
|
|
@ -28,6 +29,7 @@ namespace foodmarket.Api.Controllers.Telegram;
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Authorize(Policy = "AdminAccess")]
|
[Authorize(Policy = "AdminAccess")]
|
||||||
|
[RequiresAdvancedMode]
|
||||||
[Route("api/organization/telegram")]
|
[Route("api/organization/telegram")]
|
||||||
public class TelegramBindingController : ControllerBase
|
public class TelegramBindingController : ControllerBase
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue