feat(super-admin): Phase 3 — edit-mode с reason + audit-trail

В режиме «открыть как…» 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) <noreply@anthropic.com>
This commit is contained in:
nns 2026-04-26 15:07:10 +05:00
parent b2d6584f09
commit 4dd6b16eed
6 changed files with 175 additions and 19 deletions

View file

@ -11,6 +11,11 @@ public class HttpContextTenantContext : ITenantContext
/// конкретную организацию. Без этого header'а супер-админ не привязан к /// конкретную организацию. Без этого header'а супер-админ не привязан к
/// конкретной орге (видит всё через query-filter bypass).</summary> /// конкретной орге (видит всё через query-filter bypass).</summary>
public const string OrgOverrideHeader = "X-Org-Override"; public const string OrgOverrideHeader = "X-Org-Override";
/// <summary>HTTP-заголовок включения edit-mode в режиме «открыть как…»:
/// клиент шлёт reason (≥10 символов), сервер разрешает мутации и пишет
/// каждую запись в SuperAdminAuditLog с этой причиной. Срок действия
/// токена ограничен 30 минутами на стороне фронта (после — UI отключает).</summary>
public const string EditReasonHeader = "X-Org-Override-Reason";
// Override для background задач (например, импорт из MoySklad): сохраняем tenant // Override для background задач (например, импорт из MoySklad): сохраняем tenant
// в AsyncLocal на время выполнения фонового Task. HttpContext там отсутствует, // в AsyncLocal на время выполнения фонового Task. HttpContext там отсутствует,

View file

@ -29,10 +29,18 @@ public async Task InvokeAsync(HttpContext ctx)
{ {
await _next(ctx); return; 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; ctx.Response.StatusCode = StatusCodes.Status403Forbidden;
await ctx.Response.WriteAsJsonAsync(new await ctx.Response.WriteAsJsonAsync(new
{ {
error = "Read-only mode: вы в режиме «Супер-админ → открыть как…», мутации запрещены. Чтобы редактировать — выйдите из режима или включите edit-mode (Phase 3).", error = "Read-only mode: вы в режиме «Супер-админ → открыть как…», мутации запрещены. Включите edit-mode с указанием причины или выйдите из режима.",
}); });
} }
} }

View file

@ -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;
/// <summary>Phase 3 audit-trail: когда SuperAdmin в режиме «открыть как…» с
/// edit-mode (X-Org-Override + X-Org-Override-Reason ≥ 10 символов) делает
/// успешную мутацию — пишем запись в SuperAdminAuditLog с reason, путём,
/// methodом, status code'ом. Запускается ПОСЛЕ controller'а, только если
/// ответ 2xx (успех) — неудачные запросы не засоряют журнал.</summary>
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();
}
}

View file

@ -119,7 +119,13 @@
ctx.User.HasClaim(c => c.Type == Claims.Role && (c.Value == "Admin" || c.Value == "SuperAdmin")))); ctx.User.HasClaim(c => c.Type == Claims.Role && (c.Value == "Admin" || c.Value == "SuperAdmin"))));
}); });
builder.Services.AddControllers(); builder.Services.AddScoped<foodmarket.Api.Infrastructure.Tenancy.SuperAdminEditAuditFilter>();
builder.Services.AddControllers(o =>
{
// Глобальный action filter — пишет audit-log при успешных мутациях
// в режиме «SuperAdmin открыть как… + edit-mode» (Phase 3).
o.Filters.AddService<foodmarket.Api.Infrastructure.Tenancy.SuperAdminEditAuditFilter>();
});
builder.Services.AddEndpointsApiExplorer(); builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(); builder.Services.AddSwaggerGen();

View file

