feat(super-admin): Phase 2 — read-only «открыть как…» context switch

SuperAdmin может зайти в данные конкретной орги в режиме просмотра
(аналог «view as customer» в SaaS). Все запросы летят с tenant'ом
выбранной орги, но любая мутация запрещена — Phase 3 (edit-mode +
audit-trail) будет ослаблять ограничение по reason'у.

API:
- HttpContextTenantContext.OrganizationId: если у юзера роль SuperAdmin
  И header X-Org-Override присутствует — возвращаем его как tenant
  (вместо org_id из JWT).
- ReadonlyOverrideMiddleware (после UseAuthorization): когда заголовок
  активен, отбивает 403 любую non-GET операцию, кроме /api/super-admin/*
  (управление орг) и /connect/* (refresh tokens).
- Безопасность: проверка SuperAdmin-роли — без неё header игнорируется,
  обычный юзер ничего подменить не может.

Web:
- api.ts: localStorage 'superAdminAsOrg' = {id, name}; axios interceptor
  добавляет X-Org-Override на каждый запрос. setOrgOverride(...) делает
  hard reload чтобы сбросить TanStack Query-кэш.
- SuperAdminAsOrgBanner — жёлтая полоса сверху main-area с названием
  орги и кнопкой «Выйти». Подключена в AppLayout перед <Outlet/>.
- В таблице /super-admin/organizations добавлена кнопка LogIn (синяя)
  в actions; клик → setOrgOverride → reload в режим просмотра.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
nns 2026-04-26 15:03:48 +05:00
parent 8ff0a56144
commit b2d6584f09
7 changed files with 115 additions and 3 deletions

View file

@ -7,6 +7,10 @@ public class HttpContextTenantContext : ITenantContext
{ {
public const string OrganizationClaim = "org_id"; public const string OrganizationClaim = "org_id";
public const string SuperAdminRole = "SuperAdmin"; public const string SuperAdminRole = "SuperAdmin";
/// <summary>HTTP-заголовок переключения tenant'а для SuperAdmin'а: «открыть как…»
/// конкретную организацию. Без этого header'а супер-админ не привязан к
/// конкретной орге (видит всё через query-filter bypass).</summary>
public const string OrgOverrideHeader = "X-Org-Override";
// Override для background задач (например, импорт из MoySklad): сохраняем tenant // Override для background задач (например, импорт из MoySklad): сохраняем tenant
// в AsyncLocal на время выполнения фонового Task. HttpContext там отсутствует, // в AsyncLocal на время выполнения фонового Task. HttpContext там отсутствует,
@ -54,7 +58,17 @@ public bool IsSuperAdmin
get get
{ {
if (_override.Value is { OrgId: var o }) return o; if (_override.Value is { OrgId: var o }) return o;
var claim = _accessor.HttpContext?.User?.FindFirst(OrganizationClaim)?.Value; var ctx = _accessor.HttpContext;
// SuperAdmin может прислать X-Org-Override чтобы посмотреть данные
// конкретной организации в режиме «открыть как…». Блокируем мутации
// через ReadonlyOverrideMiddleware — здесь только подменяем tenant.
if (ctx is not null && ctx.User?.IsInRole(SuperAdminRole) == true
&& ctx.Request.Headers.TryGetValue(OrgOverrideHeader, out var headerVal)
&& Guid.TryParse(headerVal.ToString(), out var override_))
{
return override_;
}
var claim = ctx?.User?.FindFirst(OrganizationClaim)?.Value;
return Guid.TryParse(claim, out var id) ? id : null; return Guid.TryParse(claim, out var id) ? id : null;
} }
} }

View file

@ -0,0 +1,38 @@
using foodmarket.Application.Common.Tenancy;
namespace foodmarket.Api.Infrastructure.Tenancy;
/// <summary>Когда SuperAdmin прислал X-Org-Override — режим «открыть как…»
/// должен быть строго read-only (Phase 2). Любая мутация (PUT/POST/DELETE/PATCH)
/// на любой endpoint, кроме самих /api/super-admin/* (управление орг)
/// и /api/auth/* (refresh tokens) — отбивается 403 с понятным сообщением.
/// Phase 3 (edit-mode с reason + audit-trail) будет ослаблять ограничение.</summary>
public class ReadonlyOverrideMiddleware
{
private readonly RequestDelegate _next;
public ReadonlyOverrideMiddleware(RequestDelegate next) => _next = next;
public async Task InvokeAsync(HttpContext ctx)
{
if (!ctx.Request.Headers.ContainsKey(HttpContextTenantContext.OrgOverrideHeader))
{
await _next(ctx); return;
}
var method = ctx.Request.Method.ToUpperInvariant();
if (method == "GET" || method == "HEAD" || method == "OPTIONS")
{
await _next(ctx); return;
}
var path = ctx.Request.Path.Value ?? "";
if (path.StartsWith("/api/super-admin/", StringComparison.OrdinalIgnoreCase)
|| path.StartsWith("/connect/", StringComparison.OrdinalIgnoreCase))
{
await _next(ctx); return;
}
ctx.Response.StatusCode = StatusCodes.Status403Forbidden;
await ctx.Response.WriteAsJsonAsync(new
{
error = "Read-only mode: вы в режиме «Супер-админ → открыть как…», мутации запрещены. Чтобы редактировать — выйдите из режима или включите edit-mode (Phase 3).",
});
}
}

View file

@ -150,6 +150,9 @@
app.UseCors(CorsPolicy); app.UseCors(CorsPolicy);
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
// SuperAdmin «открыть как…» — тот же tenant как у выбранной орги, но
// только GET. Любая мутация → 403, кроме /api/super-admin/* и /connect/*.
app.UseMiddleware<foodmarket.Api.Infrastructure.Tenancy.ReadonlyOverrideMiddleware>();
// Статика товарных изображений: физически /app/uploads (volume в compose), // Статика товарных изображений: физически /app/uploads (volume в compose),
// публичный URL /uploads/... — раздаются public, без auth. // публичный URL /uploads/... — раздаются public, без auth.

View file

@ -10,6 +10,7 @@ import {
Boxes, History, TruckIcon, ShoppingCart, Settings, Menu, X, Boxes, History, TruckIcon, ShoppingCart, Settings, Menu, X,
} from 'lucide-react' } from 'lucide-react'
import { Logo } from './Logo' import { Logo } from './Logo'
import { SuperAdminAsOrgBanner } from './SuperAdminAsOrgBanner'
interface MeResponse { interface MeResponse {
sub: string sub: string
@ -184,6 +185,7 @@ export function AppLayout() {
)} )}
<main className="flex-1 min-w-0 min-h-0 flex flex-col overflow-hidden"> <main className="flex-1 min-w-0 min-h-0 flex flex-col overflow-hidden">
<SuperAdminAsOrgBanner />
<Outlet /> <Outlet />
</main> </main>
</div> </div>

View file

@ -0,0 +1,26 @@
import { ShieldAlert, X } from 'lucide-react'
import { getOrgOverride, setOrgOverride } from '@/lib/api'
/** Полоса сверху страницы, видна только когда SuperAdmin вошёл в режим
* «открыть как» все запросы летят с X-Org-Override, мутации заблокированы
* на стороне API (ReadonlyOverrideMiddleware). Кнопка X выход из режима. */
export function SuperAdminAsOrgBanner() {
const ov = getOrgOverride()
if (!ov) return null
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>
<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>
)
}

