Add post and delete methods to user-group/id/users to add/remove user… (#15490)

* 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 <sge@umbraco.dk>
Co-authored-by: Bjarke Berg <mail@bergmania.dk>
This commit is contained in:
Sven Geusens
2023-12-22 08:05:45 +01:00
committed by GitHub
parent 4b3131fda4
commit 8acb5665e8
8 changed files with 395 additions and 16 deletions

View File

@@ -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<IActionResult> 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);
}
}

View File

@@ -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<IActionResult> 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);
}
}

View File

@@ -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.")

View File

@@ -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 @@
}
}
}
}
}

View File

@@ -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;
}
}

View File

@@ -95,4 +95,7 @@ public interface IUserGroupService
/// <param name="userKeys">The user whose groups we want to alter.</param>
/// <returns>An attempt indicating if the operation was a success as well as a more detailed <see cref="UserGroupOperationStatus"/>.</returns>
Task UpdateUserGroupsOnUsers(ISet<Guid> userGroupKeys, ISet<Guid> userKeys);
Task<UserGroupOperationStatus> AddUsersToUserGroupAsync(UsersToUserGroupManipulationModel addUsersModel, Guid performingUserKey);
Task<UserGroupOperationStatus> RemoveUsersFromUserGroupAsync(UsersToUserGroupManipulationModel removeUsersModel, Guid performingUserKey);
}

View File

@@ -5,6 +5,7 @@ public enum UserGroupOperationStatus
{
Success,
NotFound,
UserNotFound,
AlreadyExists,
DuplicateAlias,
MissingUser,

View File

@@ -49,11 +49,7 @@ internal sealed class UserGroupService : RepositoryService, IUserGroupService
var total = groups.Length;
return Task.FromResult(new PagedModel<IUserGroup>
{
Items = groups.Skip(skip).Take(take),
Total = total,
});
return Task.FromResult(new PagedModel<IUserGroup> { Items = groups.Skip(skip).Take(take), Total = total, });
}
/// <inheritdoc />
@@ -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<IUserGroup, UserGroupOperationStatus> validationAttempt = await ValidateUserGroupCreationAsync(userGroup);
Attempt<IUserGroup, UserGroupOperationStatus> 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<Guid>()).ToArray();
Guid[] checkedGroupMembersKeys =
EnsureNonAdminUserIsInSavedUserGroup(performingUser, groupMembersKeys ?? Enumerable.Empty<Guid>())
.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<IUser>());
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<Attempt<IUserGroup, UserGroupOperationStatus>> ValidateUserGroupCreationAsync(IUserGroup userGroup)
private async Task<Attempt<IUserGroup, UserGroupOperationStatus>> 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<UserGroupOperationStatus> 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<UserGroupOperationStatus> 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;
}
/// <summary>
/// Checks whether all users that are part of the manipulation exist,
/// performs the manipulation,
/// saves the users
/// </summary>
private async Task<UserGroupOperationStatus> SafelyManipulateUsersBasedOnGroupAsync(UsersToUserGroupManipulationModel assignModel, Guid performingUserKey, Action<IUser[], IUserGroup> 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<UserGroupOperationStatus> ValidateUserGroupUpdateAsync(IUserGroup userGroup)
{
UserGroupOperationStatus commonValidationStatus = ValidateCommon(userGroup);
@@ -421,7 +494,8 @@ internal sealed class UserGroupService : RepositoryService, IUserGroupService
/// <remarks>
/// This is to ensure that the user can access the group they themselves created at a later point and modify it.
/// </remarks>
private IEnumerable<Guid> EnsureNonAdminUserIsInSavedUserGroup(IUser performingUser, IEnumerable<Guid> groupMembersUserKeys)
private IEnumerable<Guid> EnsureNonAdminUserIsInSavedUserGroup(IUser performingUser,
IEnumerable<Guid> groupMembersUserKeys)
{
var userKeys = groupMembersUserKeys.ToList();