Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature #1106: Limit admins to stores PART II #1178

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using Smartstore.Core.Stores;

namespace Smartstore.Core.Catalog.Products
{
public static partial class ProductReviewQueryExtensions
{
/// <summary>
/// Filters away items in a query belonging to stores to which a given authenticated customer is not authorized to access.
/// </summary>
/// <param name="query">Query of type <see cref="ProductReview"/> from which to filter.</param>
/// <param name="customerAuthorizedStores">The stores the authenticated customer is authorized to access.</param>
/// <param name="storeMappings">The mappings of all items of type T belonging to a limited number of stores.</param>
/// <returns><see cref="IQueryable"/> of <see cref="BaseEntity"/>.</returns>
public static IQueryable<ProductReview> ApplyReviewStoreFilter(
this IQueryable<ProductReview> query,
int[] customerAuthorizedStores,
StoreMappingCollection storeMappings)
{
if (customerAuthorizedStores.IsNullOrEmpty()) return query;
Guard.NotNull(query, nameof(query));

var groupedStoreMappings = storeMappings.GroupBy(
sm => sm.EntityId,
sm => sm.StoreId,
(key, g) => new { EntityId = key, StoreIdsList = g.ToList() });

foreach (var groupedMapping in groupedStoreMappings)
{
if (!customerAuthorizedStores.Any(casId => groupedMapping.StoreIdsList.Any(storeId => storeId == casId)))
{
query = query.Where(x => x.ProductId != groupedMapping.EntityId);
}
}

return query;
}
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Smartstore.Core.Checkout.Orders;
using Smartstore.Core.Checkout.Orders.Reporting;
using Smartstore.Core.Checkout.Shipping;
using Smartstore.Core.Data;

namespace Smartstore
Expand Down Expand Up @@ -189,5 +190,21 @@ public static IQueryable<BestsellersReportLine> SelectAsBestsellersReportLine(th

return selector;
}

/// <summary>
/// Selects order items that the currently authenticated customer is authorized to access.
/// </summary>
/// <param name="query">Order items query to filter from.</param>
/// <param name="authorizedStoreIds">Ids of stores customer has access to</param>
/// <returns><see cref="IQueryable"/> of <see cref="OrderItem"/>.</returns>
public static IQueryable<OrderItem> ApplyCustomerStoreFilter(this IQueryable<OrderItem> query, int[] authorizedStoreIds)
{
Guard.NotNull(query);
if (!authorizedStoreIds.IsNullOrEmpty())
{
query = query.Where(oi => authorizedStoreIds.Contains(oi.Order.StoreId));
}
return query;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -350,8 +350,9 @@ public static Task<decimal> GetOrdersTotalAsync(this IQueryable<Order> query)
/// Selects customer authorized orders from query.
/// </summary>
/// <param name="query">Order query from which to select.</param>
/// <param name="authorizedStoreIds">Ids of stores customer has access to</param>
/// <returns><see cref="IQueryable"/> of <see cref="OrderDataPoint"/>.</returns>
public static IQueryable<Order> ApplyCustomerFilter(this IQueryable<Order> query, int[] authorizedStoreIds)
public static IQueryable<Order> ApplyCustomerStoreFilter(this IQueryable<Order> query, int[] authorizedStoreIds)
{
Guard.NotNull(query);
if (!authorizedStoreIds.IsNullOrEmpty())
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
namespace Smartstore.Core.Checkout.Shipping
using Smartstore.Core.Checkout.Orders;
using Smartstore.Core.Checkout.Orders.Reporting;

namespace Smartstore.Core.Checkout.Shipping
{
/// <summary>
/// Shipment query extensions
Expand Down Expand Up @@ -52,5 +55,21 @@ public static IOrderedQueryable<Shipment> ApplyShipmentFilter(this IQueryable<Sh
.OrderBy(x => x.Id)
.ThenBy(x => x.CreatedOnUtc);
}

/// <summary>
/// Selects shipments that the currently authenticated customer is authorized to access.
/// </summary>
/// <param name="query">Shipment query to filter from.</param>
/// <param name="authorizedStoreIds">Ids of stores customer has access to</param>
/// <returns><see cref="IQueryable"/> of <see cref="Shipment"/>.</returns>
public static IQueryable<Shipment> ApplyCustomerStoreFilter(this IQueryable<Shipment> query, int[] authorizedStoreIds)
{
Guard.NotNull(query);
if (!authorizedStoreIds.IsNullOrEmpty())
{
query = query.Where(s => authorizedStoreIds.Contains(s.Order.StoreId));
}
return query;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
using Smartstore.Core.Stores;

namespace Smartstore
{
public static partial class ICustomerStoreQueryExtensions
{
/// <summary>
/// Filters away items in a query belonging to stores to which a given authenticated customer is not authorized to access.
/// </summary>
/// <param name="query">Query of type <see cref="BaseEntity"/> from which to filter.</param>
/// <param name="customerAuthorizedStores">The stores the authenticated customer is authorized to access.</param>
/// <param name="storeMappings">The mappings of all items of type T belonging to a limited number of stores.</param>
/// <returns><see cref="IQueryable"/> of <see cref="BaseEntity"/>.</returns>
public static IQueryable<T> ApplyCustomerStoreFilter<T>(
this IQueryable<T> query,
int[] customerAuthorizedStores,
StoreMappingCollection storeMappings)
where T : BaseEntity
{
if (customerAuthorizedStores.IsNullOrEmpty()) return query;
Guard.NotNull(query, nameof(query));

var groupedStoreMappings = storeMappings.GroupBy(
sm => sm.EntityId,
sm => sm.StoreId,
(key, g) => new { EntityId = key, StoreIdsList = g.ToList() });

foreach (var groupedMapping in groupedStoreMappings)
{
if (!customerAuthorizedStores.Any(casId => groupedMapping.StoreIdsList.Any(storeId => storeId == casId)))
{
query = query.Where(x => x.Id != groupedMapping.EntityId);
}
}

return query;
}
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore.Query;
using Smartstore.Core.Catalog.Products;
using Smartstore.Core.Checkout.Orders;
using Smartstore.Core.Data;

namespace Smartstore.Core.Identity
Expand Down Expand Up @@ -179,6 +180,23 @@ public static IQueryable<Customer> ApplyRolesFilter(this IQueryable<Customer> qu
return query;
}


/// <summary>
/// Selects customers that the currently authenticated customer is authorized to access.
/// </summary>
/// <param name="query">Customers query to filter from.</param>
/// <param name="authorizedStoreIds">Ids of stores customer has access to</param>
/// <returns><see cref="IQueryable"/> of <see cref="Customer"/>.</returns>
public static IQueryable<Customer> ApplyCustomerStoreFilter(this IQueryable<Customer> query, int[] authorizedStoreIds)
{
Guard.NotNull(query);
if (!authorizedStoreIds.IsNullOrEmpty())
{
query = query.Where(c => authorizedStoreIds.Contains(c.Id));
}
return query;
}

/// <summary>
/// Selects customers who are currently online since <paramref name="minutes"/> and orders by <see cref="Customer.LastActivityDateUtc"/> descending.
/// </summary>
Expand All @@ -194,6 +212,21 @@ public static IOrderedQueryable<Customer> ApplyOnlineCustomersFilter(this IQuery
.ApplyLastActivityFilter(fromUtc, null);
}

/// <summary>
/// Filters out super admins when the current customer is not a super admin - when isSuperAdmin = false.
/// </summary>
/// <param name="isSuperAdmin"></param>
public static IQueryable<Customer> ApplySuperAdminFilter(this IQueryable<Customer> query, bool isSuperAdmin)
{
Guard.NotNull(query);

if (!isSuperAdmin)
{
return query.Where(customer => !customer.CustomerRoleMappings.Any(mapping => mapping.CustomerRole.SystemName == SystemCustomerRoleNames.SuperAdministrators));
}
return query;
}

/// <summary>
/// Selects customers who use given password <paramref name="format"/>.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,5 +73,22 @@ public interface IPermissionService
/// <param name="permissionProviders">Providers whose permissions are to be installed.</param>
/// <param name="removeUnusedPermissions">Whether to remove permissions no longer supported by the providers.</param>
Task InstallPermissionsAsync(IPermissionProvider[] permissionProviders, bool removeUnusedPermissions = false);

/// <summary>
/// Controls that:
/// - only super admins can add new super admins
/// - if there is no existing super admin, then any admin can give itself super admin priviledges
/// - if there is already a super admin, then no admins can give itself super admin priviledges
/// </summary>
/// <param name="selectedCustomerRoleIds">Role Ids that are currently selected from the customer being edited</param>
/// <returns>true if validation passed, otherwise false</returns>
bool ValidateSuperAdmin(int[] selectedCustomerRoleIds);

/// <summary>
/// Forbids customers from entering into unauthorized customers' edit pages by manipulating the url.
/// </summary>
/// <param name="entity">The entity intended to be edited by currently authenticated customer</param>
/// <returns>true if authenticated customer is authorized, false otherwise</returns>
Task<bool> CanAccessEntity<T>(T entity) where T : BaseEntity;
}
}
115 changes: 95 additions & 20 deletions src/Smartstore.Core/Platform/Security/Services/PermissionService.cs
Original file line number Diff line number Diff line change
@@ -1,16 +1,25 @@
using System.Text;
using Smartstore.Caching;
using Smartstore.Collections;
using Smartstore.Core.Catalog.Products;
using Smartstore.Core.Checkout.Orders;
using Smartstore.Core.Checkout.Shipping;
using Smartstore.Core.Data;
using Smartstore.Core.Identity;
using Smartstore.Core.Localization;
using Smartstore.Core.Stores;
using Smartstore.Data;
using Smartstore.Data.Hooks;
using EState = Smartstore.Data.EntityState;

namespace Smartstore.Core.Security
{
public partial class PermissionService : AsyncDbSaveHook<CustomerRole>, IPermissionService
public partial class PermissionService(
SmartDbContext db,
Lazy<IWorkContext> workContext,
ILocalizationService localizationService,
ICacheManager cache,
IStoreMappingService storeMappingService) : AsyncDbSaveHook<CustomerRole>, IPermissionService
{
// {0} = roleId
private readonly static CompositeFormat PERMISSION_TREE_KEY = CompositeFormat.Parse("permission:tree-{0}");
Expand Down Expand Up @@ -90,25 +99,14 @@ public partial class PermissionService : AsyncDbSaveHook<CustomerRole>, IPermiss
{ "rule", "Common.Rules" },
};

private readonly SmartDbContext _db;
private readonly Lazy<IWorkContext> _workContext;
private readonly ILocalizationService _localizationService;
private readonly ICacheManager _cache;
private readonly SmartDbContext _db = db;
private readonly Lazy<IWorkContext> _workContext = workContext;
private readonly ILocalizationService _localizationService = localizationService;
private readonly ICacheManager _cache = cache;
private readonly IStoreMappingService _storeMappingService = storeMappingService;

private string _hookErrorMessage;

public PermissionService(
SmartDbContext db,
Lazy<IWorkContext> workContext,
ILocalizationService localizationService,
ICacheManager cache)
{
_db = db;
_workContext = workContext;
_localizationService = localizationService;
_cache = cache;
}

#region Hook

protected override Task<HookResult> OnUpdatedAsync(CustomerRole entity, IHookedEntity entry, CancellationToken cancelToken)
Expand Down Expand Up @@ -328,7 +326,7 @@ public virtual async Task<Dictionary<string, string>> GetAllSystemNamesAsync()
public virtual async Task<string> GetDisplayNameAsync(string permissionSystemName)
{
var tokens = permissionSystemName.EmptyNull().ToLower().Split(new char[] { '.' }, StringSplitOptions.RemoveEmptyEntries);
if (tokens.Any())
if (tokens.Length != 0)
{
var resourcesLookup = await GetDisplayNameLookup(_workContext.Value.WorkingLanguage.Id);

Expand All @@ -352,7 +350,6 @@ public virtual async Task InstallPermissionsAsync(IPermissionProvider[] permissi
{
return;
}

var allPermissionNames = await _db.PermissionRecords
.AsQueryable()
.Select(x => x.SystemName)
Expand All @@ -365,7 +362,7 @@ public virtual async Task InstallPermissionsAsync(IPermissionProvider[] permissi
var log = existing.Any();
var clearCache = false;

if (existing.Any())
if (existing.Count != 0)
{
var permissionsMigrated = existing.Contains(Permissions.System.AccessShop) && !existing.Contains("PublicStoreAllowNavigation");
if (!permissionsMigrated)
Expand Down Expand Up @@ -692,5 +689,83 @@ private async Task<Dictionary<string, string>> GetDisplayNameLookup(int language
}

#endregion

#region Store restricted utilities

public bool ValidateSuperAdmin(int[] selectedCustomerRoleIds)
{
var superAdminRole = _db.CustomerRoles.FirstOrDefault(x => x.SystemName == SystemCustomerRoleNames.SuperAdministrators);

// Only if there is currently no super admin, allow an admin customer to set itself as super admin.
if (!_workContext.Value.CurrentCustomer.IsSuperAdmin() && selectedCustomerRoleIds.Any(x => x == superAdminRole?.Id))
{
var superAdminExists = _db.Customers.Any(
customer => customer.CustomerRoleMappings.Any(
mapping => mapping.CustomerRole.SystemName == SystemCustomerRoleNames.SuperAdministrators));
if (superAdminExists)
{
return false;
}
}
return true;
}

public async Task<bool> CanAccessEntity<T>(T entity) where T : BaseEntity
{
var customerAuthorizedStores = await _storeMappingService.GetCustomerAuthorizedStoreIdsAsync();
if (_workContext.Value.CurrentCustomer.IsSuperAdmin() || customerAuthorizedStores.Length == 0)
{
return true;
}

switch (typeof(T).Name)
{
case "Order":
var order = entity as Order;
if (!customerAuthorizedStores.Any(casId => order.StoreId == casId))
{
return false;
}
break;
case "Shipment":
var shipment = entity as Shipment;
if (!customerAuthorizedStores.Any(casId => shipment.Order.StoreId == casId))
{
return false;
}
break;
case "ProductReview":
var productReview = entity as ProductReview;
var prStoreMappings = await _storeMappingService.GetStoreMappingCollectionAsync(nameof(Product), [productReview.ProductId]);
if (prStoreMappings.Count != 0 && !customerAuthorizedStores.Any(casId => prStoreMappings.Any(storeMapping => storeMapping.StoreId == casId)))
{
return false;
}
break;
default:
var storeMappings = await _storeMappingService.GetStoreMappingCollectionAsync(typeof(T).Name, [entity.Id]);
if (storeMappings.Count != 0 && !customerAuthorizedStores.Any(casId => storeMappings.Any(storeMapping => storeMapping.StoreId == casId)))
{
return false;
}
break;
}

try
{
var customer = (Customer)Convert.ChangeType(entity, typeof(Customer));
if ((customer.IsAdmin() && !_workContext.Value.CurrentCustomer.IsAdmin()) || (customer.IsSuperAdmin() && !_workContext.Value.CurrentCustomer.IsSuperAdmin()))
{
return false;
}
return true;
}
catch (Exception)
{
return true;
}
}

#endregion
}
}
Loading