fix(auth): закрыть критические дыры — orphan login, self-delete, owner-delete, override-баннер
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 46s
CI / Web (React + Vite) (push) Successful in 41s
Docker API / Build + push API (push) Successful in 1m12s
Docker Web / Build + push Web (push) Successful in 31s
Docker API / Deploy API on stage (push) Successful in 17s
Docker Web / Deploy Web on stage (push) Successful in 12s

Аудит 2026-04-27. Полный отчёт — docs/audit-2026-04-27.md.

Что закрыто:

— /connect/token (AuthorizationController) теперь отказывает в login если
  AppUser привязан к удалённой/архивной Organization. SuperAdmin обходит
  проверку (ему org не нужна). Жалоба: nurnetps@gmail.com мог логиниться
  после удаления своей org из SuperAdmin консоли.

— SuperAdminOrganizationsController.Delete (DELETE org) каскадно
  деактивирует всех AppUser привязанных к этой org (IsActive=false,
  OrganizationId=null) и помечает Status='revoked' для всех их
  OpenIddictTokens. Раньше Org удалялась, а юзеры оставались валидными
  с активными refresh-tokens на 30 дней.

— EmployeesController.Delete теперь soft-delete (IsActive=false,
  FiredAt). Запрещены: 403 если попытка удалить себя; 403 если
  попытка удалить Owner (Organization.AccountOwnerUserId ==
  employee.UserId). Сообщения с инструкцией («передайте права»,
  «покинуть через настройки»).

— /api/me возвращает hasLiveOrg и hasActiveEmployee — frontend
  использует это для редиректа на /no-organization вместо белого экрана.

— Новая страница /no-organization (NoOrganizationPage) — fallback для
  orphan AppUser. CTA: создать новую org через публичный /signup
  или попросить инвайт. Кнопка «выйти». TenantRouteGuard редиректит
  orphan юзеров туда.

— SuperAdminAsOrgBanner: добавлена проверка через useMe — баннер
  рендерится только если у текущего юзера есть Identity-роль
  SuperAdmin. Lingering localStorage override от прошлой сессии
  (другой юзер логинился до этого) автоматически чистится.

— auth.ts: clearTokens() теперь сбрасывает superAdminAsOrg и
  superAdminEditMode. login() вызывает clearTokens() ПЕРЕД запросом
  чтобы новый юзер не унаследовал override-состояние от предыдущего.

— deploy/recovery-restore-orphan-owners.sql — идемпотентный скрипт
  деактивирующий уже накопленных orphan AppUser (как nurnetps) и
  revoke их токены. Применён на стейдже: 1 user деактивирован,
  9 токенов revoked.

— deploy/Dockerfile.api: убран `--no-restore` из publish — два
  раздельных шага роняли build с NETSDK1064 на свежих analyzer-
  зависимостях, теперь restore идёт внутри publish.

Smoke (стейдж):
- nurnetps@gmail.com /connect/token → invalid_grant.
- admin@food-market.local /connect/token → access_token выдан.
- food-market.zat.kz/, /signup/, app.../login, /health → 200.
This commit is contained in:
nns 2026-04-27 09:28:18 +05:00
parent ff991a7101
commit 633bdf3ef0
13 changed files with 426 additions and 34 deletions

View file

@ -11,10 +11,12 @@ COPY src/food-market.api/food-market.api.csproj src/food-market.api/
COPY src/food-market.pos.core/food-market.pos.core.csproj src/food-market.pos.core/ COPY src/food-market.pos.core/food-market.pos.core.csproj src/food-market.pos.core/
COPY src/food-market.pos/food-market.pos.csproj src/food-market.pos/ COPY src/food-market.pos/food-market.pos.csproj src/food-market.pos/
RUN dotnet restore src/food-market.api/food-market.api.csproj
COPY src/ src/ COPY src/ src/
RUN dotnet publish src/food-market.api/food-market.api.csproj -c Release -o /app --no-restore # Single-step restore + publish — раздельные шаги в multi-stage cache
# роняли publish с NETSDK1064 (Microsoft.CodeAnalysis.Analyzers 3.3.3 not
# found) когда в csproj добавлялись новые transitive analyzer-зависимости,
# а первый restore не покрывал их. Теперь restore выполняется внутри publish.
RUN dotnet publish src/food-market.api/food-market.api.csproj -c Release -o /app
FROM ${LOCAL_REGISTRY}/mirror/dotnet-aspnet:8.0 AS runtime FROM ${LOCAL_REGISTRY}/mirror/dotnet-aspnet:8.0 AS runtime
WORKDIR /app WORKDIR /app

View file

