diff --git a/src/Umbraco.Cms.Api.Management/Factories/IUserPresentationFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/IUserPresentationFactory.cs
index ac33b042eb..9ae171b02e 100644
--- a/src/Umbraco.Cms.Api.Management/Factories/IUserPresentationFactory.cs
+++ b/src/Umbraco.Cms.Api.Management/Factories/IUserPresentationFactory.cs
@@ -6,25 +6,58 @@ using Umbraco.Cms.Core.Models.Membership;
namespace Umbraco.Cms.Api.Management.Factories;
+///
+/// Defines factory methods for the creation of user presentation models.
+///
public interface IUserPresentationFactory
{
+ ///
+ /// Creates a response model for the provided user.
+ ///
UserResponseModel CreateResponseModel(IUser user);
+ ///
+ /// Creates a create model for a user based on the provided request model.
+ ///
Task CreateCreationModelAsync(CreateUserRequestModel requestModel);
+ ///
+ /// Creates an invite model for a user based on the provided request model.
+ ///
Task CreateInviteModelAsync(InviteUserRequestModel requestModel);
+ ///
+ /// Creates an update model for an existing user based on the provided request model.
+ ///
Task CreateUpdateModelAsync(Guid existingUserKey, UpdateUserRequestModel updateModel);
+ ///
+ /// Creates a response model for the current user based on the provided user.
+ ///
Task CreateCurrentUserResponseModelAsync(IUser user);
+ ///
+ /// Creates an resend invite model for a user based on the provided request model.
+ ///
Task CreateResendInviteModelAsync(ResendInviteUserRequestModel requestModel);
+ ///
+ /// Creates a user configuration model that contains the necessary data for user management operations.
+ ///
Task CreateUserConfigurationModelAsync();
+ ///
+ /// Creates a current user configuration model that contains the necessary data for the current user's management operations.
+ ///
Task CreateCurrentUserConfigurationModelAsync();
+ ///
+ /// Creates a user item response model for the provided user.
+ ///
UserItemResponseModel CreateItemResponseModel(IUser user);
+ ///
+ /// Creates a calculated user start nodes response model based on the provided user.
+ ///
Task CreateCalculatedUserStartNodesResponseModelAsync(IUser user);
}
diff --git a/src/Umbraco.Cms.Api.Management/Factories/UserPresentationFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/UserPresentationFactory.cs
index 4d7ed3c853..80c7b17e78 100644
--- a/src/Umbraco.Cms.Api.Management/Factories/UserPresentationFactory.cs
+++ b/src/Umbraco.Cms.Api.Management/Factories/UserPresentationFactory.cs
@@ -1,5 +1,6 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
+using Umbraco.Cms.Api.Management.Mapping.Permissions;
using Umbraco.Cms.Api.Management.Routing;
using Umbraco.Cms.Api.Management.Security;
using Umbraco.Cms.Api.Management.ViewModels;
@@ -22,6 +23,9 @@ using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Management.Factories;
+///
+/// Factory for creating user presentation models, implementing .
+///
public class UserPresentationFactory : IUserPresentationFactory
{
private readonly IEntityService _entityService;
@@ -34,9 +38,11 @@ public class UserPresentationFactory : IUserPresentationFactory
private readonly IPasswordConfigurationPresentationFactory _passwordConfigurationPresentationFactory;
private readonly IBackOfficeExternalLoginProviders _externalLoginProviders;
private readonly SecuritySettings _securitySettings;
- private readonly IUserService _userService;
- private readonly IContentService _contentService;
+ private readonly Dictionary _permissionPresentationMappersByType;
+ ///
+ /// Initializes a new instance of the class.
+ ///
[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 17.")]
public UserPresentationFactory(
IEntityService entityService,
@@ -50,7 +56,7 @@ public class UserPresentationFactory : IUserPresentationFactory
IOptionsSnapshot securitySettings,
IBackOfficeExternalLoginProviders externalLoginProviders)
: this(
- entityService,
+ entityService,
appCaches,
mediaFileManager,
imageUrlGenerator,
@@ -61,10 +67,15 @@ public class UserPresentationFactory : IUserPresentationFactory
securitySettings,
externalLoginProviders,
StaticServiceProvider.Instance.GetRequiredService(),
- StaticServiceProvider.Instance.GetRequiredService())
+ StaticServiceProvider.Instance.GetRequiredService(),
+ StaticServiceProvider.Instance.GetRequiredService>())
{
}
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ [Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 17.")]
public UserPresentationFactory(
IEntityService entityService,
AppCaches appCaches,
@@ -78,6 +89,44 @@ public class UserPresentationFactory : IUserPresentationFactory
IBackOfficeExternalLoginProviders externalLoginProviders,
IUserService userService,
IContentService contentService)
+ : this(
+ entityService,
+ appCaches,
+ mediaFileManager,
+ imageUrlGenerator,
+ userGroupPresentationFactory,
+ absoluteUrlBuilder,
+ emailSender,
+ passwordConfigurationPresentationFactory,
+ securitySettings,
+ externalLoginProviders,
+ userService,
+ contentService,
+ StaticServiceProvider.Instance.GetRequiredService>())
+ {
+ }
+
+ // TODO (V17): Remove the unused userService and contentService parameters from this constructor.
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public UserPresentationFactory(
+ IEntityService entityService,
+ AppCaches appCaches,
+ MediaFileManager mediaFileManager,
+ IImageUrlGenerator imageUrlGenerator,
+ IUserGroupPresentationFactory userGroupPresentationFactory,
+ IAbsoluteUrlBuilder absoluteUrlBuilder,
+ IEmailSender emailSender,
+ IPasswordConfigurationPresentationFactory passwordConfigurationPresentationFactory,
+ IOptionsSnapshot securitySettings,
+ IBackOfficeExternalLoginProviders externalLoginProviders,
+#pragma warning disable IDE0060 // Remove unused parameter - need to keep these until the next major to avoid breaking changes and/or ambiguous constructor errors
+ IUserService userService,
+ IContentService contentService,
+#pragma warning restore IDE0060 // Remove unused parameter
+ IEnumerable permissionPresentationMappers)
{
_entityService = entityService;
_appCaches = appCaches;
@@ -89,10 +138,10 @@ public class UserPresentationFactory : IUserPresentationFactory
_externalLoginProviders = externalLoginProviders;
_securitySettings = securitySettings.Value;
_absoluteUrlBuilder = absoluteUrlBuilder;
- _userService = userService;
- _contentService = contentService;
+ _permissionPresentationMappersByType = permissionPresentationMappers.ToDictionary(x => x.PresentationModelToHandle);
}
+ ///
public UserResponseModel CreateResponseModel(IUser user)
{
var responseModel = new UserResponseModel
@@ -123,6 +172,7 @@ public class UserPresentationFactory : IUserPresentationFactory
return responseModel;
}
+ ///
public UserItemResponseModel CreateItemResponseModel(IUser user) =>
new()
{
@@ -130,9 +180,10 @@ public class UserPresentationFactory : IUserPresentationFactory
Name = user.Name ?? user.Username,
AvatarUrls = user.GetUserAvatarUrls(_appCaches.RuntimeCache, _mediaFileManager, _imageUrlGenerator)
.Select(url => _absoluteUrlBuilder.ToAbsoluteUrl(url).ToString()),
- Kind = user.Kind
+ Kind = user.Kind,
};
+ ///
public Task CreateCreationModelAsync(CreateUserRequestModel requestModel)
{
var createModel = new UserCreateModel
@@ -142,12 +193,13 @@ public class UserPresentationFactory : IUserPresentationFactory
Name = requestModel.Name,
UserName = requestModel.UserName,
UserGroupKeys = requestModel.UserGroupIds.Select(x => x.Id).ToHashSet(),
- Kind = requestModel.Kind
+ Kind = requestModel.Kind,
};
return Task.FromResult(createModel);
}
+ ///
public Task CreateInviteModelAsync(InviteUserRequestModel requestModel)
{
var inviteModel = new UserInviteModel
@@ -162,6 +214,7 @@ public class UserPresentationFactory : IUserPresentationFactory
return Task.FromResult(inviteModel);
}
+ ///
public Task CreateResendInviteModelAsync(ResendInviteUserRequestModel requestModel)
{
var inviteModel = new UserResendInviteModel
@@ -173,6 +226,7 @@ public class UserPresentationFactory : IUserPresentationFactory
return Task.FromResult(inviteModel);
}
+ ///
public Task CreateCurrentUserConfigurationModelAsync()
{
var model = new CurrentUserConfigurationResponseModel
@@ -188,6 +242,7 @@ public class UserPresentationFactory : IUserPresentationFactory
return Task.FromResult(model);
}
+ ///
public Task CreateUserConfigurationModelAsync() =>
Task.FromResult(new UserConfigurationResponseModel
{
@@ -201,6 +256,7 @@ public class UserPresentationFactory : IUserPresentationFactory
AllowTwoFactor = _externalLoginProviders.HasDenyLocalLogin() is false,
});
+ ///
public Task CreateUpdateModelAsync(Guid existingUserKey, UpdateUserRequestModel updateModel)
{
var model = new UserUpdateModel
@@ -214,24 +270,24 @@ public class UserPresentationFactory : IUserPresentationFactory
HasContentRootAccess = updateModel.HasDocumentRootAccess,
MediaStartNodeKeys = updateModel.MediaStartNodeIds.Select(x => x.Id).ToHashSet(),
HasMediaRootAccess = updateModel.HasMediaRootAccess,
+ UserGroupKeys = updateModel.UserGroupIds.Select(x => x.Id).ToHashSet()
};
- model.UserGroupKeys = updateModel.UserGroupIds.Select(x => x.Id).ToHashSet();
-
return Task.FromResult(model);
}
+ ///
public async Task CreateCurrentUserResponseModelAsync(IUser user)
{
- var presentationUser = CreateResponseModel(user);
- var presentationGroups = await _userGroupPresentationFactory.CreateMultipleAsync(user.Groups);
+ UserResponseModel presentationUser = CreateResponseModel(user);
+ IEnumerable presentationGroups = await _userGroupPresentationFactory.CreateMultipleAsync(user.Groups);
var languages = presentationGroups.SelectMany(x => x.Languages).Distinct().ToArray();
var mediaStartNodeIds = user.CalculateMediaStartNodeIds(_entityService, _appCaches);
- var mediaStartNodeKeys = GetKeysFromIds(mediaStartNodeIds, UmbracoObjectTypes.Media);
+ ISet mediaStartNodeKeys = GetKeysFromIds(mediaStartNodeIds, UmbracoObjectTypes.Media);
var contentStartNodeIds = user.CalculateContentStartNodeIds(_entityService, _appCaches);
- var documentStartNodeKeys = GetKeysFromIds(contentStartNodeIds, UmbracoObjectTypes.Document);
+ ISet documentStartNodeKeys = GetKeysFromIds(contentStartNodeIds, UmbracoObjectTypes.Document);
- var permissions = GetAggregatedGranularPermissions(user, presentationGroups);
+ HashSet permissions = GetAggregatedGranularPermissions(user, presentationGroups);
var fallbackPermissions = presentationGroups.SelectMany(x => x.FallbackPermissions).ToHashSet();
var hasAccessToAllLanguages = presentationGroups.Any(x => x.HasAccessToAllLanguages);
@@ -263,70 +319,56 @@ public class UserPresentationFactory : IUserPresentationFactory
private HashSet GetAggregatedGranularPermissions(IUser user, IEnumerable presentationGroups)
{
- var aggregatedPermissions = new HashSet();
-
var permissions = presentationGroups.SelectMany(x => x.Permissions).ToHashSet();
+ return GetAggregatedGranularPermissions(user, permissions);
+ }
- AggregateAndAddDocumentPermissions(user, aggregatedPermissions, permissions);
+ private HashSet GetAggregatedGranularPermissions(IUser user, HashSet permissions)
+ {
+ // The raw permission data consists of several permissions for each entity (e.g. document), as permissions are assigned to user groups
+ // and a user may be part of multiple groups. We want to aggregate this server-side so we return one set of aggregate permissions per
+ // entity that the client will use.
+ // We need to handle here not just permissions known to core (e.g. document and document property value permissions), but also custom
+ // permissions defined by packages or implemetors.
+ IEnumerable<(Type, IEnumerable)> permissionModelsByType = permissions
+ .GroupBy(x => x.GetType())
+ .Select(x => (x.Key, x.Select(y => y)));
- AggregateAndAddDocumentPropertyValuePermissions(aggregatedPermissions, permissions);
+ var aggregatedPermissions = new HashSet();
+ foreach ((Type Type, IEnumerable Models) permissionModelByType in permissionModelsByType)
+ {
+ if (_permissionPresentationMappersByType.TryGetValue(permissionModelByType.Type, out IPermissionPresentationMapper? mapper))
+ {
+
+ IEnumerable aggregatedModels = mapper.AggregatePresentationModels(user, permissionModelByType.Models);
+ foreach (IPermissionPresentationModel aggregatedModel in aggregatedModels)
+ {
+ aggregatedPermissions.Add(aggregatedModel);
+ }
+ }
+ else
+ {
+ IEnumerable<(string Context, ISet Verbs)> groupedModels = permissionModelByType.Models
+ .Where(x => x is UnknownTypePermissionPresentationModel)
+ .Cast()
+ .GroupBy(x => x.Context)
+ .Select(x => (x.Key, (ISet)x.SelectMany(y => y.Verbs).Distinct().ToHashSet()));
+
+ foreach ((string context, ISet verbs) in groupedModels)
+ {
+ aggregatedPermissions.Add(new UnknownTypePermissionPresentationModel
+ {
+ Context = context,
+ Verbs = verbs
+ });
+ }
+ }
+ }
return aggregatedPermissions;
}
- private void AggregateAndAddDocumentPermissions(IUser user, HashSet aggregatedPermissions, HashSet permissions)
- {
- // The raw permission data consists of several permissions for each document. We want to aggregate this server-side so
- // we return one set of aggregate permissions per document that the client will use.
-
- // Get the unique document keys that have granular permissions.
- IEnumerable documentKeysWithGranularPermissions = permissions
- .Where(x => x is DocumentPermissionPresentationModel)
- .Cast()
- .Select(x => x.Document.Id)
- .Distinct();
-
- foreach (Guid documentKey in documentKeysWithGranularPermissions)
- {
- // Retrieve the path of the document.
- var path = _contentService.GetById(documentKey)?.Path;
- if (string.IsNullOrEmpty(path))
- {
- continue;
- }
-
- // With the path we can call the same logic as used server-side for authorizing access to resources.
- EntityPermissionSet permissionsForPath = _userService.GetPermissionsForPath(user, path);
- aggregatedPermissions.Add(new DocumentPermissionPresentationModel
- {
- Document = new ReferenceByIdModel(documentKey),
- Verbs = permissionsForPath.GetAllPermissions()
- });
- }
- }
-
- private static void AggregateAndAddDocumentPropertyValuePermissions(HashSet aggregatedPermissions, HashSet permissions)
- {
- // We also have permissions for document type/property type combinations.
- // These don't have an ancestor relationship that we need to take into account, but should be aggregated
- // and included in the set.
- IEnumerable<((Guid DocumentTypeId, Guid PropertyTypeId) Key, ISet Verbs)> documentTypePropertyTypeKeysWithGranularPermissions = permissions
- .Where(x => x is DocumentPropertyValuePermissionPresentationModel)
- .Cast()
- .GroupBy(x => (x.DocumentType.Id, x.PropertyType.Id))
- .Select(x => (x.Key, (ISet)x.SelectMany(y => y.Verbs).Distinct().ToHashSet()));
-
- foreach (((Guid DocumentTypeId, Guid PropertyTypeId) Key, ISet Verbs) documentTypePropertyTypeKey in documentTypePropertyTypeKeysWithGranularPermissions)
- {
- aggregatedPermissions.Add(new DocumentPropertyValuePermissionPresentationModel
- {
- DocumentType = new ReferenceByIdModel(documentTypePropertyTypeKey.Key.DocumentTypeId),
- PropertyType = new ReferenceByIdModel(documentTypePropertyTypeKey.Key.PropertyTypeId),
- Verbs = documentTypePropertyTypeKey.Verbs
- });
- }
- }
-
+ ///
public Task CreateCalculatedUserStartNodesResponseModelAsync(IUser user)
{
var mediaStartNodeIds = user.CalculateMediaStartNodeIds(_entityService, _appCaches);
@@ -357,6 +399,6 @@ public class UserPresentationFactory : IUserPresentationFactory
: new HashSet(models);
}
- private bool HasRootAccess(IEnumerable? startNodeIds)
+ private static bool HasRootAccess(IEnumerable? startNodeIds)
=> startNodeIds?.Contains(Constants.System.Root) is true;
}
diff --git a/src/Umbraco.Cms.Api.Management/Mapping/Permissions/DocumentPermissionMapper.cs b/src/Umbraco.Cms.Api.Management/Mapping/Permissions/DocumentPermissionMapper.cs
index 55c66221fc..abab7a7fab 100644
--- a/src/Umbraco.Cms.Api.Management/Mapping/Permissions/DocumentPermissionMapper.cs
+++ b/src/Umbraco.Cms.Api.Management/Mapping/Permissions/DocumentPermissionMapper.cs
@@ -1,19 +1,50 @@
+using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Api.Management.ViewModels;
using Umbraco.Cms.Api.Management.ViewModels.UserGroup.Permissions;
+using Umbraco.Cms.Core.DependencyInjection;
+using Umbraco.Cms.Core.Models;
+using Umbraco.Cms.Core.Models.Membership;
using Umbraco.Cms.Core.Models.Membership.Permissions;
+using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Infrastructure.Persistence.Dtos;
using Umbraco.Cms.Infrastructure.Persistence.Mappers;
namespace Umbraco.Cms.Api.Management.Mapping.Permissions;
+
///
-/// Mapping required for mapping all the way from viewmodel to database and back.
+/// Implements for document permissions.
///
///
/// This mapping maps all the way from management api to database in one file intentionally, so it is very clear what it takes, if we wanna add permissions to media or other types in the future.
///
public class DocumentPermissionMapper : IPermissionPresentationMapper, IPermissionMapper
{
+ private readonly Lazy _entityService;
+ private readonly Lazy _userService;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ [Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 17.")]
+ public DocumentPermissionMapper()
+ : this(
+ StaticServiceProvider.Instance.GetRequiredService>(),
+ StaticServiceProvider.Instance.GetRequiredService>())
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public DocumentPermissionMapper(Lazy entityService, Lazy userService)
+ {
+ _entityService = entityService;
+ _userService = userService;
+ }
+
+ ///
public string Context => DocumentGranularPermission.ContextType;
+
public IGranularPermission MapFromDto(UserGroup2GranularPermissionDto dto) =>
new DocumentGranularPermission()
{
@@ -21,8 +52,10 @@ public class DocumentPermissionMapper : IPermissionPresentationMapper, IPermissi
Permission = dto.Permission,
};
+ ///
public Type PresentationModelToHandle => typeof(DocumentPermissionPresentationModel);
+ ///
public IEnumerable MapManyAsync(IEnumerable granularPermissions)
{
IEnumerable> keyGroups = granularPermissions.GroupBy(x => x.Key);
@@ -40,6 +73,7 @@ public class DocumentPermissionMapper : IPermissionPresentationMapper, IPermissi
}
}
+ ///
public IEnumerable MapToGranularPermissions(IPermissionPresentationModel permissionViewModel)
{
if (permissionViewModel is not DocumentPermissionPresentationModel documentPermissionPresentationModel)
@@ -47,7 +81,7 @@ public class DocumentPermissionMapper : IPermissionPresentationMapper, IPermissi
yield break;
}
- if(documentPermissionPresentationModel.Verbs.Any() is false || (documentPermissionPresentationModel.Verbs.Count == 1 && documentPermissionPresentationModel.Verbs.Contains(string.Empty)))
+ if (documentPermissionPresentationModel.Verbs.Any() is false || (documentPermissionPresentationModel.Verbs.Count == 1 && documentPermissionPresentationModel.Verbs.Contains(string.Empty)))
{
yield return new DocumentGranularPermission
{
@@ -56,12 +90,14 @@ public class DocumentPermissionMapper : IPermissionPresentationMapper, IPermissi
};
yield break;
}
+
foreach (var verb in documentPermissionPresentationModel.Verbs)
{
if (string.IsNullOrEmpty(verb))
{
continue;
}
+
yield return new DocumentGranularPermission
{
Key = documentPermissionPresentationModel.Document.Id,
@@ -69,4 +105,37 @@ public class DocumentPermissionMapper : IPermissionPresentationMapper, IPermissi
};
}
}
+
+ ///
+ public IEnumerable AggregatePresentationModels(IUser user, IEnumerable models)
+ {
+ // Get the unique document keys that have granular permissions.
+ Guid[] documentKeysWithGranularPermissions = models
+ .Cast()
+ .Select(x => x.Document.Id)
+ .Distinct()
+ .ToArray();
+
+ // Batch retrieve all documents by their keys.
+ var documents = _entityService.Value.GetAll(documentKeysWithGranularPermissions)
+ .ToDictionary(doc => doc.Key, doc => doc.Path);
+
+ // Iterate through each document key that has granular permissions.
+ foreach (Guid documentKey in documentKeysWithGranularPermissions)
+ {
+ // Retrieve the path from the pre-fetched documents.
+ if (!documents.TryGetValue(documentKey, out var path) || string.IsNullOrEmpty(path))
+ {
+ continue;
+ }
+
+ // With the path we can call the same logic as used server-side for authorizing access to resources.
+ EntityPermissionSet permissionsForPath = _userService.Value.GetPermissionsForPath(user, path);
+ yield return new DocumentPermissionPresentationModel
+ {
+ Document = new ReferenceByIdModel(documentKey),
+ Verbs = permissionsForPath.GetAllPermissions(),
+ };
+ }
+ }
}
diff --git a/src/Umbraco.Cms.Api.Management/Mapping/Permissions/DocumentPropertyValuePermissionMapper.cs b/src/Umbraco.Cms.Api.Management/Mapping/Permissions/DocumentPropertyValuePermissionMapper.cs
index ed0964abda..2de518f0ba 100644
--- a/src/Umbraco.Cms.Api.Management/Mapping/Permissions/DocumentPropertyValuePermissionMapper.cs
+++ b/src/Umbraco.Cms.Api.Management/Mapping/Permissions/DocumentPropertyValuePermissionMapper.cs
@@ -1,5 +1,6 @@
-using Umbraco.Cms.Api.Management.ViewModels;
+using Umbraco.Cms.Api.Management.ViewModels;
using Umbraco.Cms.Api.Management.ViewModels.UserGroup.Permissions;
+using Umbraco.Cms.Core.Models.Membership;
using Umbraco.Cms.Core.Models.Membership.Permissions;
using Umbraco.Cms.Infrastructure.Persistence.Dtos;
using Umbraco.Cms.Infrastructure.Persistence.Mappers;
@@ -7,10 +8,18 @@ using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Management.Mapping.Permissions;
+///
+/// Implements for document property value permissions.
+///
public class DocumentPropertyValuePermissionMapper : IPermissionPresentationMapper, IPermissionMapper
{
+ ///
public string Context => DocumentPropertyValueGranularPermission.ContextType;
+ ///
+ public Type PresentationModelToHandle => typeof(DocumentPropertyValuePermissionPresentationModel);
+
+ ///
public IGranularPermission MapFromDto(UserGroup2GranularPermissionDto dto) =>
new DocumentPropertyValueGranularPermission()
{
@@ -18,8 +27,7 @@ public class DocumentPropertyValuePermissionMapper : IPermissionPresentationMapp
Permission = dto.Permission,
};
- public Type PresentationModelToHandle => typeof(DocumentPropertyValuePermissionPresentationModel);
-
+ ///
public IEnumerable MapManyAsync(IEnumerable granularPermissions)
{
var intermediate = granularPermissions.Where(p => p.Key.HasValue).Select(p =>
@@ -50,6 +58,7 @@ public class DocumentPropertyValuePermissionMapper : IPermissionPresentationMapp
}
}
+ ///
public IEnumerable MapToGranularPermissions(IPermissionPresentationModel permissionViewModel)
{
if (permissionViewModel is not DocumentPropertyValuePermissionPresentationModel documentTypePermissionPresentationModel)
@@ -66,4 +75,23 @@ public class DocumentPropertyValuePermissionMapper : IPermissionPresentationMapp
};
}
}
+
+ ///
+ public IEnumerable AggregatePresentationModels(IUser user, IEnumerable models)
+ {
+ IEnumerable<((Guid DocumentTypeId, Guid PropertyTypeId) Key, ISet Verbs)> groupedModels = models
+ .Cast()
+ .GroupBy(x => (x.DocumentType.Id, x.PropertyType.Id))
+ .Select(x => (x.Key, (ISet)x.SelectMany(y => y.Verbs).Distinct().ToHashSet()));
+
+ foreach (((Guid DocumentTypeId, Guid PropertyTypeId) key, ISet verbs) in groupedModels)
+ {
+ yield return new DocumentPropertyValuePermissionPresentationModel
+ {
+ DocumentType = new ReferenceByIdModel(key.DocumentTypeId),
+ PropertyType = new ReferenceByIdModel(key.PropertyTypeId),
+ Verbs = verbs
+ };
+ }
+ }
}
diff --git a/src/Umbraco.Cms.Api.Management/Mapping/Permissions/IPermissionPresentationMapper.cs b/src/Umbraco.Cms.Api.Management/Mapping/Permissions/IPermissionPresentationMapper.cs
index 8ba261ce6e..8860f187dc 100644
--- a/src/Umbraco.Cms.Api.Management/Mapping/Permissions/IPermissionPresentationMapper.cs
+++ b/src/Umbraco.Cms.Api.Management/Mapping/Permissions/IPermissionPresentationMapper.cs
@@ -1,15 +1,36 @@
using Umbraco.Cms.Api.Management.ViewModels.UserGroup.Permissions;
+using Umbraco.Cms.Core.Models.Membership;
using Umbraco.Cms.Core.Models.Membership.Permissions;
namespace Umbraco.Cms.Api.Management.Mapping.Permissions;
+///
+/// Defines methods for mapping and aggregating granular permissions to presentation models.
+///
public interface IPermissionPresentationMapper
{
+ ///
+ /// Gets the context type for the permissions being handled by this mapper.
+ ///
string Context { get; }
+ ///
+ /// Gets the type of the presentation model that this mapper handles.
+ ///
Type PresentationModelToHandle { get; }
+ ///
+ /// Maps a granular permission entity to a granular permission model.
+ ///
IEnumerable MapManyAsync(IEnumerable granularPermissions);
+ ///
+ /// Maps a granular permission to a granular permission model.
+ ///
IEnumerable MapToGranularPermissions(IPermissionPresentationModel permissionViewModel);
+
+ ///
+ /// Aggregates multiple permission presentation models into a collection containing only one item per entity with aggregated permissions.
+ ///
+ IEnumerable AggregatePresentationModels(IUser user, IEnumerable models) => [];
}
diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Factories/UserPresentationFactoryTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Factories/UserPresentationFactoryTests.cs
index 80277ab259..f9d103f1e4 100644
--- a/tests/Umbraco.Tests.Integration/ManagementApi/Factories/UserPresentationFactoryTests.cs
+++ b/tests/Umbraco.Tests.Integration/ManagementApi/Factories/UserPresentationFactoryTests.cs
@@ -10,6 +10,7 @@ using Umbraco.Cms.Core.Models.Membership;
using Umbraco.Cms.Core.Models.Membership.Permissions;
using Umbraco.Cms.Core.Routing;
using Umbraco.Cms.Core.Services;
+using Umbraco.Cms.Infrastructure.Persistence.Dtos;
using Umbraco.Cms.Infrastructure.Persistence.Mappers;
using Umbraco.Cms.Tests.Common.Builders;
using Umbraco.Cms.Tests.Common.Builders.Extensions;
@@ -46,6 +47,10 @@ public class UserPresentationFactoryTests : UmbracoIntegrationTestWithContent
services.AddSingleton();
services.AddSingleton();
+
+ services.AddSingleton();
+ services.AddSingleton();
+
}
[Test]
@@ -254,12 +259,172 @@ public class UserPresentationFactoryTests : UmbracoIntegrationTestWithContent
Assert.IsTrue(propertyTypePermission2.Verbs.ContainsAll(["D"]));
}
+ [Test]
+ public async Task Can_Create_Current_User_Response_Model_With_Aggregated_Custom_Permissions()
+ {
+ var key1 = Guid.NewGuid();
+ var key2 = Guid.NewGuid();
+ var groupOne = await CreateUserGroup(
+ "Group One",
+ "groupOne",
+ [],
+ [],
+ [
+ new CustomGranularPermission
+ {
+ Permission = $"{key1}|A",
+ },
+ new CustomGranularPermission
+ {
+ Permission = $"{key1}|B",
+ },
+ new CustomGranularPermission
+ {
+ Permission = $"{key2}|C",
+ }
+ ],
+ Constants.System.Root);
+ var groupTwo = await CreateUserGroup(
+ "Group Two",
+ "groupTwo",
+ [],
+ [],
+ [
+ new CustomGranularPermission
+ {
+ Permission = $"{key1}|A",
+ },
+ new CustomGranularPermission
+ {
+ Permission = $"{key2}|B",
+ },
+ ],
+ Constants.System.Root);
+ var user = await CreateUser([groupOne.Key, groupTwo.Key]);
+
+ var model = await UserPresentationFactory.CreateCurrentUserResponseModelAsync(user);
+ Assert.AreEqual(2, model.Permissions.Count);
+
+ var customPermissions = model.Permissions
+ .Where(x => x is CustomPermissionPresentationModel)
+ .Cast();
+ Assert.AreEqual(2, customPermissions.Count());
+
+ var customPermission1 = customPermissions
+ .Single(x => x.Key == key1);
+ Assert.AreEqual(2, customPermission1.Verbs.Count);
+ Assert.IsTrue(customPermission1.Verbs.ContainsAll(["A", "B"]));
+
+ var customPermission2 = customPermissions
+ .Single(x => x.Key == key2);
+ Assert.AreEqual(2, customPermission2.Verbs.Count);
+ Assert.IsTrue(customPermission2.Verbs.ContainsAll(["B", "C"]));
+ }
+
+ private class CustomGranularPermission : IGranularPermission
+ {
+ public const string ContextType = "Custom";
+
+ public string Context => ContextType;
+
+ public required string Permission { get; set; }
+
+ protected bool Equals(CustomGranularPermission other) => Permission == other.Permission;
+
+ public override bool Equals(object? obj)
+ {
+ if (ReferenceEquals(null, obj))
+ {
+ return false;
+ }
+
+ if (ReferenceEquals(this, obj))
+ {
+ return true;
+ }
+
+ if (obj.GetType() != GetType())
+ {
+ return false;
+ }
+
+ return Equals((CustomGranularPermission)obj);
+ }
+
+ public override int GetHashCode() => HashCode.Combine(Permission);
+ }
+
+ private class CustomPermissionPresentationModel : IPermissionPresentationModel
+ {
+ public required ISet Verbs { get; set; }
+
+ public required Guid Key { get; set; }
+ }
+
+ private class CustomPermissionMapper : IPermissionMapper, IPermissionPresentationMapper
+ {
+ public string Context => CustomGranularPermission.ContextType;
+
+ public Type PresentationModelToHandle => typeof(CustomPermissionPresentationModel);
+
+ public IGranularPermission MapFromDto(UserGroup2GranularPermissionDto dto)
+ {
+ return new CustomGranularPermission
+ {
+ Permission = dto.Permission,
+ };
+ }
+
+ public IEnumerable MapManyAsync(IEnumerable granularPermissions)
+ => granularPermissions
+ .Where(x => x is CustomGranularPermission)
+ .Cast()
+ .Select(x => new CustomPermissionPresentationModel
+ {
+ Key = Guid.Parse(x.Permission.Split('|')[0]),
+ Verbs = new HashSet { x.Permission.Split('|')[1] },
+ });
+
+ public IEnumerable MapToGranularPermissions(IPermissionPresentationModel permissionViewModel)
+ {
+ if (permissionViewModel is not CustomPermissionPresentationModel customPermissionPresentationModel)
+ {
+ yield break;
+ }
+
+ foreach (var verb in customPermissionPresentationModel.Verbs.Distinct().DefaultIfEmpty(string.Empty))
+ {
+ yield return new CustomGranularPermission
+ {
+ Permission = customPermissionPresentationModel.Key + "|" + verb,
+ };
+ }
+ }
+
+ public IEnumerable AggregatePresentationModels(IUser user, IEnumerable models)
+ {
+ IEnumerable<(Guid Key, ISet Verbs)> groupedModels = models
+ .Cast()
+ .GroupBy(x => x.Key)
+ .Select(x => (x.Key, (ISet)x.SelectMany(y => y.Verbs).Distinct().ToHashSet()));
+
+ foreach ((Guid key, ISet verbs) in groupedModels)
+ {
+ yield return new CustomPermissionPresentationModel
+ {
+ Key = key,
+ Verbs = verbs,
+ };
+ }
+ }
+ }
+
private async Task CreateUserGroup(
string name,
string alias,
int[] allowedLanguages,
string[] permissions,
- INodeGranularPermission[] granularPermissions,
+ IGranularPermission[] granularPermissions,
int startMediaId)
{
var userGroup = new UserGroupBuilder()