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'а супер-админ не привязан к
|
/// конкретную организацию. Без этого 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 там отсутствует,
|
||||||
|
|
|
||||||
|
|
@ -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 с указанием причины или выйдите из режима.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"))));
|
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();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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="flex items-center gap-2 min-w-0">
|
<div className={`${baseColor} text-white text-sm flex items-center justify-between gap-3 px-4 py-2 shadow`}>
|
||||||
<ShieldAlert className="w-4 h-4 flex-shrink-0" />
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<span className="truncate">
|
<ShieldAlert className="w-4 h-4 flex-shrink-0" />
|
||||||
🛡️ ВЫ В РЕЖИМЕ СУПЕР-АДМИНА — ОРГАНИЗАЦИЯ <strong>«{ov.name}»</strong> — ТОЛЬКО ПРОСМОТР
|
<span className="truncate">
|
||||||
</span>
|
🛡️ ВЫ В РЕЖИМЕ СУПЕР-АДМИНА — ОРГАНИЗАЦИЯ <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>
|
</div>
|
||||||
<button
|
|
||||||
onClick={() => setOrgOverride(null)}
|
{askReason && (
|
||||||
className="flex items-center gap-1 px-2 py-0.5 rounded bg-white/20 hover:bg-white/30 flex-shrink-0"
|
<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()}>
|
||||||
<X className="w-3.5 h-3.5" /> Выйти
|
<h2 className="text-lg font-semibold mb-2">Включить редактирование на 30 минут</h2>
|
||||||
</button>
|
<p className="text-sm text-slate-600 dark:text-slate-400 mb-3">
|
||||||
</div>
|
Каждое изменение будет записано в журнал супер-админа с этой причиной.
|
||||||
|
Минимум 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()
|
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(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue