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 символов. +

+