diff --git a/src/Umbraco.Core/Persistence/Repositories/IEntityRepository.cs b/src/Umbraco.Core/Persistence/Repositories/IEntityRepository.cs index d5ac7dae6bb9..b6842bcba133 100644 --- a/src/Umbraco.Core/Persistence/Repositories/IEntityRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/IEntityRepository.cs @@ -180,6 +180,14 @@ IEnumerable GetTrashedSiblings( /// A collection of entity paths. IEnumerable GetAllPaths(Guid objectType, params Guid[] keys); + /// + /// Gets all paths for entities of the specified object types, optionally filtered by the provided entity IDs. + /// + /// The unique identifiers of the object types. + /// Optional array of entity keys to filter the paths. If not provided, paths for all entities of the specified type are returned. + /// A collection of entity paths. + IEnumerable GetAllPaths(Guid[] objectTypes, params Guid[] keys); + /// /// Checks whether an entity with the specified identifier exists. /// diff --git a/src/Umbraco.Core/Services/ElementPermissionService.cs b/src/Umbraco.Core/Services/ElementPermissionService.cs index 6f9175deb5c6..62f88c4f8b92 100644 --- a/src/Umbraco.Core/Services/ElementPermissionService.cs +++ b/src/Umbraco.Core/Services/ElementPermissionService.cs @@ -2,6 +2,7 @@ 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.AuthorizationStatus; using Umbraco.Extensions; @@ -39,25 +40,26 @@ public Task AuthorizeAccessAsync( return Task.FromResult(ElementAuthorizationStatus.NotFound); } - // Fetch both Elements and ElementContainers (folders) - IEntitySlim[] entities = _entityService.GetAll( - new[] { UmbracoObjectTypes.Element, UmbracoObjectTypes.ElementContainer }, - keys).ToArray(); - - if (entities.Length == 0) + // Use GetAllPaths instead of loading full content items - we only need paths for authorization + TreeEntityPath[] entityPaths = _entityService.GetAllPaths([UmbracoObjectTypes.Element, UmbracoObjectTypes.ElementContainer], keys).ToArray(); + if (entityPaths.Length == 0) { return Task.FromResult(ElementAuthorizationStatus.NotFound); } - if (entities.Any(entity => user.HasElementPathAccess(entity, _entityService, _appCaches) == false)) + // Check path access using the paths directly + int[]? startNodeIds = user.CalculateElementStartNodeIds(_entityService, _appCaches); + foreach (TreeEntityPath entityPath in entityPaths) { - return Task.FromResult(ElementAuthorizationStatus.UnauthorizedMissingPathAccess); + if (ContentPermissions.HasPathAccess(entityPath.Path, startNodeIds, Constants.System.RecycleBinElement) == false) + { + return Task.FromResult(ElementAuthorizationStatus.UnauthorizedMissingPathAccess); + } } - return Task.FromResult( - HasPermissionAccess(user, entities.Select(e => e.Path), permissionsToCheck) - ? ElementAuthorizationStatus.Success - : ElementAuthorizationStatus.UnauthorizedMissingPermissionAccess); + return Task.FromResult(HasPermissionAccess(user, entityPaths.Select(p => p.Path), permissionsToCheck) + ? ElementAuthorizationStatus.Success + : ElementAuthorizationStatus.UnauthorizedMissingPermissionAccess); } /// diff --git a/src/Umbraco.Core/Services/EntityService.cs b/src/Umbraco.Core/Services/EntityService.cs index 3d4c8c39bfe1..bc85b2fd27cf 100644 --- a/src/Umbraco.Core/Services/EntityService.cs +++ b/src/Umbraco.Core/Services/EntityService.cs @@ -831,6 +831,15 @@ public virtual IEnumerable GetAllPaths(UmbracoObjectTypes object } } + /// + public virtual IEnumerable GetAllPaths(IEnumerable objectTypes, params Guid[] keys) + { + using (ScopeProvider.CreateCoreScope(autoComplete: true)) + { + return _entityRepository.GetAllPaths(objectTypes.Select(objectType => objectType.GetGuid()).ToArray(), keys); + } + } + /// public int ReserveId(Guid key) { diff --git a/src/Umbraco.Core/Services/IEntityService.cs b/src/Umbraco.Core/Services/IEntityService.cs index 1dc61c28240a..a619a1e32b70 100644 --- a/src/Umbraco.Core/Services/IEntityService.cs +++ b/src/Umbraco.Core/Services/IEntityService.cs @@ -505,6 +505,11 @@ IEnumerable GetPagedDescendants( /// IEnumerable GetAllPaths(UmbracoObjectTypes objectType, params Guid[] keys); + /// + /// Gets paths for entities. + /// + IEnumerable GetAllPaths(IEnumerable objectTypes, params Guid[] keys); + /// /// Reserves an identifier for a key. /// diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/EntityRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/EntityRepository.cs index 8ae8da50e686..667ea349366d 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/EntityRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/EntityRepository.cs @@ -587,14 +587,23 @@ public IEnumerable GetAllPaths(Guid objectType, params Guid[] ke ? PerformGetAllPaths(objectType, sql => sql.WhereIn(x => x.UniqueId, keys.Distinct())) : PerformGetAllPaths(objectType); + /// + public IEnumerable GetAllPaths(Guid[] objectTypes, params Guid[] keys) => + keys.Any() + ? PerformGetAllPaths(objectTypes, sql => sql.WhereIn(x => x.UniqueId, keys.Distinct())) + : PerformGetAllPaths(objectTypes); + private IEnumerable PerformGetAllPaths(Guid objectType, Action>? filter = null) + => PerformGetAllPaths([objectType], filter); + + private IEnumerable PerformGetAllPaths(Guid[] objectTypes, Action>? filter = null) { // NodeId is named Id on TreeEntityPath = use an alias Sql sql = Sql().Select( x => Alias(x.NodeId, nameof(TreeEntityPath.Id)), x => x.Path, x => Alias(x.UniqueId, nameof(TreeEntityPath.Key))) - .From().Where(x => x.NodeObjectType == objectType); + .From().WhereIn(x => x.NodeObjectType, objectTypes); filter?.Invoke(sql); return Database.Fetch(sql); } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/ElementPermissionServiceTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/ElementPermissionServiceTests.cs new file mode 100644 index 000000000000..b423923c917c --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/ElementPermissionServiceTests.cs @@ -0,0 +1,357 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Entities; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.AuthorizationStatus; +using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Builders.Extensions; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Services; + +[TestFixture] +public class ElementPermissionServiceTests +{ + private const int ElementNodeId = 5678; + private const string ElementNodePath = "-1,1234,5678"; + private const int UserStartNodeId = 1234; + private const string UserStartNodePath = "-1,1234"; + private const int UnrelatedStartNodeId = 9876; + + private Mock _entityServiceMock; + private Mock _userServiceMock; + private Mock _languageServiceMock; + private IElementPermissionService _sut; + + [SetUp] + public void SetUp() + { + _entityServiceMock = new Mock(); + _userServiceMock = new Mock(); + _languageServiceMock = new Mock(); + _sut = new ElementPermissionService( + _entityServiceMock.Object, + _userServiceMock.Object, + AppCaches.Disabled, + _languageServiceMock.Object); + } + + [Test] + public async Task Can_Authorize_Access_By_Path() + { + // Arrange + var elementKey = Guid.NewGuid(); + var user = CreateUser(); + + _entityServiceMock + .Setup(x => x.GetAllPaths(It.Is>(t => t.Contains(UmbracoObjectTypes.Element)), new[] { elementKey })) + .Returns([CreateTreeEntityPath(elementKey, ElementNodeId, ElementNodePath)]); + + SetupPermissions(user, ElementNodePath, ["A"]); + + // Act + var result = await _sut.AuthorizeAccessAsync(user, elementKey, "A"); + + // Assert + Assert.That(result, Is.EqualTo(ElementAuthorizationStatus.Success)); + } + + [Test] + public async Task Cannot_Authorize_Access_When_Element_Not_Found() + { + // Arrange + var elementKey = Guid.NewGuid(); + var user = CreateUser(); + + _entityServiceMock + .Setup(x => x.GetAllPaths(It.Is>(t => t.Contains(UmbracoObjectTypes.Element)), new[] { elementKey })) + .Returns([]); + + // Act + var result = await _sut.AuthorizeAccessAsync(user, elementKey, "A"); + + // Assert + Assert.That(result, Is.EqualTo(ElementAuthorizationStatus.NotFound)); + } + + [Test] + public async Task Cannot_Authorize_Access_Without_Path_Access() + { + // Arrange + var elementKey = Guid.NewGuid(); + var user = CreateUser(startElementId: UnrelatedStartNodeId); + + _entityServiceMock + .Setup(x => x.GetAllPaths(It.Is>(t => t.Contains(UmbracoObjectTypes.Element)), new[] { elementKey })) + .Returns([CreateTreeEntityPath(elementKey, ElementNodeId, ElementNodePath)]); + + _entityServiceMock + .Setup(x => x.GetAllPaths(It.IsAny(), It.IsAny())) + .Returns([CreateTreeEntityPath(Guid.NewGuid(), UnrelatedStartNodeId, $"-1,{UnrelatedStartNodeId}")]); + + // Act + var result = await _sut.AuthorizeAccessAsync(user, elementKey, "A"); + + // Assert + Assert.That(result, Is.EqualTo(ElementAuthorizationStatus.UnauthorizedMissingPathAccess)); + } + + [Test] + public async Task Cannot_Authorize_Access_Without_Required_Permission() + { + // Arrange + var elementKey = Guid.NewGuid(); + var user = CreateUser(); + + _entityServiceMock + .Setup(x => x.GetAllPaths(It.Is>(t => t.Contains(UmbracoObjectTypes.Element)), new[] { elementKey })) + .Returns([CreateTreeEntityPath(elementKey, ElementNodeId, ElementNodePath)]); + + SetupPermissions(user, ElementNodePath, ["A", "B", "C"]); + + // Act + var result = await _sut.AuthorizeAccessAsync(user, elementKey, "F"); + + // Assert + Assert.That(result, Is.EqualTo(ElementAuthorizationStatus.UnauthorizedMissingPermissionAccess)); + } + + [Test] + public async Task Can_Authorize_Access_With_Required_Permission() + { + // Arrange + var elementKey = Guid.NewGuid(); + var user = CreateUser(); + + _entityServiceMock + .Setup(x => x.GetAllPaths(It.Is>(t => t.Contains(UmbracoObjectTypes.Element)), new[] { elementKey })) + .Returns([CreateTreeEntityPath(elementKey, ElementNodeId, ElementNodePath)]); + + SetupPermissions(user, ElementNodePath, ["A", "F", "C"]); + + // Act + var result = await _sut.AuthorizeAccessAsync(user, elementKey, "F"); + + // Assert + Assert.That(result, Is.EqualTo(ElementAuthorizationStatus.Success)); + } + + [Test] + public async Task Cannot_Authorize_Access_With_Empty_Keys() + { + // Arrange + var user = CreateUser(); + + // Act + var result = await _sut.AuthorizeAccessAsync(user, [], new HashSet { "A" }); + + // Assert + Assert.That(result, Is.EqualTo(ElementAuthorizationStatus.NotFound)); + } + + [Test] + public async Task Can_Authorize_Root_Access_By_Path() + { + // Arrange + var user = CreateUser(); + + SetupPermissions(user, Constants.System.RootString, ["A"]); + + // Act + var result = await _sut.AuthorizeRootAccessAsync(user, "A"); + + // Assert + Assert.That(result, Is.EqualTo(ElementAuthorizationStatus.Success)); + } + + [Test] + public async Task Cannot_Authorize_Root_Access_By_Path() + { + // Arrange + var user = CreateUser(startElementId: UserStartNodeId); + + _entityServiceMock + .Setup(x => x.GetAllPaths(It.IsAny(), It.IsAny())) + .Returns([CreateTreeEntityPath(Guid.NewGuid(), UserStartNodeId, UserStartNodePath)]); + + // Act + var result = await _sut.AuthorizeRootAccessAsync(user, "A"); + + // Assert + Assert.That(result, Is.EqualTo(ElementAuthorizationStatus.UnauthorizedMissingRootAccess)); + } + + [Test] + public async Task Cannot_Authorize_Root_Access_By_Permission() + { + // Arrange + var user = CreateUser(); + + SetupPermissions(user, Constants.System.RootString, ["A"]); + + // Act + var result = await _sut.AuthorizeRootAccessAsync(user, "B"); + + // Assert + Assert.That(result, Is.EqualTo(ElementAuthorizationStatus.UnauthorizedMissingPermissionAccess)); + } + + [Test] + public async Task Can_Authorize_Bin_Access_By_Path() + { + // Arrange + var user = CreateUser(); + + SetupPermissions(user, Constants.System.RecycleBinElementString, ["A"]); + + // Act + var result = await _sut.AuthorizeBinAccessAsync(user, "A"); + + // Assert + Assert.That(result, Is.EqualTo(ElementAuthorizationStatus.Success)); + } + + [Test] + public async Task Cannot_Authorize_Bin_Access_By_Path() + { + // Arrange + var user = CreateUser(startElementId: UserStartNodeId); + + _entityServiceMock + .Setup(x => x.GetAllPaths(It.IsAny(), It.IsAny())) + .Returns([CreateTreeEntityPath(Guid.NewGuid(), UserStartNodeId, UserStartNodePath)]); + + // Act + var result = await _sut.AuthorizeBinAccessAsync(user, "A"); + + // Assert + Assert.That(result, Is.EqualTo(ElementAuthorizationStatus.UnauthorizedMissingBinAccess)); + } + + [Test] + public async Task Cannot_Authorize_Bin_Access_By_Permission() + { + // Arrange + var user = CreateUser(); + + SetupPermissions(user, Constants.System.RecycleBinElementString, ["A"]); + + // Act + var result = await _sut.AuthorizeBinAccessAsync(user, "B"); + + // Assert + Assert.That(result, Is.EqualTo(ElementAuthorizationStatus.UnauthorizedMissingPermissionAccess)); + } + + [Test] + public async Task Can_Authorize_Culture_Access_When_Group_Has_All_Languages() + { + // Arrange + var user = CreateUserWithAllLanguageAccess(); + + // Act + var result = await _sut.AuthorizeCultureAccessAsync(user, new HashSet { "en-US", "da-DK" }); + + // Assert + Assert.That(result, Is.EqualTo(ElementAuthorizationStatus.Success)); + } + + [Test] + public async Task Can_Authorize_Culture_Access_When_User_Has_Language() + { + // Arrange + var languageId = 1; + var user = CreateUserWithLanguageAccess(languageId); + + _languageServiceMock + .Setup(x => x.GetIsoCodesByIdsAsync(It.Is>(ids => ids.Contains(languageId)))) + .ReturnsAsync(["en-US"]); + + // Act + var result = await _sut.AuthorizeCultureAccessAsync(user, new HashSet { "en-US" }); + + // Assert + Assert.That(result, Is.EqualTo(ElementAuthorizationStatus.Success)); + } + + [Test] + public async Task Cannot_Authorize_Culture_Access_When_User_Lacks_Language() + { + // Arrange + var languageId = 1; + var user = CreateUserWithLanguageAccess(languageId); + + _languageServiceMock + .Setup(x => x.GetIsoCodesByIdsAsync(It.Is>(ids => ids.Contains(languageId)))) + .ReturnsAsync(["en-US"]); + + // Act + var result = await _sut.AuthorizeCultureAccessAsync(user, new HashSet { "da-DK" }); + + // Assert + Assert.That(result, Is.EqualTo(ElementAuthorizationStatus.UnauthorizedMissingCulture)); + } + + private static IUser CreateUser(int id = 0, int? startElementId = null, bool withUserGroup = true) + { + var builder = new UserBuilder() + .WithId(id) + .WithStartElementIds(startElementId.HasValue ? [startElementId.Value] : []); + + if (withUserGroup) + { + builder = builder + .AddUserGroup() + .WithId(1) + .WithName("admin") + .WithAlias("admin") + .Done(); + } + + return builder.Build(); + } + + private static IUser CreateUserWithAllLanguageAccess() + { + var userGroupMock = new Mock(); + userGroupMock.Setup(x => x.HasAccessToAllLanguages).Returns(true); + userGroupMock.Setup(x => x.AllowedLanguages).Returns(new HashSet()); + // userGroupMock.Setup(x => x.StartContentId).Returns(-1); + // userGroupMock.Setup(x => x.StartMediaId).Returns(-1); + userGroupMock.Setup(x => x.StartElementId).Returns(-1); + + var userMock = new Mock(); + userMock.Setup(x => x.Groups).Returns([userGroupMock.Object]); + return userMock.Object; + } + + private static IUser CreateUserWithLanguageAccess(int languageId) + { + var userGroupMock = new Mock(); + userGroupMock.Setup(x => x.HasAccessToAllLanguages).Returns(false); + userGroupMock.Setup(x => x.AllowedLanguages).Returns(new HashSet { languageId }); + // userGroupMock.Setup(x => x.StartContentId).Returns(-1); + // userGroupMock.Setup(x => x.StartMediaId).Returns(-1); + userGroupMock.Setup(x => x.StartElementId).Returns(-1); + + var userMock = new Mock(); + userMock.Setup(x => x.Groups).Returns([userGroupMock.Object]); + return userMock.Object; + } + + private void SetupPermissions(IUser user, string path, string[] assignedPermissions) + { + var permissions = new EntityPermissionCollection { new(9876, 1234, assignedPermissions.ToHashSet()) }; + var permissionSet = new EntityPermissionSet(1234, permissions); + _userServiceMock.Setup(x => x.GetPermissionsForPath(user, path)).Returns(permissionSet); + } + + private static TreeEntityPath CreateTreeEntityPath(Guid key, int id, string path) + => Mock.Of(e => e.Key == key && e.Id == id && e.Path == path); +}