Skip to content

Commit 4c87cc5

Browse files
authored
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.
1 parent 8b4849b commit 4c87cc5

File tree

6 files changed

+431
-73
lines changed

6 files changed

+431
-73
lines changed

src/Umbraco.Cms.Api.Management/Factories/IUserPresentationFactory.cs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,25 +6,58 @@
66

77
namespace Umbraco.Cms.Api.Management.Factories;
88

9+
/// <summary>
10+
/// Defines factory methods for the creation of user presentation models.
11+
/// </summary>
912
public interface IUserPresentationFactory
1013
{
14+
/// <summary>
15+
/// Creates a response model for the provided user.
16+
/// </summary>
1117
UserResponseModel CreateResponseModel(IUser user);
1218

19+
/// <summary>
20+
/// Creates a create model for a user based on the provided request model.
21+
/// </summary>
1322
Task<UserCreateModel> CreateCreationModelAsync(CreateUserRequestModel requestModel);
1423

24+
/// <summary>
25+
/// Creates an invite model for a user based on the provided request model.
26+
/// </summary>
1527
Task<UserInviteModel> CreateInviteModelAsync(InviteUserRequestModel requestModel);
1628

29+
/// <summary>
30+
/// Creates an update model for an existing user based on the provided request model.
31+
/// </summary>
1732
Task<UserUpdateModel> CreateUpdateModelAsync(Guid existingUserKey, UpdateUserRequestModel updateModel);
1833

34+
/// <summary>
35+
/// Creates a response model for the current user based on the provided user.
36+
/// </summary>
1937
Task<CurrentUserResponseModel> CreateCurrentUserResponseModelAsync(IUser user);
2038

39+
/// <summary>
40+
/// Creates an resend invite model for a user based on the provided request model.
41+
/// </summary>
2142
Task<UserResendInviteModel> CreateResendInviteModelAsync(ResendInviteUserRequestModel requestModel);
2243

44+
/// <summary>
45+
/// Creates a user configuration model that contains the necessary data for user management operations.
46+
/// </summary>
2347
Task<UserConfigurationResponseModel> CreateUserConfigurationModelAsync();
2448

49+
/// <summary>
50+
/// Creates a current user configuration model that contains the necessary data for the current user's management operations.
51+
/// </summary>
2552
Task<CurrentUserConfigurationResponseModel> CreateCurrentUserConfigurationModelAsync();
2653

54+
/// <summary>
55+
/// Creates a user item response model for the provided user.
56+
/// </summary>
2757
UserItemResponseModel CreateItemResponseModel(IUser user);
2858

59+
/// <summary>
60+
/// Creates a calculated user start nodes response model based on the provided user.
61+
/// </summary>
2962
Task<CalculatedUserStartNodesResponseModel> CreateCalculatedUserStartNodesResponseModelAsync(IUser user);
3063
}

src/Umbraco.Cms.Api.Management/Factories/UserPresentationFactory.cs

Lines changed: 109 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using Microsoft.Extensions.DependencyInjection;
22
using Microsoft.Extensions.Options;
3+
using Umbraco.Cms.Api.Management.Mapping.Permissions;
34
using Umbraco.Cms.Api.Management.Routing;
45
using Umbraco.Cms.Api.Management.Security;
56
using Umbraco.Cms.Api.Management.ViewModels;
@@ -22,6 +23,9 @@
2223

2324
namespace Umbraco.Cms.Api.Management.Factories;
2425

