Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,25 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.Security.Authorization.User;
using Umbraco.Cms.Api.Management.Controllers.UserGroup;
using Umbraco.Cms.Api.Management.Routing;
using Umbraco.Cms.Api.Management.ViewModels.User;
using Umbraco.Cms.Core.Security.Authorization;
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.User;

// This controller is a bit of a weird case, for all intents and purposes this should be a UserGroupController
// It uses the UserGroupService to manipulate the members of a user group, however, from the frontend perspective it is a user(s) operation
// In order to not have to re-implement all the UserGroupOperationStatusResults this controller inherits from UserGroupControllerBase
// But manually specifies its route and APIExplorerSettings to be under users.
[ApiVersion("1.0")]
public class UpdateUserGroupsUserController : UserControllerBase
[VersionedApiBackOfficeRoute("user")]
[ApiExplorerSettings(GroupName = "User")]
public class UpdateUserGroupsUserController : UserGroupControllerBase
{
private readonly IAuthorizationService _authorizationService;
private readonly IUserGroupService _userGroupService;
Expand All @@ -33,12 +41,13 @@ public async Task<IActionResult> UpdateUserGroups(UpdateUserGroupsOnUserRequestM
UserPermissionResource.WithKeys(requestModel.UserIds),
AuthorizationPolicies.UserPermissionByResource);

if (!authorizationResult.Succeeded)
if (authorizationResult.Succeeded is false)
{
return Forbidden();
}

await _userGroupService.UpdateUserGroupsOnUsers(requestModel.UserGroupIds, requestModel.UserIds);
return Ok();
UserGroupOperationStatus status = await _userGroupService.UpdateUserGroupsOnUsersAsync(requestModel.UserGroupIds, requestModel.UserIds);

return UserGroupOperationStatusResult(status);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ protected IActionResult UserOperationStatusResult(UserOperationStatus status, Er
.WithTitle("Missing User Group")
.WithDetail("The specified user group was not found.")
.Build()),
UserOperationStatus.AdminUserGroupMustNotBeEmpty => BadRequest(problemDetailsBuilder
.WithTitle("Admin User Group Must Not Be Empty")
.WithDetail("The admin user group must not be empty.")
.Build()),
UserOperationStatus.NoUserGroup => BadRequest(problemDetailsBuilder
.WithTitle("No User Group Specified")
.WithDetail("A user group must be specified to create a user")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,7 @@
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.Security.Authorization;
using Umbraco.Cms.Core.Services;
Expand Down Expand Up @@ -45,7 +40,7 @@ public async Task<IActionResult> Update(Guid id, Guid[] userIds)
UserGroupPermissionResource.WithKeys(id),
AuthorizationPolicies.UserBelongsToUserGroupInRequest);

if (!authorizationResult.Succeeded)
if (authorizationResult.Succeeded is false)
{
return Forbidden();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,13 @@ protected IActionResult UserGroupOperationStatusResult(UserGroupOperationStatus
.WithTitle("Missing user group name.")
.WithDetail("The user group name is required, and cannot be an empty string.")
.Build()),
UserGroupOperationStatus.AdminGroupCannotBeEmpty => BadRequest(problemDetailsBuilder
.WithTitle("Admin group cannot be empty")
.WithDetail("The admin group cannot be empty.")
.Build()),
UserGroupOperationStatus.UserNotInGroup => BadRequest(problemDetailsBuilder
.WithTitle("User not in group")
.WithDetail("The user is not in the group.")),
UserGroupOperationStatus.UnauthorizedMissingAllowedSectionAccess => Unauthorized(problemDetailsBuilder
.WithTitle("Unauthorized section access")
.WithDetail("The performing user does not have access to all sections specified as allowed for this user group.")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
using Umbraco.Cms.Core.Models.Membership;

namespace Umbraco.Cms.Core.Models;

public class ResolvedUserToUserGroupManipulationModel
{
public required IUser[] Users { get; init; }

public required IUserGroup UserGroup { get; init; }
}
3 changes: 1 addition & 2 deletions src/Umbraco.Core/Models/UsersToUserGroupManipulationModel.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
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)
Expand Down
2 changes: 1 addition & 1 deletion src/Umbraco.Core/Services/IUserGroupService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ public interface IUserGroupService
/// <param name="userGroupKeys">The user groups the users should be part of.</param>
/// <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> UpdateUserGroupsOnUsersAsync(ISet<Guid> userGroupKeys, ISet<Guid> userKeys);

Task<UserGroupOperationStatus> AddUsersToUserGroupAsync(UsersToUserGroupManipulationModel addUsersModel, Guid performingUserKey);
Task<UserGroupOperationStatus> RemoveUsersFromUserGroupAsync(UsersToUserGroupManipulationModel removeUsersModel, Guid performingUserKey);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,7 @@ public enum UserGroupOperationStatus
UnauthorizedMissingContentStartNodeAccess,
UnauthorizedMissingMediaStartNodeAccess,
UnauthorizedMissingUserGroupAccess,
UnauthorizedMissingUsersSectionAccess
UnauthorizedMissingUsersSectionAccess,
AdminGroupCannotBeEmpty,
UserNotInGroup,
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ public enum UserOperationStatus
UserNameIsNotEmail,
EmailCannotBeChanged,
NoUserGroup,
AdminUserGroupMustNotBeEmpty,
DuplicateUserName,
InvalidEmail,
DuplicateEmail,
Expand Down
124 changes: 96 additions & 28 deletions src/Umbraco.Core/Services/UserGroupService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -178,16 +178,34 @@ public async Task<Attempt<UserGroupOperationStatus>> DeleteAsync(ISet<Guid> keys
return Attempt.Succeed(UserGroupOperationStatus.Success);
}

public async Task UpdateUserGroupsOnUsers(
public async Task<UserGroupOperationStatus> UpdateUserGroupsOnUsersAsync(
ISet<Guid> userGroupKeys,
ISet<Guid> userKeys)
{
using ICoreScope scope = ScopeProvider.CreateCoreScope();

IUser[] users = (await _userService.GetAsync(userKeys)).ToArray();

IReadOnlyUserGroup[] userGroups = (await GetAsync(userGroupKeys))
.Select(x => x.ToReadOnlyGroup())
.ToArray();

// This means that we're potentially de-admining a user, which might cause the admin group to be empty.
if (userGroupKeys.Contains(Constants.Security.AdminGroupKey) is false)
{
IUser[] usersToDeAdmin = users.Where(x => x.IsAdmin()).ToArray();
if (usersToDeAdmin.Length > 0)
{
// Unfortunately we have to resolve the admin group to ensure that it would not be left empty.
IUserGroup? adminGroup = await GetAsync(Constants.Security.AdminGroupKey);
if (adminGroup is not null && adminGroup.UserCount <= usersToDeAdmin.Length)
{
scope.Complete();
return UserGroupOperationStatus.AdminGroupCannotBeEmpty;
}
}
}

foreach (IUser user in users)
{
user.ClearGroups();
Expand All @@ -198,6 +216,10 @@ public async Task UpdateUserGroupsOnUsers(
}

_userService.Save(users);

scope.Complete();

return UserGroupOperationStatus.Success;
}

private Attempt<UserGroupOperationStatus> ValidateUserGroupDeletion(IUserGroup? userGroup)
Expand Down Expand Up @@ -345,68 +367,114 @@ public async Task<UserGroupOperationStatus> AddUsersToUserGroupAsync(UsersToUser
{
using ICoreScope scope = ScopeProvider.CreateCoreScope();

UserGroupOperationStatus result = await SafelyManipulateUsersBasedOnGroupAsync(addUsersModel, performingUserKey, (users, group) =>
Attempt<ResolvedUserToUserGroupManipulationModel?, UserGroupOperationStatus> resolveAttempt = await ResolveUserGroupManipulationModel(addUsersModel, performingUserKey);

if (resolveAttempt.Success is false)
{
IReadOnlyUserGroup readOnlyGroup = group.ToReadOnlyGroup();
return resolveAttempt.Status;
}

foreach (IUser user in users)
{
user.AddGroup(readOnlyGroup);
}
});
ResolvedUserToUserGroupManipulationModel? resolvedModel = resolveAttempt.Result;

// This should never happen, but we need to check it to avoid null reference exceptions
if (resolvedModel is null)
{
throw new InvalidOperationException("The resolved model should not be null.");
}

IReadOnlyUserGroup readOnlyGroup = resolvedModel.UserGroup.ToReadOnlyGroup();

foreach (IUser user in resolvedModel.Users)
{
user.AddGroup(readOnlyGroup);
}

_userService.Save(resolvedModel.Users);

scope.Complete();
return result;

return UserGroupOperationStatus.Success;
}

public async Task<UserGroupOperationStatus> RemoveUsersFromUserGroupAsync(UsersToUserGroupManipulationModel removeUsersModel, Guid performingUserKey)
{
using ICoreScope scope = ScopeProvider.CreateCoreScope();

UserGroupOperationStatus result = await SafelyManipulateUsersBasedOnGroupAsync(removeUsersModel, performingUserKey, (users, group) =>
Attempt<ResolvedUserToUserGroupManipulationModel?, UserGroupOperationStatus> resolveAttempt = await ResolveUserGroupManipulationModel(removeUsersModel, performingUserKey);

if (resolveAttempt.Success is false)
{
return resolveAttempt.Status;
}

ResolvedUserToUserGroupManipulationModel? resolvedModel = resolveAttempt.Result;

// This should never happen, but we need to check it to avoid null reference exceptions
if (resolvedModel is null)
{
throw new InvalidOperationException("The resolved model should not be null.");
}

foreach (IUser user in resolvedModel.Users)
{
foreach (IUser user in users)
// We can't remove a user from a group they're not part of.
if (user.Groups.Select(x => x.Key).Contains(resolvedModel.UserGroup.Key) is false)
{
user.RemoveGroup(group.Alias);
return UserGroupOperationStatus.UserNotInGroup;
}
});

user.RemoveGroup(resolvedModel.UserGroup.Alias);
}

// Ensure that that the admin group is never empty.
// This would mean that you could never add a user to the admin group again, since you need to be part of the admin group to do so.
if (resolvedModel.UserGroup.Key == Constants.Security.AdminGroupKey
&& resolvedModel.UserGroup.UserCount <= resolvedModel.Users.Length)
{
return UserGroupOperationStatus.AdminGroupCannotBeEmpty;
}

_userService.Save(resolvedModel.Users);

scope.Complete();
return result;

return UserGroupOperationStatus.Success;
}

/// <summary>
/// Checks whether all users that are part of the manipulation exist,
/// performs the manipulation,
/// saves the users
/// Resolves the user group manipulation model keys into actual entities.
/// Checks whether the performing user exists.
/// Checks whether all users that are part of the manipulation exist.
/// </summary>
private async Task<UserGroupOperationStatus> SafelyManipulateUsersBasedOnGroupAsync(UsersToUserGroupManipulationModel assignModel, Guid performingUserKey, Action<IUser[], IUserGroup> manipulation)
private async Task<Attempt<ResolvedUserToUserGroupManipulationModel?, UserGroupOperationStatus>> ResolveUserGroupManipulationModel(UsersToUserGroupManipulationModel model, Guid performingUserKey)
{
IUser? performingUser = await _userService.GetAsync(performingUserKey);
if (performingUser is null)
{
return UserGroupOperationStatus.MissingUser;
return Attempt.FailWithStatus<ResolvedUserToUserGroupManipulationModel?, UserGroupOperationStatus>(UserGroupOperationStatus.MissingUser, null);
}

IUserGroup? existingUserGroup = await GetAsync(assignModel.UserGroupKey);
IUserGroup? existingUserGroup = await GetAsync(model.UserGroupKey);

if (existingUserGroup is null)
{
return UserGroupOperationStatus.NotFound;
return Attempt.FailWithStatus<ResolvedUserToUserGroupManipulationModel?, UserGroupOperationStatus>(UserGroupOperationStatus.NotFound, null);
}

IUser[] users = (await _userService.GetAsync(assignModel.UserKeys)).ToArray();
IUser[] users = (await _userService.GetAsync(model.UserKeys)).ToArray();

if (users.Length != assignModel.UserKeys.Length)
if (users.Length != model.UserKeys.Length)
{
return UserGroupOperationStatus.UserNotFound;
return Attempt.FailWithStatus<ResolvedUserToUserGroupManipulationModel?, UserGroupOperationStatus>(UserGroupOperationStatus.UserNotFound, null);
}

manipulation(users, existingUserGroup);

_userService.Save(users);
var resolvedModel = new ResolvedUserToUserGroupManipulationModel
{
UserGroup = existingUserGroup,
Users = users,
};

return UserGroupOperationStatus.Success;
return Attempt.SucceedWithStatus<ResolvedUserToUserGroupManipulationModel?, UserGroupOperationStatus>(UserGroupOperationStatus.Success, resolvedModel);
}

private async Task<UserGroupOperationStatus> ValidateUserGroupUpdateAsync(IUserGroup userGroup)
Expand Down
Loading