fix(auth): Cashier/Storekeeper больше не видят /api/organization/employees + Identity-роль маппится из orgRole

Найдено в 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 (рабочая авторизация).
This commit is contained in:
nns 2026-05-08 01:03:30 +05:00
parent 7bb941259a
commit bcf81c57ee
3 changed files with 41 additions and 5 deletions

View file

@ -13,7 +13,7 @@
namespace foodmarket.Api.Controllers.Organizations; namespace foodmarket.Api.Controllers.Organizations;
[ApiController] [ApiController]
[Authorize] [Authorize(Roles = "SuperAdmin,Admin")]
[Route("api/organization/employees")] [Route("api/organization/employees")]
public class EmployeesController : ControllerBase public class EmployeesController : ControllerBase
{ {
@ -111,12 +111,12 @@ public async Task<ActionResult<EmployeeDto>> Get(Guid id, CancellationToken ct)
return dto is null ? NotFound() : Ok(dto); return dto is null ? NotFound() : Ok(dto);
} }
[HttpPost, Authorize(Roles = "SuperAdmin,Admin")] [HttpPost]
public async Task<ActionResult<EmployeeCreateResult>> Create([FromBody] EmployeeInput input, CancellationToken ct) public async Task<ActionResult<EmployeeCreateResult>> Create([FromBody] EmployeeInput input, CancellationToken ct)
{ {
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant."); var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
var roleExists = await _db.EmployeeRoles.AnyAsync(r => r.Id == input.RoleId, ct); var role = await _db.EmployeeRoles.FirstOrDefaultAsync(r => r.Id == input.RoleId, ct);
if (!roleExists) return BadRequest(new { error = "Роль не найдена." }); if (role is null) return BadRequest(new { error = "Роль не найдена." });
var employee = new Employee var employee = new Employee
{ {
@ -151,6 +151,15 @@ public async Task<ActionResult<EmployeeCreateResult>> Create([FromBody] Employee
if (!result.Succeeded) if (!result.Succeeded)
return BadRequest(new { error = string.Join("; ", result.Errors.Select(e => e.Description)) }); return BadRequest(new { error = string.Join("; ", result.Errors.Select(e => e.Description)) });
employee.UserId = user.Id; 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 ?? []) foreach (var rpId in input.RetailPointIds ?? [])

View file

@ -162,7 +162,9 @@ public async Task<ActionResult<EmployeeDetail>> Create(Guid orgId, [FromBody] Cr
var ur = await _userMgr.CreateAsync(u, tempPassword); var ur = await _userMgr.CreateAsync(u, tempPassword);
if (!ur.Succeeded) if (!ur.Succeeded)
return BadRequest(new { error = string.Join("; ", ur.Errors.Select(x => x.Description)) }); 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; newUserId = u.Id;
} }

View file

@ -0,0 +1,25 @@
namespace foodmarket.Api.Infrastructure;
/// <summary>Маппинг доменной orgRole.Name (русский, видимый юзеру в UI)
/// на Identity-роль (английский, используется в [Authorize(Roles=...)]).
/// Identity-роль определяет ЧТО юзер может вызывать через API; orgRole —
/// видимое имя в UI и набор прав для конкретной орги.
///
/// Системные orgRole сидятся при создании org (см. DevDataSeeder.SeedEmployeeRolesAsync).
/// Кастомные orgRole создаются админом орги — для них Identity-роль НЕ
/// присваивается (юзер не сможет дёрнуть Admin/Cashier/Storekeeper-only
/// endpoints, что и нужно: кастомные роли — только UI-permissions внутри org).</summary>
public static class IdentityRoleMapper
{
public static string? FromOrgRoleName(string? orgRoleName)
{
if (string.IsNullOrWhiteSpace(orgRoleName)) return null;
return orgRoleName.Trim() switch
{
"Администратор" => "Admin",
"Кладовщик" => "Storekeeper",
"Кассир" => "Cashier",
_ => null,
};
}
}