Завершающий пункт пакета фиксов по ролям/валидации/удалению. Обход: 1. /connect/token — IsActive + BelongsToLiveOrg + SuperAdmin bypass. 2. JWT cookie vs Bearer — все три AuthN-схемы переопределены в OpenIddictValidationAspNetCoreDefaults; cookie не активна для API. 3. X-Org-Override — фильтрует по IsInRole(SuperAdmin), подделать нельзя. 4. Tenant query filters — ITenantEntity и IOptionalTenantEntity подключаются через reflection, фильтр консистентен с tenant.context. 5. Smoke per-role — sidebar+RoleGuard за один проход покрывает все tenant-роуты; tenant-admin на /super-admin URL → описан risk + future fix. 6. Reset password / deactivate account — токены revoke в БД одним SQL. 7. Catch-22 для SuperAdmin платформы — он не Employee и не имеет OrganizationId, через текущие endpoint-ы deactivate невозможен. Findings разбиты на critical (закрыто этим пакетом), high/medium (не закрыто — будущая серия) и low (косметика).
11 KiB
Системный аудит авторизации — 2026-05-06
Финальный пункт пакета фиксов по системе ролей. Прохожу цепочку авторизации от логина до серверной защиты конкретных endpoint-ов и фиксирую все findings; критичные — сразу починены, в коммитах этого же дня.
1. Логин: OpenIddict /connect/token
AuthorizationController.cs:
- Password grant:
_userManager.FindByNameAsync+CheckPasswordSignInAsync+ проверкаUser.IsActive. - После успешного password-grant — дополнительная проверка
CheckUserStillBelongsToLiveOrgAsync(исключение для SuperAdmin): отказывает в токене, еслиUser.OrganizationIdуказывает на удалённую/архивированную org. Это закрывает orphan-AppUser сценарий из аудита 2026-04-27. - Refresh grant: повторно проверяет
IsActiveиBelongsToLiveOrg. - Поле
org_idпишется в JWT-claims какHttpContextTenantContext.OrganizationClaim.
Status: OK. Проверки покрывают: deactivated user, orphan org, SuperAdmin override.
2. JWT cookie vs Bearer
API использует только Bearer-токены через OpenIddict. Cookie-схему AspNetCore Identity подавляет AddAuthentication (см. Program.cs:108-113 — все три схемы переопределены в OpenIddictValidationAspNetCoreDefaults). Это критично — иначе [Authorize] бы редиректил API-запросы на /Account/Login.
Status: OK.
3. X-Org-Override (impersonation)
HttpContextTenantContext.cs:
OrgOverrideHeader = "X-Org-Override".TryGetHttpOverrideOrg: возвращаетtrueТОЛЬКО еслиUser.IsInRole("SuperAdmin")И header присутствует. Обычный юзер не может задать override (даже если подсунет header —IsInRoleфильтрует).- В режиме override
IsTenantOverride=true. Tenant-фильтр вAppDbContext.ApplyTenantFilterстроится так:
То есть SuperAdmin без override видит всё; SuperAdmin в override — фильтр обязан применяться к выбранному(IsSuperAdmin && !IsTenantOverride) || OrganizationId == _tenant.OrganizationIdOrganizationId. Ровно так, как нужно.
Status: OK. Проверка роли защищает от подделки header'а.
4. Tenant query filters
AppDbContext.cs:109-153:
- Для каждого
ITenantEntityчерез reflection ставитсяHasQueryFilter. - Для
IOptionalTenantEntity(системные справочники с nullableOrganizationId) — отдельный фильтр: NULL-записи видны всем, остальные — обычная изоляция. - Все Identity-таблицы (Users/Roles/UserRoles) — НЕ tenant-scoped (они не реализуют ITenantEntity), запросы к ним идут без фильтра. Это by design — Identity управляется через UserManager/RoleManager.
Status: OK.
5. Smoke (UI) ожидаемое поведение по ролям
Согласно AppLayout.buildNav (после step 7) и RoleGuard (новый в этом пакете):
| Юзер пытается зайти | Поведение |
|---|---|
Cashier на /super-admin/orgs |
TenantRouteGuard / RoleGuard не пускает на /super-admin (он под отдельным layout с [Authorize(Roles = "SuperAdmin")] на эндпойнтах). Юзер увидит «Нет доступа» из RoleGuard и/или 403 от API. |
Storekeeper на /settings/employees |
RoleGuard roles=['Admin'] → «Нет доступа». |
Cashier на /catalog/counterparties |
RoleGuard roles=['Admin'] → «Нет доступа». |
Tenant-Admin на /super-admin/... |
TenantRouteGuard для не-SuperAdmin не редиректит туда (он только tenant-роуты охраняет); сам /super-admin под <SuperAdminLayout> без guard'а, но все [Authorize(Roles = "SuperAdmin")] на endpoint-ах вернут 403. UI покажет 403-страницы пустые таблицы / ошибки. Findings: добавить RoleGuard на сам <Route path="/super-admin"> чтобы Tenant-Admin не видел индиго-sidebar админа платформы. → Не сделано в этом пакете, описано как future. |
| 401 на любом запросе | api.ts interceptor: попытка refresh; если refresh упал — clearTokens() + редирект на /login. |
6. Reset пароля и инвалидация токенов
SuperAdminEmployeesController.ResetPassword:
_userMgr.RemovePasswordAsync+AddPasswordAsync(temp).UPDATE OpenIddictTokens SET Status='revoked' WHERE Subject = userId AND Status='valid'— обрывает все активные сессии.
SuperAdminEmployeesController.ToggleAccountActive при IsActive=false:
- Те же
revokedдля всех valid токенов.
Status: OK.
7. Catch-22: SuperAdmin блочит свою же учётку
SuperAdminEmployeesController оперирует на сущности Employee конкретной org (/api/super-admin/organizations/{orgId}/employees/...). SuperAdmin платформы — это User БЕЗ OrganizationId и БЕЗ Employee. Через этот контроллер до его учётки не дойти.
Других endpoint-ов, через которые можно User.IsActive=false для произвольного user-id — НЕТ. SuperAdminOrganizationsController.Delete деактивирует только тех, чей OrganizationId совпадает с удаляемой org — SuperAdmin платформы туда не попадает (u.OrganizationId == null).
Status: OK сейчас. Future risk: если добавится /api/super-admin/users/... с возможностью deactivate любого user-id, нужен гард if (currentUserId == targetUserId) → 403 «нельзя себя». Запишу как TODO.
8. Findings (зафиксированы / не зафиксированы)
Critical (зафиксировано в этом пакете)
| # | Описание | Где | Коммит |
|---|---|---|---|
| 1 | Manager — лишняя системная роль, путала UI и Authorize-гарды |
SystemRoles, 13 контроллеров, DevDataSeeder | fce9be9 |
| 2 | Системная роль выкидывала alert вместо show-permissions | EmployeeRolesPage | 77de34f |
| 3 | ИИН-формы маркированы как «ИНН/ИИН» (РФ-термин) | EmployeesPage, Counterparties, SuperAdmin* | 9a31650 |
| 4 | Salary через <input type=number> (не учитывал org-настройку копеек) |
EmployeesPage | 9f9d273 |
| 5 | type=email не требовал TLD на patternMismatch | TextInput общий | ed7740e |
| 6 | Удаление сотрудника одноступенчатое, нельзя «уволить → удалить» отдельно | Employee domain + EmployeesController + UI | 049e847 |
| 7 | Sidebar показывал Cashier/Storekeeper лишние пункты | AppLayout + RoleGuard + App.tsx | 542eff2 |
High / Medium (не зафиксировано — отдельная серия)
- Tenant-Admin может открыть
/super-adminURL и увидеть пустой индиго-sidebar (API вернёт 403 на каждый запрос). Нет RoleGuard на сам Route/super-admin/*. Фикс: обернуть<Route element={<RoleGuard roles={['SuperAdmin']}><SuperAdminLayout /></RoleGuard>}>или добавить ранний return в SuperAdminLayout. Authorize(Policy = "AdminAccess")вMoySkladImportController/AdminJobsController/AdminCleanupController— policy вProgram.cs:118-119пропускает Admin или SuperAdmin. SuperAdmin без override проходит — нужен ли он там? Если нет (cleanup задачи tenant-scoped), тогда либоIsTenantOverrideобязателен, либо policy сузить до Admin. Это не critical, но архитектурно хочется единообразия.- Catch-22 защита для будущего
/api/super-admin/users/...— если такой endpoint появится, нужноif (currentUserId == targetUserId) → 403. Сейчас такого endpoint-а нет, риска тоже нет. - Identity-Manager-роль (
AddToRoleAsync(user, "Manager")) использовалась только в DevDataSeeder и signup; обе ветки убраны вfce9be9. У существующих юзеров Identity-рольManagerможет остаться в БД (раньше signup её ставил → нет, signup ставилAdmin, Manager только для dev-сидов). Нужно лиRoleManager.DeleteAsync(Manager)? Решение: оставил; роль есть в БД, но нигде не назначается и не используется в коде. Безопасно. - DevDataSeeder продолжает создавать Demo Market и admin@food-market.local на каждом старте API. Для production это лишнее — Demo Market и dev-admin засоряют prod-БД. Не критично сейчас (dev-данные предсказуемы), но стоит вынести seed в
IsDevelopment(). - MoneyInput не используется в SuperAdminOrgEmployeesPage (там нет поля Salary). При добавлении Salary в SuperAdmin-form'у нужно сразу применять MoneyInput.
Low (косметика / документация)
EmployeesPage.useEffectимеет логику дефолтной роли «Менеджер ?? roles[0]» — после удаления Manager-сидера всегда упадёт на roles[0]. Не баг, но стоит переписать наКассирилиКладовщиккак дефолт.EmployeeRole.cssummary упоминает «Менеджер/Кладовщик/Закупщик/Бухгалтер» — устарело, обновить.
Итог
Все 8 пунктов задачи закрыты или зафиксированы в этом отчёте. 7 атомарных коммитов между fce9be9..542eff2. Билд и API, и web проходят чисто (0 errors). Финальный отчёт по аудиту оставляю в отдельном коммите вместе с обновлённой docs-секцией.