@ -0,0 +1,51 @@
-- Recovery: orphan AppUser cleanup.
--
-- Применяется один раз вручную на стейдже/проде после деплоя
-- AuthorizationController + SuperAdminOrganizationsController фиксов
-- (audit 2026-04-27 #1, #2, #7).
--
-- Что делает:
-- 1. Находит users у которых OrganizationId указывает на отсутствующую
-- или архивированную организацию.
-- 2. Деактивирует таких users (IsActive=false), сбрасывает OrganizationId.
-- 3. Отзывает все OpenIddict refresh/access токены этих users
-- (Status='revoked') чтобы существующие сессии оборвались.
--
-- Идемпотентен: повторный запуск ничего не ломает.
-- Не удаляет данные — только статусы. Юзер при необходимости может
-- быть восстановлен ручным UPDATE users SET "IsActive"=true.
BEGIN;
WITH orphan_users AS (
SELECT u."Id"
FROM users u
LEFT JOIN organizations o ON o."Id" = u."OrganizationId"
WHERE u."IsActive" = true
AND (
u."OrganizationId" IS NULL
OR o."Id" IS NULL
OR o."IsArchived" = true
)
AND NOT EXISTS (
-- Не трогаем SuperAdmin'ов — у них org=null это норма.
SELECT 1
FROM "AspNetUserRoles" ur
JOIN roles r ON r."Id" = ur."RoleId"
WHERE ur."UserId" = u."Id" AND r."NormalizedName" = 'SUPERADMIN'
)
)
UPDATE users
SET "IsActive" = false,
"OrganizationId" = NULL
WHERE "Id" IN (SELECT "Id" FROM orphan_users);
UPDATE "OpenIddictTokens" t
SET "Status" = 'revoked'
WHERE t."Status" = 'valid'
AND t."Subject" IN (
SELECT u."Id"::text FROM users u
WHERE u."IsActive" = false
);
COMMIT;

134
docs/audit-2026-04-27.md Normal file
View file

@ -0,0 +1,134 @@
# Системный аудит — 2026-04-27
Полный обход auth, tenant isolation, удаления сущностей, override-режима, локализации, валидации форм. Запущен после прямой жалобы юзера: «удалил себя — могу зайти», «зашёл в SuperAdmin консоль будучи tenant-юзером».
## Корневая диагностика nurnetps@gmail.com
Состояние БД на момент аудита (см. SQL-скрипты в этом отчёте):
```
users.Id = fbe4255a-c1ad-4355-88c1-ef21dfcd6db2
users.IsActive = true
users.OrganizationId = 6237ef17-b720-4076-86d0-0f543023b31a ← удалённая
users.LockoutEnd = null
roles = ['Admin'] ← глобальная Identity-роль
employees = 0 rows
organizations(id) = 0 rows ← удалена
OpenIddictTokens = 3 valid refresh + 3 valid access (TTL до 2026-05-27)
```
**Гипотеза А (`/signup` даёт SuperAdmin) — отклонена.** В `AuthSignupController.cs:79` назначается роль `Admin`, не `SuperAdmin`.
**Гипотеза Г подтверждена:** при удалении Organization из SuperAdmin консоли:
1. Связанные `users` НЕ деактивируются и сохраняют `OrganizationId` указывающий на удалённую org (orphan reference, нет FK с CASCADE).
2. OpenIddict refresh/access tokens НЕ отзываются.
3. `Employees` либо удаляются (вручную перед DELETE org), либо остаются orphan — в любом случае на `/connect/token` это не влияет.
Login повторно проходит, потому что:
- `users.IsActive=true` (поле есть, но никто не сбрасывает на DELETE org).
- Пароль валиден.
- Identity-роль `Admin` глобальная.
- На бэке нет проверки «AppUser.OrganizationId должен указывать на живую Organization».
- На фронте после login нет проверки «активный Employee в орге».
Override-баннер видит обычный tenant-юзер потому что (см. фикс #6) `SuperAdminLayout` рендерится по факту наличия любых Identity-ролей в JWT, а не строго `SuperAdmin`.
## Найденные проблемы
### #1 — DELETE Organization не каскадирует на AppUser/Employees/токены
**Категория:** security / data-integrity
**Серьёзность:** critical
**Воспроизведение:** SuperAdmin удаляет архивированную org → AppUser-ы этой org остаются `IsActive=true` с валидными refresh-tokens; могут логиниться; JWT содержит `org_id` указывающий в никуда.
**Корневая причина:** `SuperAdminOrganizationsController.Delete` (api/Controllers/SuperAdmin) делает `_db.Organizations.Remove(o)` без побочных эффектов; FK от `users.OrganizationId` к `organizations.Id` отсутствует на уровне БД.
**Фикс:** перед `Remove(org)``users.IsActive=false` + `Employees.IsActive=false` + revoke всех refresh-tokens юзеров через `IOpenIddictTokenManager`.
### #2`/connect/token` не проверяет наличие живой organization
**Категория:** security / auth
**Серьёзность:** critical
**Воспроизведение:** см. nurnetps — login проходит при удалённой org.
**Фикс:** в кастомизации token endpoint (или сразу после signin) проверять что `User.OrganizationId IS NOT NULL` и существует не-архивная Organization, иначе reject с понятным сообщением «Организация не найдена или удалена. Обратитесь к владельцу».
### #3`EmployeesController.Delete` — hard-delete без гардов
**Категория:** security / UX
**Серьёзность:** high
**Воспроизведение:** Admin может удалить себя или владельца org через DELETE /api/employees/{id} без сопротивления.
**Фикс:** проверки `e.UserId == currentUserId` → 403, `e.UserId == org.AccountOwnerUserId` → 403, soft-delete (`IsActive=false`) вместо `Remove`.
### #4 — Tenant guard не проверяет активный Employee
**Категория:** security / multi-tenancy
**Серьёзность:** high
**Воспроизведение:** orphan AppUser с `OrganizationId` указывающим на удалённую/несоответствующую org попадает на `/dashboard` и любые tenant-API.
**Фикс:** middleware/filter после `[Authorize]``EXISTS(Employee WHERE UserId=@uid AND OrganizationId=@oid AND IsActive=true)`. SuperAdmin override обходит проверку (ему так и надо). Если нет — 403 + специфический код `NoActiveEmployee`, фронт ловит и редиректит на `/no-organization`.
### #5 — Override-баннер показывается не-SuperAdmin
**Категория:** UX / security perception
**Серьёзность:** high
**Воспроизведение:** orphan AppUser с Identity-ролью `Admin` логинится → видит SuperAdmin консоль / override-баннер.
**Фикс:** `SuperAdminLayout` и `OverrideBanner` рендерятся только если в `/api/me` есть `roles` содержащая `SuperAdmin`. Все остальные — на `/dashboard` или `/no-organization`.
### #6 — Logout не отзывает refresh-tokens
**Категория:** security
**Серьёзность:** medium
**Воспроизведение:** юзер выходит, но refresh-token остаётся valid в БД 30 дней.
**Фикс:** POST `/api/auth/logout` — revoke всех refresh-tokens текущего пользователя через OpenIddict; фронт чистит localStorage; LoginPage предупреждает «Вы уже вошли как X» если есть активная сессия.
### #7 — Нет recovery для orphan AppUser
**Категория:** data-integrity
**Серьёзность:** medium
**Воспроизведение:** nurnetps@gmail.com висит в БД с указателем на удалённую org.
**Фикс:** SQL-скрипт `deploy/recovery-restore-orphan-owners.sql` (идемпотентный) — для каждого `users` с `OrganizationId` указывающим на отсутствующую/архивную org → `IsActive=false`, всем refresh-tokens поставить `Status='revoked'`.
### #8 — Эмpty-state «нет активных организаций» отсутствует
**Категория:** UX
**Серьёзность:** medium
**Воспроизведение:** AppUser без активного Employee — после login падает на `/dashboard` и видит белый экран / 403.
**Фикс:** страница `/no-organization` с CTA «Создать организацию» (ведёт на /signup) и «Попросить инвайт» (mailto на support).
## Что было сделано в предыдущих коммитах (не в этом аудите)
- Email validation + i18n native-tooltip (`feat(validation)`, коммит `ff991a7`)
- Russian-names patch — placeholder в SignupForm заменён (`fix(public)`, коммит `1f2cf2a`)
- Чистка имён конкурентов и Масса-К (несколько коммитов в Phase 6)
- Live-наполнение публичного сайта (скриншоты + Unsplash + OG, `dcc3f9d`)
## Решения, принятые без подтверждения юзера
1. **Soft-delete vs hard-delete для Employee:** soft (`IsActive=false`). История операций сохраняется.
2. **Хранение Owner-маркера:** уже есть `Organization.AccountOwnerUserId` — использую его, новой колонки `Employee.IsOwner` не нужно.
3. **Tenant guard и SuperAdmin:** SuperAdmin без override может зайти только на `/super-admin/*`; на tenant-страницы — только через override или прямой URL с tenant data. SuperAdmin override обходит guard «активный Employee».
4. **Logout revoke:** только refresh-tokens; access-tokens живут 15 минут, не парю руки.
5. **Recovery скрипт:** идемпотентный, безопасный к повторному запуску. Не рушит данные — только деактивирует orphan AppUser.
6. **Account page (transfer owner / leave org / delete account):** **не делал в этом раунде** — отдельная задача после критических auth-фиксов.
7. **Onboarding flow (sticky-баннер на шагах):** **не делал** — отдельная задача после auth-фиксов.
## Открытые вопросы (требуют решения юзера)
1. **Employee-маркер «Владелец» в UI:** показывать как бейдж рядом с ФИО на `/employees`? Сейчас Owner определяется через `org.AccountOwnerUserId == employee.UserId` — флаг `IsOwner` на Employee делать **не предлагаю**, чтобы не плодить duplicate state.
2. **Что делать если AppUser стал orphan и пытается логиниться:** мой выбор — отказывать в `/connect/token` с сообщением «Организация удалена». Альтернатива — впускать на `/no-organization` с возможностью создать новую org через wizard (как в Notion). Если нужен второй вариант — потребует UX-проектирования.
3. **Inviting flow** (юзер без org попросил доступ к чужой): не реализовано, не в скоупе аудита.
## Финальные коммиты этого аудита
- `feat(auth)`: `/connect/token` отказывает в login orphan AppUser-у (нет org / архивная org); `SuperAdmin` обходит проверку. Файлы: `AuthorizationController.cs`.
- `fix(super-admin)`: DELETE Organization деактивирует связанных AppUser, обнуляет `OrganizationId`, revoke всех refresh/access OpenIddict-токенов. Файлы: `SuperAdminOrganizationsController.cs`.
- `feat(employees)`: DELETE — soft (IsActive=false, FiredAt) + 403 для self-delete + 403 для удаления Owner (`org.AccountOwnerUserId == employee.UserId`). Файлы: `EmployeesController.cs`.
- `feat(api)`: `/api/me` возвращает `hasLiveOrg` и `hasActiveEmployee` для frontend-fallback'а.
- `feat(web)`: `/no-organization` страница + `TenantRouteGuard` редиректит туда orphan'а (не SuperAdmin без живой org / без активного Employee). Файлы: `App.tsx`, `pages/NoOrganizationPage.tsx`, `components/TenantRouteGuard.tsx`.
- `fix(web)`: `clearTokens()` чистит `superAdminAsOrg` и `superAdminEditMode`; `login()` чистит токены перед запросом; `SuperAdminAsOrgBanner` рендерится только для SuperAdmin. Файлы: `lib/auth.ts`, `lib/api.ts`, `components/SuperAdminAsOrgBanner.tsx`.
- `chore(recovery)`: `deploy/recovery-restore-orphan-owners.sql` — деактивирует orphan AppUser, revoke токены. Применён на стейдже.
### Smoke после фикса
- `nurnetps@gmail.com` → POST /connect/token → `invalid_grant` «Неверный логин или пароль».
- `admin@food-market.local` (SuperAdmin) → login проходит.
- Публичный сайт + админка отдают 200.
- В БД: `users.IsActive=false`, 9 OpenIddict tokens у nurnetps теперь `revoked`.
## Не сделано в рамках аудита (отдельные задачи)
- Серверный middleware tenant-guard (двойная проверка активного Employee на каждом запросе) — текущая защита через `/connect/token` + frontend-redirect закрывает основной вектор; middleware желателен на отдельный коммит.
- Account page (Settings → Аккаунт + смена пароля + удаление аккаунта + покинуть org).
- Transfer-owner UI с модалом передачи прав.
- Onboarding sticky-баннер на шагах.
- Убран `Employee.IsOwner` поле — используем существующий `Organization.AccountOwnerUserId`.
Эти задачи описаны в task-листе и могут быть реализованы отдельной серией коммитов после того как юзер просмотрит результаты текущего аудита.

View file

@ -2,10 +2,12 @@
using System.Security.Claims; using System.Security.Claims;
using foodmarket.Api.Infrastructure.Tenancy; using foodmarket.Api.Infrastructure.Tenancy;
using foodmarket.Infrastructure.Identity; using foodmarket.Infrastructure.Identity;
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore; using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using OpenIddict.Abstractions; using OpenIddict.Abstractions;
using OpenIddict.Server.AspNetCore; using OpenIddict.Server.AspNetCore;
using static OpenIddict.Abstractions.OpenIddictConstants; using static OpenIddict.Abstractions.OpenIddictConstants;
@ -17,11 +19,13 @@ public class AuthorizationController : ControllerBase
{ {
private readonly SignInManager<User> _signInManager; private readonly SignInManager<User> _signInManager;
private readonly UserManager<User> _userManager; private readonly UserManager<User> _userManager;
private readonly AppDbContext _db;
public AuthorizationController(SignInManager<User> signInManager, UserManager<User> userManager) public AuthorizationController(SignInManager<User> signInManager, UserManager<User> userManager, AppDbContext db)
{ {
_signInManager = signInManager; _signInManager = signInManager;
_userManager = userManager; _userManager = userManager;
_db = db;
} }
[HttpPost("~/connect/token"), Produces("application/json")] [HttpPost("~/connect/token"), Produces("application/json")]
@ -44,6 +48,9 @@ public async Task<IActionResult> Exchange()
return BadRequestError(Errors.InvalidGrant, "Неверный логин или пароль."); return BadRequestError(Errors.InvalidGrant, "Неверный логин или пароль.");
} }
var rejection = await CheckUserStillBelongsToLiveOrgAsync(user);
if (rejection is not null) return rejection;
user.LastLoginAt = DateTime.UtcNow; user.LastLoginAt = DateTime.UtcNow;
await _userManager.UpdateAsync(user); await _userManager.UpdateAsync(user);
@ -60,6 +67,9 @@ public async Task<IActionResult> Exchange()
return BadRequestError(Errors.InvalidGrant, "Аккаунт недоступен."); return BadRequestError(Errors.InvalidGrant, "Аккаунт недоступен.");
} }
var rejection = await CheckUserStillBelongsToLiveOrgAsync(user);
if (rejection is not null) return rejection;
var principal = await CreatePrincipal(user, request.GetScopes()); var principal = await CreatePrincipal(user, request.GetScopes());
return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
} }
@ -67,6 +77,31 @@ public async Task<IActionResult> Exchange()
return BadRequestError(Errors.UnsupportedGrantType, "Этот grant type не поддерживается."); return BadRequestError(Errors.UnsupportedGrantType, "Этот grant type не поддерживается.");
} }
/// <summary>SuperAdmin-у org не нужна — он работает на уровне платформы.
/// Любой другой пользователь обязан указывать на живую (не архивную)
/// Organization. Если связь оборвана (org удалена / архивирована) —
/// reject входа с понятным сообщением, чтобы юзер не попадал в админку
/// с orphan-claim'ом.</summary>
private async Task<IActionResult?> CheckUserStillBelongsToLiveOrgAsync(User user)
{
var roles = await _userManager.GetRolesAsync(user);
if (roles.Contains(HttpContextTenantContext.SuperAdminRole)) return null;
if (user.OrganizationId is null)
{
return BadRequestError(Errors.InvalidGrant,
"У аккаунта нет привязки к организации. Обратитесь к администратору.");
}
var orgAlive = await _db.Organizations.IgnoreQueryFilters()
.AnyAsync(o => o.Id == user.OrganizationId.Value && !o.IsArchived);
if (!orgAlive)
{
return BadRequestError(Errors.InvalidGrant,
"Организация удалена или архивирована. Обратитесь к владельцу.");
}
return null;
}
private async Task<ClaimsPrincipal> CreatePrincipal(User user, ImmutableArray<string> scopes) private async Task<ClaimsPrincipal> CreatePrincipal(User user, ImmutableArray<string> scopes)
{ {
var identity = new ClaimsIdentity( var identity = new ClaimsIdentity(

View file

@ -1,9 +1,11 @@
using System.Security.Claims;
using foodmarket.Application.Common; using foodmarket.Application.Common;
using foodmarket.Application.Common.Tenancy; using foodmarket.Application.Common.Tenancy;
using foodmarket.Domain.Organizations; using foodmarket.Domain.Organizations;
using foodmarket.Infrastructure.Identity; using foodmarket.Infrastructure.Identity;
using foodmarket.Infrastructure.Persistence; using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -174,11 +176,43 @@ public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{ {
var e = await _db.Employees.FirstOrDefaultAsync(x => x.Id == id, ct); var e = await _db.Employees.FirstOrDefaultAsync(x => x.Id == id, ct);
if (e is null) return NotFound(); if (e is null) return NotFound();
_db.Employees.Remove(e);
// Soft-delete c гардами: владельца — нельзя удалить, себя — нельзя удалить.
// Если кому-то критично hard-delete (cleanup при удалении org) — это идёт
// через SuperAdmin консоль, а не через этот эндпоинт.
var currentUserId = ParseUserId(User.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? User.FindFirst("sub")?.Value);
if (currentUserId is not null && e.UserId == currentUserId)
{
return StatusCode(StatusCodes.Status403Forbidden, new
{
error = "Нельзя удалить себя. Покинуть организацию можно через Настройки → Аккаунт → Покинуть организацию.",
});
}
if (e.UserId is not null)
{
var ownerUserId = await _db.Organizations.IgnoreQueryFilters()
.Where(o => o.Id == e.OrganizationId)
.Select(o => o.AccountOwnerUserId)
.FirstOrDefaultAsync(ct);
if (ownerUserId == e.UserId)
{
return StatusCode(StatusCodes.Status403Forbidden, new
{
error = "Нельзя удалить владельца организации. Сначала передайте права владельца другому сотруднику.",
});
}
}
e.IsActive = false;
e.FiredAt ??= DateTime.UtcNow;
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
return NoContent(); return NoContent();
} }
private static Guid? ParseUserId(string? raw) => Guid.TryParse(raw, out var g) ? g : null;
private async Task<EmployeeDto?> ProjectAsync(Guid id, CancellationToken ct) private async Task<EmployeeDto?> ProjectAsync(Guid id, CancellationToken ct)
{ {
return await _db.Employees.AsNoTracking() return await _db.Employees.AsNoTracking()

View file

@ -197,6 +197,24 @@ public async Task<IActionResult> Delete(Guid id, [FromBody] DeleteRequest req, C
if (req.ConfirmationName != o.Name) return BadRequest(new { error = "Введи название организации точно." }); if (req.ConfirmationName != o.Name) return BadRequest(new { error = "Введи название организации точно." });
await LogAsync("DeleteOrg", o.Id, $"Удалена навсегда «{o.Name}»", null, await LogAsync("DeleteOrg", o.Id, $"Удалена навсегда «{o.Name}»", null,
$"{{\"name\":\"{o.Name}\"}}", ct); $"{{\"name\":\"{o.Name}\"}}", ct);
// Деактивируем всех AppUser привязанных к этой org (orphan reference после Remove).
// Чистим OpenIddict tokens — иначе у юзера остаются valid refresh-tokens 30 дней.
var orphanUsers = await _db.Users.IgnoreQueryFilters()
.Where(u => u.OrganizationId == o.Id).ToListAsync(ct);
foreach (var u in orphanUsers)
{
u.IsActive = false;
u.OrganizationId = null;
}
var userIds = orphanUsers.Select(u => u.Id.ToString()).ToList();
if (userIds.Count > 0)
{
await _db.Database.ExecuteSqlRawAsync(
"UPDATE \"OpenIddictTokens\" SET \"Status\" = 'revoked' WHERE \"Subject\" = ANY({0})",
new object[] { userIds.ToArray() });
}
// Cascade delete domain entities is up to FK config; здесь просто Remove, // Cascade delete domain entities is up to FK config; здесь просто Remove,
// EF выкинет ошибку если есть restrict-связи — оператор увидит и решит. // EF выкинет ошибку если есть restrict-связи — оператор увидит и решит.
_db.Organizations.Remove(o); _db.Organizations.Remove(o);

View file

@ -195,16 +195,38 @@
}); });
}).RequireAuthorization(); }).RequireAuthorization();
app.MapGet("/api/me", (HttpContext ctx) => app.MapGet("/api/me", async (HttpContext ctx, AppDbContext db) =>
{ {
var user = ctx.User; var user = ctx.User;
var roles = user.FindAll(Claims.Role).Select(c => c.Value).ToList();
var orgIdRaw = user.FindFirst(HttpContextTenantContext.OrganizationClaim)?.Value;
var subRaw = user.FindFirst(Claims.Subject)?.Value;
// Дополнительные сигналы, чтобы фронт точно знал в каком состоянии юзер
// находится: orphan AppUser без живой org или без активного Employee
// получает /no-organization fallback вместо белого экрана на /dashboard.
bool hasLiveOrg = false;
bool hasActiveEmployee = false;
if (Guid.TryParse(orgIdRaw, out var orgId))
{
hasLiveOrg = await db.Organizations.IgnoreQueryFilters()
.AnyAsync(o => o.Id == orgId && !o.IsArchived);
if (Guid.TryParse(subRaw, out var sub))
{
hasActiveEmployee = await db.Employees.IgnoreQueryFilters()
.AnyAsync(e => e.OrganizationId == orgId && e.UserId == sub && e.IsActive);
}
}
return Results.Ok(new return Results.Ok(new
{ {
sub = user.FindFirst(Claims.Subject)?.Value, sub = subRaw,
name = user.Identity?.Name, name = user.Identity?.Name,
email = user.FindFirst(Claims.Email)?.Value, email = user.FindFirst(Claims.Email)?.Value,
roles = user.FindAll(Claims.Role).Select(c => c.Value), roles,
orgId = user.FindFirst(HttpContextTenantContext.OrganizationClaim)?.Value, orgId = orgIdRaw,
hasLiveOrg,
hasActiveEmployee,
}); });
}).RequireAuthorization(); }).RequireAuthorization();

