From b2d6584f0947a367c38b7e79bf747d45418fb4e3 Mon Sep 17 00:00:00 2001 From: nns <278048682+nurdotnet@users.noreply.github.com> Date: Sun, 26 Apr 2026 15:03:48 +0500 Subject: [PATCH] =?UTF-8?q?feat(super-admin):=20Phase=202=20=E2=80=94=20re?= =?UTF-8?q?ad-only=20=C2=AB=D0=BE=D1=82=D0=BA=D1=80=D1=8B=D1=82=D1=8C=20?= =?UTF-8?q?=D0=BA=D0=B0=D0=BA=E2=80=A6=C2=BB=20context=20switch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 перед . - В таблице /super-admin/organizations добавлена кнопка LogIn (синяя) в actions; клик → setOrgOverride → reload в режим просмотра. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Tenancy/HttpContextTenantContext.cs | 16 +++++++- .../Tenancy/ReadonlyOverrideMiddleware.cs | 38 +++++++++++++++++++ src/food-market.api/Program.cs | 3 ++ .../src/components/AppLayout.tsx | 2 + .../src/components/SuperAdminAsOrgBanner.tsx | 26 +++++++++++++ src/food-market.web/src/lib/api.ts | 22 +++++++++++ .../src/pages/SuperAdminOrganizationsPage.tsx | 11 +++++- 7 files changed, 115 insertions(+), 3 deletions(-) create mode 100644 src/food-market.api/Infrastructure/Tenancy/ReadonlyOverrideMiddleware.cs create mode 100644 src/food-market.web/src/components/SuperAdminAsOrgBanner.tsx diff --git a/src/food-market.api/Infrastructure/Tenancy/HttpContextTenantContext.cs b/src/food-market.api/Infrastructure/Tenancy/HttpContextTenantContext.cs index 6380156..c5cbd86 100644 --- a/src/food-market.api/Infrastructure/Tenancy/HttpContextTenantContext.cs +++ b/src/food-market.api/Infrastructure/Tenancy/HttpContextTenantContext.cs @@ -7,6 +7,10 @@ public class HttpContextTenantContext : ITenantContext { public const string OrganizationClaim = "org_id"; public const string SuperAdminRole = "SuperAdmin"; + /// HTTP-заголовок переключения tenant'а для SuperAdmin'а: «открыть как…» + /// конкретную организацию. Без этого header'а супер-админ не привязан к + /// конкретной орге (видит всё через query-filter bypass). + public const string OrgOverrideHeader = "X-Org-Override"; // Override для background задач (например, импорт из MoySklad): сохраняем tenant // в AsyncLocal на время выполнения фонового Task. HttpContext там отсутствует, @@ -54,7 +58,17 @@ public bool IsSuperAdmin get { 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; } } diff --git a/src/food-market.api/Infrastructure/Tenancy/ReadonlyOverrideMiddleware.cs b/src/food-market.api/Infrastructure/Tenancy/ReadonlyOverrideMiddleware.cs new file mode 100644 index 0000000..cff3e22 --- /dev/null +++ b/src/food-market.api/Infrastructure/Tenancy/ReadonlyOverrideMiddleware.cs @@ -0,0 +1,38 @@ +using foodmarket.Application.Common.Tenancy; + +namespace foodmarket.Api.Infrastructure.Tenancy; + +/// Когда 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) будет ослаблять ограничение. +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).", + }); + } +} diff --git a/src/food-market.api/Program.cs b/src/food-market.api/Program.cs index e1431f4..9885a30 100644 --- a/src/food-market.api/Program.cs +++ b/src/food-market.api/Program.cs @@ -150,6 +150,9 @@ app.UseCors(CorsPolicy); app.UseAuthentication(); app.UseAuthorization(); + // SuperAdmin «открыть как…» — тот же tenant как у выбранной орги, но + // только GET. Любая мутация → 403, кроме /api/super-admin/* и /connect/*. + app.UseMiddleware(); // Статика товарных изображений: физически /app/uploads (volume в compose), // публичный URL /uploads/... — раздаются public, без auth. diff --git a/src/food-market.web/src/components/AppLayout.tsx b/src/food-market.web/src/components/AppLayout.tsx index 7122b29..533d67c 100644 --- a/src/food-market.web/src/components/AppLayout.tsx +++ b/src/food-market.web/src/components/AppLayout.tsx @@ -10,6 +10,7 @@ import { Boxes, History, TruckIcon, ShoppingCart, Settings, Menu, X, } from 'lucide-react' import { Logo } from './Logo' +import { SuperAdminAsOrgBanner } from './SuperAdminAsOrgBanner' interface MeResponse { sub: string @@ -184,6 +185,7 @@ export function AppLayout() { )}
+
diff --git a/src/food-market.web/src/components/SuperAdminAsOrgBanner.tsx b/src/food-market.web/src/components/SuperAdminAsOrgBanner.tsx new file mode 100644 index 0000000..3c48d5a --- /dev/null +++ b/src/food-market.web/src/components/SuperAdminAsOrgBanner.tsx @@ -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 ( +
+
+ + + 🛡️ ВЫ В РЕЖИМЕ СУПЕР-АДМИНА — ОРГАНИЗАЦИЯ «{ov.name}» — ТОЛЬКО ПРОСМОТР + +
+ +
+ ) +} diff --git a/src/food-market.web/src/lib/api.ts b/src/food-market.web/src/lib/api.ts index 332adf5..d47d4bf 100644 --- a/src/food-market.web/src/lib/api.ts +++ b/src/food-market.web/src/lib/api.ts @@ -11,9 +11,31 @@ api.interceptors.request.use((config: InternalAxiosRequestConfig) => { if (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 }) +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 | null = null api.interceptors.response.use( diff --git a/src/food-market.web/src/pages/SuperAdminOrganizationsPage.tsx b/src/food-market.web/src/pages/SuperAdminOrganizationsPage.tsx index 29080ca..a9d5ee5 100644 --- a/src/food-market.web/src/pages/SuperAdminOrganizationsPage.tsx +++ b/src/food-market.web/src/pages/SuperAdminOrganizationsPage.tsx @@ -1,6 +1,7 @@ import { useState } from 'react' 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 { DataTable } from '@/components/DataTable' import { Pagination } from '@/components/Pagination' @@ -97,7 +98,13 @@ export function SuperAdminOrganizationsPage() { : Активна }, { header: '', width: '160px', cell: (r) => (
e.stopPropagation()}> - + {!r.isArchived && ( + + )} {!r.isArchived ? (