fix(auth): кассир без модуля «Деньги», кладовщик со складскими правами, ИЛИ-семантика прав

Роль «Кассир» больше не имеет MoneyView (видит только свою смену/Z через
CashShiftOperate); роль «Кладовщик» получает InventoryEdit/EnterEdit/LossEdit
(веб уже показывал эти пункты — API теперь совпадает). PermissionRequirement
поддерживает список прав через запятую (ИЛИ); CashShifts List/Get/Report →
MoneyView,CashShiftOperate. DevDataSeeder: дефолты + backfill существующих орг.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
nns 2026-10-08 02:10:11 +05:00
parent 659a749d7f
commit 24646e0649
4 changed files with 50 additions and 19 deletions

View file

@ -64,7 +64,7 @@ public record DrawerOpInput(
[Range(0.0001, 1e12)] decimal Amount, [Range(0.0001, 1e12)] decimal Amount,
[Required, MaxLength(500)] string Reason); [Required, MaxLength(500)] string Reason);
[HttpGet, RequiresPermission("MoneyView")] [HttpGet, RequiresPermission("MoneyView,CashShiftOperate")]
public async Task<ActionResult<PagedResult<CashShiftRow>>> List( public async Task<ActionResult<PagedResult<CashShiftRow>>> List(
[FromQuery] PagedRequest req, [FromQuery] PagedRequest req,
[FromQuery] CashShiftStatus? status, [FromQuery] CashShiftStatus? status,
@ -115,7 +115,7 @@ public record DrawerOpInput(
return new PagedResult<CashShiftRow> { Items = items, Total = total, Page = req.Page, PageSize = req.Take }; return new PagedResult<CashShiftRow> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
} }
[HttpGet("{id:guid}"), RequiresPermission("MoneyView")] [HttpGet("{id:guid}"), RequiresPermission("MoneyView,CashShiftOperate")]
public async Task<ActionResult<object>> Get(Guid id, CancellationToken ct) public async Task<ActionResult<object>> Get(Guid id, CancellationToken ct)
{ {
var s = await _db.CashShifts.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct); var s = await _db.CashShifts.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
@ -172,7 +172,7 @@ public async Task<ActionResult<object>> Drawer(Guid id, [FromBody] DrawerOpInput
} }
/// <summary>Z-отчёт: все цифры смены + внесения/выплаты для печати.</summary> /// <summary>Z-отчёт: все цифры смены + внесения/выплаты для печати.</summary>
[HttpGet("{id:guid}/report"), RequiresPermission("MoneyView")] [HttpGet("{id:guid}/report"), RequiresPermission("MoneyView,CashShiftOperate")]
public async Task<ActionResult<object>> Report(Guid id, CancellationToken ct) public async Task<ActionResult<object>> Report(Guid id, CancellationToken ct)
{ {
var s = await _db.CashShifts.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct); var s = await _db.CashShifts.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);

View file

@ -57,7 +57,8 @@ public sealed class PermissionAuthorizationHandler : AuthorizationHandler<Permis
.Select(e => e.Role) .Select(e => e.Role)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (role is not null && HasPermission(role.Permissions, requirement.Permission)) // ИЛИ-семантика: достаточно любого из перечисленных прав.
if (role is not null && requirement.Permissions.Any(p => HasPermission(role.Permissions, p)))
{ {
context.Succeed(requirement); context.Succeed(requirement);
} }

View file

@ -2,12 +2,18 @@
namespace foodmarket.Api.Infrastructure.Authorization; namespace foodmarket.Api.Infrastructure.Authorization;
/// <summary>Требование «у текущего пользователя включён флаг права /// <summary>Требование «у текущего пользователя включён ХОТЯ БЫ ОДИН из флагов
/// <see cref="Permission"/> в его роли (RolePermissions)». Имя права = имя /// прав <see cref="Permissions"/> в его роли (RolePermissions)». Имя права = имя
/// boolean-свойства <c>RolePermissions</c> (например, "ProductsEdit").</summary> /// boolean-свойства <c>RolePermissions</c> (например, "ProductsEdit"). Несколько
/// прав через запятую = ИЛИ-семантика (например "MoneyView,CashShiftOperate" —
/// эндпоинт доступен и финансисту, и кассиру для своей смены).</summary>
public sealed class PermissionRequirement : IAuthorizationRequirement public sealed class PermissionRequirement : IAuthorizationRequirement
{ {
public string Permission { get; } public IReadOnlyList<string> Permissions { get; }
public PermissionRequirement(string permission) => Permission = permission; /// <summary>Первое право — для обратной совместимости/логов.</summary>
public string Permission => Permissions[0];
public PermissionRequirement(string permission)
=> Permissions = permission.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
} }

View file

@ -144,10 +144,10 @@ public async Task StartAsync(CancellationToken ct)
} }
/// <summary>Идемпотентный back-fill модуля «Деньги» по всем организациям: /// <summary>Идемпотентный back-fill модуля «Деньги» по всем организациям:
/// системной роли «Кассир» проставляет MoneyView + CashShiftOperate (если /// системной роли «Кассир» гарантирует CashShiftOperate=true (своя смена) и
/// ещё не стоят), и гарантирует 5 системных статей расходов в каждой орге. /// MoneyView=FALSE (модуль «Деньги» кассиру недоступен — требование владельца),
/// Нужно только для орг, созданных ДО Sprint 33 — у новых всё ставит /// и гарантирует 5 системных статей расходов в каждой орге. Нужно для орг,
/// <see cref="SeedEmployeeRolesAsync"/>.</summary> /// созданных ДО этого изменения (у них кассиру раньше ставился MoneyView=true).</summary>
private static async Task EnsureMoneyDefaultsAsync(AppDbContext db, CancellationToken ct) private static async Task EnsureMoneyDefaultsAsync(AppDbContext db, CancellationToken ct)
{ {
var cashierRoles = await db.EmployeeRoles.IgnoreQueryFilters() var cashierRoles = await db.EmployeeRoles.IgnoreQueryFilters()
@ -156,19 +156,38 @@ private static async Task EnsureMoneyDefaultsAsync(AppDbContext db, Cancellation
var changed = false; var changed = false;
foreach (var role in cashierRoles) foreach (var role in cashierRoles)
{ {
if (!role.Permissions.MoneyView || !role.Permissions.CashShiftOperate) if (role.Permissions.MoneyView || !role.Permissions.CashShiftOperate)
{ {
// Permissions — owned-тип; мутация скалярных флагов на // Permissions — owned-тип; мутация скалярных флагов на
// отслеживаемой сущности ловится DetectChanges при SaveChanges. // отслеживаемой сущности ловится DetectChanges при SaveChanges.
// Остальные права кассира (ProductsView/RetailSalesOperate/…) // Остальные права кассира (ProductsView/RetailSalesOperate/…)
// сохраняются — мы их не трогаем. // сохраняются — мы их не трогаем.
role.Permissions.MoneyView = true; role.Permissions.MoneyView = false;
role.Permissions.CashShiftOperate = true; role.Permissions.CashShiftOperate = true;
changed = true; changed = true;
} }
} }
if (changed) await db.SaveChangesAsync(ct); if (changed) await db.SaveChangesAsync(ct);
// Back-fill прав кладовщика: системной роли «Кладовщик» нужны права на
// инвентаризацию/оприходование/списание (веб их показывает, API должен
// совпадать). Старые орги их не имели → ловили 403.
var keeperRoles = await db.EmployeeRoles.IgnoreQueryFilters()
.Where(r => r.IsSystem && r.Name == "Кладовщик")
.ToListAsync(ct);
var keeperChanged = false;
foreach (var role in keeperRoles)
{
if (!role.Permissions.InventoryEdit || !role.Permissions.EnterEdit || !role.Permissions.LossEdit)
{
role.Permissions.InventoryEdit = true;
role.Permissions.EnterEdit = true;
role.Permissions.LossEdit = true;
keeperChanged = true;
}
}
if (keeperChanged) await db.SaveChangesAsync(ct);
var orgIds = await db.Organizations.IgnoreQueryFilters().Select(o => o.Id).ToListAsync(ct); var orgIds = await db.Organizations.IgnoreQueryFilters().Select(o => o.Id).ToListAsync(ct);
foreach (var orgId in orgIds) foreach (var orgId in orgIds)
await SeedExpenseItemsAsync(db, orgId, ct); await SeedExpenseItemsAsync(db, orgId, ct);
@ -303,10 +322,11 @@ private static async Task SeedEmployeeRolesAsync(AppDbContext db, Guid orgId, Ca
ProductsView = true, ProductsView = true,
SuppliesView = true, SuppliesEdit = true, SuppliesPost = true, SuppliesView = true, SuppliesEdit = true, SuppliesPost = true,
StocksView = true, StocksView = true,
// Складская работа кладовщика: инвентаризация, оприходование,
// списание (веб уже показывает эти пункты — API должен совпадать).
InventoryEdit = true, EnterEdit = true, LossEdit = true,
// Кладовщику деньги не положены — только видимость оплаченности // Кладовщику деньги не положены — только видимость оплаченности
// приёмки через SuppliesView (payedSum в DTO). MoneyView=false // приёмки через SuppliesView (payedSum в DTO). MoneyView=false.
// здесь, этим флаг «кладовщик без доступа к деньгам» (простой
// режим, п.7 ТЗ модуля).
}, },
}; };
var cashier = new EmployeeRole var cashier = new EmployeeRole
@ -325,7 +345,11 @@ private static async Task SeedEmployeeRolesAsync(AppDbContext db, Guid orgId, Ca
// вносить/выплачивать деньги в ней. Чужие смены и движения // вносить/выплачивать деньги в ней. Чужие смены и движения
// по банковским счетам ему недоступны (контроллер фильтрует). // по банковским счетам ему недоступны (контроллер фильтрует).
CashShiftOperate = true, CashShiftOperate = true,
MoneyView = true, // Кассир НЕ видит модуль «Деньги» (счета/платежи/долги/чужие
// смены) — требование владельца «кассир не видит денег». Свою
// смену и Z-отчёт он видит через CashShiftOperate (эндпоинты
// shifts List/Get/Report принимают его право по ИЛИ-семантике).
MoneyView = false,
}, },
}; };