# Системный аудит — 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-листе и могут быть реализованы отдельной серией коммитов после того как юзер просмотрит результаты текущего аудита.
