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:
parent
b2d6584f09
commit
4dd6b16eed
|
|
@ -11,6 +11,11 @@ public class HttpContextTenantContext : ITenantContext
|
|||
/// конкретную организацию. Без этого header'а супер-админ не привязан к
|
||||
/// конкретной орге (видит всё через query-filter bypass).</summary>
|
||||
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
|
||||
// в AsyncLocal на время выполнения фонового Task. HttpContext там отсутствует,
|
||||
|
|
|
|||
|
|
@ -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 с указанием причины или выйдите из режима.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -119,7 +119,13 @@
|
|||
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.AddSwaggerGen();
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="bg-amber-500 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">
|
||||
<ShieldAlert className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="truncate">
|
||||
🛡️ ВЫ В РЕЖИМЕ СУПЕР-АДМИНА — ОРГАНИЗАЦИЯ <strong>«{ov.name}»</strong> — ТОЛЬКО ПРОСМОТР
|
||||
</span>
|
||||
<>
|
||||
<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">
|
||||
<ShieldAlert className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="truncate">
|
||||
🛡️ ВЫ В РЕЖИМЕ СУПЕР-АДМИНА — ОРГАНИЗАЦИЯ <strong>«{ov.name}»</strong>
|
||||
{edit
|
||||
? <> — <strong>EDIT-MODE</strong> ({minutesLeft} мин), мутации пишутся в журнал</>
|
||||
: <> — ТОЛЬКО ПРОСМОТР</>}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{!edit ? (
|
||||
<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" /> Выйти
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setOrgOverride(null)}
|
||||
className="flex items-center gap-1 px-2 py-0.5 rounded bg-white/20 hover:bg-white/30 flex-shrink-0"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" /> Выйти
|
||||
</button>
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,10 @@ api.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
|||
const asOrg = getOrgOverride()
|
||||
if (asOrg && !(config.url ?? '').startsWith('/api/super-admin/')) {
|
||||
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
|
||||
})
|
||||
|
|
@ -30,12 +34,35 @@ export function getOrgOverride(): OrgOverride | null {
|
|||
}
|
||||
export function setOrgOverride(value: OrgOverride | null) {
|
||||
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 построен по
|
||||
// tenant'у, нужен hard 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
|
||||
|
||||
api.interceptors.response.use(
|
||||
|
|
|
|||
Loading…
Reference in a new issue