diff --git a/src/food-market.api/Controllers/Catalog/CounterpartiesController.cs b/src/food-market.api/Controllers/Catalog/CounterpartiesController.cs index 4ef9ade..fe1a950 100644 --- a/src/food-market.api/Controllers/Catalog/CounterpartiesController.cs +++ b/src/food-market.api/Controllers/Catalog/CounterpartiesController.cs @@ -2,6 +2,7 @@ using foodmarket.Application.Common; using foodmarket.Domain.Catalog; using foodmarket.Infrastructure.Persistence; +using foodmarket.Api.Infrastructure.Authorization; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -69,7 +70,7 @@ public async Task> Get(Guid id, CancellationToken c.BankName, c.BankAccount, c.Bik, c.ContactPerson, c.Notes); } - [HttpPost, Authorize(Roles = "Admin,Storekeeper")] + [HttpPost, RequiresPermission("CounterpartiesEdit")] public async Task> Create([FromBody] CounterpartyInput input, CancellationToken ct) { if (NormalizePhoneOrError(input.Phone) is { } err) return BadRequest(new { error = err }); @@ -79,7 +80,7 @@ public async Task> Create([FromBody] CounterpartyI 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 Update(Guid id, [FromBody] CounterpartyInput input, CancellationToken ct) { if (NormalizePhoneOrError(input.Phone) is { } err) return BadRequest(new { error = err }); @@ -98,7 +99,7 @@ public async Task Update(Guid id, [FromBody] CounterpartyInput in : foodmarket.Application.Common.PhoneNormalization.ErrorMessage; } - [HttpDelete("{id:guid}"), Authorize(Roles = "Admin")] + [HttpDelete("{id:guid}"), RequiresPermission("CounterpartiesDelete")] public async Task Delete(Guid id, CancellationToken ct) { var e = await _db.Counterparties.FirstOrDefaultAsync(x => x.Id == id, ct); diff --git a/src/food-market.api/Controllers/Catalog/PriceTypesController.cs b/src/food-market.api/Controllers/Catalog/PriceTypesController.cs index 4d5150f..795c3c4 100644 --- a/src/food-market.api/Controllers/Catalog/PriceTypesController.cs +++ b/src/food-market.api/Controllers/Catalog/PriceTypesController.cs @@ -2,6 +2,7 @@ using foodmarket.Application.Common; using foodmarket.Domain.Catalog; using foodmarket.Infrastructure.Persistence; +using foodmarket.Api.Infrastructure.Authorization; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -49,7 +50,7 @@ public async Task> Get(Guid id, CancellationToken ct) 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> Create([FromBody] PriceTypeInput input, CancellationToken ct) { 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)); } - [HttpPut("{id:guid}"), Authorize(Roles = "Admin")] + [HttpPut("{id:guid}"), RequiresPermission("PriceTypesManage")] public async Task Update(Guid id, [FromBody] PriceTypeInput input, CancellationToken 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(); } - [HttpDelete("{id:guid}"), Authorize(Roles = "Admin")] + [HttpDelete("{id:guid}"), RequiresPermission("PriceTypesManage")] public async Task Delete(Guid id, CancellationToken ct) { var e = await _db.PriceTypes.FirstOrDefaultAsync(x => x.Id == id, ct); diff --git a/src/food-market.api/Controllers/Catalog/ProductGroupsController.cs b/src/food-market.api/Controllers/Catalog/ProductGroupsController.cs index 6fcb838..ec0107e 100644 --- a/src/food-market.api/Controllers/Catalog/ProductGroupsController.cs +++ b/src/food-market.api/Controllers/Catalog/ProductGroupsController.cs @@ -2,6 +2,7 @@ using foodmarket.Application.Common; using foodmarket.Domain.Catalog; using foodmarket.Infrastructure.Persistence; +using foodmarket.Api.Infrastructure.Authorization; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -56,7 +57,7 @@ public async Task> 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); } - [HttpPost, Authorize(Roles = "Admin")] + [HttpPost, RequiresPermission("ProductGroupsManage")] public async Task> Create([FromBody] ProductGroupInput input, CancellationToken ct) { var path = await BuildPathAsync(input.ParentId, input.Name, ct); @@ -72,7 +73,7 @@ public async Task> Create([FromBody] ProductGroupI 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 Update(Guid id, [FromBody] ProductGroupInput input, CancellationToken ct) { var e = await _db.ProductGroups.FirstOrDefaultAsync(x => x.Id == id, ct); @@ -91,7 +92,7 @@ public async Task Update(Guid id, [FromBody] ProductGroupInput in return NoContent(); } - [HttpDelete("{id:guid}"), Authorize(Roles = "Admin,SuperAdmin")] + [HttpDelete("{id:guid}"), RequiresPermission("ProductGroupsManage")] public async Task Delete(Guid id, CancellationToken ct) { var meta = await _db.ProductGroups diff --git a/src/food-market.api/Controllers/Catalog/ProductImagesController.cs b/src/food-market.api/Controllers/Catalog/ProductImagesController.cs index aaccdd9..7516571 100644 --- a/src/food-market.api/Controllers/Catalog/ProductImagesController.cs +++ b/src/food-market.api/Controllers/Catalog/ProductImagesController.cs @@ -1,6 +1,7 @@ using foodmarket.Application.Common.Tenancy; using foodmarket.Domain.Catalog; using foodmarket.Infrastructure.Persistence; +using foodmarket.Api.Infrastructure.Authorization; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -49,7 +50,7 @@ public async Task>> List(Guid productId, Ca return images; } - [HttpPost, Authorize(Roles = "Admin,Storekeeper")] + [HttpPost, RequiresPermission("ProductsEdit")] [RequestSizeLimit(MaxBytes)] public async Task> Upload(Guid productId, IFormFile file, CancellationToken ct) { @@ -90,7 +91,7 @@ public async Task> Upload(Guid productId, IFormFile file, 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 Delete(Guid productId, Guid imageId, CancellationToken ct) { var image = await _db.ProductImages.FirstOrDefaultAsync(i => i.Id == imageId && i.ProductId == productId, ct); @@ -128,7 +129,7 @@ public async Task Delete(Guid productId, Guid imageId, Cancellati return NoContent(); } - [HttpPost("{imageId:guid}/main"), Authorize(Roles = "Admin,Storekeeper")] + [HttpPost("{imageId:guid}/main"), RequiresPermission("ProductsEdit")] public async Task SetMain(Guid productId, Guid imageId, CancellationToken ct) { var image = await _db.ProductImages.FirstOrDefaultAsync(i => i.Id == imageId && i.ProductId == productId, ct); diff --git a/src/food-market.api/Controllers/Catalog/ProductsController.cs b/src/food-market.api/Controllers/Catalog/ProductsController.cs index bfdf088..c7a02f3 100644 --- a/src/food-market.api/Controllers/Catalog/ProductsController.cs +++ b/src/food-market.api/Controllers/Catalog/ProductsController.cs @@ -3,6 +3,7 @@ using foodmarket.Application.Common.Tenancy; using foodmarket.Domain.Catalog; using foodmarket.Infrastructure.Persistence; +using foodmarket.Api.Infrastructure.Authorization; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -195,7 +196,7 @@ public async Task> Get(Guid id, CancellationToken ct) return p is null ? NotFound() : p; } - [HttpPost, Authorize(Roles = "Admin,Storekeeper")] + [HttpPost, RequiresPermission("ProductsEdit")] public async Task> Create([FromBody] ProductInput input, CancellationToken ct) { if (string.IsNullOrWhiteSpace(input.Name)) @@ -238,7 +239,7 @@ public async Task> Create([FromBody] ProductInput input return CreatedAtAction(nameof(Get), new { id = e.Id }, dto); } - [HttpPut("{id:guid}"), Authorize(Roles = "Admin,Storekeeper")] + [HttpPut("{id:guid}"), RequiresPermission("ProductsEdit")] public async Task Update(Guid id, [FromBody] ProductInput input, CancellationToken ct) { if (RequiredGuid.FirstMissing( @@ -322,7 +323,7 @@ public async Task Update(Guid id, [FromBody] ProductInput input, /// «Привести розничную к себестоимости»: ставит дефолтную /// розничную цену = ceil(Cost * (1 + Group.MarkupPercent/100)). Если у /// группы товара не задан MarkupPercent — 400 с подсказкой. - [HttpPost("{id:guid}/recalc-retail"), Authorize(Roles = "Admin,Storekeeper")] + [HttpPost("{id:guid}/recalc-retail"), RequiresPermission("ProductsEdit")] public async Task RecalcRetail(Guid id, CancellationToken ct) { var p = await _db.Products @@ -371,7 +372,7 @@ public async Task RecalcRetail(Guid id, CancellationToken ct) return Ok(new { retail = newRetail }); } - [HttpDelete("{id:guid}"), Authorize(Roles = "Admin")] + [HttpDelete("{id:guid}"), RequiresPermission("ProductsDelete")] public async Task Delete(Guid id, CancellationToken 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-импорта. - [HttpGet("barcode-duplicates"), Authorize(Roles = "Admin")] + [HttpGet("barcode-duplicates"), RequiresPermission("ProductsView")] public async Task>> BarcodeDuplicates(CancellationToken ct) { var rows = await _db.ProductBarcodes diff --git a/src/food-market.api/Controllers/Catalog/RetailPointsController.cs b/src/food-market.api/Controllers/Catalog/RetailPointsController.cs index 839f907..5ccbb50 100644 --- a/src/food-market.api/Controllers/Catalog/RetailPointsController.cs +++ b/src/food-market.api/Controllers/Catalog/RetailPointsController.cs @@ -2,6 +2,7 @@ using foodmarket.Application.Common; using foodmarket.Domain.Catalog; using foodmarket.Infrastructure.Persistence; +using foodmarket.Api.Infrastructure.Authorization; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -56,7 +57,7 @@ public async Task> Get(Guid id, CancellationToken c r.Address, r.Phone, r.FiscalSerial, r.FiscalRegNumber, r.IsActive); } - [HttpPost, Authorize(Roles = "Admin")] + [HttpPost, RequiresPermission("RetailPointsManage")] public async Task> Create([FromBody] RetailPointInput input, CancellationToken ct) { var store = await _db.Stores.FirstOrDefaultAsync(s => s.Id == input.StoreId, ct); @@ -76,7 +77,7 @@ public async Task> Create([FromBody] RetailPointInp e.Address, e.Phone, e.FiscalSerial, e.FiscalRegNumber, e.IsActive)); } - [HttpPut("{id:guid}"), Authorize(Roles = "Admin")] + [HttpPut("{id:guid}"), RequiresPermission("RetailPointsManage")] public async Task Update(Guid id, [FromBody] RetailPointInput input, CancellationToken ct) { var e = await _db.RetailPoints.FirstOrDefaultAsync(x => x.Id == id, ct); @@ -93,7 +94,7 @@ public async Task Update(Guid id, [FromBody] RetailPointInput inp return NoContent(); } - [HttpDelete("{id:guid}"), Authorize(Roles = "Admin")] + [HttpDelete("{id:guid}"), RequiresPermission("RetailPointsManage")] public async Task Delete(Guid id, CancellationToken ct) { var e = await _db.RetailPoints.FirstOrDefaultAsync(x => x.Id == id, ct); diff --git a/src/food-market.api/Controllers/Catalog/StoresController.cs b/src/food-market.api/Controllers/Catalog/StoresController.cs index 3f0e98d..4b430da 100644 --- a/src/food-market.api/Controllers/Catalog/StoresController.cs +++ b/src/food-market.api/Controllers/Catalog/StoresController.cs @@ -2,6 +2,7 @@ using foodmarket.Application.Common; using foodmarket.Domain.Catalog; using foodmarket.Infrastructure.Persistence; +using foodmarket.Api.Infrastructure.Authorization; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -55,7 +56,7 @@ public async Task> 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); } - [HttpPost, Authorize(Roles = "Admin")] + [HttpPost, RequiresPermission("StoresManage")] public async Task> Create([FromBody] StoreInput input, CancellationToken ct) { if (input.IsMain) @@ -73,7 +74,7 @@ public async Task> Create([FromBody] StoreInput input, Ca 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 Update(Guid id, [FromBody] StoreInput input, CancellationToken ct) { var e = await _db.Stores.FirstOrDefaultAsync(x => x.Id == id, ct); @@ -94,7 +95,7 @@ public async Task Update(Guid id, [FromBody] StoreInput input, Ca return NoContent(); } - [HttpDelete("{id:guid}"), Authorize(Roles = "Admin")] + [HttpDelete("{id:guid}"), RequiresPermission("StoresManage")] public async Task Delete(Guid id, CancellationToken ct) { var e = await _db.Stores.FirstOrDefaultAsync(x => x.Id == id, ct); diff --git a/src/food-market.api/Controllers/Catalog/UnitsOfMeasureController.cs b/src/food-market.api/Controllers/Catalog/UnitsOfMeasureController.cs index e79522a..7b7f669 100644 --- a/src/food-market.api/Controllers/Catalog/UnitsOfMeasureController.cs +++ b/src/food-market.api/Controllers/Catalog/UnitsOfMeasureController.cs @@ -3,6 +3,7 @@ using foodmarket.Application.Common.Tenancy; using foodmarket.Domain.Catalog; using foodmarket.Infrastructure.Persistence; +using foodmarket.Api.Infrastructure.Authorization; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -92,7 +93,7 @@ public async Task> Get(Guid id, CancellationToken /// Включить global для текущей орги. Идемпотентно: повторный /// вызов отдаёт 204 и не плодит дубликатов junction. - [HttpPost("{id:guid}/enable"), Authorize(Roles = "Admin,SuperAdmin")] + [HttpPost("{id:guid}/enable"), RequiresPermission("UnitsManage")] public async Task Enable(Guid id, CancellationToken ct) { var orgId = _tenant.OrganizationId; @@ -116,7 +117,7 @@ public async Task Enable(Guid id, CancellationToken ct) /// Отключить global для текущей орги. Если на эту единицу /// ссылаются продукты орги — 409 со списком названий, чтобы админ /// перепривязал их сначала. - [HttpDelete("{id:guid}/enable"), Authorize(Roles = "Admin,SuperAdmin")] + [HttpDelete("{id:guid}/enable"), RequiresPermission("UnitsManage")] public async Task Disable(Guid id, CancellationToken ct) { var orgId = _tenant.OrganizationId; diff --git a/src/food-market.api/Controllers/Purchases/SuppliesController.cs b/src/food-market.api/Controllers/Purchases/SuppliesController.cs index f0c0987..7df7d43 100644 --- a/src/food-market.api/Controllers/Purchases/SuppliesController.cs +++ b/src/food-market.api/Controllers/Purchases/SuppliesController.cs @@ -4,6 +4,7 @@ using foodmarket.Domain.Inventory; using foodmarket.Domain.Purchases; using foodmarket.Infrastructure.Persistence; +using foodmarket.Api.Infrastructure.Authorization; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -121,7 +122,7 @@ public async Task> Get(Guid id, CancellationToken ct) return dto is null ? NotFound() : Ok(dto); } - [HttpPost, Authorize(Roles = "Admin,Storekeeper")] + [HttpPost, RequiresPermission("SuppliesEdit")] public async Task> Create([FromBody] SupplyInput input, CancellationToken ct) { if (RequiredGuid.FirstMissing( @@ -193,7 +194,7 @@ public async Task> Create([FromBody] SupplyInput input, } } - [HttpPut("{id:guid}"), Authorize(Roles = "Admin,Storekeeper")] + [HttpPut("{id:guid}"), RequiresPermission("SuppliesEdit")] public async Task Update(Guid id, [FromBody] SupplyInput input, CancellationToken ct) { if (RequiredGuid.FirstMissing( @@ -242,7 +243,7 @@ public async Task Update(Guid id, [FromBody] SupplyInput input, C return NoContent(); } - [HttpDelete("{id:guid}"), Authorize(Roles = "Admin,Storekeeper")] + [HttpDelete("{id:guid}"), RequiresPermission("SuppliesDelete")] public async Task Delete(Guid id, CancellationToken ct) { var supply = await _db.Supplies.FirstOrDefaultAsync(s => s.Id == id, ct); @@ -254,7 +255,7 @@ public async Task Delete(Guid id, CancellationToken ct) return NoContent(); } - [HttpPost("{id:guid}/post"), Authorize(Roles = "Admin,Storekeeper")] + [HttpPost("{id:guid}/post"), RequiresPermission("SuppliesPost")] public async Task Post(Guid id, CancellationToken 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 Unpost(Guid id, CancellationToken ct) { var supply = await _db.Supplies.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct); diff --git a/src/food-market.api/Controllers/Sales/RetailSalesController.cs b/src/food-market.api/Controllers/Sales/RetailSalesController.cs index 6aeb712..d8cb2ff 100644 --- a/src/food-market.api/Controllers/Sales/RetailSalesController.cs +++ b/src/food-market.api/Controllers/Sales/RetailSalesController.cs @@ -4,6 +4,7 @@ using foodmarket.Domain.Inventory; using foodmarket.Domain.Sales; using foodmarket.Infrastructure.Persistence; +using foodmarket.Api.Infrastructure.Authorization; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -194,7 +195,7 @@ public async Task> Get(Guid id, CancellationToken ct return dto is null ? NotFound() : Ok(dto); } - [HttpPost, Authorize(Roles = "Admin,Cashier")] + [HttpPost, RequiresPermission("RetailSalesOperate")] public async Task> Create([FromBody] RetailSaleInput input, CancellationToken ct) { if (RequiredGuid.FirstMissing( @@ -251,7 +252,7 @@ public async Task> Create([FromBody] RetailSaleInput } } - [HttpPut("{id:guid}"), Authorize(Roles = "Admin,Cashier")] + [HttpPut("{id:guid}"), RequiresPermission("RetailSalesOperate")] public async Task Update(Guid id, [FromBody] RetailSaleInput input, CancellationToken ct) { if (RequiredGuid.FirstMissing( @@ -283,7 +284,7 @@ public async Task Update(Guid id, [FromBody] RetailSaleInput inpu return NoContent(); } - [HttpDelete("{id:guid}"), Authorize(Roles = "Admin")] + [HttpDelete("{id:guid}"), RequiresPermission("RetailSalesRefund")] public async Task Delete(Guid id, CancellationToken ct) { var sale = await _db.RetailSales.FirstOrDefaultAsync(s => s.Id == id, ct); @@ -295,7 +296,7 @@ public async Task Delete(Guid id, CancellationToken ct) return NoContent(); } - [HttpPost("{id:guid}/post"), Authorize(Roles = "Admin,Cashier")] + [HttpPost("{id:guid}/post"), RequiresPermission("RetailSalesOperate")] public async Task Post(Guid id, CancellationToken ct) { var sale = await _db.RetailSales.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct); @@ -389,7 +390,7 @@ public async Task Post(Guid id, CancellationToken ct) return NoContent(); } - [HttpPost("{id:guid}/unpost"), Authorize(Roles = "Admin")] + [HttpPost("{id:guid}/unpost"), RequiresPermission("RetailSalesRefund")] public async Task Unpost(Guid id, CancellationToken ct) { var sale = await _db.RetailSales.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct); diff --git a/src/food-market.api/Infrastructure/Authorization/PermissionAuthorizationHandler.cs b/src/food-market.api/Infrastructure/Authorization/PermissionAuthorizationHandler.cs new file mode 100644 index 0000000..4226184 --- /dev/null +++ b/src/food-market.api/Infrastructure/Authorization/PermissionAuthorizationHandler.cs @@ -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; + +/// Проверяет по флагам роли +/// организации (RolePermissions) текущего пользователя. +/// +/// Логика: +/// +/// SuperAdmin — полный платформенный доступ. +/// Identity-роль Admin — системная роль «Администратор» = RolePermissions.All(). +/// Шорткат, чтобы не зависеть от наличия идеально засиженной Employee-записи +/// (custom-роли НЕ маппятся на Identity Admin — см. IdentityRoleMapper — +/// поэтому шорткат их не задевает). +/// Иначе — ищем активного Employee пользователя в его орге и читаем +/// флаг права из его роли. Нет Employee/нет флага → доступ запрещён (fail-closed). +/// +/// +/// Регистрируется scoped: использует . +public sealed class PermissionAuthorizationHandler : AuthorizationHandler +{ + private static readonly ConcurrentDictionary 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); + } + } + + /// Читает boolean-флаг права по имени через рефлексию (с кэшем + /// PropertyInfo). Неизвестное имя или не-bool → false (fail-closed). + 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; + } +} diff --git a/src/food-market.api/Infrastructure/Authorization/PermissionAuthorizationPolicyProvider.cs b/src/food-market.api/Infrastructure/Authorization/PermissionAuthorizationPolicyProvider.cs new file mode 100644 index 0000000..a891dad --- /dev/null +++ b/src/food-market.api/Infrastructure/Authorization/PermissionAuthorizationPolicyProvider.cs @@ -0,0 +1,35 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.Options; + +namespace foodmarket.Api.Infrastructure.Authorization; + +/// Динамически собирает policy для perm:<Permission>, +/// чтобы не регистрировать каждую из ~30 permission-policy вручную. Всё +/// остальное (именованные policy типа AdminAccess, default) делегируется +/// штатному провайдеру. +public sealed class PermissionAuthorizationPolicyProvider : IAuthorizationPolicyProvider +{ + private readonly DefaultAuthorizationPolicyProvider _fallback; + + public PermissionAuthorizationPolicyProvider(IOptions options) + => _fallback = new DefaultAuthorizationPolicyProvider(options); + + public Task GetDefaultPolicyAsync() => _fallback.GetDefaultPolicyAsync(); + + public Task GetFallbackPolicyAsync() => _fallback.GetFallbackPolicyAsync(); + + public Task 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(policy); + } + + return _fallback.GetPolicyAsync(policyName); + } +} diff --git a/src/food-market.api/Infrastructure/Authorization/PermissionRequirement.cs b/src/food-market.api/Infrastructure/Authorization/PermissionRequirement.cs new file mode 100644 index 0000000..ef7da53 --- /dev/null +++ b/src/food-market.api/Infrastructure/Authorization/PermissionRequirement.cs @@ -0,0 +1,13 @@ +using Microsoft.AspNetCore.Authorization; + +namespace foodmarket.Api.Infrastructure.Authorization; + +/// Требование «у текущего пользователя включён флаг права +/// в его роли (RolePermissions)». Имя права = имя +/// boolean-свойства RolePermissions (например, "ProductsEdit"). +public sealed class PermissionRequirement : IAuthorizationRequirement +{ + public string Permission { get; } + + public PermissionRequirement(string permission) => Permission = permission; +} diff --git a/src/food-market.api/Infrastructure/Authorization/RequiresPermissionAttribute.cs b/src/food-market.api/Infrastructure/Authorization/RequiresPermissionAttribute.cs new file mode 100644 index 0000000..300374f --- /dev/null +++ b/src/food-market.api/Infrastructure/Authorization/RequiresPermissionAttribute.cs @@ -0,0 +1,22 @@ +using Microsoft.AspNetCore.Authorization; + +namespace foodmarket.Api.Infrastructure.Authorization; + +/// Гейтит endpoint на конкретное право роли. Имя права = имя +/// boolean-свойства RolePermissions. Реализовано поверх policy-механизма: +/// атрибут выставляет policy perm:<Permission>, которую +/// разворачивает в +/// , а проверяет +/// . +/// +/// Пример: [RequiresPermission("ProductsEdit")]. Заменяет грубое +/// [Authorize(Roles="Admin,Storekeeper")] — теперь доступ определяют +/// флаги роли организации, а не зашитый список Identity-ролей. +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false)] +public sealed class RequiresPermissionAttribute : AuthorizeAttribute +{ + public const string PolicyPrefix = "perm:"; + + public RequiresPermissionAttribute(string permission) + => Policy = $"{PolicyPrefix}{permission}"; +} diff --git a/src/food-market.api/Program.cs b/src/food-market.api/Program.cs index 23c8aad..6dc1efb 100644 --- a/src/food-market.api/Program.cs +++ b/src/food-market.api/Program.cs @@ -139,6 +139,12 @@ opts.AddPolicy("AdminAccess", p => p.RequireAssertion(ctx => ctx.User.HasClaim(c => c.Type == Claims.Role && (c.Value == "Admin" || c.Value == "SuperAdmin")))); }); + // Permission-based авторизация: [RequiresPermission("...")] → policy perm:* → + // PermissionRequirement → проверка флага RolePermissions роли сотрудника. + builder.Services.AddSingleton(); + builder.Services.AddScoped(); builder.Services.AddScoped();