@ -1,26 +1,86 @@
import { ShieldAlert, X } from 'lucide-react' import { useState } from 'react'
import { getOrgOverride, setOrgOverride } from '@/lib/api' import { ShieldAlert, X, Edit3, Lock } from 'lucide-react'
import { getOrgOverride, setOrgOverride, getEditMode, enableEditMode, disableEditMode } from '@/lib/api'
/** Полоса сверху страницы, видна только когда SuperAdmin вошёл в режим /** Полоса сверху страницы, видна только когда SuperAdmin вошёл в режим
* «открыть как» все запросы летят с X-Org-Override, мутации заблокированы * «открыть как» все запросы летят с X-Org-Override, мутации заблокированы
* на стороне API (ReadonlyOverrideMiddleware). Кнопка X выход из режима. */ * на стороне API (ReadonlyOverrideMiddleware). По умолчанию read-only;
* Phase 3: можно включить edit-mode на 30 минут с указанием причины все
* мутации в этом режиме пишутся в SuperAdminAuditLog. */
export function SuperAdminAsOrgBanner() { export function SuperAdminAsOrgBanner() {
const ov = getOrgOverride() const ov = getOrgOverride()
const edit = getEditMode()
const [askReason, setAskReason] = useState(false)
const [reason, setReason] = useState('')
if (!ov) return null 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 ( return (
<div className="bg-amber-500 text-white text-sm flex items-center justify-between gap-3 px-4 py-2 shadow"> <>
<div className={`${baseColor} text-white text-sm flex items-center justify-between gap-3 px-4 py-2 shadow`}>
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<ShieldAlert className="w-4 h-4 flex-shrink-0" /> <ShieldAlert className="w-4 h-4 flex-shrink-0" />
<span className="truncate"> <span className="truncate">
🛡 ВЫ В РЕЖИМЕ СУПЕР-АДМИНА ОРГАНИЗАЦИЯ <strong>«{ov.name}»</strong> ТОЛЬКО ПРОСМОТР 🛡 ВЫ В РЕЖИМЕ СУПЕР-АДМИНА ОРГАНИЗАЦИЯ <strong>«{ov.name}»</strong>
{edit
? <> <strong>EDIT-MODE</strong> ({minutesLeft} мин), мутации пишутся в журнал</>
: <> ТОЛЬКО ПРОСМОТР</>}
</span> </span>
</div> </div>
<button <div className="flex items-center gap-2 flex-shrink-0">
onClick={() => setOrgOverride(null)} {!edit ? (
className="flex items-center gap-1 px-2 py-0.5 rounded bg-white/20 hover:bg-white/30 flex-shrink-0" <button onClick={() => setAskReason(true)}
> className="flex items-center gap-1 px-2 py-0.5 rounded bg-white/20 hover:bg-white/30 text-xs">
<Edit3 className="w-3.5 h-3.5" /> Включить редактирование
</button>
) : (
<button onClick={disableEditMode}
className="flex items-center gap-1 px-2 py-0.5 rounded bg-white/20 hover:bg-white/30 text-xs">
<Lock className="w-3.5 h-3.5" /> Снять edit
</button>
)}
<button onClick={() => setOrgOverride(null)}
className="flex items-center gap-1 px-2 py-0.5 rounded bg-white/20 hover:bg-white/30">
<X className="w-3.5 h-3.5" /> Выйти <X className="w-3.5 h-3.5" /> Выйти
</button> </button>
</div> </div>
</div>
{askReason && (
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-slate-900/50 backdrop-blur-sm" onClick={() => setAskReason(false)}>
<div className="w-full max-w-md bg-white dark:bg-slate-900 rounded-xl shadow-xl p-5 m-4" onClick={(e) => e.stopPropagation()}>
<h2 className="text-lg font-semibold mb-2">Включить редактирование на 30 минут</h2>
<p className="text-sm text-slate-600 dark:text-slate-400 mb-3">
Каждое изменение будет записано в журнал супер-админа с этой причиной.
Минимум 10 символов.
</p>
<textarea
value={reason} onChange={(e) => setReason(e.target.value)}
rows={3} placeholder="Например: запрос клиента в тикете #42 — поправить цены"
className="w-full rounded-md border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-900 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--color-brand)]"
/>
<label className="flex items-start gap-2 mt-3 text-xs text-slate-600 dark:text-slate-400">
<input type="checkbox" id="ack-audit" className="mt-0.5" />
<span>Я понимаю что мои действия будут записаны в журнал и доступны для аудита.</span>
</label>
<div className="flex justify-end gap-2 mt-4">
<button onClick={() => setAskReason(false)} className="px-3 py-1.5 rounded-md border border-slate-300 dark:border-slate-600 text-sm">Отмена</button>
<button
disabled={reason.trim().length < 10}
onClick={() => {
const ack = (document.getElementById('ack-audit') as HTMLInputElement | null)?.checked
if (!ack) { alert('Подтвердите согласие на аудит'); return }
enableEditMode(reason)
}}
className="px-3 py-1.5 rounded-md bg-red-600 text-white text-sm disabled:opacity-50">
Включить на 30 мин
</button>
</div>
</div>
</div>
)}
</>
) )
} }

