Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .ai/knowledge/admin-areas.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ Use existing permission patterns from nearby controllers.

Check:
- `StandardPermission` usage
- `_permissionService.Authorize(...)`
- `_permissionService.Authorize(...)` (`IRequestPermissionService`)
- area-specific access checks
- menu permission names in `src/Modules/Grand.Module.Installer/Utilities/StandardAdminSiteMap.cs`
- seeded permission names in installer utilities when adding new management areas
Expand Down
8 changes: 5 additions & 3 deletions .ai/knowledge/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@ Patterns from `Grand.Web`, `Grand.Module.Api`, `Grand.Business.*`. Complementary

### Check permission before touching data

Every API and admin controller action must enforce permissions (either via `[PermissionAuthorize]` / `[PermissionAuthorizeAction]` attributes or explicit `IPermissionService.Authorize` checks) before returning any data or performing any mutation.
Every API and admin controller action must enforce permissions (either via `[PermissionAuthorize]` / `[PermissionAuthorizeAction]` attributes or explicit `Authorize` checks) before returning any data or performing any mutation.

`IPermissionService` (Business) only authorizes a customer you pass in. Code in Web, the admin panels and plugins injects `IRequestPermissionService` (`Grand.Web.Common.Security.Authorization`), which provides `Authorize(permission)` for the customer of the current request. `Grand.Module.Api` has no Web.Common reference, so its controllers keep `IPermissionService` and pass `CurrentCustomer` from `BaseApiController`. Code in `src/Business` never authorizes "the current customer".

```csharp
public async Task<IActionResult> Get()
{
if (!await _permissionService.Authorize(PermissionSystemName.Brands))
if (!await _permissionService.Authorize(PermissionSystemName.Brands, CurrentCustomer))
return Forbid();

return Ok(await _mediator.Send(new GetGenericQuery<BrandDto, Brand>()));
Expand Down Expand Up @@ -110,7 +112,7 @@ Never commit connection strings, API keys, or credentials to source. Use `appset

## Summary Checklist

- [ ] Every action checks `IPermissionService.Authorize` before data access
- [ ] Every action checks a permission (attribute or `Authorize`) before data access
- [ ] Returns `Forbid()` not `Unauthorized()` for permission failures
- [ ] Business rule validation uses FluentValidation with record input types
- [ ] Service entry points guard null arguments with `ArgumentNullException.ThrowIfNull`
Expand Down
5 changes: 3 additions & 2 deletions .ai/skills/permission-navigation.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ Do not use this skill as the primary review for MongoDB query safety or authenti
10. Ensure Store Owner controllers inherit from `BaseStoreController` (it already applies `[AuthorizeStore]`) to enforce store panel access.
11. Apply `[PermissionAuthorize(PermissionSystemName.X)]` at class level on Admin, Store, or Vendor controllers to gate the entire controller on a specific feature permission. This must come after the panel-level attribute.
12. Apply `[PermissionAuthorizeAction(PermissionActionName.X)]` at action method level to enforce granular action gating within a controller that already carries `[PermissionAuthorize(...)]`. This checks both the class-level permission and the named action.
13. Never substitute `IPermissionService.Authorize(...)` in a controller action for the attribute-based approach unless the check is conditional on runtime data. Attribute checks run before the action body executes.
13. Never substitute `IRequestPermissionService.Authorize(...)` in a controller action for the attribute-based approach unless the check is conditional on runtime data. Attribute checks run before the action body executes.

#### Admin Sitemap (Navigation)
14. Add a new `AdminSiteMap` entry to `StandardAdminSiteMap.SiteMap` in `src/Modules/Grand.Module.Installer/Utilities/StandardAdminSiteMap.cs` for new installations. Set:
Expand Down Expand Up @@ -133,7 +133,8 @@ public class AdminSiteMap : BaseEntity {
| DefaultPermission entity | `src/Core/Grand.Domain/Permissions/DefaultPermission.cs` |
| IPermissionProvider interface | `src/Business/Grand.Business.Core/Interfaces/Common/Security/IPermissionProvider.cs` |
| PermissionProvider implementation | `src/Business/Grand.Business.Common/Services/Security/PermissionProvider.cs` |
| IPermissionService interface | `src/Business/Grand.Business.Core/Interfaces/Common/Security/IPermissionService.cs` |
| IPermissionService interface (explicit customer) | `src/Business/Grand.Business.Core/Interfaces/Common/Security/IPermissionService.cs` |
| IRequestPermissionService (customer of the request) | `src/Web/Grand.Web.Common/Security/Authorization/IRequestPermissionService.cs` |
| AuthorizeAdmin attribute | `src/Web/Grand.Web.Common/Filters/AuthorizeAdminAttribute.cs` |
| AuthorizeVendor attribute | `src/Web/Grand.Web.Common/Filters/AuthorizeVendorAttribute.cs` |
| AuthorizeStore attribute | `src/Web/Grand.Web.Common/Filters/AuthorizeStoreAttribute.cs` |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
using Grand.Data;
using Grand.Domain.Customers;
using Grand.Domain.Permissions;
using Grand.Infrastructure;
using Grand.Infrastructure.Caching;
using Grand.Infrastructure.Caching.Constants;

Expand All @@ -21,19 +20,16 @@ public class PermissionService : IPermissionService
/// </summary>
/// <param name="permissionRepository">Permission repository</param>
/// <param name="permissionActionRepository">Permission action repository</param>
/// <param name="workContext">Work context</param>
/// <param name="groupService">Group service</param>
/// <param name="cacheBase">Cache manager</param>
public PermissionService(
IRepository<Permission> permissionRepository,
IRepository<PermissionAction> permissionActionRepository,
IContextAccessor contextAccessor,
IGroupService groupService,
ICacheBase cacheBase)
{
_permissionRepository = permissionRepository;
_permissionActionRepository = permissionActionRepository;
_contextAccessor = contextAccessor;
_groupService = groupService;
_cacheBase = cacheBase;
}
Expand Down Expand Up @@ -69,7 +65,6 @@ await _permissionRepository.FirstOrDefaultAsync(

private readonly IRepository<Permission> _permissionRepository;
private readonly IRepository<PermissionAction> _permissionActionRepository;
private readonly IContextAccessor _contextAccessor;
private readonly IGroupService _groupService;
private readonly ICacheBase _cacheBase;

Expand Down Expand Up @@ -155,20 +150,6 @@ public virtual async Task UpdatePermission(Permission permission)
await _cacheBase.RemoveByPrefix(CacheKey.PERMISSIONS_PATTERN_KEY);
}

/// <summary>
/// Authorize permission
/// </summary>
/// <param name="permission">Permission record</param>
/// <returns>true - authorized; otherwise, false</returns>
/// <remarks>
/// Reads the customer of the current request. Code that may run outside a request uses the overload that
/// takes the customer.
/// </remarks>
public virtual async Task<bool> Authorize(Permission permission)
{
return await Authorize(permission, _contextAccessor.WorkContext.CurrentCustomer);
}

/// <summary>
/// Authorize permission
/// </summary>
Expand All @@ -186,20 +167,6 @@ public virtual async Task<bool> Authorize(Permission permission, Customer custom
return await Authorize(permission.SystemName, customer);
}

/// <summary>
/// Authorize permission
/// </summary>
/// <param name="permissionSystemName">Permission system name</param>
/// <returns>true - authorized; otherwise, false</returns>
/// <remarks>
/// Reads the customer of the current request. Code that may run outside a request uses the overload that
/// takes the customer.
/// </remarks>
public virtual async Task<bool> Authorize(string permissionSystemName)
{
return await Authorize(permissionSystemName, _contextAccessor.WorkContext.CurrentCustomer);
}

/// <summary>
/// Authorize permission
/// </summary>
Expand All @@ -211,6 +178,9 @@ public virtual async Task<bool> Authorize(string permissionSystemName, Customer
if (string.IsNullOrEmpty(permissionSystemName))
return false;

if (customer == null)
return false;

var customerGroups = await _groupService.GetAllByIds(customer.Groups.ToArray());
foreach (var group in customerGroups)
if (await Authorize(permissionSystemName, group))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,17 +46,6 @@ public interface IPermissionService
/// <param name="permission">Permission</param>
Task DeletePermission(Permission permission);

/// <summary>
/// Authorize permission
/// </summary>
/// <param name="permission">Permission</param>
/// <returns>true - authorized; otherwise, false</returns>
/// <remarks>
/// Reads the customer of the current request. Code that may run outside a request uses the overload that
/// takes the customer.
/// </remarks>
Task<bool> Authorize(Permission permission);

/// <summary>
/// Authorize permission
/// </summary>
Expand All @@ -65,17 +54,6 @@ public interface IPermissionService
/// <returns>true - authorized; otherwise, false</returns>
Task<bool> Authorize(Permission permission, Customer customer);

/// <summary>
/// Authorize permission
/// </summary>
/// <param name="permissionSystemName">Permission system name</param>
/// <returns>true - authorized; otherwise, false</returns>
/// <remarks>
/// Reads the customer of the current request. Code that may run outside a request uses the overload that
/// takes the customer.
/// </remarks>
Task<bool> Authorize(string permissionSystemName);

/// <summary>
/// Authorize permission
/// </summary>
Expand Down
11 changes: 10 additions & 1 deletion src/Modules/Grand.Module.Api/Controllers/BaseApiController.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
using Grand.Module.Api.Attributes;
using Grand.Domain.Customers;
using Grand.Infrastructure;
using Grand.Module.Api.Attributes;
using Grand.Module.Api.Constants;
using Grand.Module.Api.Filters;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;

namespace Grand.Module.Api.Controllers;

Expand All @@ -15,6 +18,12 @@ namespace Grand.Module.Api.Controllers;
[Produces("application/json")]
public abstract class BaseApiController : ControllerBase
{
/// <summary>
/// Customer of the current request
/// </summary>
protected Customer CurrentCustomer =>
HttpContext.RequestServices.GetRequiredService<IContextAccessor>().WorkContext?.CurrentCustomer;

public override ForbidResult Forbid()
{
return new ForbidResult(JwtBearerDefaults.AuthenticationScheme);
Expand Down
12 changes: 6 additions & 6 deletions src/Modules/Grand.Module.Api/Controllers/BrandController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public BrandController(IMediator mediator, IPermissionService permissionService)
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable<BrandDto>))]
public async Task<IActionResult> Get()
{
if (!await _permissionService.Authorize(PermissionSystemName.Brands)) return Forbid();
if (!await _permissionService.Authorize(PermissionSystemName.Brands, CurrentCustomer)) return Forbid();

return Ok(await _mediator.Send(new GetGenericQuery<BrandDto, Brand>()));
}
Expand All @@ -45,7 +45,7 @@ public async Task<IActionResult> Get()
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(BrandDto))]
public async Task<IActionResult> GetById([FromRoute] string key)
{
if (!await _permissionService.Authorize(PermissionSystemName.Brands)) return Forbid();
if (!await _permissionService.Authorize(PermissionSystemName.Brands, CurrentCustomer)) return Forbid();

var brand = await _mediator.Send(new GetGenericQuery<BrandDto, Brand>(key));
if (!brand.Any()) return NotFound();
Expand All @@ -61,7 +61,7 @@ public async Task<IActionResult> GetById([FromRoute] string key)
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> Post([FromBody] BrandDto model)
{
if (!await _permissionService.Authorize(PermissionSystemName.Brands)) return Forbid();
if (!await _permissionService.Authorize(PermissionSystemName.Brands, CurrentCustomer)) return Forbid();

model = await _mediator.Send(new AddBrandCommand { Model = model });
return Ok(model);
Expand All @@ -75,7 +75,7 @@ public async Task<IActionResult> Post([FromBody] BrandDto model)
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> Put([FromBody] BrandDto model)
{
if (!await _permissionService.Authorize(PermissionSystemName.Brands)) return Forbid();
if (!await _permissionService.Authorize(PermissionSystemName.Brands, CurrentCustomer)) return Forbid();

model = await _mediator.Send(new UpdateBrandCommand { Model = model });
return Ok(model);
Expand All @@ -100,7 +100,7 @@ public async Task<IActionResult> Patch([FromRoute] string key, [FromBody] JsonPa
if (string.IsNullOrEmpty(key))
return BadRequest("Key is null or empty");

if (!await _permissionService.Authorize(PermissionSystemName.Brands)) return Forbid();
if (!await _permissionService.Authorize(PermissionSystemName.Brands, CurrentCustomer)) return Forbid();

var brand = await _mediator.Send(new GetGenericQuery<BrandDto, Brand>(key));
if (!brand.Any()) return NotFound();
Expand All @@ -119,7 +119,7 @@ public async Task<IActionResult> Patch([FromRoute] string key, [FromBody] JsonPa
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Delete([FromRoute] string key)
{
if (!await _permissionService.Authorize(PermissionSystemName.Brands)) return Forbid();
if (!await _permissionService.Authorize(PermissionSystemName.Brands, CurrentCustomer)) return Forbid();

var brand = await _mediator.Send(new GetGenericQuery<BrandDto, Brand>(key));
if (!brand.Any()) return NotFound();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ public BrandLayoutController(IMediator mediator, IPermissionService permissionSe
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Get([FromRoute] string key)
{
if (!await _permissionService.Authorize(PermissionSystemName.Maintenance)) return Forbid();
if (!await _permissionService.Authorize(PermissionSystemName.Maintenance, CurrentCustomer)) return Forbid();

var layout = await _mediator.Send(new GetGenericQuery<LayoutDto, BrandLayout>(key));
if (!layout.Any()) return NotFound();
Expand All @@ -46,7 +46,7 @@ public async Task<IActionResult> Get([FromRoute] string key)
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable<LayoutDto>))]
public async Task<IActionResult> Get()
{
if (!await _permissionService.Authorize(PermissionSystemName.Maintenance)) return Forbid();
if (!await _permissionService.Authorize(PermissionSystemName.Maintenance, CurrentCustomer)) return Forbid();

return Ok(await _mediator.Send(new GetGenericQuery<LayoutDto, BrandLayout>()));
}
Expand Down
12 changes: 6 additions & 6 deletions src/Modules/Grand.Module.Api/Controllers/CategoryController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ public CategoryController(
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Get([FromRoute] string key)
{
if (!await _permissionService.Authorize(PermissionSystemName.Categories)) return Forbid();
if (!await _permissionService.Authorize(PermissionSystemName.Categories, CurrentCustomer)) return Forbid();

var category = await _mediator.Send(new GetGenericQuery<CategoryDto, Category>(key));
if (!category.Any()) return NotFound();
Expand All @@ -50,7 +50,7 @@ public async Task<IActionResult> Get([FromRoute] string key)
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable<CategoryDto>))]
public async Task<IActionResult> Get()
{
if (!await _permissionService.Authorize(PermissionSystemName.Categories)) return Forbid();
if (!await _permissionService.Authorize(PermissionSystemName.Categories, CurrentCustomer)) return Forbid();

return Ok(await _mediator.Send(new GetGenericQuery<CategoryDto, Category>()));
}
Expand All @@ -63,7 +63,7 @@ public async Task<IActionResult> Get()
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> Post([FromBody] CategoryDto model)
{
if (!await _permissionService.Authorize(PermissionSystemName.Categories)) return Forbid();
if (!await _permissionService.Authorize(PermissionSystemName.Categories, CurrentCustomer)) return Forbid();

model = await _mediator.Send(new AddCategoryCommand { Model = model });
return Ok(model);
Expand All @@ -77,7 +77,7 @@ public async Task<IActionResult> Post([FromBody] CategoryDto model)
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> Put([FromBody] CategoryDto model)
{
if (!await _permissionService.Authorize(PermissionSystemName.Categories)) return Forbid();
if (!await _permissionService.Authorize(PermissionSystemName.Categories, CurrentCustomer)) return Forbid();

model = await _mediator.Send(new UpdateCategoryCommand { Model = model });
return Ok(model);
Expand All @@ -95,7 +95,7 @@ public async Task<IActionResult> Patch([FromRoute] string key, [FromBody] JsonPa
if (string.IsNullOrEmpty(key))
return BadRequest("Key is null or empty");

if (!await _permissionService.Authorize(PermissionSystemName.Categories)) return Forbid();
if (!await _permissionService.Authorize(PermissionSystemName.Categories, CurrentCustomer)) return Forbid();

var category = await _mediator.Send(new GetGenericQuery<CategoryDto, Category>(key));
if (!category.Any()) return NotFound();
Expand All @@ -114,7 +114,7 @@ public async Task<IActionResult> Patch([FromRoute] string key, [FromBody] JsonPa
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Delete([FromRoute] string key)
{
if (!await _permissionService.Authorize(PermissionSystemName.Categories)) return Forbid();
if (!await _permissionService.Authorize(PermissionSystemName.Categories, CurrentCustomer)) return Forbid();

var category = await _mediator.Send(new GetGenericQuery<CategoryDto, Category>(key));
if (!category.Any()) return NotFound();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public CategoryLayoutController(
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Get([FromRoute] string key)
{
if (!await _permissionService.Authorize(PermissionSystemName.Maintenance)) return Forbid();
if (!await _permissionService.Authorize(PermissionSystemName.Maintenance, CurrentCustomer)) return Forbid();

var layout = await _mediator.Send(new GetGenericQuery<LayoutDto, CategoryLayout>(key));
if (!layout.Any()) return NotFound();
Expand All @@ -48,7 +48,7 @@ public async Task<IActionResult> Get([FromRoute] string key)
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable<LayoutDto>))]
public async Task<IActionResult> Get()
{
if (!await _permissionService.Authorize(PermissionSystemName.Maintenance)) return Forbid();
if (!await _permissionService.Authorize(PermissionSystemName.Maintenance, CurrentCustomer)) return Forbid();

return Ok(await _mediator.Send(new GetGenericQuery<LayoutDto, CategoryLayout>()));
}
Expand Down
Loading
Loading