feat(super-admin): Phase 2 — read-only «открыть как…» context switch
Some checks failed
CI / POS (WPF, Windows) (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
CI / Backend (.NET 8) (push) Successful in 1m7s
CI / Web (React + Vite) (push) Successful in 39s
Docker API / Build + push API (push) Successful in 58s
Docker Web / Build + push Web (push) Successful in 28s
Docker API / Deploy API on stage (push) Has been cancelled
Some checks failed
CI / POS (WPF, Windows) (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
CI / Backend (.NET 8) (push) Successful in 1m7s
CI / Web (React + Vite) (push) Successful in 39s
Docker API / Build + push API (push) Successful in 58s
Docker Web / Build + push Web (push) Successful in 28s
Docker API / Deploy API on stage (push) Has been cancelled
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:
parent
4acb51c270
commit
ef32dac0a6
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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).",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
26
src/food-market.web/src/components/SuperAdminAsOrgBanner.tsx
Normal file
26
src/food-market.web/src/components/SuperAdminAsOrgBanner.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue