From 01de66493aaf1e1fbe7887f5b602a0b978e72036 Mon Sep 17 00:00:00 2001 From: nns <278048682+nurdotnet@users.noreply.github.com> Date: Sun, 26 Apr 2026 15:07:10 +0500 Subject: [PATCH] =?UTF-8?q?feat(super-admin):=20Phase=203=20=E2=80=94=20ed?= =?UTF-8?q?it-mode=20=D1=81=20reason=20+=20audit-trail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit В режиме «открыть как…» SuperAdmin может временно (на 30 минут) включить редактирование с обязательной причиной — каждая успешная мутация пишется в SuperAdminAuditLog. Чтобы лог был полезным: API: - Header X-Org-Override-Reason. Если присутствует и trimmed >= 10 символов — ReadonlyOverrideMiddleware пропускает мутации (вместо 403). - SuperAdminEditAuditFilter — глобальный IAsyncActionFilter после controller'а: при наличии обоих headers + успешном статусе 2xx + методе POST/PUT/PATCH/DELETE пишет запись ActionType=EditEntity с reason, описанием «METHOD /path → 200», IP и SuperAdminUserId. Регистрируется как Scoped + AddService<>() в AddControllers. Web: - enableEditMode(reason)/disableEditMode/getEditMode в lib/api.ts — хранение в localStorage с expiresAt = now + 30мин. Axios interceptor добавляет header только пока edit активен и не истёк. - SuperAdminAsOrgBanner расширен: цвет меняется amber→red в edit-mode, кнопка «Включить редактирование» открывает модалку с textarea reason (≥10 символов) + чекбокс согласия на аудит. После активации баннер показывает «EDIT-MODE (N мин)», кнопка «Снять edit» отключает до истечения таймера. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Tenancy/HttpContextTenantContext.cs | 5 + .../Tenancy/ReadonlyOverrideMiddleware.cs | 10 +- .../Tenancy/SuperAdminEditAuditFilter.cs | 50 ++++++++++ src/food-market.api/Program.cs | 8 +- .../src/components/SuperAdminAsOrgBanner.tsx | 92 +++++++++++++++---- src/food-market.web/src/lib/api.ts | 29 +++++- 6 files changed, 175 insertions(+), 19 deletions(-) create mode 100644 src/food-market.api/Infrastructure/Tenancy/SuperAdminEditAuditFilter.cs diff --git a/src/food-market.api/Infrastructure/Tenancy/HttpContextTenantContext.cs b/src/food-market.api/Infrastructure/Tenancy/HttpContextTenantContext.cs index c5cbd86..ca54b9b 100644 --- a/src/food-market.api/Infrastructure/Tenancy/HttpContextTenantContext.cs +++ b/src/food-market.api/Infrastructure/Tenancy/HttpContextTenantContext.cs @@ -11,6 +11,11 @@ public class HttpContextTenantContext : ITenantContext /// конкретную организацию. Без этого header'а супер-админ не привязан к /// конкретной орге (видит всё через query-filter bypass). public const string OrgOverrideHeader = "X-Org-Override"; + /// HTTP-заголовок включения edit-mode в режиме «открыть как…»: + /// клиент шлёт reason (≥10 символов), сервер разрешает мутации и пишет + /// каждую запись в SuperAdminAuditLog с этой причиной. Срок действия + /// токена ограничен 30 минутами на стороне фронта (после — UI отключает). + public const string EditReasonHeader = "X-Org-Override-Reason"; // Override для background задач (например, импорт из MoySklad): сохраняем tenant // в AsyncLocal на время выполнения фонового Task. HttpContext там отсутствует, diff --git a/src/food-market.api/Infrastructure/Tenancy/ReadonlyOverrideMiddleware.cs b/src/food-market.api/Infrastructure/Tenancy/ReadonlyOverrideMiddleware.cs index cff3e22..46725b3 100644 --- a/src/food-market.api/Infrastructure/Tenancy/ReadonlyOverrideMiddleware.cs +++ b/src/food-market.api/Infrastructure/Tenancy/ReadonlyOverrideMiddleware.cs @@ -29,10 +29,18 @@ public async Task InvokeAsync(HttpContext ctx) { await _next(ctx); return; } + // Phase 3: edit-mode — SuperAdmin прислал X-Org-Override-Reason ≥ 10 + // символов, мутации пропускаем. Запись в audit-log делает Pipeline- + // фильтр (см. SuperAdminEditAuditFilter) после успешного ответа. + if (ctx.Request.Headers.TryGetValue(HttpContextTenantContext.EditReasonHeader, out var reason) + && (reason.ToString() ?? "").Trim().Length >= 10) + { + await _next(ctx); return; + } ctx.Response.StatusCode = StatusCodes.Status403Forbidden; await ctx.Response.WriteAsJsonAsync(new { - error = "Read-only mode: вы в режиме «Супер-админ → открыть как…», мутации запрещены. Чтобы редактировать — выйдите из режима или включите edit-mode (Phase 3).", + error = "Read-only mode: вы в режиме «Супер-админ → открыть как…», мутации запрещены. Включите edit-mode с указанием причины или выйдите из режима.", }); } } diff --git a/src/food-market.api/Infrastructure/Tenancy/SuperAdminEditAuditFilter.cs b/src/food-market.api/Infrastructure/Tenancy/SuperAdminEditAuditFilter.cs new file mode 100644 index 0000000..b9ea54f --- /dev/null +++ b/src/food-market.api/Infrastructure/Tenancy/SuperAdminEditAuditFilter.cs @@ -0,0 +1,50 @@ +using foodmarket.Domain.Organizations; +using foodmarket.Infrastructure.Persistence; +using Microsoft.AspNetCore.Mvc.Filters; +using System.Security.Claims; + +namespace foodmarket.Api.Infrastructure.Tenancy; + +/// Phase 3 audit-trail: когда SuperAdmin в режиме «открыть как…» с +/// edit-mode (X-Org-Override + X-Org-Override-Reason ≥ 10 символов) делает +/// успешную мутацию — пишем запись в SuperAdminAuditLog с reason, путём, +/// methodом, status code'ом. Запускается ПОСЛЕ controller'а, только если +/// ответ 2xx (успех) — неудачные запросы не засоряют журнал. +public class SuperAdminEditAuditFilter : IAsyncActionFilter +{ + private readonly AppDbContext _db; + public SuperAdminEditAuditFilter(AppDbContext db) => _db = db; + + public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + var ctx = context.HttpContext; + var method = ctx.Request.Method.ToUpperInvariant(); + var hasOverride = ctx.Request.Headers.ContainsKey(HttpContextTenantContext.OrgOverrideHeader); + var hasReason = ctx.Request.Headers.TryGetValue(HttpContextTenantContext.EditReasonHeader, out var reasonHv) + && (reasonHv.ToString() ?? "").Trim().Length >= 10; + var isMutation = method is "POST" or "PUT" or "PATCH" or "DELETE"; + var isSuper = ctx.User?.IsInRole(HttpContextTenantContext.SuperAdminRole) == true; + + var executed = await next(); + + if (!hasOverride || !hasReason || !isMutation || !isSuper) return; + if (executed.Exception is not null) return; + var status = ctx.Response.StatusCode; + if (status < 200 || status >= 300) return; + + var orgGuid = Guid.TryParse(ctx.Request.Headers[HttpContextTenantContext.OrgOverrideHeader].ToString(), out var g) ? g : (Guid?)null; + var userIdRaw = ctx.User?.FindFirstValue(ClaimTypes.NameIdentifier) ?? ctx.User?.FindFirstValue("sub"); + Guid.TryParse(userIdRaw, out var uid); + _db.SuperAdminAuditLogs.Add(new SuperAdminAuditLog + { + SuperAdminUserId = uid, + ActionType = "EditEntity", + OrganizationId = orgGuid, + Description = $"{method} {ctx.Request.Path.Value} → {status}", + Reason = reasonHv.ToString().Trim(), + ChangesJson = "{}", + IpAddress = ctx.Connection?.RemoteIpAddress?.ToString() ?? "", + }); + await _db.SaveChangesAsync(); + } +} diff --git a/src/food-market.api/Program.cs b/src/food-market.api/Program.cs index 9885a30..b27b5db 100644 --- a/src/food-market.api/Program.cs +++ b/src/food-market.api/Program.cs @@ -119,7 +119,13 @@ ctx.User.HasClaim(c => c.Type == Claims.Role && (c.Value == "Admin" || c.Value == "SuperAdmin")))); }); - builder.Services.AddControllers(); + builder.Services.AddScoped(); + builder.Services.AddControllers(o => + { + // Глобальный action filter — пишет audit-log при успешных мутациях + // в режиме «SuperAdmin открыть как… + edit-mode» (Phase 3). + o.Filters.AddService(); + }); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); diff --git a/src/food-market.web/src/components/SuperAdminAsOrgBanner.tsx b/src/food-market.web/src/components/SuperAdminAsOrgBanner.tsx index 3c48d5a..d62117e 100644 --- a/src/food-market.web/src/components/SuperAdminAsOrgBanner.tsx +++ b/src/food-market.web/src/components/SuperAdminAsOrgBanner.tsx @@ -1,26 +1,86 @@ -import { ShieldAlert, X } from 'lucide-react' -import { getOrgOverride, setOrgOverride } from '@/lib/api' +import { useState } from 'react' +import { ShieldAlert, X, Edit3, Lock } from 'lucide-react' +import { getOrgOverride, setOrgOverride, getEditMode, enableEditMode, disableEditMode } from '@/lib/api' /** Полоса сверху страницы, видна только когда SuperAdmin вошёл в режим * «открыть как…» — все запросы летят с X-Org-Override, мутации заблокированы - * на стороне API (ReadonlyOverrideMiddleware). Кнопка X — выход из режима. */ + * на стороне API (ReadonlyOverrideMiddleware). По умолчанию read-only; + * Phase 3: можно включить edit-mode на 30 минут с указанием причины — все + * мутации в этом режиме пишутся в SuperAdminAuditLog. */ export function SuperAdminAsOrgBanner() { const ov = getOrgOverride() + const edit = getEditMode() + const [askReason, setAskReason] = useState(false) + const [reason, setReason] = useState('') if (!ov) return null + + const baseColor = edit ? 'bg-red-600' : 'bg-amber-500' + const minutesLeft = edit ? Math.max(1, Math.round((edit.expiresAt - Date.now()) / 60000)) : 0 + return ( -
-
- - - 🛡️ ВЫ В РЕЖИМЕ СУПЕР-АДМИНА — ОРГАНИЗАЦИЯ «{ov.name}» — ТОЛЬКО ПРОСМОТР - + <> +
+
+ + + 🛡️ ВЫ В РЕЖИМЕ СУПЕР-АДМИНА — ОРГАНИЗАЦИЯ «{ov.name}» + {edit + ? <> — EDIT-MODE ({minutesLeft} мин), мутации пишутся в журнал + : <> — ТОЛЬКО ПРОСМОТР} + +
+
+ {!edit ? ( + + ) : ( + + )} + +
- -
+ + {askReason && ( +
setAskReason(false)}> +
e.stopPropagation()}> +

Включить редактирование на 30 минут

+

+ Каждое изменение будет записано в журнал супер-админа с этой причиной. + Минимум 10 символов. +

+