Adds support for custom granular permissions when aggregating across user groups (#19660)
* Added abstraction for aggregation of granular permissions to support custom permissions. * Refactor to move responsibility for aggregating granular permissions to the respective mappers. * Added XML header comments for permission mappers. * Tidied up/removed warnings in UserPresentationFactory interface and implementation. * Optimized retrieval of documents in DocumentPermissionMapper. * Fixed method header comment. * Use entity service rather than content service to retrieve key and path.
This commit is contained in:
@@ -6,25 +6,58 @@ using Umbraco.Cms.Core.Models.Membership;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.Factories;
|
||||
|
||||
/// <summary>
|
||||
/// Defines factory methods for the creation of user presentation models.
|
||||
/// </summary>
|
||||
public interface IUserPresentationFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a response model for the provided user.
|
||||
/// </summary>
|
||||
UserResponseModel CreateResponseModel(IUser user);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a create model for a user based on the provided request model.
|
||||
/// </summary>
|
||||
Task<UserCreateModel> CreateCreationModelAsync(CreateUserRequestModel requestModel);
|
||||
|
||||
/// <summary>
|
||||
/// Creates an invite model for a user based on the provided request model.
|
||||
/// </summary>
|
||||
Task<UserInviteModel> CreateInviteModelAsync(InviteUserRequestModel requestModel);
|
||||
|
||||
/// <summary>
|
||||
/// Creates an update model for an existing user based on the provided request model.
|
||||
/// </summary>
|
||||
Task<UserUpdateModel> CreateUpdateModelAsync(Guid existingUserKey, UpdateUserRequestModel updateModel);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a response model for the current user based on the provided user.
|
||||
/// </summary>
|
||||
Task<CurrentUserResponseModel> CreateCurrentUserResponseModelAsync(IUser user);
|
||||
|
||||
/// <summary>
|
||||
/// Creates an resend invite model for a user based on the provided request model.
|
||||
/// </summary>
|
||||
Task<UserResendInviteModel> CreateResendInviteModelAsync(ResendInviteUserRequestModel requestModel);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a user configuration model that contains the necessary data for user management operations.
|
||||
/// </summary>
|
||||
Task<UserConfigurationResponseModel> CreateUserConfigurationModelAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Creates a current user configuration model that contains the necessary data for the current user's management operations.
|
||||
/// </summary>
|
||||
Task<CurrentUserConfigurationResponseModel> CreateCurrentUserConfigurationModelAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Creates a user item response model for the provided user.
|
||||
/// </summary>
|
||||
UserItemResponseModel CreateItemResponseModel(IUser user);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a calculated user start nodes response model based on the provided user.
|
||||
/// </summary>
|
||||
Task<CalculatedUserStartNodesResponseModel> CreateCalculatedUserStartNodesResponseModelAsync(IUser user);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating user presentation models, implementing <see cref="IUserPresentationFactory"/>.
|
||||
/// </summary>
|
||||
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<Type, IPermissionPresentationMapper> _permissionPresentationMappersByType;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="UserPresentationFactory"/> class.
|
||||
/// </summary>
|
||||
[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 17.")]
|
||||
public UserPresentationFactory(
|
||||
IEntityService entityService,
|
||||
@@ -61,10 +67,15 @@ public class UserPresentationFactory : IUserPresentationFactory
|
||||
securitySettings,
|
||||
externalLoginProviders,
|
||||
StaticServiceProvider.Instance.GetRequiredService<IUserService>(),
|
||||
StaticServiceProvider.Instance.GetRequiredService<IContentService>())
|
||||
StaticServiceProvider.Instance.GetRequiredService<IContentService>(),
|
||||
StaticServiceProvider.Instance.GetRequiredService<IEnumerable<IPermissionPresentationMapper>>())
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="UserPresentationFactory"/> class.
|
||||
/// </summary>
|
||||
[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<IEnumerable<IPermissionPresentationMapper>>())
|
||||
{
|
||||
}
|
||||
|
||||
// TODO (V17): Remove the unused userService and contentService parameters from this constructor.
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="UserPresentationFactory"/> class.
|
||||
/// </summary>
|
||||
public UserPresentationFactory(
|
||||
IEntityService entityService,
|
||||
AppCaches appCaches,
|
||||
MediaFileManager mediaFileManager,
|
||||
IImageUrlGenerator imageUrlGenerator,
|
||||
IUserGroupPresentationFactory userGroupPresentationFactory,
|
||||
IAbsoluteUrlBuilder absoluteUrlBuilder,
|
||||
IEmailSender emailSender,
|
||||
IPasswordConfigurationPresentationFactory passwordConfigurationPresentationFactory,
|
||||
IOptionsSnapshot<SecuritySettings> 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<IPermissionPresentationMapper> 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);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public UserResponseModel CreateResponseModel(IUser user)
|
||||
{
|
||||
var responseModel = new UserResponseModel
|
||||
@@ -123,6 +172,7 @@ public class UserPresentationFactory : IUserPresentationFactory
|
||||
return responseModel;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
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,
|
||||
};
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<UserCreateModel> 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);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<UserInviteModel> CreateInviteModelAsync(InviteUserRequestModel requestModel)
|
||||
{
|
||||
var inviteModel = new UserInviteModel
|
||||
@@ -162,6 +214,7 @@ public class UserPresentationFactory : IUserPresentationFactory
|
||||
return Task.FromResult(inviteModel);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<UserResendInviteModel> CreateResendInviteModelAsync(ResendInviteUserRequestModel requestModel)
|
||||
{
|
||||
var inviteModel = new UserResendInviteModel
|
||||
@@ -173,6 +226,7 @@ public class UserPresentationFactory : IUserPresentationFactory
|
||||
return Task.FromResult(inviteModel);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<CurrentUserConfigurationResponseModel> CreateCurrentUserConfigurationModelAsync()
|
||||
{
|
||||
var model = new CurrentUserConfigurationResponseModel
|
||||
@@ -188,6 +242,7 @@ public class UserPresentationFactory : IUserPresentationFactory
|
||||
return Task.FromResult(model);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<UserConfigurationResponseModel> CreateUserConfigurationModelAsync() =>
|
||||
Task.FromResult(new UserConfigurationResponseModel
|
||||
{
|
||||
@@ -201,6 +256,7 @@ public class UserPresentationFactory : IUserPresentationFactory
|
||||
AllowTwoFactor = _externalLoginProviders.HasDenyLocalLogin() is false,
|
||||
});
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<UserUpdateModel> 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);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<CurrentUserResponseModel> CreateCurrentUserResponseModelAsync(IUser user)
|
||||
{
|
||||
var presentationUser = CreateResponseModel(user);
|
||||
var presentationGroups = await _userGroupPresentationFactory.CreateMultipleAsync(user.Groups);
|
||||
UserResponseModel presentationUser = CreateResponseModel(user);
|
||||
IEnumerable<UserGroupResponseModel> 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<ReferenceByIdModel> mediaStartNodeKeys = GetKeysFromIds(mediaStartNodeIds, UmbracoObjectTypes.Media);
|
||||
var contentStartNodeIds = user.CalculateContentStartNodeIds(_entityService, _appCaches);
|
||||
var documentStartNodeKeys = GetKeysFromIds(contentStartNodeIds, UmbracoObjectTypes.Document);
|
||||
ISet<ReferenceByIdModel> documentStartNodeKeys = GetKeysFromIds(contentStartNodeIds, UmbracoObjectTypes.Document);
|
||||
|
||||
var permissions = GetAggregatedGranularPermissions(user, presentationGroups);
|
||||
HashSet<IPermissionPresentationModel> 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<IPermissionPresentationModel> GetAggregatedGranularPermissions(IUser user, IEnumerable<UserGroupResponseModel> presentationGroups)
|
||||
{
|
||||
var aggregatedPermissions = new HashSet<IPermissionPresentationModel>();
|
||||
|
||||
var permissions = presentationGroups.SelectMany(x => x.Permissions).ToHashSet();
|
||||
return GetAggregatedGranularPermissions(user, permissions);
|
||||
}
|
||||
|
||||
AggregateAndAddDocumentPermissions(user, aggregatedPermissions, permissions);
|
||||
private HashSet<IPermissionPresentationModel> GetAggregatedGranularPermissions(IUser user, HashSet<IPermissionPresentationModel> 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<IPermissionPresentationModel>)> permissionModelsByType = permissions
|
||||
.GroupBy(x => x.GetType())
|
||||
.Select(x => (x.Key, x.Select(y => y)));
|
||||
|
||||
AggregateAndAddDocumentPropertyValuePermissions(aggregatedPermissions, permissions);
|
||||
var aggregatedPermissions = new HashSet<IPermissionPresentationModel>();
|
||||
foreach ((Type Type, IEnumerable<IPermissionPresentationModel> Models) permissionModelByType in permissionModelsByType)
|
||||
{
|
||||
if (_permissionPresentationMappersByType.TryGetValue(permissionModelByType.Type, out IPermissionPresentationMapper? mapper))
|
||||
{
|
||||
|
||||
IEnumerable<IPermissionPresentationModel> aggregatedModels = mapper.AggregatePresentationModels(user, permissionModelByType.Models);
|
||||
foreach (IPermissionPresentationModel aggregatedModel in aggregatedModels)
|
||||
{
|
||||
aggregatedPermissions.Add(aggregatedModel);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
IEnumerable<(string Context, ISet<string> Verbs)> groupedModels = permissionModelByType.Models
|
||||
.Where(x => x is UnknownTypePermissionPresentationModel)
|
||||
.Cast<UnknownTypePermissionPresentationModel>()
|
||||
.GroupBy(x => x.Context)
|
||||
.Select(x => (x.Key, (ISet<string>)x.SelectMany(y => y.Verbs).Distinct().ToHashSet()));
|
||||
|
||||
foreach ((string context, ISet<string> verbs) in groupedModels)
|
||||
{
|
||||
aggregatedPermissions.Add(new UnknownTypePermissionPresentationModel
|
||||
{
|
||||
Context = context,
|
||||
Verbs = verbs
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return aggregatedPermissions;
|
||||
}
|
||||
|
||||
private void AggregateAndAddDocumentPermissions(IUser user, HashSet<IPermissionPresentationModel> aggregatedPermissions, HashSet<IPermissionPresentationModel> 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<Guid> documentKeysWithGranularPermissions = permissions
|
||||
.Where(x => x is DocumentPermissionPresentationModel)
|
||||
.Cast<DocumentPermissionPresentationModel>()
|
||||
.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<IPermissionPresentationModel> aggregatedPermissions, HashSet<IPermissionPresentationModel> 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<string> Verbs)> documentTypePropertyTypeKeysWithGranularPermissions = permissions
|
||||
.Where(x => x is DocumentPropertyValuePermissionPresentationModel)
|
||||
.Cast<DocumentPropertyValuePermissionPresentationModel>()
|
||||
.GroupBy(x => (x.DocumentType.Id, x.PropertyType.Id))
|
||||
.Select(x => (x.Key, (ISet<string>)x.SelectMany(y => y.Verbs).Distinct().ToHashSet()));
|
||||
|
||||
foreach (((Guid DocumentTypeId, Guid PropertyTypeId) Key, ISet<string> Verbs) documentTypePropertyTypeKey in documentTypePropertyTypeKeysWithGranularPermissions)
|
||||
{
|
||||
aggregatedPermissions.Add(new DocumentPropertyValuePermissionPresentationModel
|
||||
{
|
||||
DocumentType = new ReferenceByIdModel(documentTypePropertyTypeKey.Key.DocumentTypeId),
|
||||
PropertyType = new ReferenceByIdModel(documentTypePropertyTypeKey.Key.PropertyTypeId),
|
||||
Verbs = documentTypePropertyTypeKey.Verbs
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<CalculatedUserStartNodesResponseModel> CreateCalculatedUserStartNodesResponseModelAsync(IUser user)
|
||||
{
|
||||
var mediaStartNodeIds = user.CalculateMediaStartNodeIds(_entityService, _appCaches);
|
||||
@@ -357,6 +399,6 @@ public class UserPresentationFactory : IUserPresentationFactory
|
||||
: new HashSet<ReferenceByIdModel>(models);
|
||||
}
|
||||
|
||||
private bool HasRootAccess(IEnumerable<int>? startNodeIds)
|
||||
private static bool HasRootAccess(IEnumerable<int>? startNodeIds)
|
||||
=> startNodeIds?.Contains(Constants.System.Root) is true;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Mapping required for mapping all the way from viewmodel to database and back.
|
||||
/// Implements <see cref="IPermissionPresentationMapper" /> for document permissions.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public class DocumentPermissionMapper : IPermissionPresentationMapper, IPermissionMapper
|
||||
{
|
||||
private readonly Lazy<IEntityService> _entityService;
|
||||
private readonly Lazy<IUserService> _userService;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DocumentPermissionMapper"/> class.
|
||||
/// </summary>
|
||||
[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 17.")]
|
||||
public DocumentPermissionMapper()
|
||||
: this(
|
||||
StaticServiceProvider.Instance.GetRequiredService<Lazy<IEntityService>>(),
|
||||
StaticServiceProvider.Instance.GetRequiredService<Lazy<IUserService>>())
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DocumentPermissionMapper"/> class.
|
||||
/// </summary>
|
||||
public DocumentPermissionMapper(Lazy<IEntityService> entityService, Lazy<IUserService> userService)
|
||||
{
|
||||
_entityService = entityService;
|
||||
_userService = userService;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string Context => DocumentGranularPermission.ContextType;
|
||||
|
||||
public IGranularPermission MapFromDto(UserGroup2GranularPermissionDto dto) =>
|
||||
new DocumentGranularPermission()
|
||||
{
|
||||
@@ -21,8 +52,10 @@ public class DocumentPermissionMapper : IPermissionPresentationMapper, IPermissi
|
||||
Permission = dto.Permission,
|
||||
};
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Type PresentationModelToHandle => typeof(DocumentPermissionPresentationModel);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IEnumerable<IPermissionPresentationModel> MapManyAsync(IEnumerable<IGranularPermission> granularPermissions)
|
||||
{
|
||||
IEnumerable<IGrouping<Guid?, IGranularPermission>> keyGroups = granularPermissions.GroupBy(x => x.Key);
|
||||
@@ -40,6 +73,7 @@ public class DocumentPermissionMapper : IPermissionPresentationMapper, IPermissi
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IEnumerable<IGranularPermission> MapToGranularPermissions(IPermissionPresentationModel permissionViewModel)
|
||||
{
|
||||
if (permissionViewModel is not DocumentPermissionPresentationModel documentPermissionPresentationModel)
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IEnumerable<IPermissionPresentationModel> AggregatePresentationModels(IUser user, IEnumerable<IPermissionPresentationModel> models)
|
||||
{
|
||||
// Get the unique document keys that have granular permissions.
|
||||
Guid[] documentKeysWithGranularPermissions = models
|
||||
.Cast<DocumentPermissionPresentationModel>()
|
||||
.Select(x => x.Document.Id)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
|
||||
// Batch retrieve all documents by their keys.
|
||||
var documents = _entityService.Value.GetAll<IContent>(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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Implements <see cref="IPermissionPresentationMapper" /> for document property value permissions.
|
||||
/// </summary>
|
||||
public class DocumentPropertyValuePermissionMapper : IPermissionPresentationMapper, IPermissionMapper
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public string Context => DocumentPropertyValueGranularPermission.ContextType;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Type PresentationModelToHandle => typeof(DocumentPropertyValuePermissionPresentationModel);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IGranularPermission MapFromDto(UserGroup2GranularPermissionDto dto) =>
|
||||
new DocumentPropertyValueGranularPermission()
|
||||
{
|
||||
@@ -18,8 +27,7 @@ public class DocumentPropertyValuePermissionMapper : IPermissionPresentationMapp
|
||||
Permission = dto.Permission,
|
||||
};
|
||||
|
||||
public Type PresentationModelToHandle => typeof(DocumentPropertyValuePermissionPresentationModel);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IEnumerable<IPermissionPresentationModel> MapManyAsync(IEnumerable<IGranularPermission> granularPermissions)
|
||||
{
|
||||
var intermediate = granularPermissions.Where(p => p.Key.HasValue).Select(p =>
|
||||
@@ -50,6 +58,7 @@ public class DocumentPropertyValuePermissionMapper : IPermissionPresentationMapp
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IEnumerable<IGranularPermission> MapToGranularPermissions(IPermissionPresentationModel permissionViewModel)
|
||||
{
|
||||
if (permissionViewModel is not DocumentPropertyValuePermissionPresentationModel documentTypePermissionPresentationModel)
|
||||
@@ -66,4 +75,23 @@ public class DocumentPropertyValuePermissionMapper : IPermissionPresentationMapp
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IEnumerable<IPermissionPresentationModel> AggregatePresentationModels(IUser user, IEnumerable<IPermissionPresentationModel> models)
|
||||
{
|
||||
IEnumerable<((Guid DocumentTypeId, Guid PropertyTypeId) Key, ISet<string> Verbs)> groupedModels = models
|
||||
.Cast<DocumentPropertyValuePermissionPresentationModel>()
|
||||
.GroupBy(x => (x.DocumentType.Id, x.PropertyType.Id))
|
||||
.Select(x => (x.Key, (ISet<string>)x.SelectMany(y => y.Verbs).Distinct().ToHashSet()));
|
||||
|
||||
foreach (((Guid DocumentTypeId, Guid PropertyTypeId) key, ISet<string> verbs) in groupedModels)
|
||||
{
|
||||
yield return new DocumentPropertyValuePermissionPresentationModel
|
||||
{
|
||||
DocumentType = new ReferenceByIdModel(key.DocumentTypeId),
|
||||
PropertyType = new ReferenceByIdModel(key.PropertyTypeId),
|
||||
Verbs = verbs
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Defines methods for mapping and aggregating granular permissions to presentation models.
|
||||
/// </summary>
|
||||
public interface IPermissionPresentationMapper
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the context type for the permissions being handled by this mapper.
|
||||
/// </summary>
|
||||
string Context { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the type of the presentation model that this mapper handles.
|
||||
/// </summary>
|
||||
Type PresentationModelToHandle { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Maps a granular permission entity to a granular permission model.
|
||||
/// </summary>
|
||||
IEnumerable<IPermissionPresentationModel> MapManyAsync(IEnumerable<IGranularPermission> granularPermissions);
|
||||
|
||||
/// <summary>
|
||||
/// Maps a granular permission to a granular permission model.
|
||||
/// </summary>
|
||||
IEnumerable<IGranularPermission> MapToGranularPermissions(IPermissionPresentationModel permissionViewModel);
|
||||
|
||||
/// <summary>
|
||||
/// Aggregates multiple permission presentation models into a collection containing only one item per entity with aggregated permissions.
|
||||
/// </summary>
|
||||
IEnumerable<IPermissionPresentationModel> AggregatePresentationModels(IUser user, IEnumerable<IPermissionPresentationModel> models) => [];
|
||||
}
|
||||
|
||||
@@ -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<IPermissionMapper, DocumentPropertyValuePermissionMapper>();
|
||||
services.AddSingleton<IPermissionPresentationMapper, DocumentPropertyValuePermissionMapper>();
|
||||
|
||||
services.AddSingleton<IPermissionMapper, CustomPermissionMapper>();
|
||||
services.AddSingleton<IPermissionPresentationMapper, CustomPermissionMapper>();
|
||||
|
||||
}
|
||||
|
||||
[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<CustomPermissionPresentationModel>();
|
||||
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<string> 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<IPermissionPresentationModel> MapManyAsync(IEnumerable<IGranularPermission> granularPermissions)
|
||||
=> granularPermissions
|
||||
.Where(x => x is CustomGranularPermission)
|
||||
.Cast<CustomGranularPermission>()
|
||||
.Select(x => new CustomPermissionPresentationModel
|
||||
{
|
||||
Key = Guid.Parse(x.Permission.Split('|')[0]),
|
||||
Verbs = new HashSet<string> { x.Permission.Split('|')[1] },
|
||||
});
|
||||
|
||||
public IEnumerable<IGranularPermission> 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<IPermissionPresentationModel> AggregatePresentationModels(IUser user, IEnumerable<IPermissionPresentationModel> models)
|
||||
{
|
||||
IEnumerable<(Guid Key, ISet<string> Verbs)> groupedModels = models
|
||||
.Cast<CustomPermissionPresentationModel>()
|
||||
.GroupBy(x => x.Key)
|
||||
.Select(x => (x.Key, (ISet<string>)x.SelectMany(y => y.Verbs).Distinct().ToHashSet()));
|
||||
|
||||
foreach ((Guid key, ISet<string> verbs) in groupedModels)
|
||||
{
|
||||
yield return new CustomPermissionPresentationModel
|
||||
{
|
||||
Key = key,
|
||||
Verbs = verbs,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IUserGroup> CreateUserGroup(
|
||||
string name,
|
||||
string alias,
|
||||
int[] allowedLanguages,
|
||||
string[] permissions,
|
||||
INodeGranularPermission[] granularPermissions,
|
||||
IGranularPermission[] granularPermissions,
|
||||
int startMediaId)
|
||||
{
|
||||
var userGroup = new UserGroupBuilder()
|
||||
|
||||
Reference in New Issue
Block a user