Elements: Add permission-based filtering to element tree endpoints (#21729)
* Add permission-based filtering to element tree endpoints The element tree endpoints now filter results based on the current user's browse permissions via a new IElementPermissionFilterService, mirroring the existing document tree behavior. Also extracts shared filtering logic from DocumentPermissionFilterService into a PermissionFilterServiceBase to avoid duplication. * Add unit tests for ElementPermissionFilterService * Replace document-specific inheritdoc with neutral XML docs in PermissionFilterServiceBase * Fix GetPermissionsAsync to use the provided objectTypes parameter instead of hardcoded Document type --------- Co-authored-by: Kenn Jacobsen <kja@umbraco.dk>
This commit is contained in:
co-authored by
Kenn Jacobsen
parent
765a3b2968
commit
cf9c2908b4
+4
-2
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using Umbraco.Cms.Api.Management.Factories;
|
||||
using Umbraco.Cms.Api.Management.Services.Entities;
|
||||
using Umbraco.Cms.Api.Management.Services.Flags;
|
||||
using Umbraco.Cms.Api.Management.Services.PermissionFilter;
|
||||
using Umbraco.Cms.Api.Management.ViewModels.Tree;
|
||||
using Umbraco.Cms.Core.Cache;
|
||||
using Umbraco.Cms.Core.Security;
|
||||
@@ -21,8 +22,9 @@ public class AncestorsElementTreeController : ElementTreeControllerBase
|
||||
IDataTypeService dataTypeService,
|
||||
AppCaches appCaches,
|
||||
IBackOfficeSecurityAccessor backOfficeSecurityAccessor,
|
||||
IElementPresentationFactory elementPresentationFactory)
|
||||
: base(entityService, flagProviders, userStartNodeEntitiesService, dataTypeService, appCaches, backOfficeSecurityAccessor, elementPresentationFactory)
|
||||
IElementPresentationFactory elementPresentationFactory,
|
||||
IElementPermissionFilterService elementPermissionFilterService)
|
||||
: base(entityService, flagProviders, userStartNodeEntitiesService, dataTypeService, appCaches, backOfficeSecurityAccessor, elementPresentationFactory, elementPermissionFilterService)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
+4
-2
@@ -5,6 +5,7 @@ using Umbraco.Cms.Api.Common.ViewModels.Pagination;
|
||||
using Umbraco.Cms.Api.Management.Factories;
|
||||
using Umbraco.Cms.Api.Management.Services.Entities;
|
||||
using Umbraco.Cms.Api.Management.Services.Flags;
|
||||
using Umbraco.Cms.Api.Management.Services.PermissionFilter;
|
||||
using Umbraco.Cms.Api.Management.ViewModels.Tree;
|
||||
using Umbraco.Cms.Core.Cache;
|
||||
using Umbraco.Cms.Core.Security;
|
||||
@@ -22,8 +23,9 @@ public class ChildrenElementTreeController : ElementTreeControllerBase
|
||||
IDataTypeService dataTypeService,
|
||||
AppCaches appCaches,
|
||||
IBackOfficeSecurityAccessor backOfficeSecurityAccessor,
|
||||
IElementPresentationFactory elementPresentationFactory)
|
||||
: base(entityService, flagProviders, userStartNodeEntitiesService, dataTypeService, appCaches, backOfficeSecurityAccessor, elementPresentationFactory)
|
||||
IElementPresentationFactory elementPresentationFactory,
|
||||
IElementPermissionFilterService elementPermissionFilterService)
|
||||
: base(entityService, flagProviders, userStartNodeEntitiesService, dataTypeService, appCaches, backOfficeSecurityAccessor, elementPresentationFactory, elementPermissionFilterService)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
+19
-1
@@ -5,6 +5,7 @@ using Umbraco.Cms.Api.Management.Factories;
|
||||
using Umbraco.Cms.Api.Management.Routing;
|
||||
using Umbraco.Cms.Api.Management.Services.Entities;
|
||||
using Umbraco.Cms.Api.Management.Services.Flags;
|
||||
using Umbraco.Cms.Api.Management.Services.PermissionFilter;
|
||||
using Umbraco.Cms.Api.Management.ViewModels.Tree;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Cache;
|
||||
@@ -24,6 +25,7 @@ public class ElementTreeControllerBase : UserStartNodeFolderTreeControllerBase<E
|
||||
private readonly AppCaches _appCaches;
|
||||
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
|
||||
private readonly IElementPresentationFactory _elementPresentationFactory;
|
||||
private readonly IElementPermissionFilterService _elementPermissionFilterService;
|
||||
|
||||
public ElementTreeControllerBase(
|
||||
IEntityService entityService,
|
||||
@@ -32,12 +34,14 @@ public class ElementTreeControllerBase : UserStartNodeFolderTreeControllerBase<E
|
||||
IDataTypeService dataTypeService,
|
||||
AppCaches appCaches,
|
||||
IBackOfficeSecurityAccessor backOfficeSecurityAccessor,
|
||||
IElementPresentationFactory elementPresentationFactory)
|
||||
IElementPresentationFactory elementPresentationFactory,
|
||||
IElementPermissionFilterService elementPermissionFilterService)
|
||||
: base(entityService, flagProviders, userStartNodeEntitiesService, dataTypeService)
|
||||
{
|
||||
_appCaches = appCaches;
|
||||
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
|
||||
_elementPresentationFactory = elementPresentationFactory;
|
||||
_elementPermissionFilterService = elementPermissionFilterService;
|
||||
}
|
||||
|
||||
protected override UmbracoObjectTypes ItemObjectType => UmbracoObjectTypes.Element;
|
||||
@@ -79,4 +83,18 @@ public class ElementTreeControllerBase : UserStartNodeFolderTreeControllerBase<E
|
||||
viewModel.NoAccess = true;
|
||||
return viewModel;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override Task<(IEntitySlim[] Entities, long TotalItems)> FilterTreeEntities(
|
||||
IEntitySlim[] entities,
|
||||
long totalItems)
|
||||
=> _elementPermissionFilterService.FilterAsync(entities, totalItems);
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override Task<(IEntitySlim[] Entities, long TotalBefore, long TotalAfter)> FilterTreeEntities(
|
||||
Guid targetKey,
|
||||
IEntitySlim[] entities,
|
||||
long totalBefore,
|
||||
long totalAfter)
|
||||
=> _elementPermissionFilterService.FilterAsync(targetKey, entities, totalBefore, totalAfter);
|
||||
}
|
||||
|
||||
+4
-2
@@ -5,6 +5,7 @@ using Umbraco.Cms.Api.Common.ViewModels.Pagination;
|
||||
using Umbraco.Cms.Api.Management.Factories;
|
||||
using Umbraco.Cms.Api.Management.Services.Entities;
|
||||
using Umbraco.Cms.Api.Management.Services.Flags;
|
||||
using Umbraco.Cms.Api.Management.Services.PermissionFilter;
|
||||
using Umbraco.Cms.Api.Management.ViewModels.Tree;
|
||||
using Umbraco.Cms.Core.Cache;
|
||||
using Umbraco.Cms.Core.Security;
|
||||
@@ -22,8 +23,9 @@ public class RootElementTreeController : ElementTreeControllerBase
|
||||
IDataTypeService dataTypeService,
|
||||
AppCaches appCaches,
|
||||
IBackOfficeSecurityAccessor backOfficeSecurityAccessor,
|
||||
IElementPresentationFactory elementPresentationFactory)
|
||||
: base(entityService, flagProviders, userStartNodeEntitiesService, dataTypeService, appCaches, backOfficeSecurityAccessor, elementPresentationFactory)
|
||||
IElementPresentationFactory elementPresentationFactory,
|
||||
IElementPermissionFilterService elementPermissionFilterService)
|
||||
: base(entityService, flagProviders, userStartNodeEntitiesService, dataTypeService, appCaches, backOfficeSecurityAccessor, elementPresentationFactory, elementPermissionFilterService)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
+4
-2
@@ -5,6 +5,7 @@ using Umbraco.Cms.Api.Common.ViewModels.Pagination;
|
||||
using Umbraco.Cms.Api.Management.Factories;
|
||||
using Umbraco.Cms.Api.Management.Services.Entities;
|
||||
using Umbraco.Cms.Api.Management.Services.Flags;
|
||||
using Umbraco.Cms.Api.Management.Services.PermissionFilter;
|
||||
using Umbraco.Cms.Api.Management.ViewModels.Tree;
|
||||
using Umbraco.Cms.Core.Cache;
|
||||
using Umbraco.Cms.Core.Security;
|
||||
@@ -22,8 +23,9 @@ public class SiblingsElementTreeController : ElementTreeControllerBase
|
||||
IDataTypeService dataTypeService,
|
||||
AppCaches appCaches,
|
||||
IBackOfficeSecurityAccessor backOfficeSecurityAccessor,
|
||||
IElementPresentationFactory elementPresentationFactory)
|
||||
: base(entityService, flagProviders, userStartNodeEntitiesService, dataTypeService, appCaches, backOfficeSecurityAccessor, elementPresentationFactory)
|
||||
IElementPresentationFactory elementPresentationFactory,
|
||||
IElementPermissionFilterService elementPermissionFilterService)
|
||||
: base(entityService, flagProviders, userStartNodeEntitiesService, dataTypeService, appCaches, backOfficeSecurityAccessor, elementPresentationFactory, elementPermissionFilterService)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Umbraco.Cms.Api.Management.Factories;
|
||||
using Umbraco.Cms.Api.Management.Mapping.Element;
|
||||
using Umbraco.Cms.Api.Management.Services.PermissionFilter;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Cms.Core.Mapping;
|
||||
|
||||
@@ -13,6 +14,7 @@ internal static class ElementBuilderExtensions
|
||||
builder.Services.AddTransient<IElementPresentationFactory, ElementPresentationFactory>();
|
||||
builder.Services.AddTransient<IElementEditingPresentationFactory, ElementEditingPresentationFactory>();
|
||||
builder.Services.AddTransient<IElementVersionPresentationFactory, ElementVersionPresentationFactory>();
|
||||
builder.Services.AddScoped<IElementPermissionFilterService, ElementPermissionFilterService>();
|
||||
|
||||
builder.WithCollectionBuilder<MapDefinitionCollectionBuilder>()
|
||||
.Add<ElementMapDefinition>()
|
||||
|
||||
+10
-65
@@ -1,7 +1,6 @@
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Actions;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Models.Entities;
|
||||
using Umbraco.Cms.Core.Security;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Services.OperationStatus;
|
||||
@@ -11,9 +10,8 @@ namespace Umbraco.Cms.Api.Management.Services.PermissionFilter;
|
||||
/// <summary>
|
||||
/// Provides functionality to filter document entities based on the current user's permissions.
|
||||
/// </summary>
|
||||
internal sealed class DocumentPermissionFilterService : IDocumentPermissionFilterService
|
||||
internal sealed class DocumentPermissionFilterService : PermissionFilterServiceBase, IDocumentPermissionFilterService
|
||||
{
|
||||
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
|
||||
private readonly IUserService _userService;
|
||||
|
||||
/// <summary>
|
||||
@@ -24,68 +22,15 @@ internal sealed class DocumentPermissionFilterService : IDocumentPermissionFilte
|
||||
public DocumentPermissionFilterService(
|
||||
IBackOfficeSecurityAccessor backOfficeSecurityAccessor,
|
||||
IUserService userService)
|
||||
{
|
||||
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
|
||||
_userService = userService;
|
||||
}
|
||||
: base(backOfficeSecurityAccessor)
|
||||
=> _userService = userService;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<(IEntitySlim[] Entities, long TotalItems)> FilterAsync(IEntitySlim[] entities, long totalItems)
|
||||
{
|
||||
Dictionary<Guid, NodePermissions>? permissionsByNodeKey = await GetDocumentPermissionsByNodeKeyAsync(entities);
|
||||
if (permissionsByNodeKey is null)
|
||||
{
|
||||
return (entities, totalItems);
|
||||
}
|
||||
/// <inheritdoc/>
|
||||
protected override string BrowseActionLetter => ActionBrowse.ActionLetter;
|
||||
|
||||
IEntitySlim[] filteredEntities = FilterEntitiesWithBrowsePermission(entities, permissionsByNodeKey);
|
||||
var removedCount = entities.Length - filteredEntities.Length;
|
||||
|
||||
return (filteredEntities, totalItems - removedCount);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<(IEntitySlim[] Entities, long TotalBefore, long TotalAfter)> FilterAsync(Guid targetKey, IEntitySlim[] entities, long totalBefore, long totalAfter)
|
||||
{
|
||||
Dictionary<Guid, NodePermissions>? permissionsByNodeKey = await GetDocumentPermissionsByNodeKeyAsync(entities);
|
||||
if (permissionsByNodeKey is null)
|
||||
{
|
||||
return (entities, totalBefore, totalAfter);
|
||||
}
|
||||
|
||||
// Find the index of the target entity to determine before/after boundaries
|
||||
var targetIndex = Array.FindIndex(entities, e => e.Key == targetKey);
|
||||
|
||||
// Count removed entities before and after the target separately
|
||||
var removedBefore = entities.Take(targetIndex).Count(e => HasBrowsePermission(e, permissionsByNodeKey) is false);
|
||||
var removedAfter = entities.Skip(targetIndex + 1).Count(e => HasBrowsePermission(e, permissionsByNodeKey) is false);
|
||||
|
||||
IEntitySlim[] filteredEntities = FilterEntitiesWithBrowsePermission(entities, permissionsByNodeKey);
|
||||
|
||||
return (filteredEntities, totalBefore - removedBefore, totalAfter - removedAfter);
|
||||
}
|
||||
|
||||
private async Task<Dictionary<Guid, NodePermissions>?> GetDocumentPermissionsByNodeKeyAsync(IEntitySlim[] entities)
|
||||
{
|
||||
Guid userKey = CurrentUserKey();
|
||||
var entityKeys = entities.Select(e => e.Key).ToHashSet();
|
||||
|
||||
Attempt<IEnumerable<NodePermissions>, UserOperationStatus> permissionsAttempt =
|
||||
await _userService.GetDocumentPermissionsAsync(userKey, entityKeys);
|
||||
|
||||
return permissionsAttempt.Success
|
||||
? permissionsAttempt.Result.ToDictionary(p => p.NodeKey)
|
||||
: null;
|
||||
}
|
||||
|
||||
private Guid CurrentUserKey()
|
||||
=> _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Key
|
||||
?? throw new InvalidOperationException("No backoffice user found");
|
||||
|
||||
private static IEntitySlim[] FilterEntitiesWithBrowsePermission(IEntitySlim[] entities, Dictionary<Guid, NodePermissions> permissionsByNodeKey)
|
||||
=> entities.Where(e => HasBrowsePermission(e, permissionsByNodeKey)).ToArray();
|
||||
|
||||
private static bool HasBrowsePermission(IEntitySlim entity, Dictionary<Guid, NodePermissions> permissionsByNodeKey)
|
||||
=> permissionsByNodeKey.TryGetValue(entity.Key, out NodePermissions? nodePermissions) is false
|
||||
|| nodePermissions.Permissions.Contains(ActionBrowse.ActionLetter);
|
||||
/// <inheritdoc/>
|
||||
protected override Task<Attempt<IEnumerable<NodePermissions>, UserOperationStatus>> GetPermissionsAsync(
|
||||
Guid userKey,
|
||||
HashSet<Guid> entityKeys)
|
||||
=> _userService.GetDocumentPermissionsAsync(userKey, entityKeys);
|
||||
}
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Actions;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Security;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Services.OperationStatus;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.Services.PermissionFilter;
|
||||
|
||||
/// <summary>
|
||||
/// Provides functionality to filter element entities based on the current user's permissions.
|
||||
/// </summary>
|
||||
internal sealed class ElementPermissionFilterService : PermissionFilterServiceBase, IElementPermissionFilterService
|
||||
{
|
||||
private readonly IUserService _userService;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ElementPermissionFilterService"/> class.
|
||||
/// </summary>
|
||||
/// <param name="backOfficeSecurityAccessor">Provides access to the current backoffice user's security context.</param>
|
||||
/// <param name="userService">Service used to retrieve user and element permissions.</param>
|
||||
public ElementPermissionFilterService(
|
||||
IBackOfficeSecurityAccessor backOfficeSecurityAccessor,
|
||||
IUserService userService)
|
||||
: base(backOfficeSecurityAccessor)
|
||||
=> _userService = userService;
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override string BrowseActionLetter => ActionElementBrowse.ActionLetter;
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override Task<Attempt<IEnumerable<NodePermissions>, UserOperationStatus>> GetPermissionsAsync(
|
||||
Guid userKey,
|
||||
HashSet<Guid> entityKeys)
|
||||
=> _userService.GetElementPermissionsAsync(userKey, entityKeys);
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
using Umbraco.Cms.Core.Models.Entities;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.Services.PermissionFilter;
|
||||
|
||||
/// <summary>
|
||||
/// Service for filtering element entities based on user browse permissions.
|
||||
/// </summary>
|
||||
public interface IElementPermissionFilterService
|
||||
{
|
||||
/// <summary>
|
||||
/// Filters element entities based on the current user's browse permissions.
|
||||
/// </summary>
|
||||
/// <param name="entities">The entities to filter.</param>
|
||||
/// <param name="totalItems">The total number of items before filtering.</param>
|
||||
/// <returns>A tuple containing the filtered entities and the adjusted total items count.</returns>
|
||||
Task<(IEntitySlim[] Entities, long TotalItems)> FilterAsync(IEntitySlim[] entities, long totalItems);
|
||||
|
||||
/// <summary>
|
||||
/// Filters sibling element entities based on the current user's browse permissions.
|
||||
/// </summary>
|
||||
/// <param name="targetKey">The key of the target entity around which siblings are being retrieved.</param>
|
||||
/// <param name="entities">The entities to filter.</param>
|
||||
/// <param name="totalBefore">The total number of siblings before the target entity.</param>
|
||||
/// <param name="totalAfter">The total number of siblings after the target entity.</param>
|
||||
/// <returns>A tuple containing the filtered entities and the adjusted before/after counts.</returns>
|
||||
Task<(IEntitySlim[] Entities, long TotalBefore, long TotalAfter)> FilterAsync(
|
||||
Guid targetKey,
|
||||
IEntitySlim[] entities,
|
||||
long totalBefore,
|
||||
long totalAfter);
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Models.Entities;
|
||||
using Umbraco.Cms.Core.Security;
|
||||
using Umbraco.Cms.Core.Services.OperationStatus;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.Services.PermissionFilter;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for filtering entities based on the current user's browse permissions.
|
||||
/// </summary>
|
||||
internal abstract class PermissionFilterServiceBase
|
||||
{
|
||||
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PermissionFilterServiceBase"/> class.
|
||||
/// </summary>
|
||||
/// <param name="backOfficeSecurityAccessor">Provides access to the current backoffice user's security context.</param>
|
||||
protected PermissionFilterServiceBase(IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
|
||||
=> _backOfficeSecurityAccessor = backOfficeSecurityAccessor;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the browse action letter used to check permissions.
|
||||
/// </summary>
|
||||
protected abstract string BrowseActionLetter { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Filters entities based on the current user's browse permissions.
|
||||
/// </summary>
|
||||
/// <param name="entities">The entities to filter.</param>
|
||||
/// <param name="totalItems">The total number of items before filtering.</param>
|
||||
/// <returns>A tuple containing the filtered entities and the adjusted total items count.</returns>
|
||||
public async Task<(IEntitySlim[] Entities, long TotalItems)> FilterAsync(IEntitySlim[] entities, long totalItems)
|
||||
{
|
||||
Dictionary<Guid, NodePermissions>? permissionsByNodeKey = await GetPermissionsByNodeKeyAsync(entities);
|
||||
if (permissionsByNodeKey is null)
|
||||
{
|
||||
return (entities, totalItems);
|
||||
}
|
||||
|
||||
IEntitySlim[] filteredEntities = FilterEntitiesWithBrowsePermission(entities, permissionsByNodeKey);
|
||||
var removedCount = entities.Length - filteredEntities.Length;
|
||||
|
||||
return (filteredEntities, totalItems - removedCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filters sibling entities based on the current user's browse permissions.
|
||||
/// </summary>
|
||||
/// <param name="targetKey">The key of the target entity around which siblings are being retrieved.</param>
|
||||
/// <param name="entities">The entities to filter.</param>
|
||||
/// <param name="totalBefore">The total number of siblings before the target entity.</param>
|
||||
/// <param name="totalAfter">The total number of siblings after the target entity.</param>
|
||||
/// <returns>A tuple containing the filtered entities and the adjusted before/after counts.</returns>
|
||||
public async Task<(IEntitySlim[] Entities, long TotalBefore, long TotalAfter)> FilterAsync(
|
||||
Guid targetKey,
|
||||
IEntitySlim[] entities,
|
||||
long totalBefore,
|
||||
long totalAfter)
|
||||
{
|
||||
Dictionary<Guid, NodePermissions>? permissionsByNodeKey = await GetPermissionsByNodeKeyAsync(entities);
|
||||
if (permissionsByNodeKey is null)
|
||||
{
|
||||
return (entities, totalBefore, totalAfter);
|
||||
}
|
||||
|
||||
// Find the index of the target entity to determine before/after boundaries
|
||||
var targetIndex = Array.FindIndex(entities, e => e.Key == targetKey);
|
||||
|
||||
// Count removed entities before and after the target separately
|
||||
var removedBefore = entities.Take(targetIndex).Count(e => HasBrowsePermission(e, permissionsByNodeKey) is false);
|
||||
var removedAfter = entities.Skip(targetIndex + 1).Count(e => HasBrowsePermission(e, permissionsByNodeKey) is false);
|
||||
|
||||
IEntitySlim[] filteredEntities = FilterEntitiesWithBrowsePermission(entities, permissionsByNodeKey);
|
||||
|
||||
return (filteredEntities, totalBefore - removedBefore, totalAfter - removedAfter);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves node permissions for the given user and entity keys.
|
||||
/// </summary>
|
||||
/// <param name="userKey">The key of the user to retrieve permissions for.</param>
|
||||
/// <param name="entityKeys">The keys of the entities to retrieve permissions for.</param>
|
||||
/// <returns>An attempt containing the node permissions or a failure status.</returns>
|
||||
protected abstract Task<Attempt<IEnumerable<NodePermissions>, UserOperationStatus>> GetPermissionsAsync(
|
||||
Guid userKey,
|
||||
HashSet<Guid> entityKeys);
|
||||
|
||||
private async Task<Dictionary<Guid, NodePermissions>?> GetPermissionsByNodeKeyAsync(IEntitySlim[] entities)
|
||||
{
|
||||
Guid userKey = CurrentUserKey();
|
||||
var entityKeys = entities.Select(e => e.Key).ToHashSet();
|
||||
|
||||
Attempt<IEnumerable<NodePermissions>, UserOperationStatus> permissionsAttempt =
|
||||
await GetPermissionsAsync(userKey, entityKeys);
|
||||
|
||||
return permissionsAttempt.Success
|
||||
? permissionsAttempt.Result.ToDictionary(p => p.NodeKey)
|
||||
: null;
|
||||
}
|
||||
|
||||
private Guid CurrentUserKey()
|
||||
=> _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Key
|
||||
?? throw new InvalidOperationException("No backoffice user found");
|
||||
|
||||
private IEntitySlim[] FilterEntitiesWithBrowsePermission(IEntitySlim[] entities, Dictionary<Guid, NodePermissions> permissionsByNodeKey)
|
||||
=> entities.Where(e => HasBrowsePermission(e, permissionsByNodeKey)).ToArray();
|
||||
|
||||
private bool HasBrowsePermission(IEntitySlim entity, Dictionary<Guid, NodePermissions> permissionsByNodeKey)
|
||||
=> permissionsByNodeKey.TryGetValue(entity.Key, out NodePermissions? nodePermissions) is false
|
||||
|| nodePermissions.Permissions.Contains(BrowseActionLetter);
|
||||
}
|
||||
@@ -2053,7 +2053,7 @@ internal partial class UserService : RepositoryService, IUserService
|
||||
}
|
||||
|
||||
Attempt<IEnumerable<NodePermissions>, UserOperationStatus> permissions =
|
||||
await GetPermissionsAsync(userKey, idAttempt.Result, [UmbracoObjectTypes.Document]);
|
||||
await GetPermissionsAsync(userKey, idAttempt.Result, objectTypes);
|
||||
scope.Complete();
|
||||
|
||||
return permissions;
|
||||
|
||||
+266
@@ -0,0 +1,266 @@
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
using Umbraco.Cms.Api.Management.Services.PermissionFilter;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Actions;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Models.Entities;
|
||||
using Umbraco.Cms.Core.Models.Membership;
|
||||
using Umbraco.Cms.Core.Security;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Services.OperationStatus;
|
||||
|
||||
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Cms.Api.Management.Services.PermissionFilter;
|
||||
|
||||
[TestFixture]
|
||||
public class ElementPermissionFilterServiceTests
|
||||
{
|
||||
private readonly Mock<IBackOfficeSecurityAccessor> _backOfficeSecurityAccessor = new(MockBehavior.Strict);
|
||||
private readonly Mock<IUserService> _userService = new(MockBehavior.Strict);
|
||||
|
||||
private ElementPermissionFilterService ElementPermissionFilterService
|
||||
=> new(_backOfficeSecurityAccessor.Object, _userService.Object);
|
||||
|
||||
[SetUp]
|
||||
public void SetUp() => SetupCurrentUser();
|
||||
|
||||
[Test]
|
||||
public async Task FilterAsync_ReturnsAllEntities_WhenAllHaveBrowsePermission()
|
||||
{
|
||||
// Arrange
|
||||
var entities = CreateEntities(3);
|
||||
SetupGetElementPermissionsAsync(
|
||||
entities.Select(e => CreateNodePermissions(e.Key, ActionElementBrowse.ActionLetter)));
|
||||
|
||||
// Act
|
||||
var (filteredEntities, totalItems) = await ElementPermissionFilterService
|
||||
.FilterAsync(entities, 100);
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(3, filteredEntities.Length);
|
||||
Assert.AreEqual(100, totalItems);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task FilterAsync_FiltersEntities_WhenSomeAreDeniedBrowsePermission()
|
||||
{
|
||||
// Arrange
|
||||
var entities = CreateEntities(3);
|
||||
SetupGetElementPermissionsAsync(
|
||||
[
|
||||
CreateNodePermissions(entities[0].Key, ActionElementBrowse.ActionLetter),
|
||||
CreateNodePermissions(entities[1].Key), // No browse permission
|
||||
CreateNodePermissions(entities[2].Key, ActionElementBrowse.ActionLetter),
|
||||
]);
|
||||
|
||||
// Act
|
||||
var (filteredEntities, totalItems) = await ElementPermissionFilterService
|
||||
.FilterAsync(entities, 100);
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(2, filteredEntities.Length);
|
||||
Assert.AreEqual(99, totalItems);
|
||||
Assert.IsTrue(filteredEntities.Any(e => e.Key == entities[0].Key));
|
||||
Assert.IsFalse(filteredEntities.Any(e => e.Key == entities[1].Key));
|
||||
Assert.IsTrue(filteredEntities.Any(e => e.Key == entities[2].Key));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task FilterAsync_IncludesEntities_WhenNoPermissionEntryExists()
|
||||
{
|
||||
// Arrange
|
||||
var entities = CreateEntities(3);
|
||||
SetupGetElementPermissionsAsync(
|
||||
[
|
||||
CreateNodePermissions(entities[0].Key, ActionElementBrowse.ActionLetter),
|
||||
// entities[1] has no permission entry - should be included
|
||||
CreateNodePermissions(entities[2].Key, ActionElementBrowse.ActionLetter),
|
||||
]);
|
||||
|
||||
// Act
|
||||
var (filteredEntities, totalItems) = await ElementPermissionFilterService
|
||||
.FilterAsync(entities, 100);
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(3, filteredEntities.Length);
|
||||
Assert.AreEqual(100, totalItems);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task FilterAsync_FiltersAllEntities_WhenNoneHaveBrowsePermission()
|
||||
{
|
||||
// Arrange
|
||||
var entities = CreateEntities(3);
|
||||
SetupGetElementPermissionsAsync(
|
||||
entities.Select(e => CreateNodePermissions(e.Key))); // No browse permission
|
||||
|
||||
// Act
|
||||
var (filteredEntities, totalItems) = await ElementPermissionFilterService
|
||||
.FilterAsync(entities, 100);
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(0, filteredEntities.Length);
|
||||
Assert.AreEqual(97, totalItems);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task FilterAsync_Siblings_ReturnsAllEntities_WhenAllHaveBrowsePermission()
|
||||
{
|
||||
// Arrange
|
||||
var entities = CreateEntities(5);
|
||||
var targetKey = entities[2].Key;
|
||||
SetupGetElementPermissionsAsync(
|
||||
entities.Select(e => CreateNodePermissions(e.Key, ActionElementBrowse.ActionLetter)));
|
||||
|
||||
// Act
|
||||
var (filteredEntities, totalBefore, totalAfter) = await ElementPermissionFilterService
|
||||
.FilterAsync(targetKey, entities, 10, 20);
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(5, filteredEntities.Length);
|
||||
Assert.AreEqual(10, totalBefore);
|
||||
Assert.AreEqual(20, totalAfter);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task FilterAsync_Siblings_DecrementsTotalBefore_WhenEntityBeforeTargetIsFiltered()
|
||||
{
|
||||
// Arrange
|
||||
var entities = CreateEntities(5);
|
||||
var targetKey = entities[2].Key; // Index 2 is target, indices 0,1 are before
|
||||
SetupGetElementPermissionsAsync(
|
||||
[
|
||||
CreateNodePermissions(entities[0].Key), // No browse - BEFORE target
|
||||
CreateNodePermissions(entities[1].Key, ActionElementBrowse.ActionLetter),
|
||||
CreateNodePermissions(entities[2].Key, ActionElementBrowse.ActionLetter), // Target
|
||||
CreateNodePermissions(entities[3].Key, ActionElementBrowse.ActionLetter),
|
||||
CreateNodePermissions(entities[4].Key, ActionElementBrowse.ActionLetter),
|
||||
]);
|
||||
|
||||
// Act
|
||||
var (filteredEntities, totalBefore, totalAfter) = await ElementPermissionFilterService
|
||||
.FilterAsync(targetKey, entities, 10, 20);
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(4, filteredEntities.Length);
|
||||
Assert.AreEqual(9, totalBefore); // Decremented by 1
|
||||
Assert.AreEqual(20, totalAfter); // Unchanged
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task FilterAsync_Siblings_DecrementsTotalAfter_WhenEntityAfterTargetIsFiltered()
|
||||
{
|
||||
// Arrange
|
||||
var entities = CreateEntities(5);
|
||||
var targetKey = entities[2].Key; // Index 2 is target, indices 3,4 are after
|
||||
SetupGetElementPermissionsAsync(
|
||||
[
|
||||
CreateNodePermissions(entities[0].Key, ActionElementBrowse.ActionLetter),
|
||||
CreateNodePermissions(entities[1].Key, ActionElementBrowse.ActionLetter),
|
||||
CreateNodePermissions(entities[2].Key, ActionElementBrowse.ActionLetter), // Target
|
||||
CreateNodePermissions(entities[3].Key), // No browse - AFTER target
|
||||
CreateNodePermissions(entities[4].Key, ActionElementBrowse.ActionLetter),
|
||||
]);
|
||||
|
||||
// Act
|
||||
var (filteredEntities, totalBefore, totalAfter) = await ElementPermissionFilterService
|
||||
.FilterAsync(targetKey, entities, 10, 20);
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(4, filteredEntities.Length);
|
||||
Assert.AreEqual(10, totalBefore); // Unchanged
|
||||
Assert.AreEqual(19, totalAfter); // Decremented by 1
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task FilterAsync_Siblings_DecrementsBothTotals_WhenEntitiesBeforeAndAfterAreFiltered()
|
||||
{
|
||||
// Arrange
|
||||
var entities = CreateEntities(5);
|
||||
var targetKey = entities[2].Key;
|
||||
SetupGetElementPermissionsAsync(
|
||||
[
|
||||
CreateNodePermissions(entities[0].Key), // No browse - BEFORE
|
||||
CreateNodePermissions(entities[1].Key), // No browse - BEFORE
|
||||
CreateNodePermissions(entities[2].Key, ActionElementBrowse.ActionLetter), // Target
|
||||
CreateNodePermissions(entities[3].Key), // No browse - AFTER
|
||||
CreateNodePermissions(entities[4].Key), // No browse - AFTER
|
||||
]);
|
||||
|
||||
// Act
|
||||
var (filteredEntities, totalBefore, totalAfter) = await ElementPermissionFilterService
|
||||
.FilterAsync(targetKey, entities, 10, 20);
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(1, filteredEntities.Length); // Only target remains
|
||||
Assert.AreEqual(8, totalBefore); // Decremented by 2
|
||||
Assert.AreEqual(18, totalAfter); // Decremented by 2
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task FilterAsync_Siblings_DoesNotAffectTotals_WhenTargetEntityIsFiltered()
|
||||
{
|
||||
// Arrange
|
||||
var entities = CreateEntities(5);
|
||||
var targetKey = entities[2].Key;
|
||||
SetupGetElementPermissionsAsync(
|
||||
[
|
||||
CreateNodePermissions(entities[0].Key, ActionElementBrowse.ActionLetter),
|
||||
CreateNodePermissions(entities[1].Key, ActionElementBrowse.ActionLetter),
|
||||
CreateNodePermissions(entities[2].Key), // No browse - TARGET itself
|
||||
CreateNodePermissions(entities[3].Key, ActionElementBrowse.ActionLetter),
|
||||
CreateNodePermissions(entities[4].Key, ActionElementBrowse.ActionLetter),
|
||||
]);
|
||||
|
||||
// Act
|
||||
var (filteredEntities, totalBefore, totalAfter) = await ElementPermissionFilterService
|
||||
.FilterAsync(targetKey, entities, 10, 20);
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(4, filteredEntities.Length);
|
||||
Assert.AreEqual(10, totalBefore); // Unchanged - target is not before or after
|
||||
Assert.AreEqual(20, totalAfter); // Unchanged - target is not before or after
|
||||
}
|
||||
|
||||
private static IEntitySlim[] CreateEntities(int count)
|
||||
{
|
||||
var entities = new IEntitySlim[count];
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var mock = new Mock<IEntitySlim>();
|
||||
mock.Setup(e => e.Key).Returns(Guid.NewGuid());
|
||||
mock.Setup(e => e.Id).Returns(i + 1);
|
||||
entities[i] = mock.Object;
|
||||
}
|
||||
|
||||
return entities;
|
||||
}
|
||||
|
||||
private void SetupCurrentUser()
|
||||
{
|
||||
var userMock = new Mock<IUser>();
|
||||
userMock.Setup(u => u.Key).Returns(Guid.NewGuid());
|
||||
|
||||
var backOfficeSecurityMock = new Mock<IBackOfficeSecurity>();
|
||||
backOfficeSecurityMock.Setup(b => b.CurrentUser).Returns(userMock.Object);
|
||||
|
||||
_backOfficeSecurityAccessor
|
||||
.Setup(a => a.BackOfficeSecurity)
|
||||
.Returns(backOfficeSecurityMock.Object);
|
||||
}
|
||||
|
||||
private void SetupGetElementPermissionsAsync(IEnumerable<NodePermissions> permissions)
|
||||
{
|
||||
var attempt = Attempt.SucceedWithStatus(UserOperationStatus.Success, permissions);
|
||||
_userService
|
||||
.Setup(s => s.GetElementPermissionsAsync(It.IsAny<Guid>(), It.IsAny<ISet<Guid>>()))
|
||||
.ReturnsAsync(attempt);
|
||||
}
|
||||
|
||||
private static NodePermissions CreateNodePermissions(Guid nodeKey, params string[] permissions)
|
||||
=> new()
|
||||
{
|
||||
NodeKey = nodeKey,
|
||||
Permissions = permissions.ToHashSet(),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user