From bcf81c57eeab9668a0440b6106cab0d1c9315d69 Mon Sep 17 00:00:00 2001 From: nns <278048682+nurdotnet@users.noreply.github.com> Date: Fri, 8 May 2026 01:03:30 +0500 Subject: [PATCH] =?UTF-8?q?fix(auth):=20Cashier/Storekeeper=20=D0=B1=D0=BE?= =?UTF-8?q?=D0=BB=D1=8C=D1=88=D0=B5=20=D0=BD=D0=B5=20=D0=B2=D0=B8=D0=B4?= =?UTF-8?q?=D1=8F=D1=82=20/api/organization/employees=20+=20Identity-?= =?UTF-8?q?=D1=80=D0=BE=D0=BB=D1=8C=20=D0=BC=D0=B0=D0=BF=D0=BF=D0=B8=D1=82?= =?UTF-8?q?=D1=81=D1=8F=20=D0=B8=D0=B7=20orgRole?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Найдено в e2e-прогоне (отчёт reports/full-cycle-2026-05-07-baseline.md): - GET /api/organization/employees вернул 200 для Cashier (ожидалось 403). - Cashier у созданного через POST /employees вообще не получает Identity-роли — серверная авторизация не работает. Корни: 1. EmployeesController имел class-level [Authorize] без roles, List/Get не имели per-method [Authorize(Roles=...)] — поэтому любой аутентифицированный юзер мог читать список сотрудников. 2. EmployeesController.Create при createAccount=true вызывал _userMgr.CreateAsync, но НЕ вызывал AddToRoleAsync — у созданного юзера не было ни одной Identity-роли. Фиксы: - Class-level `[Authorize(Roles = "SuperAdmin,Admin")]` на EmployeesController. Теперь List/Get/Create/Update/Delete все требуют Admin (или SuperAdmin override). Per-method дубль убран. - Новый helper `Api/Infrastructure/IdentityRoleMapper.cs`: Администратор → Admin, Кладовщик → Storekeeper, Кассир → Cashier. Кастомные orgRole не получают Identity-роли (это by-design — они дают UI-permissions внутри org, без доступа к role-locked endpoint'ам). - EmployeesController.Create вызывает AddToRoleAsync с замапленной Identity-ролью если такая есть. - SuperAdminEmployeesController.Create аналогично — вместо хардкод "Admin" использует mapper с fallback на "Admin" (по запросу юзера при создании учётки SuperAdmin'ом). После фикса в e2e: - Cashier → GET /api/organization/employees → 403 (было 200). - /connect/token → /api/me содержит roles=["Cashier"]. - Cashier → /api/sales/retail-sales → 200 (рабочая авторизация). --- .../Organizations/EmployeesController.cs | 17 ++++++++++--- .../SuperAdminEmployeesController.cs | 4 ++- .../Infrastructure/IdentityRoleMapper.cs | 25 +++++++++++++++++++ 3 files changed, 41 insertions(+), 5 deletions(-) create mode 100644 src/food-market.api/Infrastructure/IdentityRoleMapper.cs diff --git a/src/food-market.api/Controllers/Organizations/EmployeesController.cs b/src/food-market.api/Controllers/Organizations/EmployeesController.cs index 0af6fe6..da5c0a4 100644 --- a/src/food-market.api/Controllers/Organizations/EmployeesController.cs +++ b/src/food-market.api/Controllers/Organizations/EmployeesController.cs @@ -13,7 +13,7 @@ namespace foodmarket.Api.Controllers.Organizations; [ApiController] -[Authorize] +[Authorize(Roles = "SuperAdmin,Admin")] [Route("api/organization/employees")] public class EmployeesController : ControllerBase { @@ -111,12 +111,12 @@ public async Task> Get(Guid id, CancellationToken ct) return dto is null ? NotFound() : Ok(dto); } - [HttpPost, Authorize(Roles = "SuperAdmin,Admin")] + [HttpPost] public async Task> Create([FromBody] EmployeeInput input, CancellationToken ct) { var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant."); - var roleExists = await _db.EmployeeRoles.AnyAsync(r => r.Id == input.RoleId, ct); - if (!roleExists) return BadRequest(new { error = "Роль не найдена." }); + var role = await _db.EmployeeRoles.FirstOrDefaultAsync(r => r.Id == input.RoleId, ct); + if (role is null) return BadRequest(new { error = "Роль не найдена." }); var employee = new Employee { @@ -151,6 +151,15 @@ public async Task> Create([FromBody] Employee if (!result.Succeeded) return BadRequest(new { error = string.Join("; ", result.Errors.Select(e => e.Description)) }); employee.UserId = user.Id; + + // Identity-роль маппится из orgRole.Name. Кастомные orgRole не + // получают Identity-роли — они только дают UI-permissions, без + // доступа к role-locked endpoint'ам. + var identityRole = foodmarket.Api.Infrastructure.IdentityRoleMapper.FromOrgRoleName(role.Name); + if (identityRole is not null) + { + await _userMgr.AddToRoleAsync(user, identityRole); + } } foreach (var rpId in input.RetailPointIds ?? []) diff --git a/src/food-market.api/Controllers/SuperAdmin/SuperAdminEmployeesController.cs b/src/food-market.api/Controllers/SuperAdmin/SuperAdminEmployeesController.cs index 3d5967d..02a3337 100644 --- a/src/food-market.api/Controllers/SuperAdmin/SuperAdminEmployeesController.cs +++ b/src/food-market.api/Controllers/SuperAdmin/SuperAdminEmployeesController.cs @@ -162,7 +162,9 @@ public async Task> Create(Guid orgId, [FromBody] Cr var ur = await _userMgr.CreateAsync(u, tempPassword); if (!ur.Succeeded) return BadRequest(new { error = string.Join("; ", ur.Errors.Select(x => x.Description)) }); - await _userMgr.AddToRoleAsync(u, "Admin"); + // Identity-роль из orgRole (Администратор/Кладовщик/Кассир) — кастомные не получают. + var identityRole = foodmarket.Api.Infrastructure.IdentityRoleMapper.FromOrgRoleName(role.Name) ?? "Admin"; + await _userMgr.AddToRoleAsync(u, identityRole); newUserId = u.Id; } diff --git a/src/food-market.api/Infrastructure/IdentityRoleMapper.cs b/src/food-market.api/Infrastructure/IdentityRoleMapper.cs new file mode 100644 index 0000000..60f483d --- /dev/null +++ b/src/food-market.api/Infrastructure/IdentityRoleMapper.cs @@ -0,0 +1,25 @@ +namespace foodmarket.Api.Infrastructure; + +/// Маппинг доменной orgRole.Name (русский, видимый юзеру в UI) +/// на Identity-роль (английский, используется в [Authorize(Roles=...)]). +/// Identity-роль определяет ЧТО юзер может вызывать через API; orgRole — +/// видимое имя в UI и набор прав для конкретной орги. +/// +/// Системные orgRole сидятся при создании org (см. DevDataSeeder.SeedEmployeeRolesAsync). +/// Кастомные orgRole создаются админом орги — для них Identity-роль НЕ +/// присваивается (юзер не сможет дёрнуть Admin/Cashier/Storekeeper-only +/// endpoints, что и нужно: кастомные роли — только UI-permissions внутри org). +public static class IdentityRoleMapper +{ + public static string? FromOrgRoleName(string? orgRoleName) + { + if (string.IsNullOrWhiteSpace(orgRoleName)) return null; + return orgRoleName.Trim() switch + { + "Администратор" => "Admin", + "Кладовщик" => "Storekeeper", + "Кассир" => "Cashier", + _ => null, + }; + } +}