feat(authz): permission-based авторизация по флагам роли (P0-5)
PermissionAuthorizationHandler + [RequiresPermission("...")] + динамический
PermissionAuthorizationPolicyProvider (policy perm:*). Доступ определяют флаги
RolePermissions роли сотрудника (live из БД), а не зашитый список Identity-ролей.
SuperAdmin и Identity-роль Admin (= системная «Администратор» с All()) —
полный доступ шорткатом; custom-роли не маппятся на Admin, поэтому шорткат их
не задевает. Нет активного Employee/нет флага → 403 (fail-closed).
Заменены [Authorize(Roles=...)] в каталоге (Products/ProductGroups/PriceTypes/
Counterparties/Stores/RetailPoints/Units/ProductImages) и документах (Supplies/
RetailSales) на конкретные права. Currencies/Countries оставлены SuperAdmin
(глобальный справочник, не org-permission).
Проверено curl на :5091: custom-роль без ProductsEdit → PUT товара 403;
GET 200; admin/после выдачи права → 400 (не 403). Закрывает «роли — фикция» из аудита.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
2e98e384f5
commit
28010fafdb
|
|
@ -2,6 +2,7 @@
|
||||||
using foodmarket.Application.Common;
|
using foodmarket.Application.Common;
|
||||||
using foodmarket.Domain.Catalog;
|
using foodmarket.Domain.Catalog;
|
||||||
using foodmarket.Infrastructure.Persistence;
|
using foodmarket.Infrastructure.Persistence;
|
||||||
|
using foodmarket.Api.Infrastructure.Authorization;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
@ -69,7 +70,7 @@ public async Task<ActionResult<CounterpartyDto>> Get(Guid id, CancellationToken
|
||||||
c.BankName, c.BankAccount, c.Bik, c.ContactPerson, c.Notes);
|
c.BankName, c.BankAccount, c.Bik, c.ContactPerson, c.Notes);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost, Authorize(Roles = "Admin,Storekeeper")]
|
[HttpPost, RequiresPermission("CounterpartiesEdit")]
|
||||||
public async Task<ActionResult<CounterpartyDto>> Create([FromBody] CounterpartyInput input, CancellationToken ct)
|
public async Task<ActionResult<CounterpartyDto>> Create([FromBody] CounterpartyInput input, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (NormalizePhoneOrError(input.Phone) is { } err) return BadRequest(new { error = err });
|
if (NormalizePhoneOrError(input.Phone) is { } err) return BadRequest(new { error = err });
|
||||||
|
|
@ -79,7 +80,7 @@ public async Task<ActionResult<CounterpartyDto>> Create([FromBody] CounterpartyI
|
||||||
return CreatedAtAction(nameof(Get), new { id = e.Id }, await ProjectAsync(e.Id, ct));
|
return CreatedAtAction(nameof(Get), new { id = e.Id }, await ProjectAsync(e.Id, ct));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Storekeeper")]
|
[HttpPut("{id:guid}"), RequiresPermission("CounterpartiesEdit")]
|
||||||
public async Task<IActionResult> Update(Guid id, [FromBody] CounterpartyInput input, CancellationToken ct)
|
public async Task<IActionResult> Update(Guid id, [FromBody] CounterpartyInput input, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (NormalizePhoneOrError(input.Phone) is { } err) return BadRequest(new { error = err });
|
if (NormalizePhoneOrError(input.Phone) is { } err) return BadRequest(new { error = err });
|
||||||
|
|
@ -98,7 +99,7 @@ public async Task<IActionResult> Update(Guid id, [FromBody] CounterpartyInput in
|
||||||
: foodmarket.Application.Common.PhoneNormalization.ErrorMessage;
|
: foodmarket.Application.Common.PhoneNormalization.ErrorMessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{id:guid}"), Authorize(Roles = "Admin")]
|
[HttpDelete("{id:guid}"), RequiresPermission("CounterpartiesDelete")]
|
||||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var e = await _db.Counterparties.FirstOrDefaultAsync(x => x.Id == id, ct);
|
var e = await _db.Counterparties.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
using foodmarket.Application.Common;
|
using foodmarket.Application.Common;
|
||||||
using foodmarket.Domain.Catalog;
|
using foodmarket.Domain.Catalog;
|
||||||
using foodmarket.Infrastructure.Persistence;
|
using foodmarket.Infrastructure.Persistence;
|
||||||
|
using foodmarket.Api.Infrastructure.Authorization;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
@ -49,7 +50,7 @@ public async Task<ActionResult<PriceTypeDto>> Get(Guid id, CancellationToken ct)
|
||||||
return p is null ? NotFound() : new PriceTypeDto(p.Id, p.Name, p.IsRequired, p.IsSystem, p.IsRetail, p.SortOrder);
|
return p is null ? NotFound() : new PriceTypeDto(p.Id, p.Name, p.IsRequired, p.IsSystem, p.IsRetail, p.SortOrder);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost, Authorize(Roles = "Admin")]
|
[HttpPost, RequiresPermission("PriceTypesManage")]
|
||||||
public async Task<ActionResult<PriceTypeDto>> Create([FromBody] PriceTypeInput input, CancellationToken ct)
|
public async Task<ActionResult<PriceTypeDto>> Create([FromBody] PriceTypeInput input, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (input.IsRetail)
|
if (input.IsRetail)
|
||||||
|
|
@ -72,7 +73,7 @@ await _db.PriceTypes.Where(p => p.IsRetail)
|
||||||
new PriceTypeDto(e.Id, e.Name, e.IsRequired, e.IsSystem, e.IsRetail, e.SortOrder));
|
new PriceTypeDto(e.Id, e.Name, e.IsRequired, e.IsSystem, e.IsRetail, e.SortOrder));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}"), Authorize(Roles = "Admin")]
|
[HttpPut("{id:guid}"), RequiresPermission("PriceTypesManage")]
|
||||||
public async Task<IActionResult> Update(Guid id, [FromBody] PriceTypeInput input, CancellationToken ct)
|
public async Task<IActionResult> Update(Guid id, [FromBody] PriceTypeInput input, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var e = await _db.PriceTypes.FirstOrDefaultAsync(x => x.Id == id, ct);
|
var e = await _db.PriceTypes.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||||
|
|
@ -92,7 +93,7 @@ await _db.PriceTypes.Where(p => p.IsRetail && p.Id != id)
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{id:guid}"), Authorize(Roles = "Admin")]
|
[HttpDelete("{id:guid}"), RequiresPermission("PriceTypesManage")]
|
||||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var e = await _db.PriceTypes.FirstOrDefaultAsync(x => x.Id == id, ct);
|
var e = await _db.PriceTypes.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
using foodmarket.Application.Common;
|
using foodmarket.Application.Common;
|
||||||
using foodmarket.Domain.Catalog;
|
using foodmarket.Domain.Catalog;
|
||||||
using foodmarket.Infrastructure.Persistence;
|
using foodmarket.Infrastructure.Persistence;
|
||||||
|
using foodmarket.Api.Infrastructure.Authorization;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
@ -56,7 +57,7 @@ public async Task<ActionResult<ProductGroupDto>> Get(Guid id, CancellationToken
|
||||||
return g is null ? NotFound() : new ProductGroupDto(g.Id, g.Name, g.ParentId, g.Path, g.SortOrder, g.MarkupPercent, g.OrganizationId);
|
return g is null ? NotFound() : new ProductGroupDto(g.Id, g.Name, g.ParentId, g.Path, g.SortOrder, g.MarkupPercent, g.OrganizationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost, Authorize(Roles = "Admin")]
|
[HttpPost, RequiresPermission("ProductGroupsManage")]
|
||||||
public async Task<ActionResult<ProductGroupDto>> Create([FromBody] ProductGroupInput input, CancellationToken ct)
|
public async Task<ActionResult<ProductGroupDto>> Create([FromBody] ProductGroupInput input, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var path = await BuildPathAsync(input.ParentId, input.Name, ct);
|
var path = await BuildPathAsync(input.ParentId, input.Name, ct);
|
||||||
|
|
@ -72,7 +73,7 @@ public async Task<ActionResult<ProductGroupDto>> Create([FromBody] ProductGroupI
|
||||||
new ProductGroupDto(e.Id, e.Name, e.ParentId, e.Path, e.SortOrder, e.MarkupPercent, e.OrganizationId));
|
new ProductGroupDto(e.Id, e.Name, e.ParentId, e.Path, e.SortOrder, e.MarkupPercent, e.OrganizationId));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,SuperAdmin")]
|
[HttpPut("{id:guid}"), RequiresPermission("ProductGroupsManage")]
|
||||||
public async Task<IActionResult> Update(Guid id, [FromBody] ProductGroupInput input, CancellationToken ct)
|
public async Task<IActionResult> Update(Guid id, [FromBody] ProductGroupInput input, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var e = await _db.ProductGroups.FirstOrDefaultAsync(x => x.Id == id, ct);
|
var e = await _db.ProductGroups.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||||
|
|
@ -91,7 +92,7 @@ public async Task<IActionResult> Update(Guid id, [FromBody] ProductGroupInput in
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{id:guid}"), Authorize(Roles = "Admin,SuperAdmin")]
|
[HttpDelete("{id:guid}"), RequiresPermission("ProductGroupsManage")]
|
||||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var meta = await _db.ProductGroups
|
var meta = await _db.ProductGroups
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
using foodmarket.Application.Common.Tenancy;
|
using foodmarket.Application.Common.Tenancy;
|
||||||
using foodmarket.Domain.Catalog;
|
using foodmarket.Domain.Catalog;
|
||||||
using foodmarket.Infrastructure.Persistence;
|
using foodmarket.Infrastructure.Persistence;
|
||||||
|
using foodmarket.Api.Infrastructure.Authorization;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
@ -49,7 +50,7 @@ public async Task<ActionResult<IReadOnlyList<ImageDto>>> List(Guid productId, Ca
|
||||||
return images;
|
return images;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost, Authorize(Roles = "Admin,Storekeeper")]
|
[HttpPost, RequiresPermission("ProductsEdit")]
|
||||||
[RequestSizeLimit(MaxBytes)]
|
[RequestSizeLimit(MaxBytes)]
|
||||||
public async Task<ActionResult<ImageDto>> Upload(Guid productId, IFormFile file, CancellationToken ct)
|
public async Task<ActionResult<ImageDto>> Upload(Guid productId, IFormFile file, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
|
@ -90,7 +91,7 @@ public async Task<ActionResult<ImageDto>> Upload(Guid productId, IFormFile file,
|
||||||
return new ImageDto(entity.Id, entity.Url, entity.IsMain, entity.SortOrder);
|
return new ImageDto(entity.Id, entity.Url, entity.IsMain, entity.SortOrder);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{imageId:guid}"), Authorize(Roles = "Admin,Storekeeper")]
|
[HttpDelete("{imageId:guid}"), RequiresPermission("ProductsEdit")]
|
||||||
public async Task<IActionResult> Delete(Guid productId, Guid imageId, CancellationToken ct)
|
public async Task<IActionResult> Delete(Guid productId, Guid imageId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var image = await _db.ProductImages.FirstOrDefaultAsync(i => i.Id == imageId && i.ProductId == productId, ct);
|
var image = await _db.ProductImages.FirstOrDefaultAsync(i => i.Id == imageId && i.ProductId == productId, ct);
|
||||||
|
|
@ -128,7 +129,7 @@ public async Task<IActionResult> Delete(Guid productId, Guid imageId, Cancellati
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{imageId:guid}/main"), Authorize(Roles = "Admin,Storekeeper")]
|
[HttpPost("{imageId:guid}/main"), RequiresPermission("ProductsEdit")]
|
||||||
public async Task<IActionResult> SetMain(Guid productId, Guid imageId, CancellationToken ct)
|
public async Task<IActionResult> SetMain(Guid productId, Guid imageId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var image = await _db.ProductImages.FirstOrDefaultAsync(i => i.Id == imageId && i.ProductId == productId, ct);
|
var image = await _db.ProductImages.FirstOrDefaultAsync(i => i.Id == imageId && i.ProductId == productId, ct);
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
using foodmarket.Application.Common.Tenancy;
|
using foodmarket.Application.Common.Tenancy;
|
||||||
using foodmarket.Domain.Catalog;
|
using foodmarket.Domain.Catalog;
|
||||||
using foodmarket.Infrastructure.Persistence;
|
using foodmarket.Infrastructure.Persistence;
|
||||||
|
using foodmarket.Api.Infrastructure.Authorization;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
@ -195,7 +196,7 @@ public async Task<ActionResult<ProductDto>> Get(Guid id, CancellationToken ct)
|
||||||
return p is null ? NotFound() : p;
|
return p is null ? NotFound() : p;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost, Authorize(Roles = "Admin,Storekeeper")]
|
[HttpPost, RequiresPermission("ProductsEdit")]
|
||||||
public async Task<ActionResult<ProductDto>> Create([FromBody] ProductInput input, CancellationToken ct)
|
public async Task<ActionResult<ProductDto>> Create([FromBody] ProductInput input, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(input.Name))
|
if (string.IsNullOrWhiteSpace(input.Name))
|
||||||
|
|
@ -238,7 +239,7 @@ public async Task<ActionResult<ProductDto>> Create([FromBody] ProductInput input
|
||||||
return CreatedAtAction(nameof(Get), new { id = e.Id }, dto);
|
return CreatedAtAction(nameof(Get), new { id = e.Id }, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Storekeeper")]
|
[HttpPut("{id:guid}"), RequiresPermission("ProductsEdit")]
|
||||||
public async Task<IActionResult> Update(Guid id, [FromBody] ProductInput input, CancellationToken ct)
|
public async Task<IActionResult> Update(Guid id, [FromBody] ProductInput input, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (RequiredGuid.FirstMissing(
|
if (RequiredGuid.FirstMissing(
|
||||||
|
|
@ -322,7 +323,7 @@ public async Task<IActionResult> Update(Guid id, [FromBody] ProductInput input,
|
||||||
/// <summary>«Привести розничную к себестоимости»: ставит дефолтную
|
/// <summary>«Привести розничную к себестоимости»: ставит дефолтную
|
||||||
/// розничную цену = ceil(Cost * (1 + Group.MarkupPercent/100)). Если у
|
/// розничную цену = ceil(Cost * (1 + Group.MarkupPercent/100)). Если у
|
||||||
/// группы товара не задан MarkupPercent — 400 с подсказкой.</summary>
|
/// группы товара не задан MarkupPercent — 400 с подсказкой.</summary>
|
||||||
[HttpPost("{id:guid}/recalc-retail"), Authorize(Roles = "Admin,Storekeeper")]
|
[HttpPost("{id:guid}/recalc-retail"), RequiresPermission("ProductsEdit")]
|
||||||
public async Task<IActionResult> RecalcRetail(Guid id, CancellationToken ct)
|
public async Task<IActionResult> RecalcRetail(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var p = await _db.Products
|
var p = await _db.Products
|
||||||
|
|
@ -371,7 +372,7 @@ public async Task<IActionResult> RecalcRetail(Guid id, CancellationToken ct)
|
||||||
return Ok(new { retail = newRetail });
|
return Ok(new { retail = newRetail });
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{id:guid}"), Authorize(Roles = "Admin")]
|
[HttpDelete("{id:guid}"), RequiresPermission("ProductsDelete")]
|
||||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var e = await _db.Products.FirstOrDefaultAsync(x => x.Id == id, ct);
|
var e = await _db.Products.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||||
|
|
@ -388,7 +389,7 @@ public record DuplicateProductRef(Guid ProductId, string ProductName, string? Ar
|
||||||
/// организации. Уникальный индекс это запрещает в новых записях, но реальная
|
/// организации. Уникальный индекс это запрещает в новых записях, но реальная
|
||||||
/// БД может содержать исторические дубли (например, после ручной правки).
|
/// БД может содержать исторические дубли (например, после ручной правки).
|
||||||
/// Используется UI чистки (/admin/cleanup) и отчётом MoySklad-импорта.</summary>
|
/// Используется UI чистки (/admin/cleanup) и отчётом MoySklad-импорта.</summary>
|
||||||
[HttpGet("barcode-duplicates"), Authorize(Roles = "Admin")]
|
[HttpGet("barcode-duplicates"), RequiresPermission("ProductsView")]
|
||||||
public async Task<ActionResult<IReadOnlyList<BarcodeDuplicate>>> BarcodeDuplicates(CancellationToken ct)
|
public async Task<ActionResult<IReadOnlyList<BarcodeDuplicate>>> BarcodeDuplicates(CancellationToken ct)
|
||||||
{
|
{
|
||||||
var rows = await _db.ProductBarcodes
|
var rows = await _db.ProductBarcodes
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
using foodmarket.Application.Common;
|
using foodmarket.Application.Common;
|
||||||
using foodmarket.Domain.Catalog;
|
using foodmarket.Domain.Catalog;
|
||||||
using foodmarket.Infrastructure.Persistence;
|
using foodmarket.Infrastructure.Persistence;
|
||||||
|
using foodmarket.Api.Infrastructure.Authorization;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
@ -56,7 +57,7 @@ public async Task<ActionResult<RetailPointDto>> Get(Guid id, CancellationToken c
|
||||||
r.Address, r.Phone, r.FiscalSerial, r.FiscalRegNumber, r.IsActive);
|
r.Address, r.Phone, r.FiscalSerial, r.FiscalRegNumber, r.IsActive);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost, Authorize(Roles = "Admin")]
|
[HttpPost, RequiresPermission("RetailPointsManage")]
|
||||||
public async Task<ActionResult<RetailPointDto>> Create([FromBody] RetailPointInput input, CancellationToken ct)
|
public async Task<ActionResult<RetailPointDto>> Create([FromBody] RetailPointInput input, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var store = await _db.Stores.FirstOrDefaultAsync(s => s.Id == input.StoreId, ct);
|
var store = await _db.Stores.FirstOrDefaultAsync(s => s.Id == input.StoreId, ct);
|
||||||
|
|
@ -76,7 +77,7 @@ public async Task<ActionResult<RetailPointDto>> Create([FromBody] RetailPointInp
|
||||||
e.Address, e.Phone, e.FiscalSerial, e.FiscalRegNumber, e.IsActive));
|
e.Address, e.Phone, e.FiscalSerial, e.FiscalRegNumber, e.IsActive));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}"), Authorize(Roles = "Admin")]
|
[HttpPut("{id:guid}"), RequiresPermission("RetailPointsManage")]
|
||||||
public async Task<IActionResult> Update(Guid id, [FromBody] RetailPointInput input, CancellationToken ct)
|
public async Task<IActionResult> Update(Guid id, [FromBody] RetailPointInput input, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var e = await _db.RetailPoints.FirstOrDefaultAsync(x => x.Id == id, ct);
|
var e = await _db.RetailPoints.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||||
|
|
@ -93,7 +94,7 @@ public async Task<IActionResult> Update(Guid id, [FromBody] RetailPointInput inp
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{id:guid}"), Authorize(Roles = "Admin")]
|
[HttpDelete("{id:guid}"), RequiresPermission("RetailPointsManage")]
|
||||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var e = await _db.RetailPoints.FirstOrDefaultAsync(x => x.Id == id, ct);
|
var e = await _db.RetailPoints.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
using foodmarket.Application.Common;
|
using foodmarket.Application.Common;
|
||||||
using foodmarket.Domain.Catalog;
|
using foodmarket.Domain.Catalog;
|
||||||
using foodmarket.Infrastructure.Persistence;
|
using foodmarket.Infrastructure.Persistence;
|
||||||
|
using foodmarket.Api.Infrastructure.Authorization;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
@ -55,7 +56,7 @@ public async Task<ActionResult<StoreDto>> Get(Guid id, CancellationToken ct)
|
||||||
return x is null ? NotFound() : new StoreDto(x.Id, x.Name, x.Code, x.Address, x.Phone, x.ManagerName, x.IsMain, x.IsActive);
|
return x is null ? NotFound() : new StoreDto(x.Id, x.Name, x.Code, x.Address, x.Phone, x.ManagerName, x.IsMain, x.IsActive);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost, Authorize(Roles = "Admin")]
|
[HttpPost, RequiresPermission("StoresManage")]
|
||||||
public async Task<ActionResult<StoreDto>> Create([FromBody] StoreInput input, CancellationToken ct)
|
public async Task<ActionResult<StoreDto>> Create([FromBody] StoreInput input, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (input.IsMain)
|
if (input.IsMain)
|
||||||
|
|
@ -73,7 +74,7 @@ public async Task<ActionResult<StoreDto>> Create([FromBody] StoreInput input, Ca
|
||||||
new StoreDto(e.Id, e.Name, e.Code, e.Address, e.Phone, e.ManagerName, e.IsMain, e.IsActive));
|
new StoreDto(e.Id, e.Name, e.Code, e.Address, e.Phone, e.ManagerName, e.IsMain, e.IsActive));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}"), Authorize(Roles = "Admin")]
|
[HttpPut("{id:guid}"), RequiresPermission("StoresManage")]
|
||||||
public async Task<IActionResult> Update(Guid id, [FromBody] StoreInput input, CancellationToken ct)
|
public async Task<IActionResult> Update(Guid id, [FromBody] StoreInput input, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var e = await _db.Stores.FirstOrDefaultAsync(x => x.Id == id, ct);
|
var e = await _db.Stores.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||||
|
|
@ -94,7 +95,7 @@ public async Task<IActionResult> Update(Guid id, [FromBody] StoreInput input, Ca
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{id:guid}"), Authorize(Roles = "Admin")]
|
[HttpDelete("{id:guid}"), RequiresPermission("StoresManage")]
|
||||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var e = await _db.Stores.FirstOrDefaultAsync(x => x.Id == id, ct);
|
var e = await _db.Stores.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
using foodmarket.Application.Common.Tenancy;
|
using foodmarket.Application.Common.Tenancy;
|
||||||
using foodmarket.Domain.Catalog;
|
using foodmarket.Domain.Catalog;
|
||||||
using foodmarket.Infrastructure.Persistence;
|
using foodmarket.Infrastructure.Persistence;
|
||||||
|
using foodmarket.Api.Infrastructure.Authorization;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
@ -92,7 +93,7 @@ public async Task<ActionResult<UnitOfMeasureDto>> Get(Guid id, CancellationToken
|
||||||
|
|
||||||
/// <summary>Включить global для текущей орги. Идемпотентно: повторный
|
/// <summary>Включить global для текущей орги. Идемпотентно: повторный
|
||||||
/// вызов отдаёт 204 и не плодит дубликатов junction.</summary>
|
/// вызов отдаёт 204 и не плодит дубликатов junction.</summary>
|
||||||
[HttpPost("{id:guid}/enable"), Authorize(Roles = "Admin,SuperAdmin")]
|
[HttpPost("{id:guid}/enable"), RequiresPermission("UnitsManage")]
|
||||||
public async Task<IActionResult> Enable(Guid id, CancellationToken ct)
|
public async Task<IActionResult> Enable(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var orgId = _tenant.OrganizationId;
|
var orgId = _tenant.OrganizationId;
|
||||||
|
|
@ -116,7 +117,7 @@ public async Task<IActionResult> Enable(Guid id, CancellationToken ct)
|
||||||
/// <summary>Отключить global для текущей орги. Если на эту единицу
|
/// <summary>Отключить global для текущей орги. Если на эту единицу
|
||||||
/// ссылаются продукты орги — 409 со списком названий, чтобы админ
|
/// ссылаются продукты орги — 409 со списком названий, чтобы админ
|
||||||
/// перепривязал их сначала.</summary>
|
/// перепривязал их сначала.</summary>
|
||||||
[HttpDelete("{id:guid}/enable"), Authorize(Roles = "Admin,SuperAdmin")]
|
[HttpDelete("{id:guid}/enable"), RequiresPermission("UnitsManage")]
|
||||||
public async Task<IActionResult> Disable(Guid id, CancellationToken ct)
|
public async Task<IActionResult> Disable(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var orgId = _tenant.OrganizationId;
|
var orgId = _tenant.OrganizationId;
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
using foodmarket.Domain.Inventory;
|
using foodmarket.Domain.Inventory;
|
||||||
using foodmarket.Domain.Purchases;
|
using foodmarket.Domain.Purchases;
|
||||||
using foodmarket.Infrastructure.Persistence;
|
using foodmarket.Infrastructure.Persistence;
|
||||||
|
using foodmarket.Api.Infrastructure.Authorization;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
@ -121,7 +122,7 @@ public async Task<ActionResult<SupplyDto>> Get(Guid id, CancellationToken ct)
|
||||||
return dto is null ? NotFound() : Ok(dto);
|
return dto is null ? NotFound() : Ok(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost, Authorize(Roles = "Admin,Storekeeper")]
|
[HttpPost, RequiresPermission("SuppliesEdit")]
|
||||||
public async Task<ActionResult<SupplyDto>> Create([FromBody] SupplyInput input, CancellationToken ct)
|
public async Task<ActionResult<SupplyDto>> Create([FromBody] SupplyInput input, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (RequiredGuid.FirstMissing(
|
if (RequiredGuid.FirstMissing(
|
||||||
|
|
@ -193,7 +194,7 @@ public async Task<ActionResult<SupplyDto>> Create([FromBody] SupplyInput input,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Storekeeper")]
|
[HttpPut("{id:guid}"), RequiresPermission("SuppliesEdit")]
|
||||||
public async Task<IActionResult> Update(Guid id, [FromBody] SupplyInput input, CancellationToken ct)
|
public async Task<IActionResult> Update(Guid id, [FromBody] SupplyInput input, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (RequiredGuid.FirstMissing(
|
if (RequiredGuid.FirstMissing(
|
||||||
|
|
@ -242,7 +243,7 @@ public async Task<IActionResult> Update(Guid id, [FromBody] SupplyInput input, C
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{id:guid}"), Authorize(Roles = "Admin,Storekeeper")]
|
[HttpDelete("{id:guid}"), RequiresPermission("SuppliesDelete")]
|
||||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var supply = await _db.Supplies.FirstOrDefaultAsync(s => s.Id == id, ct);
|
var supply = await _db.Supplies.FirstOrDefaultAsync(s => s.Id == id, ct);
|
||||||
|
|
@ -254,7 +255,7 @@ public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{id:guid}/post"), Authorize(Roles = "Admin,Storekeeper")]
|
[HttpPost("{id:guid}/post"), RequiresPermission("SuppliesPost")]
|
||||||
public async Task<IActionResult> Post(Guid id, CancellationToken ct)
|
public async Task<IActionResult> Post(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var supply = await _db.Supplies.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct);
|
var supply = await _db.Supplies.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct);
|
||||||
|
|
@ -387,7 +388,7 @@ private void SetDefaultRetail(foodmarket.Domain.Catalog.Product p, decimal value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{id:guid}/unpost"), Authorize(Roles = "Admin,Storekeeper")]
|
[HttpPost("{id:guid}/unpost"), RequiresPermission("SuppliesPost")]
|
||||||
public async Task<IActionResult> Unpost(Guid id, CancellationToken ct)
|
public async Task<IActionResult> Unpost(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var supply = await _db.Supplies.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct);
|
var supply = await _db.Supplies.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct);
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
using foodmarket.Domain.Inventory;
|
using foodmarket.Domain.Inventory;
|
||||||
using foodmarket.Domain.Sales;
|
using foodmarket.Domain.Sales;
|
||||||
using foodmarket.Infrastructure.Persistence;
|
using foodmarket.Infrastructure.Persistence;
|
||||||
|
using foodmarket.Api.Infrastructure.Authorization;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
@ -194,7 +195,7 @@ public async Task<ActionResult<RetailSaleDto>> Get(Guid id, CancellationToken ct
|
||||||
return dto is null ? NotFound() : Ok(dto);
|
return dto is null ? NotFound() : Ok(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost, Authorize(Roles = "Admin,Cashier")]
|
[HttpPost, RequiresPermission("RetailSalesOperate")]
|
||||||
public async Task<ActionResult<RetailSaleDto>> Create([FromBody] RetailSaleInput input, CancellationToken ct)
|
public async Task<ActionResult<RetailSaleDto>> Create([FromBody] RetailSaleInput input, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (RequiredGuid.FirstMissing(
|
if (RequiredGuid.FirstMissing(
|
||||||
|
|
@ -251,7 +252,7 @@ public async Task<ActionResult<RetailSaleDto>> Create([FromBody] RetailSaleInput
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Cashier")]
|
[HttpPut("{id:guid}"), RequiresPermission("RetailSalesOperate")]
|
||||||
public async Task<IActionResult> Update(Guid id, [FromBody] RetailSaleInput input, CancellationToken ct)
|
public async Task<IActionResult> Update(Guid id, [FromBody] RetailSaleInput input, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (RequiredGuid.FirstMissing(
|
if (RequiredGuid.FirstMissing(
|
||||||
|
|
@ -283,7 +284,7 @@ public async Task<IActionResult> Update(Guid id, [FromBody] RetailSaleInput inpu
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{id:guid}"), Authorize(Roles = "Admin")]
|
[HttpDelete("{id:guid}"), RequiresPermission("RetailSalesRefund")]
|
||||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var sale = await _db.RetailSales.FirstOrDefaultAsync(s => s.Id == id, ct);
|
var sale = await _db.RetailSales.FirstOrDefaultAsync(s => s.Id == id, ct);
|
||||||
|
|
@ -295,7 +296,7 @@ public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{id:guid}/post"), Authorize(Roles = "Admin,Cashier")]
|
[HttpPost("{id:guid}/post"), RequiresPermission("RetailSalesOperate")]
|
||||||
public async Task<IActionResult> Post(Guid id, CancellationToken ct)
|
public async Task<IActionResult> Post(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var sale = await _db.RetailSales.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct);
|
var sale = await _db.RetailSales.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct);
|
||||||
|
|
@ -389,7 +390,7 @@ public async Task<IActionResult> Post(Guid id, CancellationToken ct)
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{id:guid}/unpost"), Authorize(Roles = "Admin")]
|
[HttpPost("{id:guid}/unpost"), RequiresPermission("RetailSalesRefund")]
|
||||||
public async Task<IActionResult> Unpost(Guid id, CancellationToken ct)
|
public async Task<IActionResult> Unpost(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var sale = await _db.RetailSales.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct);
|
var sale = await _db.RetailSales.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Reflection;
|
||||||
|
using foodmarket.Api.Infrastructure.Tenancy;
|
||||||
|
using foodmarket.Domain.Organizations;
|
||||||
|
using foodmarket.Infrastructure.Persistence;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using static OpenIddict.Abstractions.OpenIddictConstants;
|
||||||
|
|
||||||
|
namespace foodmarket.Api.Infrastructure.Authorization;
|
||||||
|
|
||||||
|
/// <summary>Проверяет <see cref="PermissionRequirement"/> по флагам роли
|
||||||
|
/// организации (RolePermissions) текущего пользователя.
|
||||||
|
///
|
||||||
|
/// Логика:
|
||||||
|
/// <list type="number">
|
||||||
|
/// <item>SuperAdmin — полный платформенный доступ.</item>
|
||||||
|
/// <item>Identity-роль Admin — системная роль «Администратор» = RolePermissions.All().
|
||||||
|
/// Шорткат, чтобы не зависеть от наличия идеально засиженной Employee-записи
|
||||||
|
/// (custom-роли НЕ маппятся на Identity Admin — см. IdentityRoleMapper —
|
||||||
|
/// поэтому шорткат их не задевает).</item>
|
||||||
|
/// <item>Иначе — ищем активного Employee пользователя в его орге и читаем
|
||||||
|
/// флаг права из его роли. Нет Employee/нет флага → доступ запрещён (fail-closed).</item>
|
||||||
|
/// </list>
|
||||||
|
///
|
||||||
|
/// Регистрируется scoped: использует <see cref="AppDbContext"/>.</summary>
|
||||||
|
public sealed class PermissionAuthorizationHandler : AuthorizationHandler<PermissionRequirement>
|
||||||
|
{
|
||||||
|
private static readonly ConcurrentDictionary<string, PropertyInfo?> PropertyCache = new();
|
||||||
|
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
|
||||||
|
public PermissionAuthorizationHandler(AppDbContext db) => _db = db;
|
||||||
|
|
||||||
|
protected override async Task HandleRequirementAsync(
|
||||||
|
AuthorizationHandlerContext context, PermissionRequirement requirement)
|
||||||
|
{
|
||||||
|
var user = context.User;
|
||||||
|
|
||||||
|
if (user.IsInRole(HttpContextTenantContext.SuperAdminRole) || user.IsInRole("Admin"))
|
||||||
|
{
|
||||||
|
context.Succeed(requirement);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var subRaw = user.FindFirst(Claims.Subject)?.Value;
|
||||||
|
var orgRaw = user.FindFirst(HttpContextTenantContext.OrganizationClaim)?.Value;
|
||||||
|
if (!Guid.TryParse(subRaw, out var userId) || !Guid.TryParse(orgRaw, out var orgId))
|
||||||
|
{
|
||||||
|
return; // нет sub/org — не можем установить роль → запрет
|
||||||
|
}
|
||||||
|
|
||||||
|
// Решение об авторизации не должно зависеть от ambient tenant-фильтра:
|
||||||
|
// фильтруем оргой из claim явно.
|
||||||
|
var role = await _db.Employees.IgnoreQueryFilters()
|
||||||
|
.Where(e => e.UserId == userId && e.OrganizationId == orgId && e.IsActive && !e.IsDeleted)
|
||||||
|
.Select(e => e.Role)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
|
if (role is not null && HasPermission(role.Permissions, requirement.Permission))
|
||||||
|
{
|
||||||
|
context.Succeed(requirement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Читает boolean-флаг права по имени через рефлексию (с кэшем
|
||||||
|
/// PropertyInfo). Неизвестное имя или не-bool → false (fail-closed).</summary>
|
||||||
|
private static bool HasPermission(RolePermissions permissions, string permissionName)
|
||||||
|
{
|
||||||
|
var prop = PropertyCache.GetOrAdd(permissionName, name =>
|
||||||
|
typeof(RolePermissions).GetProperty(name,
|
||||||
|
BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase));
|
||||||
|
|
||||||
|
return prop is not null
|
||||||
|
&& prop.PropertyType == typeof(bool)
|
||||||
|
&& prop.GetValue(permissions) is true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace foodmarket.Api.Infrastructure.Authorization;
|
||||||
|
|
||||||
|
/// <summary>Динамически собирает policy для <c>perm:<Permission></c>,
|
||||||
|
/// чтобы не регистрировать каждую из ~30 permission-policy вручную. Всё
|
||||||
|
/// остальное (именованные policy типа AdminAccess, default) делегируется
|
||||||
|
/// штатному провайдеру.</summary>
|
||||||
|
public sealed class PermissionAuthorizationPolicyProvider : IAuthorizationPolicyProvider
|
||||||
|
{
|
||||||
|
private readonly DefaultAuthorizationPolicyProvider _fallback;
|
||||||
|
|
||||||
|
public PermissionAuthorizationPolicyProvider(IOptions<AuthorizationOptions> options)
|
||||||
|
=> _fallback = new DefaultAuthorizationPolicyProvider(options);
|
||||||
|
|
||||||
|
public Task<AuthorizationPolicy> GetDefaultPolicyAsync() => _fallback.GetDefaultPolicyAsync();
|
||||||
|
|
||||||
|
public Task<AuthorizationPolicy?> GetFallbackPolicyAsync() => _fallback.GetFallbackPolicyAsync();
|
||||||
|
|
||||||
|
public Task<AuthorizationPolicy?> GetPolicyAsync(string policyName)
|
||||||
|
{
|
||||||
|
if (policyName.StartsWith(RequiresPermissionAttribute.PolicyPrefix, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var permission = policyName[RequiresPermissionAttribute.PolicyPrefix.Length..];
|
||||||
|
var policy = new AuthorizationPolicyBuilder()
|
||||||
|
.RequireAuthenticatedUser()
|
||||||
|
.AddRequirements(new PermissionRequirement(permission))
|
||||||
|
.Build();
|
||||||
|
return Task.FromResult<AuthorizationPolicy?>(policy);
|
||||||
|
}
|
||||||
|
|
||||||
|
return _fallback.GetPolicyAsync(policyName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
|
||||||
|
namespace foodmarket.Api.Infrastructure.Authorization;
|
||||||
|
|
||||||
|
/// <summary>Требование «у текущего пользователя включён флаг права
|
||||||
|
/// <see cref="Permission"/> в его роли (RolePermissions)». Имя права = имя
|
||||||
|
/// boolean-свойства <c>RolePermissions</c> (например, "ProductsEdit").</summary>
|
||||||
|
public sealed class PermissionRequirement : IAuthorizationRequirement
|
||||||
|
{
|
||||||
|
public string Permission { get; }
|
||||||
|
|
||||||
|
public PermissionRequirement(string permission) => Permission = permission;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
|
||||||
|
namespace foodmarket.Api.Infrastructure.Authorization;
|
||||||
|
|
||||||
|
/// <summary>Гейтит endpoint на конкретное право роли. Имя права = имя
|
||||||
|
/// boolean-свойства <c>RolePermissions</c>. Реализовано поверх policy-механизма:
|
||||||
|
/// атрибут выставляет policy <c>perm:<Permission></c>, которую
|
||||||
|
/// разворачивает <see cref="PermissionAuthorizationPolicyProvider"/> в
|
||||||
|
/// <see cref="PermissionRequirement"/>, а проверяет
|
||||||
|
/// <see cref="PermissionAuthorizationHandler"/>.
|
||||||
|
///
|
||||||
|
/// Пример: <c>[RequiresPermission("ProductsEdit")]</c>. Заменяет грубое
|
||||||
|
/// <c>[Authorize(Roles="Admin,Storekeeper")]</c> — теперь доступ определяют
|
||||||
|
/// флаги роли организации, а не зашитый список Identity-ролей.</summary>
|
||||||
|
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false)]
|
||||||
|
public sealed class RequiresPermissionAttribute : AuthorizeAttribute
|
||||||
|
{
|
||||||
|
public const string PolicyPrefix = "perm:";
|
||||||
|
|
||||||
|
public RequiresPermissionAttribute(string permission)
|
||||||
|
=> Policy = $"{PolicyPrefix}{permission}";
|
||||||
|
}
|
||||||
|
|
@ -139,6 +139,12 @@
|
||||||
opts.AddPolicy("AdminAccess", p => p.RequireAssertion(ctx =>
|
opts.AddPolicy("AdminAccess", p => p.RequireAssertion(ctx =>
|
||||||
ctx.User.HasClaim(c => c.Type == Claims.Role && (c.Value == "Admin" || c.Value == "SuperAdmin"))));
|
ctx.User.HasClaim(c => c.Type == Claims.Role && (c.Value == "Admin" || c.Value == "SuperAdmin"))));
|
||||||
});
|
});
|
||||||
|
// Permission-based авторизация: [RequiresPermission("...")] → policy perm:* →
|
||||||
|
// PermissionRequirement → проверка флага RolePermissions роли сотрудника.
|
||||||
|
builder.Services.AddSingleton<Microsoft.AspNetCore.Authorization.IAuthorizationPolicyProvider,
|
||||||
|
foodmarket.Api.Infrastructure.Authorization.PermissionAuthorizationPolicyProvider>();
|
||||||
|
builder.Services.AddScoped<Microsoft.AspNetCore.Authorization.IAuthorizationHandler,
|
||||||
|
foodmarket.Api.Infrastructure.Authorization.PermissionAuthorizationHandler>();
|
||||||
|
|
||||||
builder.Services.AddScoped<foodmarket.Api.Infrastructure.Tenancy.SuperAdminEditAuditFilter>();
|
builder.Services.AddScoped<foodmarket.Api.Infrastructure.Tenancy.SuperAdminEditAuditFilter>();
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue