Skip to content

Commit 9d30d5b

Browse files
authored
Fix pagination for users restricted by start nodes (#18907)
* Fix pagination for users restricted by start nodes * Default implementation to avoid breakage * Review comments * Fix failing test * Add media start node tests
1 parent 11c1984 commit 9d30d5b

10 files changed

+1241
-9
lines changed

src/Umbraco.Cms.Api.Management/Controllers/Tree/UserStartNodeTreeControllerBase.cs

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,21 @@ protected override IEntitySlim[] GetPagedRootEntities(int skip, int take, out lo
4242

4343
protected override IEntitySlim[] GetPagedChildEntities(Guid parentKey, int skip, int take, out long totalItems)
4444
{
45-
IEntitySlim[] children = base.GetPagedChildEntities(parentKey, skip, take, out totalItems);
46-
return UserHasRootAccess() || IgnoreUserStartNodes()
47-
? children
48-
// Keeping the correct totalItems amount from GetPagedChildEntities
49-
: CalculateAccessMap(() => _userStartNodeEntitiesService.ChildUserAccessEntities(children, UserStartNodePaths), out _);
45+
if (UserHasRootAccess() || IgnoreUserStartNodes())
46+
{
47+
return base.GetPagedChildEntities(parentKey, skip, take, out totalItems);
48+
}
49+
50+
IEnumerable<UserAccessEntity> userAccessEntities = _userStartNodeEntitiesService.ChildUserAccessEntities(
51+
ItemObjectType,
52+
UserStartNodePaths,
53+
parentKey,
54+
skip,
55+
take,
56+
ItemOrdering,
57+
out totalItems);
58+
59+
return CalculateAccessMap(() => userAccessEntities, out _);
5060
}
5161

5262
protected override TItem[] MapTreeItemViewModels(Guid? parentKey, IEntitySlim[] entities)

src/Umbraco.Cms.Api.Management/Services/Entities/IUserStartNodeEntitiesService.cs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,41 @@ public interface IUserStartNodeEntitiesService
1616
/// <remarks>
1717
/// The returned entities may include entities that outside of the user start node scope, but are needed to
1818
/// for browsing to the actual user start nodes. These entities will be marked as "no access" entities.
19+
///
20+
/// This method does not support pagination, because it must load all entities explicitly in order to calculate
21+
/// the correct result, given that user start nodes can be descendants of root nodes. Consumers need to apply
22+
/// pagination to the result if applicable.
1923
/// </remarks>
2024
IEnumerable<UserAccessEntity> RootUserAccessEntities(UmbracoObjectTypes umbracoObjectType, int[] userStartNodeIds);
2125

26+
/// <summary>
27+
/// Calculates the applicable child entities for a given object type for users without root access.
28+
/// </summary>
29+
/// <param name="umbracoObjectType">The object type.</param>
30+
/// <param name="userStartNodePaths">The calculated start node paths for the user.</param>
31+
/// <param name="parentKey">The key of the parent.</param>
32+
/// <param name="skip">The number of applicable children to skip.</param>
33+
/// <param name="take">The number of applicable children to take.</param>
34+
/// <param name="ordering">The ordering to apply when fetching and paginating the children.</param>
35+
/// <param name="totalItems">The total number of applicable children available.</param>
36+
/// <returns>A list of child entities applicable for the user.</returns>
37+
/// <remarks>
38+
/// The returned entities may include entities that outside of the user start node scope, but are needed to
39+
/// for browsing to the actual user start nodes. These entities will be marked as "no access" entities.
40+
/// </remarks>
41+
IEnumerable<UserAccessEntity> ChildUserAccessEntities(
42+
UmbracoObjectTypes umbracoObjectType,
43+
string[] userStartNodePaths,
44+
Guid parentKey,
45+
int skip,
46+
int take,
47+
Ordering ordering,
48+
out long totalItems)
49+
{
50+
totalItems = 0;
51+
return [];
52+
}
53+
2254
/// <summary>
2355
/// Calculates the applicable child entities from a list of candidate child entities for users without root access.
2456
/// </summary>

src/Umbraco.Cms.Api.Management/Services/Entities/UserStartNodeEntitiesService.cs

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,37 @@
1-
using Umbraco.Cms.Core;
1+
using Microsoft.Extensions.DependencyInjection;
2+
using Umbraco.Cms.Core;
23
using Umbraco.Cms.Core.Models;
34
using Umbraco.Cms.Core.Models.Entities;
45
using Umbraco.Cms.Core.Services;
56
using Umbraco.Cms.Api.Management.Models.Entities;
7+
using Umbraco.Cms.Core.DependencyInjection;
8+
using Umbraco.Cms.Core.Persistence.Querying;
9+
using Umbraco.Cms.Core.Scoping;
610
using Umbraco.Extensions;
711

812
namespace Umbraco.Cms.Api.Management.Services.Entities;
913

1014
public class UserStartNodeEntitiesService : IUserStartNodeEntitiesService
1115
{
1216
private readonly IEntityService _entityService;
17+
private readonly ICoreScopeProvider _scopeProvider;
18+
private readonly IIdKeyMap _idKeyMap;
1319

14-
public UserStartNodeEntitiesService(IEntityService entityService) => _entityService = entityService;
20+
[Obsolete("Please use the non-obsolete constructor. Scheduled for removal in V17.")]
21+
public UserStartNodeEntitiesService(IEntityService entityService)
22+
: this(
23+
entityService,
24+
StaticServiceProvider.Instance.GetRequiredService<ICoreScopeProvider>(),
25+
StaticServiceProvider.Instance.GetRequiredService<IIdKeyMap>())
26+
{
27+
}
28+
29+
public UserStartNodeEntitiesService(IEntityService entityService, ICoreScopeProvider scopeProvider, IIdKeyMap idKeyMap)
30+
{
31+
_entityService = entityService;
32+
_scopeProvider = scopeProvider;
33+
_idKeyMap = idKeyMap;
34+
}
1535

1636
/// <inheritdoc />
1737
public IEnumerable<UserAccessEntity> RootUserAccessEntities(UmbracoObjectTypes umbracoObjectType, int[] userStartNodeIds)
@@ -43,6 +63,54 @@ public IEnumerable<UserAccessEntity> RootUserAccessEntities(UmbracoObjectTypes u
4363
.ToArray();
4464
}
4565

66+
public IEnumerable<UserAccessEntity> ChildUserAccessEntities(UmbracoObjectTypes umbracoObjectType, string[] userStartNodePaths, Guid parentKey, int skip, int take, Ordering ordering, out long totalItems)
67+
{
68+
Attempt<int> parentIdAttempt = _idKeyMap.GetIdForKey(parentKey, umbracoObjectType);
69+
if (parentIdAttempt.Success is false)
70+
{
71+
totalItems = 0;
72+
return [];
73+
}
74+
75+
var parentId = parentIdAttempt.Result;
76+
IEntitySlim? parent = _entityService.Get(parentId);
77+
if (parent is null)
78+
{
79+
totalItems = 0;
80+
return [];
81+
}
82+
83+
IEntitySlim[] children;
84+
if (userStartNodePaths.Any(path => $"{parent.Path},".StartsWith($"{path},")))
85+
{
86+
// the requested parent is one of the user start nodes (or a descendant of one), all children are by definition allowed
87+
children = _entityService.GetPagedChildren(parentKey, umbracoObjectType, skip, take, out totalItems, ordering: ordering).ToArray();
88+
return ChildUserAccessEntities(children, userStartNodePaths);
89+
}
90+
91+
// if one or more of the user start nodes are descendants of the requested parent, find the "next child IDs" in those user start node paths
92+
// - e.g. given the user start node path "-1,2,3,4,5", if the requested parent ID is 3, the "next child ID" is 4.
93+
var userStartNodePathIds = userStartNodePaths.Select(path => path.Split(Constants.CharArrays.Comma).Select(int.Parse).ToArray()).ToArray();
94+
var allowedChildIds = userStartNodePathIds
95+
.Where(ids => ids.Contains(parentId))
96+
// given the previous checks, the parent ID can never be the last in the user start node path, so this is safe
97+
.Select(ids => ids[ids.IndexOf(parentId) + 1])
98+
.Distinct()
99+
.ToArray();
100+
101+
totalItems = allowedChildIds.Length;
102+
if (allowedChildIds.Length == 0)
103+
{
104+
// the requested parent is outside the scope of any user start nodes
105+
return [];
106+
}
107+
108+
// even though we know the IDs of the allowed child entities to fetch, we still use a Query to yield correctly sorted children
109+
IQuery<IUmbracoEntity> query = _scopeProvider.CreateQuery<IUmbracoEntity>().Where(x => allowedChildIds.Contains(x.Id));
110+
children = _entityService.GetPagedChildren(parentKey, umbracoObjectType, skip, take, out totalItems, query, ordering).ToArray();
111+
return ChildUserAccessEntities(children, userStartNodePaths);
112+
}
113+
46114
/// <inheritdoc />
47115
public IEnumerable<UserAccessEntity> ChildUserAccessEntities(IEnumerable<IEntitySlim> candidateChildren, string[] userStartNodePaths)
48116
// child entities for users without root access should include:
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
using NUnit.Framework;
2+
using Umbraco.Cms.Core.Models;
3+
4+
namespace Umbraco.Cms.Tests.Integration.ManagementApi.Services;
5+
6+
public partial class UserStartNodeEntitiesServiceMediaTests
7+
{
8+
[Test]
9+
public async Task RootUserAccessEntities_FirstAndLastRoot_YieldsBoth_AsAllowed()
10+
{
11+
var contentStartNodeIds = await CreateUserAndGetStartNodeIds(_mediaByName["1"].Id, _mediaByName["5"].Id);
12+
13+
var roots = UserStartNodeEntitiesService
14+
.RootUserAccessEntities(
15+
UmbracoObjectTypes.Media,
16+
contentStartNodeIds)
17+
.ToArray();
18+
19+
// expected total is 2, because only two items at root ("1" amd "10") are allowed
20+
Assert.AreEqual(2, roots.Length);
21+
Assert.Multiple(() =>
22+
{
23+
// first and last content items are the ones allowed
24+
Assert.AreEqual(_mediaByName["1"].Key, roots[0].Entity.Key);
25+
Assert.AreEqual(_mediaByName["5"].Key, roots[1].Entity.Key);
26+
27+
// explicitly verify the entity sort order, both so we know sorting works,
28+
// and so we know it's actually the first and last item at root
29+
Assert.AreEqual(0, roots[0].Entity.SortOrder);
30+
Assert.AreEqual(4, roots[1].Entity.SortOrder);
31+
32+
// both are allowed (they are the actual start nodes)
33+
Assert.IsTrue(roots[0].HasAccess);
34+
Assert.IsTrue(roots[1].HasAccess);
35+
});
36+
}
37+
38+
[Test]
39+
public async Task RootUserAccessEntities_ChildrenAsStartNode_YieldsChildRoots_AsNotAllowed()
40+
{
41+
var contentStartNodeIds = await CreateUserAndGetStartNodeIds(_mediaByName["1-3"].Id, _mediaByName["3-3"].Id, _mediaByName["5-3"].Id);
42+
43+
var roots = UserStartNodeEntitiesService
44+
.RootUserAccessEntities(
45+
UmbracoObjectTypes.Media,
46+
contentStartNodeIds)
47+
.ToArray();
48+
49+
Assert.AreEqual(3, roots.Length);
50+
Assert.Multiple(() =>
51+
{
52+
// the three start nodes are the children of the "1", "3" and "5" roots, respectively, so these are expected as roots
53+
Assert.AreEqual(_mediaByName["1"].Key, roots[0].Entity.Key);
54+
Assert.AreEqual(_mediaByName["3"].Key, roots[1].Entity.Key);
55+
Assert.AreEqual(_mediaByName["5"].Key, roots[2].Entity.Key);
56+
57+
// all are disallowed - only the children (the actual start nodes) are allowed
58+
Assert.IsTrue(roots.All(r => r.HasAccess is false));
59+
});
60+
}
61+
62+
[Test]
63+
public async Task RootUserAccessEntities_GrandchildrenAsStartNode_YieldsGrandchildRoots_AsNotAllowed()
64+
{
65+
var contentStartNodeIds = await CreateUserAndGetStartNodeIds(_mediaByName["1-2-3"].Id, _mediaByName["2-3-4"].Id, _mediaByName["3-4-5"].Id);
66+
67+
var roots = UserStartNodeEntitiesService
68+
.RootUserAccessEntities(
69+
UmbracoObjectTypes.Media,
70+
contentStartNodeIds)
71+
.ToArray();
72+
73+
Assert.AreEqual(3, roots.Length);
74+
Assert.Multiple(() =>
75+
{
76+
// the three start nodes are the grandchildren of the "1", "2" and "3" roots, respectively, so these are expected as roots
77+
Assert.AreEqual(_mediaByName["1"].Key, roots[0].Entity.Key);
78+
Assert.AreEqual(_mediaByName["2"].Key, roots[1].Entity.Key);
79+
Assert.AreEqual(_mediaByName["3"].Key, roots[2].Entity.Key);
80+
81+
// all are disallowed - only the grandchildren (the actual start nodes) are allowed
82+
Assert.IsTrue(roots.All(r => r.HasAccess is false));
83+
});
84+
}
85+
}

0 commit comments

Comments
 (0)