From 8acb5665e83e88eb67d28d32c8e4e40b3f3084bb Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Fri, 22 Dec 2023 08:05:45 +0100 Subject: [PATCH] =?UTF-8?q?Add=20post=20and=20delete=20methods=20to=20user?= =?UTF-8?q?-group/id/users=20to=20add/remove=20user=E2=80=A6=20(#15490)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add post and delete methods to user-group/id/users to add/remove users from group * Update OpenApi contract * PR feedbac Moved fetch usergroup logic into service layer Renamed methods to async * Naming * Introduced user not found on UserGroupOperationStatus, as otherwise the return message is wrong * Added authentication * Removed authorization from the service layer as its determined that that responsibiliity does not belong there. --------- Co-authored-by: Sven Geusens Co-authored-by: Bjarke Berg --- .../AddUsersToUserGroupController.cs | 59 +++++++ .../RemoveUsersFromUserGroupController.cs | 59 +++++++ .../UserGroup/UserGroupsControllerBase.cs | 4 + src/Umbraco.Cms.Api.Management/OpenApi.json | 166 +++++++++++++++++- .../UsersToUserGroupManipulationModel.cs | 15 ++ .../Services/IUserGroupService.cs | 3 + .../UserGroupOperationStatus.cs | 1 + src/Umbraco.Core/Services/UserGroupService.cs | 104 +++++++++-- 8 files changed, 395 insertions(+), 16 deletions(-) create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/UserGroup/AddUsersToUserGroupController.cs create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/UserGroup/RemoveUsersFromUserGroupController.cs create mode 100644 src/Umbraco.Core/Models/UsersToUserGroupManipulationModel.cs diff --git a/src/Umbraco.Cms.Api.Management/Controllers/UserGroup/AddUsersToUserGroupController.cs b/src/Umbraco.Cms.Api.Management/Controllers/UserGroup/AddUsersToUserGroupController.cs new file mode 100644 index 0000000000..2b1cd64314 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/UserGroup/AddUsersToUserGroupController.cs @@ -0,0 +1,59 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.Security.Authorization.UserGroup; +using Umbraco.Cms.Api.Management.ViewModels.UserGroup; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Web.Common.Authorization; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Api.Management.Controllers.UserGroup; + +[ApiVersion("1.0")] +public class AddUsersToUserGroupController : UserGroupControllerBase +{ + private readonly IUserGroupService _userGroupService; + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + private readonly IAuthorizationService _authorizationService; + + public AddUsersToUserGroupController( + IUserGroupService userGroupService, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + IAuthorizationService authorizationService) + { + _userGroupService = userGroupService; + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + _authorizationService = authorizationService; + } + + [HttpPost("{id:guid}/users")] + [MapToApiVersion("1.0")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task Update(Guid id, Guid[] userIds) + { + AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync( + User, + UserGroupPermissionResource.WithKeys(id), + AuthorizationPolicies.UserBelongsToUserGroupInRequest); + + if (!authorizationResult.Succeeded) + { + return Forbidden(); + } + + UserGroupOperationStatus result = await _userGroupService.AddUsersToUserGroupAsync( + new UsersToUserGroupManipulationModel(id, userIds), CurrentUserKey(_backOfficeSecurityAccessor)); + + return result == UserGroupOperationStatus.Success + ? Ok() + : UserGroupOperationStatusResult(result); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/UserGroup/RemoveUsersFromUserGroupController.cs b/src/Umbraco.Cms.Api.Management/Controllers/UserGroup/RemoveUsersFromUserGroupController.cs new file mode 100644 index 0000000000..04c68c7c39 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/UserGroup/RemoveUsersFromUserGroupController.cs @@ -0,0 +1,59 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.Security.Authorization.UserGroup; +using Umbraco.Cms.Api.Management.ViewModels.UserGroup; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Web.Common.Authorization; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Api.Management.Controllers.UserGroup; + +[ApiVersion("1.0")] +public class RemoveUsersFromUserGroupController : UserGroupControllerBase +{ + private readonly IUserGroupService _userGroupService; + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + private readonly IAuthorizationService _authorizationService; + + public RemoveUsersFromUserGroupController( + IUserGroupService userGroupService, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + IAuthorizationService authorizationService) + { + _userGroupService = userGroupService; + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + _authorizationService = authorizationService; + } + + [HttpDelete("{id:guid}/users")] + [MapToApiVersion("1.0")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task Update(Guid id, Guid[] userIds) + { + AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync( + User, + UserGroupPermissionResource.WithKeys(id), + AuthorizationPolicies.UserBelongsToUserGroupInRequest); + + if (!authorizationResult.Succeeded) + { + return Forbidden(); + } + + UserGroupOperationStatus result = await _userGroupService.RemoveUsersFromUserGroupAsync( + new UsersToUserGroupManipulationModel(id, userIds), CurrentUserKey(_backOfficeSecurityAccessor)); + + return result == UserGroupOperationStatus.Success + ? Ok() + : UserGroupOperationStatusResult(result); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/UserGroup/UserGroupsControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/UserGroup/UserGroupsControllerBase.cs index 334d7ea0ba..425a5dae73 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/UserGroup/UserGroupsControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/UserGroup/UserGroupsControllerBase.cs @@ -18,6 +18,10 @@ public class UserGroupControllerBase : ManagementApiControllerBase status switch { UserGroupOperationStatus.NotFound => UserGroupNotFound(), + UserGroupOperationStatus.UserNotFound => NotFound(new ProblemDetailsBuilder() + .WithTitle("User key not found") + .WithDetail("The provided user key do not exist.") + .Build()), UserGroupOperationStatus.AlreadyExists => Conflict(new ProblemDetailsBuilder() .WithTitle("User group already exists") .WithDetail("The user group exists already.") diff --git a/src/Umbraco.Cms.Api.Management/OpenApi.json b/src/Umbraco.Cms.Api.Management/OpenApi.json index 1b1aa7cebe..d188c75c80 100644 --- a/src/Umbraco.Cms.Api.Management/OpenApi.json +++ b/src/Umbraco.Cms.Api.Management/OpenApi.json @@ -16930,6 +16930,170 @@ ] } }, + "/umbraco/management/api/v1/user-group/{id}/users": { + "delete": { + "tags": [ + "User Group" + ], + "operationId": "DeleteUserGroupByIdUsers", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + "application/*+json": { + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + } + } + }, + "responses": { + "200": { + "description": "Success" + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "The resource is protected and requires an authentication token" + } + }, + "security": [ + { + "Backoffice User": [ ] + } + ] + }, + "post": { + "tags": [ + "User Group" + ], + "operationId": "PostUserGroupByIdUsers", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + "application/*+json": { + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + } + } + }, + "responses": { + "200": { + "description": "Success" + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "The resource is protected and requires an authentication token" + } + }, + "security": [ + { + "Backoffice User": [ ] + } + ] + } + }, "/umbraco/management/api/v1/user-group/item": { "get": { "tags": [ @@ -25541,4 +25705,4 @@ } } } -} +} \ No newline at end of file diff --git a/src/Umbraco.Core/Models/UsersToUserGroupManipulationModel.cs b/src/Umbraco.Core/Models/UsersToUserGroupManipulationModel.cs new file mode 100644 index 0000000000..370d598d73 --- /dev/null +++ b/src/Umbraco.Core/Models/UsersToUserGroupManipulationModel.cs @@ -0,0 +1,15 @@ +using Umbraco.Cms.Core.Models.Membership; + +namespace Umbraco.Cms.Core.Models; + +public class UsersToUserGroupManipulationModel +{ + public Guid UserGroupKey { get; init; } + public Guid[] UserKeys { get; init; } + + public UsersToUserGroupManipulationModel(Guid userGroupKey, Guid[] userKeys) + { + UserGroupKey = userGroupKey; + UserKeys = userKeys; + } +} diff --git a/src/Umbraco.Core/Services/IUserGroupService.cs b/src/Umbraco.Core/Services/IUserGroupService.cs index e6dac4752d..4a2255ea79 100644 --- a/src/Umbraco.Core/Services/IUserGroupService.cs +++ b/src/Umbraco.Core/Services/IUserGroupService.cs @@ -95,4 +95,7 @@ public interface IUserGroupService /// The user whose groups we want to alter. /// An attempt indicating if the operation was a success as well as a more detailed . Task UpdateUserGroupsOnUsers(ISet userGroupKeys, ISet userKeys); + + Task AddUsersToUserGroupAsync(UsersToUserGroupManipulationModel addUsersModel, Guid performingUserKey); + Task RemoveUsersFromUserGroupAsync(UsersToUserGroupManipulationModel removeUsersModel, Guid performingUserKey); } diff --git a/src/Umbraco.Core/Services/OperationStatus/UserGroupOperationStatus.cs b/src/Umbraco.Core/Services/OperationStatus/UserGroupOperationStatus.cs index 97e9b631ad..9165db99e4 100644 --- a/src/Umbraco.Core/Services/OperationStatus/UserGroupOperationStatus.cs +++ b/src/Umbraco.Core/Services/OperationStatus/UserGroupOperationStatus.cs @@ -5,6 +5,7 @@ public enum UserGroupOperationStatus { Success, NotFound, + UserNotFound, AlreadyExists, DuplicateAlias, MissingUser, diff --git a/src/Umbraco.Core/Services/UserGroupService.cs b/src/Umbraco.Core/Services/UserGroupService.cs index dab24cb656..e0c401a888 100644 --- a/src/Umbraco.Core/Services/UserGroupService.cs +++ b/src/Umbraco.Core/Services/UserGroupService.cs @@ -49,11 +49,7 @@ internal sealed class UserGroupService : RepositoryService, IUserGroupService var total = groups.Length; - return Task.FromResult(new PagedModel - { - Items = groups.Skip(skip).Take(take), - Total = total, - }); + return Task.FromResult(new PagedModel { Items = groups.Skip(skip).Take(take), Total = total, }); } /// @@ -173,7 +169,8 @@ internal sealed class UserGroupService : RepositoryService, IUserGroupService _userGroupRepository.Delete(userGroup); } - scope.Notifications.Publish(new UserGroupDeletedNotification(userGroupsToDelete, eventMessages).WithStateFrom(deletingNotification)); + scope.Notifications.Publish( + new UserGroupDeletedNotification(userGroupsToDelete, eventMessages).WithStateFrom(deletingNotification)); scope.Complete(); @@ -190,7 +187,7 @@ internal sealed class UserGroupService : RepositoryService, IUserGroupService .Select(x => x.ToReadOnlyGroup()) .ToArray(); - foreach(IUser user in users) + foreach (IUser user in users) { user.ClearGroups(); foreach (IReadOnlyUserGroup userGroup in userGroups) @@ -231,13 +228,15 @@ internal sealed class UserGroupService : RepositoryService, IUserGroupService return Attempt.FailWithStatus(UserGroupOperationStatus.MissingUser, userGroup); } - Attempt validationAttempt = await ValidateUserGroupCreationAsync(userGroup); + Attempt validationAttempt = + await ValidateUserGroupCreationAsync(userGroup); if (validationAttempt.Success is false) { return validationAttempt; } - UserGroupAuthorizationStatus isAuthorized = await _userGroupPermissionService.AuthorizeCreateAsync(performingUser, userGroup); + UserGroupAuthorizationStatus isAuthorized = + await _userGroupPermissionService.AuthorizeCreateAsync(performingUser, userGroup); if (isAuthorized != UserGroupAuthorizationStatus.Success) { // Convert from UserGroupAuthorizationStatus to UserGroupOperationStatus @@ -253,13 +252,16 @@ internal sealed class UserGroupService : RepositoryService, IUserGroupService return Attempt.FailWithStatus(UserGroupOperationStatus.CancelledByNotification, userGroup); } - Guid[] checkedGroupMembersKeys = EnsureNonAdminUserIsInSavedUserGroup(performingUser, groupMembersKeys ?? Enumerable.Empty()).ToArray(); + Guid[] checkedGroupMembersKeys = + EnsureNonAdminUserIsInSavedUserGroup(performingUser, groupMembersKeys ?? Enumerable.Empty()) + .ToArray(); IUser[] usersToAdd = (await _userService.GetAsync(checkedGroupMembersKeys)).ToArray(); // Since this is a brand new creation we don't have to be worried about what users were added and removed // simply put all members that are requested to be in the group will be "added" var userGroupWithUsers = new UserGroupWithUsers(userGroup, usersToAdd, Array.Empty()); - var savingUserGroupWithUsersNotification = new UserGroupWithUsersSavingNotification(userGroupWithUsers, eventMessages); + var savingUserGroupWithUsersNotification = + new UserGroupWithUsersSavingNotification(userGroupWithUsers, eventMessages); if (await scope.Notifications.PublishCancelableAsync(savingUserGroupWithUsersNotification)) { scope.Complete(); @@ -272,7 +274,8 @@ internal sealed class UserGroupService : RepositoryService, IUserGroupService return Attempt.SucceedWithStatus(UserGroupOperationStatus.Success, userGroup); } - private async Task> ValidateUserGroupCreationAsync(IUserGroup userGroup) + private async Task> ValidateUserGroupCreationAsync( + IUserGroup userGroup) { if (await IsNewUserGroup(userGroup) is false) { @@ -312,7 +315,8 @@ internal sealed class UserGroupService : RepositoryService, IUserGroupService return Attempt.FailWithStatus(validationStatus, userGroup); } - UserGroupAuthorizationStatus isAuthorized = await _userGroupPermissionService.AuthorizeUpdateAsync(performingUser, userGroup); + UserGroupAuthorizationStatus isAuthorized = + await _userGroupPermissionService.AuthorizeUpdateAsync(performingUser, userGroup); if (isAuthorized != UserGroupAuthorizationStatus.Success) { // Convert from UserGroupAuthorizationStatus to UserGroupOperationStatus @@ -329,12 +333,81 @@ internal sealed class UserGroupService : RepositoryService, IUserGroupService } _userGroupRepository.Save(userGroup); - scope.Notifications.Publish(new UserGroupSavedNotification(userGroup, eventMessages).WithStateFrom(savingNotification)); + scope.Notifications.Publish( + new UserGroupSavedNotification(userGroup, eventMessages).WithStateFrom(savingNotification)); scope.Complete(); return Attempt.SucceedWithStatus(UserGroupOperationStatus.Success, userGroup); } + public async Task AddUsersToUserGroupAsync(UsersToUserGroupManipulationModel addUsersModel, Guid performingUserKey) + { + using ICoreScope scope = ScopeProvider.CreateCoreScope(); + + UserGroupOperationStatus result = await SafelyManipulateUsersBasedOnGroupAsync(addUsersModel, performingUserKey, (users, group) => + { + IReadOnlyUserGroup readOnlyGroup = group.ToReadOnlyGroup(); + + foreach (IUser user in users) + { + user.AddGroup(readOnlyGroup); + } + }); + + scope.Complete(); + return result; + } + + public async Task RemoveUsersFromUserGroupAsync(UsersToUserGroupManipulationModel removeUsersModel, Guid performingUserKey) + { + using ICoreScope scope = ScopeProvider.CreateCoreScope(); + + UserGroupOperationStatus result = await SafelyManipulateUsersBasedOnGroupAsync(removeUsersModel, performingUserKey, (users, group) => + { + foreach (IUser user in users) + { + user.RemoveGroup(group.Alias); + } + }); + + scope.Complete(); + return result; + } + + /// + /// Checks whether all users that are part of the manipulation exist, + /// performs the manipulation, + /// saves the users + /// + private async Task SafelyManipulateUsersBasedOnGroupAsync(UsersToUserGroupManipulationModel assignModel, Guid performingUserKey, Action manipulation) + { + IUser? performingUser = await _userService.GetAsync(performingUserKey); + if (performingUser is null) + { + return UserGroupOperationStatus.MissingUser; + } + + IUserGroup? existingUserGroup = await GetAsync(assignModel.UserGroupKey); + + if (existingUserGroup is null) + { + return UserGroupOperationStatus.NotFound; + } + + IUser[] users = (await _userService.GetAsync(assignModel.UserKeys)).ToArray(); + + if (users.Length != assignModel.UserKeys.Length) + { + return UserGroupOperationStatus.UserNotFound; + } + + manipulation(users, existingUserGroup); + + _userService.Save(users); + + return UserGroupOperationStatus.Success; + } + private async Task ValidateUserGroupUpdateAsync(IUserGroup userGroup) { UserGroupOperationStatus commonValidationStatus = ValidateCommon(userGroup); @@ -421,7 +494,8 @@ internal sealed class UserGroupService : RepositoryService, IUserGroupService /// /// This is to ensure that the user can access the group they themselves created at a later point and modify it. /// - private IEnumerable EnsureNonAdminUserIsInSavedUserGroup(IUser performingUser, IEnumerable groupMembersUserKeys) + private IEnumerable EnsureNonAdminUserIsInSavedUserGroup(IUser performingUser, + IEnumerable groupMembersUserKeys) { var userKeys = groupMembersUserKeys.ToList();