26+
/// <summary>
27+
/// Factory for creating user presentation models, implementing <see cref="IUserPresentationFactory"/>.
28+
/// </summary>
2529
public class UserPresentationFactory : IUserPresentationFactory
2630
{
2731
private readonly IEntityService _entityService;
@@ -34,9 +38,11 @@ public class UserPresentationFactory : IUserPresentationFactory
3438
private readonly IPasswordConfigurationPresentationFactory _passwordConfigurationPresentationFactory;
3539
private readonly IBackOfficeExternalLoginProviders _externalLoginProviders;
3640
private readonly SecuritySettings _securitySettings;
37-
private readonly IUserService _userService;
38-
private readonly IContentService _contentService;
41+
private readonly Dictionary<Type, IPermissionPresentationMapper> _permissionPresentationMappersByType;
3942

43+
/// <summary>
44+
/// Initializes a new instance of the <see cref="UserPresentationFactory"/> class.
45+
/// </summary>
4046
[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 17.")]
4147
public UserPresentationFactory(
4248
IEntityService entityService,
@@ -50,7 +56,7 @@ public UserPresentationFactory(
5056
IOptionsSnapshot<SecuritySettings> securitySettings,
5157
IBackOfficeExternalLoginProviders externalLoginProviders)
5258
: this(
53-
entityService,
59+
entityService,
5460
appCaches,
5561
mediaFileManager,
5662
imageUrlGenerator,
@@ -61,10 +67,15 @@ public UserPresentationFactory(
6167
securitySettings,
6268
externalLoginProviders,
6369
StaticServiceProvider.Instance.GetRequiredService<IUserService>(),
64-
StaticServiceProvider.Instance.GetRequiredService<IContentService>())
70+
StaticServiceProvider.Instance.GetRequiredService<IContentService>(),
71+
StaticServiceProvider.Instance.GetRequiredService<IEnumerable<IPermissionPresentationMapper>>())
6572
{
6673
}
6774

75+
/// <summary>
76+
/// Initializes a new instance of the <see cref="UserPresentationFactory"/> class.
77+
/// </summary>
78+
[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 17.")]
6879
public UserPresentationFactory(
6980
IEntityService entityService,
7081
AppCaches appCaches,
@@ -78,6 +89,44 @@ public UserPresentationFactory(
7889
IBackOfficeExternalLoginProviders externalLoginProviders,
7990
IUserService userService,
8091
IContentService contentService)
92+
: this(
93+
entityService,
94+
appCaches,
95+
mediaFileManager,
96+
imageUrlGenerator,
97+
userGroupPresentationFactory,
98+
absoluteUrlBuilder,
99+
emailSender,
100+
passwordConfigurationPresentationFactory,
101+
securitySettings,
102+
externalLoginProviders,
103+
userService,
104+
contentService,
105+
StaticServiceProvider.Instance.GetRequiredService<IEnumerable<IPermissionPresentationMapper>>())
106+
{
107+
}
108+
109+
// TODO (V17): Remove the unused userService and contentService parameters from this constructor.
110+
111+
/// <summary>
112+
/// Initializes a new instance of the <see cref="UserPresentationFactory"/> class.
113+
/// </summary>
114+
public UserPresentationFactory(
115+
IEntityService entityService,
116+
AppCaches appCaches,
117+
MediaFileManager mediaFileManager,
118+
IImageUrlGenerator imageUrlGenerator,
119+
IUserGroupPresentationFactory userGroupPresentationFactory,
120+
IAbsoluteUrlBuilder absoluteUrlBuilder,
121+
IEmailSender emailSender,
122+
IPasswordConfigurationPresentationFactory passwordConfigurationPresentationFactory,
123+
IOptionsSnapshot<SecuritySettings> securitySettings,
124+
IBackOfficeExternalLoginProviders externalLoginProviders,
125+
#pragma warning disable IDE0060 // Remove unused parameter - need to keep these until the next major to avoid breaking changes and/or ambiguous constructor errors
126+
IUserService userService,
127+
IContentService contentService,
128+
#pragma warning restore IDE0060 // Remove unused parameter
129+
IEnumerable<IPermissionPresentationMapper> permissionPresentationMappers)
81130
{
82131
_entityService = entityService;
83132
_appCaches = appCaches;
@@ -89,10 +138,10 @@ public UserPresentationFactory(
89138
_externalLoginProviders = externalLoginProviders;
90139
_securitySettings = securitySettings.Value;
91140
_absoluteUrlBuilder = absoluteUrlBuilder;
92-
_userService = userService;
93-
_contentService = contentService;
141+
_permissionPresentationMappersByType = permissionPresentationMappers.ToDictionary(x => x.PresentationModelToHandle);
94142
}
95143

144+
/// <inheritdoc/>
96145
public UserResponseModel CreateResponseModel(IUser user)
97146
{
98147
var responseModel = new UserResponseModel
@@ -123,16 +172,18 @@ public UserResponseModel CreateResponseModel(IUser user)
123172
return responseModel;
124173
}
125174

175+
/// <inheritdoc/>
126176
public UserItemResponseModel CreateItemResponseModel(IUser user) =>
127177
new()
128178
{
129179
Id = user.Key,
130180
Name = user.Name ?? user.Username,
131181
AvatarUrls = user.GetUserAvatarUrls(_appCaches.RuntimeCache, _mediaFileManager, _imageUrlGenerator)
132182
.Select(url => _absoluteUrlBuilder.ToAbsoluteUrl(url).ToString()),
133-
Kind = user.Kind
183+
Kind = user.Kind,
134184
};
135185

186+
/// <inheritdoc/>
136187
public Task<UserCreateModel> CreateCreationModelAsync(CreateUserRequestModel requestModel)
137188
{
138189
var createModel = new UserCreateModel
@@ -142,12 +193,13 @@ public Task<UserCreateModel> CreateCreationModelAsync(CreateUserRequestModel req
142193
Name = requestModel.Name,
143194
UserName = requestModel.UserName,
144195
UserGroupKeys = requestModel.UserGroupIds.Select(x => x.Id).ToHashSet(),
145-
Kind = requestModel.Kind
196+
Kind = requestModel.Kind,
146197
};
147198

148199
return Task.FromResult(createModel);
149200
}
150201

202+
/// <inheritdoc/>
151203
public Task<UserInviteModel> CreateInviteModelAsync(InviteUserRequestModel requestModel)
152204
{
153205
var inviteModel = new UserInviteModel
@@ -162,6 +214,7 @@ public Task<UserInviteModel> CreateInviteModelAsync(InviteUserRequestModel reque
162214
return Task.FromResult(inviteModel);
163215
}
164216

217+
/// <inheritdoc/>
165218
public Task<UserResendInviteModel> CreateResendInviteModelAsync(ResendInviteUserRequestModel requestModel)
166219
{
167220
var inviteModel = new UserResendInviteModel
@@ -173,6 +226,7 @@ public Task<UserResendInviteModel> CreateResendInviteModelAsync(ResendInviteUser
173226
return Task.FromResult(inviteModel);
174227
}
175228

229+
/// <inheritdoc/>
176230
public Task<CurrentUserConfigurationResponseModel> CreateCurrentUserConfigurationModelAsync()
177231
{
178232
var model = new CurrentUserConfigurationResponseModel
@@ -188,6 +242,7 @@ public Task<CurrentUserConfigurationResponseModel> CreateCurrentUserConfiguratio
188242
return Task.FromResult(model);
189243
}
190244

245+
/// <inheritdoc/>
191246
public Task<UserConfigurationResponseModel> CreateUserConfigurationModelAsync() =>
192247
Task.FromResult(new UserConfigurationResponseModel
193248
{
@@ -201,6 +256,7 @@ public Task<UserConfigurationResponseModel> CreateUserConfigurationModelAsync()
201256
AllowTwoFactor = _externalLoginProviders.HasDenyLocalLogin() is false,
202257
});
203258

259+
/// <inheritdoc/>
204260
public Task<UserUpdateModel> CreateUpdateModelAsync(Guid existingUserKey, UpdateUserRequestModel updateModel)
205261
{
206262
var model = new UserUpdateModel
@@ -214,24 +270,24 @@ public Task<UserUpdateModel> CreateUpdateModelAsync(Guid existingUserKey, Update
214270
HasContentRootAccess = updateModel.HasDocumentRootAccess,
215271
MediaStartNodeKeys = updateModel.MediaStartNodeIds.Select(x => x.Id).ToHashSet(),
216272
HasMediaRootAccess = updateModel.HasMediaRootAccess,
273+
UserGroupKeys = updateModel.UserGroupIds.Select(x => x.Id).ToHashSet()
217274
};
218275

219-
model.UserGroupKeys = updateModel.UserGroupIds.Select(x => x.Id).ToHashSet();
220-
221276
return Task.FromResult(model);
222277
}
223278

279+
/// <inheritdoc/>
224280
public async Task<CurrentUserResponseModel> CreateCurrentUserResponseModelAsync(IUser user)
225281
{
226-
var presentationUser = CreateResponseModel(user);
227-
var presentationGroups = await _userGroupPresentationFactory.CreateMultipleAsync(user.Groups);
282+
UserResponseModel presentationUser = CreateResponseModel(user);
283+
IEnumerable<UserGroupResponseModel> presentationGroups = await _userGroupPresentationFactory.CreateMultipleAsync(user.Groups);
228284
var languages = presentationGroups.SelectMany(x => x.Languages).Distinct().ToArray();
229285
var mediaStartNodeIds = user.CalculateMediaStartNodeIds(_entityService, _appCaches);
230-
var mediaStartNodeKeys = GetKeysFromIds(mediaStartNodeIds, UmbracoObjectTypes.Media);
286+
ISet<ReferenceByIdModel> mediaStartNodeKeys = GetKeysFromIds(mediaStartNodeIds, UmbracoObjectTypes.Media);
231287
var contentStartNodeIds = user.CalculateContentStartNodeIds(_entityService, _appCaches);
232-
var documentStartNodeKeys = GetKeysFromIds(contentStartNodeIds, UmbracoObjectTypes.Document);
288+
ISet<ReferenceByIdModel> documentStartNodeKeys = GetKeysFromIds(contentStartNodeIds, UmbracoObjectTypes.Document);
233289

234-
var permissions = GetAggregatedGranularPermissions(user, presentationGroups);
290+
HashSet<IPermissionPresentationModel> permissions = GetAggregatedGranularPermissions(user, presentationGroups);
235291
var fallbackPermissions = presentationGroups.SelectMany(x => x.FallbackPermissions).ToHashSet();
236292

237293
var hasAccessToAllLanguages = presentationGroups.Any(x => x.HasAccessToAllLanguages);
@@ -263,70 +319,56 @@ public async Task<CurrentUserResponseModel> CreateCurrentUserResponseModelAsync(
263319

264320
private HashSet<IPermissionPresentationModel> GetAggregatedGranularPermissions(IUser user, IEnumerable<UserGroupResponseModel> presentationGroups)
265321
{
266-
var aggregatedPermissions = new HashSet<IPermissionPresentationModel>();
267-
268322
var permissions = presentationGroups.SelectMany(x => x.Permissions).ToHashSet();
269-
270-
AggregateAndAddDocumentPermissions(user, aggregatedPermissions, permissions);
271-
272-
AggregateAndAddDocumentPropertyValuePermissions(aggregatedPermissions, permissions);
273-
274-
return aggregatedPermissions;
323+
return GetAggregatedGranularPermissions(user, permissions);
275324
}
276325

277-
private void AggregateAndAddDocumentPermissions(IUser user, HashSet<IPermissionPresentationModel> aggregatedPermissions, HashSet<IPermissionPresentationModel> permissions)
326+
private HashSet<IPermissionPresentationModel> GetAggregatedGranularPermissions(IUser user, HashSet<IPermissionPresentationModel> permissions)
278327
{
279-
// The raw permission data consists of several permissions for each document. We want to aggregate this server-side so
280-
// we return one set of aggregate permissions per document that the client will use.
281-
282-
// Get the unique document keys that have granular permissions.
283-
IEnumerable<Guid> documentKeysWithGranularPermissions = permissions
284-
.Where(x => x is DocumentPermissionPresentationModel)
285-
.Cast<DocumentPermissionPresentationModel>()
286-
.Select(x => x.Document.Id)
287-
.Distinct();
328+
// The raw permission data consists of several permissions for each entity (e.g. document), as permissions are assigned to user groups
329+
// 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
330+
// entity that the client will use.
331+
// We need to handle here not just permissions known to core (e.g. document and document property value permissions), but also custom
332+
// permissions defined by packages or implemetors.
333+
IEnumerable<(Type, IEnumerable<IPermissionPresentationModel>)> permissionModelsByType = permissions
334+
.GroupBy(x => x.GetType())
335+
.Select(x => (x.Key, x.Select(y => y)));
288336

289-
foreach (Guid documentKey in documentKeysWithGranularPermissions)
337+
var aggregatedPermissions = new HashSet<IPermissionPresentationModel>();
338+
foreach ((Type Type, IEnumerable<IPermissionPresentationModel> Models) permissionModelByType in permissionModelsByType)
290339
{
291-
// Retrieve the path of the document.
292-
var path = _contentService.GetById(documentKey)?.Path;
293-
if (string.IsNullOrEmpty(path))
340+
if (_permissionPresentationMappersByType.TryGetValue(permissionModelByType.Type, out IPermissionPresentationMapper? mapper))
294341
{
295-
continue;
296-
}
297342

298-
// With the path we can call the same logic as used server-side for authorizing access to resources.
299-
EntityPermissionSet permissionsForPath = _userService.GetPermissionsForPath(user, path);
300-
aggregatedPermissions.Add(new DocumentPermissionPresentationModel
343+
IEnumerable<IPermissionPresentationModel> aggregatedModels = mapper.AggregatePresentationModels(user, permissionModelByType.Models);
344+
foreach (IPermissionPresentationModel aggregatedModel in aggregatedModels)
345+
{
346+
aggregatedPermissions.Add(aggregatedModel);
347+
}
348+
}
349+
else
301350
{
302-
Document = new ReferenceByIdModel(documentKey),
303-
Verbs = permissionsForPath.GetAllPermissions()
304-
});
351+
IEnumerable<(string Context, ISet<string> Verbs)> groupedModels = permissionModelByType.Models
352+
.Where(x => x is UnknownTypePermissionPresentationModel)
353+
.Cast<UnknownTypePermissionPresentationModel>()
354+
.GroupBy(x => x.Context)
355+
.Select(x => (x.Key, (ISet<string>)x.SelectMany(y => y.Verbs).Distinct().ToHashSet()));
356+
357+
foreach ((string context, ISet<string> verbs) in groupedModels)
358+
{
359+
aggregatedPermissions.Add(new UnknownTypePermissionPresentationModel
360+
{
361+
Context = context,
362+
Verbs = verbs
363+
});
364+
}
365+
}
305366
}
306-
}
307367

308-
private static void AggregateAndAddDocumentPropertyValuePermissions(HashSet<IPermissionPresentationModel> aggregatedPermissions, HashSet<IPermissionPresentationModel> permissions)
309-
{
310-
// We also have permissions for document type/property type combinations.
311-
// These don't have an ancestor relationship that we need to take into account, but should be aggregated
312-
// and included in the set.
313-
IEnumerable<((Guid DocumentTypeId, Guid PropertyTypeId) Key, ISet<string> Verbs)> documentTypePropertyTypeKeysWithGranularPermissions = permissions
314-
.Where(x => x is DocumentPropertyValuePermissionPresentationModel)
315-
.Cast<DocumentPropertyValuePermissionPresentationModel>()
316-
.GroupBy(x => (x.DocumentType.Id, x.PropertyType.Id))
317-
.Select(x => (x.Key, (ISet<string>)x.SelectMany(y => y.Verbs).Distinct().ToHashSet()));
318-
319-
foreach (((Guid DocumentTypeId, Guid PropertyTypeId) Key, ISet<string> Verbs) documentTypePropertyTypeKey in documentTypePropertyTypeKeysWithGranularPermissions)
320-
{
321-
aggregatedPermissions.Add(new DocumentPropertyValuePermissionPresentationModel
322-
{
323-
DocumentType = new ReferenceByIdModel(documentTypePropertyTypeKey.Key.DocumentTypeId),
324-
PropertyType = new ReferenceByIdModel(documentTypePropertyTypeKey.Key.PropertyTypeId),
325-
Verbs = documentTypePropertyTypeKey.Verbs
326-
});
327-
}
368+
return aggregatedPermissions;
328369
}
329370

371+
/// <inheritdoc/>
330372
public Task<CalculatedUserStartNodesResponseModel> CreateCalculatedUserStartNodesResponseModelAsync(IUser user)
331373
{
332374
var mediaStartNodeIds = user.CalculateMediaStartNodeIds(_entityService, _appCaches);
@@ -357,6 +399,6 @@ private ISet<ReferenceByIdModel> GetKeysFromIds(IEnumerable<int>? ids, UmbracoOb
357399
: new HashSet<ReferenceByIdModel>(models);
358400
}
359401

360-
private bool HasRootAccess(IEnumerable<int>? startNodeIds)
402+
private static bool HasRootAccess(IEnumerable<int>? startNodeIds)
361403
=> startNodeIds?.Contains(Constants.System.Root) is true;
362404
}

0 commit comments

Comments
 (0)