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
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:
parent
ff991a7101
commit
633bdf3ef0
|
|
@ -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/food-market.pos.csproj src/food-market.pos/
|
||||
|
||||
RUN dotnet restore src/food-market.api/food-market.api.csproj
|
||||
|
||||
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
|
||||
WORKDIR /app
|
||||
|
|
|
|||
51
deploy/recovery-restore-orphan-owners.sql
Normal file
51
deploy/recovery-restore-orphan-owners.sql
Normal 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
134
docs/audit-2026-04-27.md
Normal 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-листе и могут быть реализованы отдельной серией коммитов после того как юзер просмотрит результаты текущего аудита.
|
||||
|
|
@ -2,10 +2,12 @@
|
|||
using System.Security.Claims;
|
||||
using foodmarket.Api.Infrastructure.Tenancy;
|
||||
using foodmarket.Infrastructure.Identity;
|
||||
using foodmarket.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using OpenIddict.Abstractions;
|
||||
using OpenIddict.Server.AspNetCore;
|
||||
using static OpenIddict.Abstractions.OpenIddictConstants;
|
||||
|
|
@ -17,11 +19,13 @@ public class AuthorizationController : ControllerBase
|
|||
{
|
||||
private readonly SignInManager<User> _signInManager;
|
||||
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;
|
||||
_userManager = userManager;
|
||||
_db = db;
|
||||
}
|
||||
|
||||
[HttpPost("~/connect/token"), Produces("application/json")]
|
||||
|
|
@ -44,6 +48,9 @@ public async Task<IActionResult> Exchange()
|
|||
return BadRequestError(Errors.InvalidGrant, "Неверный логин или пароль.");
|
||||
}
|
||||
|
||||
var rejection = await CheckUserStillBelongsToLiveOrgAsync(user);
|
||||
if (rejection is not null) return rejection;
|
||||
|
||||
user.LastLoginAt = DateTime.UtcNow;
|
||||
await _userManager.UpdateAsync(user);
|
||||
|
||||
|
|
@ -60,6 +67,9 @@ public async Task<IActionResult> Exchange()
|
|||
return BadRequestError(Errors.InvalidGrant, "Аккаунт недоступен.");
|
||||
}
|
||||
|
||||
var rejection = await CheckUserStillBelongsToLiveOrgAsync(user);
|
||||
if (rejection is not null) return rejection;
|
||||
|
||||
var principal = await CreatePrincipal(user, request.GetScopes());
|
||||
return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
|
||||
}
|
||||
|
|
@ -67,6 +77,31 @@ public async Task<IActionResult> Exchange()
|
|||
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)
|
||||
{
|
||||
var identity = new ClaimsIdentity(
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
using System.Security.Claims;
|
||||
using foodmarket.Application.Common;
|
||||
using foodmarket.Application.Common.Tenancy;
|
||||
using foodmarket.Domain.Organizations;
|
||||
using foodmarket.Infrastructure.Identity;
|
||||
using foodmarket.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
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);
|
||||
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);
|
||||
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)
|
||||
{
|
||||
return await _db.Employees.AsNoTracking()
|
||||
|
|
|
|||
|
|
@ -197,6 +197,24 @@ public async Task<IActionResult> Delete(Guid id, [FromBody] DeleteRequest req, C
|
|||
if (req.ConfirmationName != o.Name) return BadRequest(new { error = "Введи название организации точно." });
|
||||
await LogAsync("DeleteOrg", o.Id, $"Удалена навсегда «{o.Name}»", null,
|
||||
$"{{\"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,
|
||||
// EF выкинет ошибку если есть restrict-связи — оператор увидит и решит.
|
||||
_db.Organizations.Remove(o);
|
||||
|
|
|
|||
|
|
@ -195,16 +195,38 @@
|
|||
});
|
||||
}).RequireAuthorization();
|
||||
|
||||
app.MapGet("/api/me", (HttpContext ctx) =>
|
||||
app.MapGet("/api/me", async (HttpContext ctx, AppDbContext db) =>
|
||||
{
|
||||
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
|
||||
{
|
||||
sub = user.FindFirst(Claims.Subject)?.Value,
|
||||
sub = subRaw,
|
||||
name = user.Identity?.Name,
|
||||
email = user.FindFirst(Claims.Email)?.Value,
|
||||
roles = user.FindAll(Claims.Role).Select(c => c.Value),
|
||||
orgId = user.FindFirst(HttpContextTenantContext.OrganizationClaim)?.Value,
|
||||
roles,
|
||||
orgId = orgIdRaw,
|
||||
hasLiveOrg,
|
||||
hasActiveEmployee,
|
||||
});
|
||||
}).RequireAuthorization();
|
||||
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import { AppLayout } from '@/components/AppLayout'
|
|||
import { SuperAdminLayout } from '@/components/SuperAdminLayout'
|
||||
import { TenantRouteGuard } from '@/components/TenantRouteGuard'
|
||||
import { ProtectedRoute } from '@/components/ProtectedRoute'
|
||||
import { NoOrganizationPage } from '@/pages/NoOrganizationPage'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
|
|
@ -51,6 +52,10 @@ export default function App() {
|
|||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/auth-bridge" element={<AuthBridgePage />} />
|
||||
<Route element={<ProtectedRoute />}>
|
||||
{/* Fallback для orphan AppUser без активной org / Employee.
|
||||
* Без layout'а — full-screen, оттуда CTA на /signup или mailto. */}
|
||||
<Route path="/no-organization" element={<NoOrganizationPage />} />
|
||||
|
||||
{/* SuperAdmin консоль — отдельный layout c индиго-сайдбаром,
|
||||
* системными разделами и быстрым «Открыть организацию» в topbar.
|
||||
* Setup wizard вне layout'а — full-screen onboarding. */}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,34 @@
|
|||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ShieldAlert, X, Edit3, Lock } from 'lucide-react'
|
||||
import { getOrgOverride, setOrgOverride, getEditMode, enableEditMode, disableEditMode } from '@/lib/api'
|
||||
import { useMe } from '@/lib/useMe'
|
||||
|
||||
/** Полоса сверху страницы, видна только когда SuperAdmin вошёл в режим
|
||||
* «открыть как…» — все запросы летят с X-Org-Override, мутации заблокированы
|
||||
* на стороне API (ReadonlyOverrideMiddleware). По умолчанию read-only;
|
||||
* Phase 3: можно включить edit-mode на 30 минут с указанием причины — все
|
||||
* мутации в этом режиме пишутся в SuperAdminAuditLog. */
|
||||
* мутации в этом режиме пишутся в SuperAdminAuditLog.
|
||||
*
|
||||
* Защита от orphan-override: если в localStorage остался ключ
|
||||
* superAdminAsOrg от прошлого SuperAdmin-сеанса, а текущий юзер не
|
||||
* SuperAdmin — баннер не рендерим и сам override чистим из localStorage. */
|
||||
export function SuperAdminAsOrgBanner() {
|
||||
const me = useMe()
|
||||
const ov = getOrgOverride()
|
||||
const edit = getEditMode()
|
||||
const [askReason, setAskReason] = useState(false)
|
||||
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 minutesLeft = edit ? Math.max(1, Math.round((edit.expiresAt - Date.now()) / 60000)) : 0
|
||||
|
|
|
|||
|
|
@ -3,13 +3,16 @@ import { useNavigate } from 'react-router-dom'
|
|||
import { useMe } from '@/lib/useMe'
|
||||
import { getOrgOverride } from '@/lib/api'
|
||||
|
||||
/** Не пускает SuperAdmin'а в tenant-роуты без активного override.
|
||||
* Защита от race: между window.location.assign('/dashboard') и тем
|
||||
* моментом когда сюда докатилось me.data — localStorage уже точно
|
||||
* установлен. На всякий случай делаем повторную проверку через rAF
|
||||
* перед редиректом — раньше были репорты что после Phase 2c сценарий
|
||||
* клика «Открыть как» снова приводит к alert'у, оказалось это был
|
||||
* цикл редиректа в SuperAdminLayout (убран отдельным фиксом). */
|
||||
/** Защита tenant-роутов на двух уровнях:
|
||||
* - SuperAdmin без активного override → /super-admin/organizations.
|
||||
* - Не-SuperAdmin без живой org или без активного Employee → /no-organization.
|
||||
* Это срабатывает для orphan AppUser (его org удалили или его Employee
|
||||
* деактивировали) — иначе он бы видел AppLayout с пустым сайдбаром
|
||||
* или 403-ошибкой на каждом запросе.
|
||||
*
|
||||
* Защита от race: между window.location.assign('/dashboard') и тем моментом
|
||||
* когда сюда докатилось me.data — localStorage уже точно установлен.
|
||||
* Повторная проверка через rAF страхует от гонок инициализации. */
|
||||
export function TenantRouteGuard({ children }: { children: React.ReactNode }) {
|
||||
const me = useMe()
|
||||
const navigate = useNavigate()
|
||||
|
|
@ -17,20 +20,25 @@ export function TenantRouteGuard({ children }: { children: React.ReactNode }) {
|
|||
useEffect(() => {
|
||||
if (!me.data) return
|
||||
const isSuper = me.data.roles?.includes('SuperAdmin')
|
||||
if (!isSuper) return
|
||||
// Re-read через rAF чтобы перебить любые гонки с инициализацией localStorage.
|
||||
requestAnimationFrame(() => {
|
||||
const ov = getOrgOverride()
|
||||
if (!ov) {
|
||||
if (checkedOnceRef.current) return
|
||||
checkedOnceRef.current = true
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[TenantRouteGuard] SuperAdmin без override → redirect /super-admin/organizations',
|
||||
{ hasLocalStorage: !!localStorage.getItem('superAdminAsOrg') })
|
||||
try { sessionStorage.setItem('tenant-guard-msg', 'Откройте конкретную организацию через "Открыть как…"') } catch { /* ignore */ }
|
||||
navigate('/super-admin/organizations', { replace: true })
|
||||
}
|
||||
})
|
||||
if (isSuper) {
|
||||
// Re-read через rAF чтобы перебить любые гонки с инициализацией localStorage.
|
||||
requestAnimationFrame(() => {
|
||||
const ov = getOrgOverride()
|
||||
if (!ov) {
|
||||
if (checkedOnceRef.current) return
|
||||
checkedOnceRef.current = true
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[TenantRouteGuard] SuperAdmin без override → redirect /super-admin/organizations',
|
||||
{ hasLocalStorage: !!localStorage.getItem('superAdminAsOrg') })
|
||||
try { sessionStorage.setItem('tenant-guard-msg', 'Откройте конкретную организацию через "Открыть как…"') } catch { /* ignore */ }
|
||||
navigate('/super-admin/organizations', { replace: true })
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
if (me.data.hasLiveOrg === false || me.data.hasActiveEmployee === false) {
|
||||
navigate('/no-organization', { replace: true })
|
||||
}
|
||||
}, [me.data, navigate])
|
||||
return <>{children}</>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
const ACCESS = 'fm.access_token'
|
||||
const REFRESH = 'fm.refresh_token'
|
||||
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 {
|
||||
access_token: string
|
||||
|
|
@ -28,9 +33,15 @@ export function storeTokens(tokens: TokenResponse) {
|
|||
export function clearTokens() {
|
||||
localStorage.removeItem(ACCESS)
|
||||
localStorage.removeItem(REFRESH)
|
||||
localStorage.removeItem(ORG_OVERRIDE)
|
||||
localStorage.removeItem(EDIT_MODE)
|
||||
}
|
||||
|
||||
export async function login(username: string, password: string): Promise<TokenResponse> {
|
||||
// Перед login сбрасываем все клиентские флаги предыдущей сессии — иначе
|
||||
// Override и edit-mode прошлого SuperAdmin'а останутся активными для
|
||||
// новoго юзера и он окажется в "режиме супер-админа" без на то прав.
|
||||
clearTokens()
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'password',
|
||||
username,
|
||||
|
|
|
|||
|
|
@ -6,7 +6,12 @@ export interface MeResponse {
|
|||
name: string
|
||||
email: 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
|
||||
}
|
||||
|
||||
/** Текущий залогиненный юзер с ролями. Используется для гейтов
|
||||
|
|
|
|||
51
src/food-market.web/src/pages/NoOrganizationPage.tsx
Normal file
51
src/food-market.web/src/pages/NoOrganizationPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in a new issue