View file

@ -11,9 +11,31 @@ api.interceptors.request.use((config: InternalAxiosRequestConfig) => {
if (token) { if (token) {
config.headers.set('Authorization', `Bearer ${token}`) config.headers.set('Authorization', `Bearer ${token}`)
} }
// SuperAdmin «открыть как…»: добавляем X-Org-Override на каждый запрос если
// в localStorage активна выбранная орга (и эндпоинт не сам super-admin).
const asOrg = getOrgOverride()
if (asOrg && !(config.url ?? '').startsWith('/api/super-admin/')) {
config.headers.set('X-Org-Override', asOrg.id)
}
return config return config
}) })
const ORG_OVERRIDE_KEY = 'superAdminAsOrg'
export interface OrgOverride { id: string; name: string }
export function getOrgOverride(): OrgOverride | null {
try {
const raw = localStorage.getItem(ORG_OVERRIDE_KEY)
return raw ? JSON.parse(raw) as OrgOverride : null
} catch { return null }
}
export function setOrgOverride(value: OrgOverride | null) {
if (value) localStorage.setItem(ORG_OVERRIDE_KEY, JSON.stringify(value))
else localStorage.removeItem(ORG_OVERRIDE_KEY)
// Силой обновляем все вкладки/страницы — кэш TanStack Query построен по
// tenant'у, нужен hard reload чтобы снять старые данные.
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(

View file

@ -1,6 +1,7 @@
import { useState } from 'react' import { useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { Plus, Archive, RotateCcw, Trash2, Eye } from 'lucide-react' import { Plus, Archive, RotateCcw, Trash2, LogIn } from 'lucide-react'
import { setOrgOverride } from '@/lib/api'
import { ListPageShell } from '@/components/ListPageShell' import { ListPageShell } from '@/components/ListPageShell'
import { DataTable } from '@/components/DataTable' import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination' import { Pagination } from '@/components/Pagination'
@ -97,7 +98,13 @@ export function SuperAdminOrganizationsPage() {
: <span className="text-xs text-emerald-600">Активна</span> }, : <span className="text-xs text-emerald-600">Активна</span> },
{ header: '', width: '160px', cell: (r) => ( { header: '', width: '160px', cell: (r) => (
<div className="flex gap-1.5" onClick={(e) => e.stopPropagation()}> <div className="flex gap-1.5" onClick={(e) => e.stopPropagation()}>
<button title="Просмотр" disabled className="p-1.5 text-slate-300 cursor-not-allowed"><Eye className="w-4 h-4" /></button> {!r.isArchived && (
<button title="Открыть как… (read-only)"
onClick={() => setOrgOverride({ id: r.id, name: r.name })}
className="p-1.5 text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded">
<LogIn className="w-4 h-4" />
</button>
)}
{!r.isArchived ? ( {!r.isArchived ? (
<button title="Архивировать" onClick={() => { setArchiveOf(r); setConfirmName('') }} <button title="Архивировать" onClick={() => { setArchiveOf(r); setConfirmName('') }}
className="p-1.5 text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-900/20 rounded"> className="p-1.5 text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-900/20 rounded">