View file

@ -16,6 +16,10 @@ api.interceptors.request.use((config: InternalAxiosRequestConfig) => {
const asOrg = getOrgOverride() const asOrg = getOrgOverride()
if (asOrg && !(config.url ?? '').startsWith('/api/super-admin/')) { if (asOrg && !(config.url ?? '').startsWith('/api/super-admin/')) {
config.headers.set('X-Org-Override', asOrg.id) config.headers.set('X-Org-Override', asOrg.id)
const edit = getEditMode()
if (edit && edit.expiresAt > Date.now()) {
config.headers.set('X-Org-Override-Reason', edit.reason)
}
} }
return config return config
}) })
@ -30,12 +34,35 @@ export function getOrgOverride(): OrgOverride | null {
} }
export function setOrgOverride(value: OrgOverride | null) { export function setOrgOverride(value: OrgOverride | null) {
if (value) localStorage.setItem(ORG_OVERRIDE_KEY, JSON.stringify(value)) if (value) localStorage.setItem(ORG_OVERRIDE_KEY, JSON.stringify(value))
else localStorage.removeItem(ORG_OVERRIDE_KEY) else { localStorage.removeItem(ORG_OVERRIDE_KEY); localStorage.removeItem(EDIT_MODE_KEY) }
// Силой обновляем все вкладки/страницы — кэш TanStack Query построен по // Силой обновляем все вкладки/страницы — кэш TanStack Query построен по
// tenant'у, нужен hard reload чтобы снять старые данные. // tenant'у, нужен hard reload чтобы снять старые данные.
if (typeof window !== 'undefined') window.location.reload() if (typeof window !== 'undefined') window.location.reload()
} }
const EDIT_MODE_KEY = 'superAdminEditMode'
export interface EditMode { reason: string; expiresAt: number }
export function getEditMode(): EditMode | null {
try {
const raw = localStorage.getItem(EDIT_MODE_KEY)
if (!raw) return null
const v = JSON.parse(raw) as EditMode
if (v.expiresAt < Date.now()) { localStorage.removeItem(EDIT_MODE_KEY); return null }
return v
} catch { return null }
}
/** Включает edit-mode на 30 минут (по таймеру localStorage). После
* истечения axios перестаёт слать reason, мутации снова блокируются. */
export function enableEditMode(reason: string) {
const v: EditMode = { reason: reason.trim(), expiresAt: Date.now() + 30 * 60 * 1000 }
localStorage.setItem(EDIT_MODE_KEY, JSON.stringify(v))
if (typeof window !== 'undefined') window.location.reload()
}
export function disableEditMode() {
localStorage.removeItem(EDIT_MODE_KEY)
if (typeof window !== 'undefined') window.location.reload()
}
let refreshing: Promise<string | null> | null = null let refreshing: Promise<string | null> | null = null
api.interceptors.response.use( api.interceptors.response.use(