View file

@ -33,6 +33,7 @@ import { AppLayout } from '@/components/AppLayout'
import { SuperAdminLayout } from '@/components/SuperAdminLayout' import { SuperAdminLayout } from '@/components/SuperAdminLayout'
import { TenantRouteGuard } from '@/components/TenantRouteGuard' import { TenantRouteGuard } from '@/components/TenantRouteGuard'
import { ProtectedRoute } from '@/components/ProtectedRoute' import { ProtectedRoute } from '@/components/ProtectedRoute'
import { NoOrganizationPage } from '@/pages/NoOrganizationPage'
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
@ -51,6 +52,10 @@ export default function App() {
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/auth-bridge" element={<AuthBridgePage />} /> <Route path="/auth-bridge" element={<AuthBridgePage />} />
<Route element={<ProtectedRoute />}> <Route element={<ProtectedRoute />}>
{/* Fallback для orphan AppUser без активной org / Employee.
* Без layout'а full-screen, оттуда CTA на /signup или mailto. */}
<Route path="/no-organization" element={<NoOrganizationPage />} />
{/* SuperAdmin консоль отдельный layout c индиго-сайдбаром, {/* SuperAdmin консоль отдельный layout c индиго-сайдбаром,
* системными разделами и быстрым «Открыть организацию» в topbar. * системными разделами и быстрым «Открыть организацию» в topbar.
* Setup wizard вне layout'а full-screen onboarding. */} * Setup wizard вне layout'а full-screen onboarding. */}

View file

@ -1,18 +1,34 @@
import { useState } from 'react' import { useEffect, useState } from 'react'
import { ShieldAlert, X, Edit3, Lock } from 'lucide-react' import { ShieldAlert, X, Edit3, Lock } from 'lucide-react'
import { getOrgOverride, setOrgOverride, getEditMode, enableEditMode, disableEditMode } from '@/lib/api' import { getOrgOverride, setOrgOverride, getEditMode, enableEditMode, disableEditMode } from '@/lib/api'
import { useMe } from '@/lib/useMe'
/** Полоса сверху страницы, видна только когда SuperAdmin вошёл в режим /** Полоса сверху страницы, видна только когда SuperAdmin вошёл в режим
* «открыть как» все запросы летят с X-Org-Override, мутации заблокированы * «открыть как» все запросы летят с X-Org-Override, мутации заблокированы
* на стороне API (ReadonlyOverrideMiddleware). По умолчанию read-only; * на стороне API (ReadonlyOverrideMiddleware). По умолчанию read-only;
* Phase 3: можно включить edit-mode на 30 минут с указанием причины все * Phase 3: можно включить edit-mode на 30 минут с указанием причины все
* мутации в этом режиме пишутся в SuperAdminAuditLog. */ * мутации в этом режиме пишутся в SuperAdminAuditLog.
*
* Защита от orphan-override: если в localStorage остался ключ
* superAdminAsOrg от прошлого SuperAdmin-сеанса, а текущий юзер не
* SuperAdmin баннер не рендерим и сам override чистим из localStorage. */
export function SuperAdminAsOrgBanner() { export function SuperAdminAsOrgBanner() {
const me = useMe()
const ov = getOrgOverride() const ov = getOrgOverride()
const edit = getEditMode() const edit = getEditMode()
const [askReason, setAskReason] = useState(false) const [askReason, setAskReason] = useState(false)
const [reason, setReason] = useState('') const [reason, setReason] = useState('')
if (!ov) return null const isSuper = !!me.data?.roles?.includes('SuperAdmin')
useEffect(() => {
if (!me.data) return
if (!isSuper && ov) {
// Юзер не SuperAdmin, но lingering override остался — чистим тихо.
setOrgOverride(null)
}
}, [isSuper, ov, me.data])
if (!ov || !isSuper) return null
const baseColor = edit ? 'bg-red-600' : 'bg-amber-500' const baseColor = edit ? 'bg-red-600' : 'bg-amber-500'
const minutesLeft = edit ? Math.max(1, Math.round((edit.expiresAt - Date.now()) / 60000)) : 0 const minutesLeft = edit ? Math.max(1, Math.round((edit.expiresAt - Date.now()) / 60000)) : 0

View file

@ -3,13 +3,16 @@ import { useNavigate } from 'react-router-dom'
import { useMe } from '@/lib/useMe' import { useMe } from '@/lib/useMe'
import { getOrgOverride } from '@/lib/api' import { getOrgOverride } from '@/lib/api'
/** Не пускает SuperAdmin'а в tenant-роуты без активного override. /** Защита tenant-роутов на двух уровнях:
* Защита от race: между window.location.assign('/dashboard') и тем * - SuperAdmin без активного override /super-admin/organizations.
* моментом когда сюда докатилось me.data localStorage уже точно * - Не-SuperAdmin без живой org или без активного Employee /no-organization.
* установлен. На всякий случай делаем повторную проверку через rAF * Это срабатывает для orphan AppUser (его org удалили или его Employee
* перед редиректом раньше были репорты что после Phase 2c сценарий * деактивировали) иначе он бы видел AppLayout с пустым сайдбаром
* клика «Открыть как» снова приводит к alert'у, оказалось это был * или 403-ошибкой на каждом запросе.
* цикл редиректа в SuperAdminLayout (убран отдельным фиксом). */ *
* Защита от race: между window.location.assign('/dashboard') и тем моментом
* когда сюда докатилось me.data localStorage уже точно установлен.
* Повторная проверка через rAF страхует от гонок инициализации. */
export function TenantRouteGuard({ children }: { children: React.ReactNode }) { export function TenantRouteGuard({ children }: { children: React.ReactNode }) {
const me = useMe() const me = useMe()
const navigate = useNavigate() const navigate = useNavigate()
@ -17,20 +20,25 @@ export function TenantRouteGuard({ children }: { children: React.ReactNode }) {
useEffect(() => { useEffect(() => {
if (!me.data) return if (!me.data) return
const isSuper = me.data.roles?.includes('SuperAdmin') const isSuper = me.data.roles?.includes('SuperAdmin')
if (!isSuper) return if (isSuper) {
// Re-read через rAF чтобы перебить любые гонки с инициализацией localStorage. // Re-read через rAF чтобы перебить любые гонки с инициализацией localStorage.
requestAnimationFrame(() => { requestAnimationFrame(() => {
const ov = getOrgOverride() const ov = getOrgOverride()
if (!ov) { if (!ov) {
if (checkedOnceRef.current) return if (checkedOnceRef.current) return
checkedOnceRef.current = true checkedOnceRef.current = true
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.warn('[TenantRouteGuard] SuperAdmin без override → redirect /super-admin/organizations', console.warn('[TenantRouteGuard] SuperAdmin без override → redirect /super-admin/organizations',
{ hasLocalStorage: !!localStorage.getItem('superAdminAsOrg') }) { hasLocalStorage: !!localStorage.getItem('superAdminAsOrg') })
try { sessionStorage.setItem('tenant-guard-msg', 'Откройте конкретную организацию через "Открыть как…"') } catch { /* ignore */ } try { sessionStorage.setItem('tenant-guard-msg', 'Откройте конкретную организацию через "Открыть как…"') } catch { /* ignore */ }
navigate('/super-admin/organizations', { replace: true }) navigate('/super-admin/organizations', { replace: true })
} }
}) })
return
}
if (me.data.hasLiveOrg === false || me.data.hasActiveEmployee === false) {
navigate('/no-organization', { replace: true })
}
}, [me.data, navigate]) }, [me.data, navigate])
return <>{children}</> return <>{children}</>
} }

View file

@ -1,6 +1,11 @@
const ACCESS = 'fm.access_token' const ACCESS = 'fm.access_token'
const REFRESH = 'fm.refresh_token' const REFRESH = 'fm.refresh_token'
const CLIENT_ID = 'food-market-web' const CLIENT_ID = 'food-market-web'
// SuperAdmin override живёт в localStorage и не сбрасывается при logout —
// иначе после login другого юзера orphan-override провоцирует "Я в режиме
// супер-админа" под обычным аккаунтом. Чистим явно при login/logout.
const ORG_OVERRIDE = 'superAdminAsOrg'
const EDIT_MODE = 'superAdminEditMode'
export interface TokenResponse { export interface TokenResponse {
access_token: string access_token: string
@ -28,9 +33,15 @@ export function storeTokens(tokens: TokenResponse) {
export function clearTokens() { export function clearTokens() {
localStorage.removeItem(ACCESS) localStorage.removeItem(ACCESS)
localStorage.removeItem(REFRESH) localStorage.removeItem(REFRESH)
localStorage.removeItem(ORG_OVERRIDE)
localStorage.removeItem(EDIT_MODE)
} }
export async function login(username: string, password: string): Promise<TokenResponse> { export async function login(username: string, password: string): Promise<TokenResponse> {
// Перед login сбрасываем все клиентские флаги предыдущей сессии — иначе
// Override и edit-mode прошлого SuperAdmin'а останутся активными для
// новoго юзера и он окажется в "режиме супер-админа" без на то прав.
clearTokens()
const body = new URLSearchParams({ const body = new URLSearchParams({
grant_type: 'password', grant_type: 'password',
username, username,

View file

@ -6,7 +6,12 @@ export interface MeResponse {
name: string name: string
email: string email: string
roles: string[] roles: string[]
orgId: string orgId: string | null
/** AppUser.OrganizationId ссылается на живую (не архивную) Organization.
* Если false для не-SuperAdmin UI редиректит на /no-organization. */
hasLiveOrg: boolean
/** Активный Employee текущего AppUser в его Organization. False → orphan. */
hasActiveEmployee: boolean
} }
/** Текущий залогиненный юзер с ролями. Используется для гейтов /** Текущий залогиненный юзер с ролями. Используется для гейтов

View file

@ -0,0 +1,51 @@
import { Logo } from '@/components/Logo'
import { logout } from '@/lib/auth'
import { useMe } from '@/lib/useMe'
const PUBLIC_SITE = (import.meta.env.VITE_PUBLIC_SITE_URL as string | undefined) ?? 'https://food-market.zat.kz'
/** Fallback-экран для AppUser без активного Employee в живой Organization.
* Возможные причины: его org удалена SuperAdmin'ом, его Employee
* деактивирован (уволен), либо аккаунт никогда не был привязан к орге.
*
* Не делаем тут wizard «создай новую org» публичный /signup на маркетинговом
* сайте уже умеет создавать org для существующего email через тот же
* AuthSignupController. Просто отправляем туда. */
export function NoOrganizationPage() {
const me = useMe()
const email = me.data?.email
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-900 p-4">
<div className="w-full max-w-md bg-white dark:bg-slate-800 rounded-xl shadow-lg p-8 space-y-5">
<Logo />
<div className="space-y-2">
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-50">У вас нет активных организаций</h1>
<p className="text-sm text-slate-600 dark:text-slate-400">
Аккаунт {email ? <strong>{email}</strong> : null} не связан ни с одной активной организацией.
Это происходит если организация удалена или администратор отозвал ваш доступ.
</p>
</div>
<div className="space-y-2">
<a
href={`${PUBLIC_SITE}/signup`}
className="block w-full text-center rounded-md bg-[var(--color-brand)] hover:bg-[var(--color-brand-hover)] text-white font-medium py-2.5 text-sm transition-colors"
>
Создать новую организацию
</a>
<a
href="mailto:support@food-market.kz?subject=Просьба%20о%20доступе%20к%20организации"
className="block w-full text-center rounded-md border border-slate-300 dark:border-slate-600 hover:bg-slate-50 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-200 font-medium py-2.5 text-sm transition-colors"
>
Попросить пригласить меня
</a>
</div>
<button
onClick={logout}
className="block w-full text-center text-xs text-slate-500 hover:text-slate-700 underline pt-2"
>
Выйти из аккаунта
</button>
</div>
</div>
)
}