using System.ComponentModel.DataAnnotations; using System.Linq.Expressions; using System.Security.Claims; using System.Security.Cryptography; using System.Text.RegularExpressions; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Editors; using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.Exceptions; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Entities; using Umbraco.Cms.Core.Models.Membership; using Umbraco.Cms.Core.Models.TemporaryFile; using Umbraco.Cms.Core.Notifications; using Umbraco.Cms.Core.Persistence; using Umbraco.Cms.Core.Persistence.Querying; using Umbraco.Cms.Core.Persistence.Repositories; using Umbraco.Cms.Core.Scoping; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services.OperationStatus; using Umbraco.Cms.Core.Strings; using Umbraco.Extensions; using Guid = System.Guid; using UserProfile = Umbraco.Cms.Core.Models.Membership.UserProfile; namespace Umbraco.Cms.Core.Services; /// /// Represents the UserService, which is an easy access to operations involving , /// and eventually Backoffice Users. /// internal partial class UserService : RepositoryService, IUserService { private readonly GlobalSettings _globalSettings; private readonly SecuritySettings _securitySettings; private readonly IUserGroupRepository _userGroupRepository; private readonly UserEditorAuthorizationHelper _userEditorAuthorizationHelper; private readonly IServiceScopeFactory _serviceScopeFactory; private readonly IEntityService _entityService; private readonly ILocalLoginSettingProvider _localLoginSettingProvider; private readonly IUserInviteSender _inviteSender; private readonly IUserForgotPasswordSender _forgotPasswordSender; private readonly MediaFileManager _mediaFileManager; private readonly ITemporaryFileService _temporaryFileService; private readonly IShortStringHelper _shortStringHelper; private readonly IIsoCodeValidator _isoCodeValidator; private readonly IUserRepository _userRepository; private readonly ContentSettings _contentSettings; private readonly IUserIdKeyResolver _userIdKeyResolver; [Obsolete("Use the constructor that takes an IUserIdKeyResolver instead. Scheduled for removal in V15.")] public UserService( ICoreScopeProvider provider, ILoggerFactory loggerFactory, IEventMessagesFactory eventMessagesFactory, IUserRepository userRepository, IUserGroupRepository userGroupRepository, IOptions globalSettings, IOptions securitySettings, UserEditorAuthorizationHelper userEditorAuthorizationHelper, IServiceScopeFactory serviceScopeFactory, IEntityService entityService, ILocalLoginSettingProvider localLoginSettingProvider, IUserInviteSender inviteSender, MediaFileManager mediaFileManager, ITemporaryFileService temporaryFileService, IShortStringHelper shortStringHelper, IOptions contentSettings, IIsoCodeValidator isoCodeValidator, IUserForgotPasswordSender forgotPasswordSender) : this( provider, loggerFactory, eventMessagesFactory, userRepository, userGroupRepository, globalSettings, securitySettings, userEditorAuthorizationHelper, serviceScopeFactory, entityService, localLoginSettingProvider, inviteSender, mediaFileManager, temporaryFileService, shortStringHelper, contentSettings, isoCodeValidator, forgotPasswordSender, StaticServiceProvider.Instance.GetRequiredService()) { } public UserService( ICoreScopeProvider provider, ILoggerFactory loggerFactory, IEventMessagesFactory eventMessagesFactory, IUserRepository userRepository, IUserGroupRepository userGroupRepository, IOptions globalSettings, IOptions securitySettings, UserEditorAuthorizationHelper userEditorAuthorizationHelper, IServiceScopeFactory serviceScopeFactory, IEntityService entityService, ILocalLoginSettingProvider localLoginSettingProvider, IUserInviteSender inviteSender, MediaFileManager mediaFileManager, ITemporaryFileService temporaryFileService, IShortStringHelper shortStringHelper, IOptions contentSettings, IIsoCodeValidator isoCodeValidator, IUserForgotPasswordSender forgotPasswordSender, IUserIdKeyResolver userIdKeyResolver) : base(provider, loggerFactory, eventMessagesFactory) { _userRepository = userRepository; _userGroupRepository = userGroupRepository; _userEditorAuthorizationHelper = userEditorAuthorizationHelper; _serviceScopeFactory = serviceScopeFactory; _entityService = entityService; _localLoginSettingProvider = localLoginSettingProvider; _inviteSender = inviteSender; _mediaFileManager = mediaFileManager; _temporaryFileService = temporaryFileService; _shortStringHelper = shortStringHelper; _isoCodeValidator = isoCodeValidator; _forgotPasswordSender = forgotPasswordSender; _userIdKeyResolver = userIdKeyResolver; _globalSettings = globalSettings.Value; _securitySettings = securitySettings.Value; _contentSettings = contentSettings.Value; } /// /// Checks in a set of permissions associated with a user for those related to a given nodeId /// /// The set of permissions /// The node Id /// The permissions to return /// True if permissions for the given path are found public static bool TryGetAssignedPermissionsForNode( IList permissions, int nodeId, out string assignedPermissions) { if (permissions.Any(x => x.EntityId == nodeId)) { EntityPermission found = permissions.First(x => x.EntityId == nodeId); ISet assignedPermissionsArray = found.AssignedPermissions; // Working with permissions assigned directly to a user AND to their groups, so maybe several per node // and we need to get the most permissive set foreach (EntityPermission permission in permissions.Where(x => x.EntityId == nodeId).Skip(1)) { AddAdditionalPermissions(assignedPermissionsArray, permission.AssignedPermissions); } assignedPermissions = string.Join(string.Empty, assignedPermissionsArray); return true; } assignedPermissions = string.Empty; return false; } #region Implementation of IMembershipUserService /// /// Checks if a User with the username exists /// /// Username to check /// True if the User exists otherwise False public bool Exists(string username) { using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) { return _userRepository.ExistsByUserName(username); } } /// /// Creates a new User /// /// The user will be saved in the database and returned with an Id /// Username of the user to create /// Email of the user to create /// /// /// public IUser CreateUserWithIdentity(string username, string email) => CreateUserWithIdentity(username, email, string.Empty); /// /// Creates and persists a new /// /// Username of the to create /// Email of the to create /// /// This value should be the encoded/encrypted/hashed value for the password that will be /// stored in the database /// /// Not used for users /// /// /// IUser IMembershipMemberService.CreateWithIdentity(string username, string email, string passwordValue, string memberTypeAlias) => CreateUserWithIdentity(username, email, passwordValue); /// /// Creates and persists a new /// /// Username of the to create /// Email of the to create /// /// This value should be the encoded/encrypted/hashed value for the password that will be /// stored in the database /// /// Alias of the Type /// Is the member approved /// /// /// IUser IMembershipMemberService.CreateWithIdentity(string username, string email, string passwordValue, string memberTypeAlias, bool isApproved) => CreateUserWithIdentity(username, email, passwordValue, isApproved); /// /// Gets a User by its integer id /// /// Id /// /// /// [Obsolete("Please use GetAsync instead. Scheduled for removal in V15.")] public IUser? GetById(int id) { using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) { Guid userKey = _userIdKeyResolver.GetAsync(id).GetAwaiter().GetResult(); return _userRepository.Get(userKey); } } /// /// Creates and persists a Member /// /// /// Using this method will persist the Member object before its returned /// meaning that it will have an Id available (unlike the CreateMember method) /// /// Username of the Member to create /// Email of the Member to create /// /// This value should be the encoded/encrypted/hashed value for the password that will be /// stored in the database /// /// Is the user approved /// /// /// private IUser CreateUserWithIdentity(string username, string email, string passwordValue, bool isApproved = true) { if (username == null) { throw new ArgumentNullException(nameof(username)); } if (string.IsNullOrWhiteSpace(username)) { throw new ArgumentException( "Value can't be empty or consist only of white-space characters.", nameof(username)); } EventMessages evtMsgs = EventMessagesFactory.Get(); // TODO: PUT lock here!! User user; using (ICoreScope scope = ScopeProvider.CreateCoreScope()) { var loginExists = _userRepository.ExistsByLogin(username); if (loginExists) { throw new ArgumentException("Login already exists"); // causes rollback } user = new User(_globalSettings) { Email = email, Language = _globalSettings.DefaultUILanguage, Name = username, RawPasswordValue = passwordValue, Username = username, IsLockedOut = false, IsApproved = isApproved, }; var savingNotification = new UserSavingNotification(user, evtMsgs); if (scope.Notifications.PublishCancelable(savingNotification)) { scope.Complete(); return user; } _userRepository.Save(user); scope.Notifications.Publish(new UserSavedNotification(user, evtMsgs).WithStateFrom(savingNotification)); scope.Complete(); } return user; } /// /// Gets an by its provider key /// /// Id to use for retrieval /// /// /// public IUser? GetByProviderKey(object id) { Attempt asInt = id.TryConvertTo(); return asInt.Success ? GetById(asInt.Result) : null; } /// /// Get an by email /// /// Email to use for retrieval /// /// /// public IUser? GetByEmail(string email) { using IServiceScope scope = _serviceScopeFactory.CreateScope(); IBackOfficeUserStore backOfficeUserStore = scope.ServiceProvider.GetRequiredService(); return backOfficeUserStore.GetByEmailAsync(email).GetAwaiter().GetResult(); } /// /// Get an by username /// /// Username to use for retrieval /// /// /// public IUser? GetByUsername(string? username) { if (username is null) { return null; } using IServiceScope scope = _serviceScopeFactory.CreateScope(); IBackOfficeUserStore backOfficeUserStore = scope.ServiceProvider.GetRequiredService(); return backOfficeUserStore.GetByUserNameAsync(username).GetAwaiter().GetResult(); } /// /// Disables an /// /// to disable public void Delete(IUser membershipUser) { using IServiceScope scope = _serviceScopeFactory.CreateScope(); IBackOfficeUserStore backOfficeUserStore = scope.ServiceProvider.GetRequiredService(); backOfficeUserStore.DisableAsync(membershipUser).GetAwaiter().GetResult(); } /// /// Deletes or disables a User /// /// to delete /// True to permanently delete the user, False to disable the user public void Delete(IUser user, bool deletePermanently) { if (deletePermanently == false) { Delete(user); } else { EventMessages evtMsgs = EventMessagesFactory.Get(); using (ICoreScope scope = ScopeProvider.CreateCoreScope()) { var deletingNotification = new UserDeletingNotification(user, evtMsgs); if (scope.Notifications.PublishCancelable(deletingNotification)) { scope.Complete(); return; } _userRepository.Delete(user); scope.Notifications.Publish( new UserDeletedNotification(user, evtMsgs).WithStateFrom(deletingNotification)); scope.Complete(); } } } /// /// Saves an /// /// to Save public void Save(IUser entity) { using IServiceScope scope = _serviceScopeFactory.CreateScope(); IBackOfficeUserStore backOfficeUserStore = scope.ServiceProvider.GetRequiredService(); backOfficeUserStore.SaveAsync(entity).GetAwaiter().GetResult(); } /// /// Saves an /// /// to Save public async Task SaveAsync(IUser entity) { using IServiceScope scope = _serviceScopeFactory.CreateScope(); IBackOfficeUserStore backOfficeUserStore = scope.ServiceProvider.GetRequiredService(); return await backOfficeUserStore.SaveAsync(entity); } /// /// Saves a list of objects /// /// to save public void Save(IEnumerable entities) { EventMessages evtMsgs = EventMessagesFactory.Get(); IUser[] entitiesA = entities.ToArray(); using (ICoreScope scope = ScopeProvider.CreateCoreScope()) { var savingNotification = new UserSavingNotification(entitiesA, evtMsgs); if (scope.Notifications.PublishCancelable(savingNotification)) { scope.Complete(); return; } foreach (IUser user in entitiesA) { if (string.IsNullOrWhiteSpace(user.Username)) { throw new ArgumentException("Empty username.", nameof(entities)); } if (string.IsNullOrWhiteSpace(user.Name)) { throw new ArgumentException("Empty name.", nameof(entities)); } _userRepository.Save(user); } scope.Notifications.Publish( new UserSavedNotification(entitiesA, evtMsgs).WithStateFrom(savingNotification)); // commit the whole lot in one go scope.Complete(); } } /// /// This is just the default user group that the membership provider will use /// /// [Obsolete("No (backend) code path is using this anymore, so it can not be considered the default. Planned for removal in V16.")] public string GetDefaultMemberType() => Constants.Security.WriterGroupAlias; /// /// Finds a list of objects by a partial email string /// /// Partial email string to match /// Current page index /// Size of the page /// Total number of records found (out) /// /// The type of match to make as . Default is /// /// /// /// /// public IEnumerable FindByEmail(string emailStringToMatch, long pageIndex, int pageSize, out long totalRecords, StringPropertyMatchType matchType = StringPropertyMatchType.StartsWith) { using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) { IQuery query = Query(); switch (matchType) { case StringPropertyMatchType.Exact: query?.Where(member => member.Email.Equals(emailStringToMatch)); break; case StringPropertyMatchType.Contains: query?.Where(member => member.Email.Contains(emailStringToMatch)); break; case StringPropertyMatchType.StartsWith: query?.Where(member => member.Email.StartsWith(emailStringToMatch)); break; case StringPropertyMatchType.EndsWith: query?.Where(member => member.Email.EndsWith(emailStringToMatch)); break; case StringPropertyMatchType.Wildcard: query?.Where(member => member.Email.SqlWildcard(emailStringToMatch, TextColumnType.NVarchar)); break; default: throw new ArgumentOutOfRangeException(nameof(matchType)); } return _userRepository.GetPagedResultsByQuery(query, pageIndex, pageSize, out totalRecords, dto => dto.Email); } } /// /// Finds a list of objects by a partial username /// /// Partial username to match /// Current page index /// Size of the page /// Total number of records found (out) /// /// The type of match to make as . Default is /// /// /// /// /// public IEnumerable FindByUsername(string login, long pageIndex, int pageSize, out long totalRecords, StringPropertyMatchType matchType = StringPropertyMatchType.StartsWith) { using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) { IQuery query = Query(); switch (matchType) { case StringPropertyMatchType.Exact: query?.Where(member => member.Username.Equals(login)); break; case StringPropertyMatchType.Contains: query?.Where(member => member.Username.Contains(login)); break; case StringPropertyMatchType.StartsWith: query?.Where(member => member.Username.StartsWith(login)); break; case StringPropertyMatchType.EndsWith: query?.Where(member => member.Username.EndsWith(login)); break; case StringPropertyMatchType.Wildcard: query?.Where(member => member.Email.SqlWildcard(login, TextColumnType.NVarchar)); break; default: throw new ArgumentOutOfRangeException(nameof(matchType)); } return _userRepository.GetPagedResultsByQuery(query, pageIndex, pageSize, out totalRecords, dto => dto.Username); } } /// /// Gets the total number of Users based on the count type /// /// /// The way the Online count is done is the same way that it is done in the MS SqlMembershipProvider - We query for any /// members /// that have their last active date within the Membership.UserIsOnlineTimeWindow (which is in minutes). It isn't exact /// science /// but that is how MS have made theirs so we'll follow that principal. /// /// to count by /// with number of Users for passed in type public int GetCount(MemberCountType countType) { using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) { IQuery? query; switch (countType) { case MemberCountType.All: query = Query(); break; case MemberCountType.LockedOut: query = Query()?.Where(x => x.IsLockedOut); break; case MemberCountType.Approved: query = Query()?.Where(x => x.IsApproved); break; default: throw new ArgumentOutOfRangeException(nameof(countType)); } return _userRepository.GetCountByQuery(query); } } public Guid CreateLoginSession(int userId, string requestingIpAddress) { using (ICoreScope scope = ScopeProvider.CreateCoreScope()) { Guid session = _userRepository.CreateLoginSession(userId, requestingIpAddress); scope.Complete(); return session; } } public int ClearLoginSessions(int userId) { using (ICoreScope scope = ScopeProvider.CreateCoreScope()) { var count = _userRepository.ClearLoginSessions(userId); scope.Complete(); return count; } } public void ClearLoginSession(Guid sessionId) { using (ICoreScope scope = ScopeProvider.CreateCoreScope()) { _userRepository.ClearLoginSession(sessionId); scope.Complete(); } } public bool ValidateLoginSession(int userId, Guid sessionId) { using (ICoreScope scope = ScopeProvider.CreateCoreScope()) { var result = _userRepository.ValidateLoginSession(userId, sessionId); scope.Complete(); return result; } } public IDictionary GetUserStates() { using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) { return _userRepository.GetUserStates(); } } /// public async Task> CreateAsync(Guid performingUserKey, UserCreateModel model, bool approveUser = false) { using ICoreScope scope = ScopeProvider.CreateCoreScope(); using IServiceScope serviceScope = _serviceScopeFactory.CreateScope(); IUser? performingUser = await GetAsync(performingUserKey); if (performingUser is null) { return Attempt.FailWithStatus(UserOperationStatus.MissingUser, new UserCreationResult()); } IUserGroup[] userGroups = _userGroupRepository.GetMany().Where(x=>model.UserGroupKeys.Contains(x.Key)).ToArray(); if (userGroups.Length != model.UserGroupKeys.Count) { return Attempt.FailWithStatus(UserOperationStatus.MissingUserGroup, new UserCreationResult()); } UserOperationStatus result = await ValidateUserCreateModel(model); if (result != UserOperationStatus.Success) { return Attempt.FailWithStatus(result, new UserCreationResult()); } Attempt authorizationAttempt = _userEditorAuthorizationHelper.IsAuthorized( performingUser, null, null, null, userGroups.Select(x => x.Alias)); if (authorizationAttempt.Success is false) { return Attempt.FailWithStatus(UserOperationStatus.Unauthorized, new UserCreationResult()); } ICoreBackOfficeUserManager backOfficeUserManager = serviceScope.ServiceProvider.GetRequiredService(); IdentityCreationResult identityCreationResult = await backOfficeUserManager.CreateAsync(model); if (identityCreationResult.Succeded is false) { // If we fail from something in Identity we can't know exactly why, so we have to resolve to returning an unknown failure. // But there should be more information in the message. return Attempt.FailWithStatus( UserOperationStatus.UnknownFailure, new UserCreationResult { Error = new ValidationResult(identityCreationResult.ErrorMessage) }); } // The user is now created, so we can fetch it to map it to a result model with our generated password. // and set it to being approved IBackOfficeUserStore backOfficeUserStore = serviceScope.ServiceProvider.GetRequiredService(); IUser? createdUser = await backOfficeUserStore.GetByEmailAsync(model.Email); if (createdUser is null) { // This really shouldn't happen, we literally just created the user throw new PanicException("Was unable to get user after creating it"); } createdUser.IsApproved = approveUser; foreach (IUserGroup userGroup in userGroups) { createdUser.AddGroup(userGroup.ToReadOnlyGroup()); } await backOfficeUserStore.SaveAsync(createdUser); scope.Complete(); var creationResult = new UserCreationResult { CreatedUser = createdUser, InitialPassword = identityCreationResult.InitialPassword }; return Attempt.SucceedWithStatus(UserOperationStatus.Success, creationResult); } public async Task> SendResetPasswordEmailAsync(string userEmail) { if (_forgotPasswordSender.CanSend() is false) { return Attempt.Fail(UserOperationStatus.CannotPasswordReset); } using ICoreScope scope = ScopeProvider.CreateCoreScope(); using IServiceScope serviceScope = _serviceScopeFactory.CreateScope(); ICoreBackOfficeUserManager userManager = serviceScope.ServiceProvider.GetRequiredService(); IBackOfficeUserStore userStore = serviceScope.ServiceProvider.GetRequiredService(); IUser? user = await userStore.GetByEmailAsync(userEmail); if (user is null) { return Attempt.Fail(UserOperationStatus.UserNotFound); } IForgotPasswordUriProvider uriProvider = serviceScope.ServiceProvider.GetRequiredService(); Attempt uriAttempt = await uriProvider.CreateForgotPasswordUriAsync(user); if (uriAttempt.Success is false) { return Attempt.Fail(uriAttempt.Status); } var message = new UserForgotPasswordMessage { ForgotPasswordUri = uriAttempt.Result, Recipient = user, }; await _forgotPasswordSender.SendForgotPassword(message); userManager.NotifyForgotPasswordRequested(new ClaimsPrincipal(), user.Id.ToString()); //A bit of a hack, but since this method will be used without a signed in user, there is no real principal anyway. scope.Complete(); return Attempt.Succeed(UserOperationStatus.Success); } public async Task> InviteAsync(Guid performingUserKey, UserInviteModel model) { using ICoreScope scope = ScopeProvider.CreateCoreScope(); using IServiceScope serviceScope = _serviceScopeFactory.CreateScope(); IUser? performingUser = await GetAsync(performingUserKey); if (performingUser is null) { return Attempt.FailWithStatus(UserOperationStatus.MissingUser, new UserInvitationResult()); } IUserGroup[] userGroups = _userGroupRepository.GetMany().Where(x => model.UserGroupKeys.Contains(x.Key)).ToArray(); if (userGroups.Length != model.UserGroupKeys.Count) { return Attempt.FailWithStatus(UserOperationStatus.MissingUserGroup, new UserInvitationResult()); } UserOperationStatus validationResult = await ValidateUserCreateModel(model); if (validationResult is not UserOperationStatus.Success) { return Attempt.FailWithStatus(validationResult, new UserInvitationResult()); } Attempt authorizationAttempt = _userEditorAuthorizationHelper.IsAuthorized( performingUser, null, null, null, userGroups.Select(x => x.Alias)); if (authorizationAttempt.Success is false) { return Attempt.FailWithStatus(UserOperationStatus.Unauthorized, new UserInvitationResult()); } if (_inviteSender.CanSendInvites() is false) { return Attempt.FailWithStatus(UserOperationStatus.CannotInvite, new UserInvitationResult()); } ICoreBackOfficeUserManager userManager = serviceScope.ServiceProvider.GetRequiredService(); IBackOfficeUserStore userStore = serviceScope.ServiceProvider.GetRequiredService(); IdentityCreationResult creationResult = await userManager.CreateForInvite(model); if (creationResult.Succeded is false) { // If we fail from something in Identity we can't know exactly why, so we have to resolve to returning an unknown failure. // But there should be more information in the message. return Attempt.FailWithStatus( UserOperationStatus.UnknownFailure, new UserInvitationResult { Error = new ValidationResult(creationResult.ErrorMessage) }); } IUser? invitedUser = await userStore.GetByEmailAsync(model.Email); if (invitedUser is null) { // This really shouldn't happen, we literally just created the user throw new PanicException("Was unable to get user after creating it"); } invitedUser.InvitedDate = DateTime.Now; invitedUser.ClearGroups(); foreach(IUserGroup userGroup in userGroups) { invitedUser.AddGroup(userGroup.ToReadOnlyGroup()); } await userStore.SaveAsync(invitedUser); Attempt invitationAttempt = await SendInvitationAsync(performingUser, serviceScope, invitedUser, model.Message); scope.Complete(); return invitationAttempt; } public async Task> ResendInvitationAsync(Guid performingUserKey, UserResendInviteModel model) { using ICoreScope scope = ScopeProvider.CreateCoreScope(); using IServiceScope serviceScope = _serviceScopeFactory.CreateScope(); IUser? performingUser = await GetAsync(performingUserKey); if (performingUser == null) { return Attempt.FailWithStatus(UserOperationStatus.MissingUser, new UserInvitationResult()); } IBackOfficeUserStore userStore = serviceScope.ServiceProvider.GetRequiredService(); IUser? invitedUser = await userStore.GetAsync(model.InvitedUserKey); if (invitedUser == null) { return Attempt.FailWithStatus(UserOperationStatus.UserNotFound, new UserInvitationResult()); } if (invitedUser.UserState != UserState.Invited) { return Attempt.FailWithStatus(UserOperationStatus.NotInInviteState, new UserInvitationResult()); } // re-inviting so update invite date invitedUser.InvitedDate = DateTime.Now; await userStore.SaveAsync(invitedUser); Attempt invitationAttempt = await SendInvitationAsync(performingUser, serviceScope, invitedUser, model.Message); scope.Complete(); return invitationAttempt; } private async Task> SendInvitationAsync(IUser performingUser, IServiceScope serviceScope, IUser invitedUser, string? message) { IInviteUriProvider inviteUriProvider = serviceScope.ServiceProvider.GetRequiredService(); Attempt inviteUriAttempt = await inviteUriProvider.CreateInviteUriAsync(invitedUser); if (inviteUriAttempt.Success is false) { return Attempt.FailWithStatus(inviteUriAttempt.Status, new UserInvitationResult()); } var invitation = new UserInvitationMessage { InviteUri = inviteUriAttempt.Result, Message = message ?? string.Empty, Recipient = invitedUser, Sender = performingUser, }; await _inviteSender.InviteUser(invitation); return Attempt.SucceedWithStatus(UserOperationStatus.Success, new UserInvitationResult { InvitedUser = invitedUser }); } private async Task ValidateUserCreateModel(UserCreateModel model) { if (_securitySettings.UsernameIsEmail && model.UserName != model.Email) { return UserOperationStatus.UserNameIsNotEmail; } if (model.Email.IsEmail() is false) { return UserOperationStatus.InvalidEmail; } if (model.Id is not null && await GetAsync(model.Id.Value) is not null) { return UserOperationStatus.DuplicateId; } if (GetByEmail(model.Email) is not null) { return UserOperationStatus.DuplicateEmail; } if (GetByUsername(model.UserName) is not null) { return UserOperationStatus.DuplicateUserName; } if(model.UserGroupKeys.Count == 0) { return UserOperationStatus.NoUserGroup; } return UserOperationStatus.Success; } public async Task> UpdateAsync(Guid performingUserKey, UserUpdateModel model) { using ICoreScope scope = ScopeProvider.CreateCoreScope(); using IServiceScope serviceScope = _serviceScopeFactory.CreateScope(); IBackOfficeUserStore userStore = serviceScope.ServiceProvider.GetRequiredService(); IUser? existingUser = await userStore.GetAsync(model.ExistingUserKey); if (existingUser is null) { return Attempt.FailWithStatus(UserOperationStatus.UserNotFound, existingUser); } IUser? performingUser = await userStore.GetAsync(performingUserKey); if (performingUser is null) { scope.Complete(); return Attempt.FailWithStatus(UserOperationStatus.MissingUser, existingUser); } // User names can only contain the configured allowed characters. This is validated by ASP.NET Identity on create // as the setting is applied to the BackOfficeIdentityOptions, but we need to check ourselves for updates. var allowedUserNameCharacters = _securitySettings.AllowedUserNameCharacters; if (model.UserName.Any(c => allowedUserNameCharacters.Contains(c) == false)) { scope.Complete(); return Attempt.FailWithStatus(UserOperationStatus.InvalidUserName, existingUser); } IEnumerable allUserGroups = _userGroupRepository.GetMany().ToArray(); var userGroups = allUserGroups.Where(x => model.UserGroupKeys.Contains(x.Key)).ToHashSet(); if (userGroups.Count != model.UserGroupKeys.Count) { scope.Complete(); return Attempt.FailWithStatus(UserOperationStatus.MissingUserGroup, existingUser); } // We're de-admining a user, we need to ensure that this would not leave the admin group empty. if (existingUser.IsAdmin() && model.UserGroupKeys.Contains(Constants.Security.AdminGroupKey) is false) { IUserGroup? adminGroup = allUserGroups.FirstOrDefault(x => x.Key == Constants.Security.AdminGroupKey); if (adminGroup?.UserCount == 1) { scope.Complete(); return Attempt.FailWithStatus(UserOperationStatus.AdminUserGroupMustNotBeEmpty, existingUser); } } // We have to resolve the keys to ids to be compatible with the repository, this could be done in the factory, // but I'd rather keep the ids out of the service API as much as possible. List? startContentIds = GetIdsFromKeys(model.ContentStartNodeKeys, UmbracoObjectTypes.Document); if (startContentIds is null || startContentIds.Count != model.ContentStartNodeKeys.Count) { scope.Complete(); return Attempt.FailWithStatus(UserOperationStatus.ContentStartNodeNotFound, existingUser); } List? startMediaIds = GetIdsFromKeys(model.MediaStartNodeKeys, UmbracoObjectTypes.Media); if (startMediaIds is null || startMediaIds.Count != model.MediaStartNodeKeys.Count) { scope.Complete(); return Attempt.FailWithStatus(UserOperationStatus.MediaStartNodeNotFound, existingUser); } if (model.HasContentRootAccess) { startContentIds.Add(Constants.System.Root); } if (model.HasMediaRootAccess) { startMediaIds.Add(Constants.System.Root); } Attempt isAuthorized = _userEditorAuthorizationHelper.IsAuthorized( performingUser, existingUser, startContentIds, startMediaIds, userGroups.Select(x => x.Alias)); if (isAuthorized.Success is false) { scope.Complete(); return Attempt.FailWithStatus(UserOperationStatus.Unauthorized, existingUser); } UserOperationStatus validationStatus = ValidateUserUpdateModel(existingUser, model); if (validationStatus is not UserOperationStatus.Success) { scope.Complete(); return Attempt.FailWithStatus(validationStatus, existingUser); } // Now that we're all authorized and validated we can actually map over changes and update the user // TODO: This probably shouldn't live here, once we have user content start nodes as keys this can be moved to a mapper // Alternatively it should be a map definition, but then we need to use entity service to resolve the IDs // TODO: Add auditing IUser updated = MapUserUpdate(model, userGroups, existingUser, startContentIds, startMediaIds); UserOperationStatus saveStatus = await userStore.SaveAsync(updated); if (saveStatus is not UserOperationStatus.Success) { return Attempt.FailWithStatus(saveStatus, existingUser); } scope.Complete(); return Attempt.SucceedWithStatus(UserOperationStatus.Success, updated); } public async Task SetAvatarAsync(Guid userKey, Guid temporaryFileKey) { using ICoreScope scope = ScopeProvider.CreateCoreScope(); IUser? user = await GetAsync(userKey); if (user is null) { return UserOperationStatus.UserNotFound; } TemporaryFileModel? avatarTemporaryFile = await _temporaryFileService.GetAsync(temporaryFileKey); _temporaryFileService.EnlistDeleteIfScopeCompletes(temporaryFileKey, ScopeProvider); if (avatarTemporaryFile is null) { return UserOperationStatus.AvatarFileNotFound; } const string allowedAvatarFileTypes = "jpeg,jpg,gif,bmp,png,tiff,tif,webp"; // This shouldn't really be necessary since we're just gonna use it to generate a hash, but that's how it was. var avatarFileName = avatarTemporaryFile.FileName.ToSafeFileName(_shortStringHelper); var extension = Path.GetExtension(avatarFileName)[1..]; if(allowedAvatarFileTypes.Contains(extension) is false || _contentSettings.DisallowedUploadedFileExtensions.Contains(extension)) { return UserOperationStatus.InvalidAvatar; } // Generate a path from known data, we don't want this to be guessable var avatarHash = $"{user.Key}{avatarFileName}".GenerateHash(); var avatarPath = $"UserAvatars/{avatarHash}.{extension}"; await using (Stream fileStream = avatarTemporaryFile.OpenReadStream()) { _mediaFileManager.FileSystem.AddFile(avatarPath, fileStream, true); } user.Avatar = avatarPath; await SaveAsync(user); scope.Complete(); return UserOperationStatus.Success; } private IUser MapUserUpdate( UserUpdateModel source, ISet sourceUserGroups, IUser target, List startContentIds, List startMediaIds) { target.Name = source.Name; target.Language = source.LanguageIsoCode; target.Email = source.Email; target.Username = source.UserName; target.StartContentIds = startContentIds.ToArray(); target.StartMediaIds = startMediaIds.ToArray(); target.ClearGroups(); foreach (IUserGroup group in sourceUserGroups) { target.AddGroup(group.ToReadOnlyGroup()); } return target; } private UserOperationStatus ValidateUserUpdateModel(IUser existingUser, UserUpdateModel model) { if (_isoCodeValidator.IsValid(model.LanguageIsoCode) is false) { return UserOperationStatus.InvalidIsoCode; } // We need to check if there's any Deny Local login providers present, if so we need to ensure that the user's email address cannot be changed. if (_localLoginSettingProvider.HasDenyLocalLogin() && model.Email != existingUser.Email) { return UserOperationStatus.EmailCannotBeChanged; } if (_securitySettings.UsernameIsEmail && model.UserName != model.Email) { return UserOperationStatus.UserNameIsNotEmail; } if (model.Email.IsEmail() is false) { return UserOperationStatus.InvalidEmail; } IUser? existing = GetByEmail(model.Email); if (existing is not null && existing.Key != existingUser.Key) { return UserOperationStatus.DuplicateEmail; } // In case the user has updated their username to be a different email, but not their actually email // we have to try and get the user by email using their username, and ensure we don't get any collisions. existing = GetByEmail(model.UserName); if (existing is not null && existing.Key != existingUser.Key) { return UserOperationStatus.DuplicateUserName; } existing = GetByUsername(model.UserName); if (existing is not null && existing.Key != existingUser.Key) { return UserOperationStatus.DuplicateUserName; } return UserOperationStatus.Success; } private List? GetIdsFromKeys(IEnumerable? guids, UmbracoObjectTypes type) { var keys = guids? .Select(x => _entityService.GetId(x, type)) .Where(x => x.Success) .Select(x => x.Result) .ToList(); return keys; } public async Task> ChangePasswordAsync(Guid performingUserKey, ChangeUserPasswordModel model) { IServiceScope serviceScope = _serviceScopeFactory.CreateScope(); using ICoreScope scope = ScopeProvider.CreateCoreScope(); IBackOfficeUserStore userStore = serviceScope.ServiceProvider.GetRequiredService(); IUser? user = await userStore.GetAsync(model.UserKey); if (user is null) { return Attempt.FailWithStatus(UserOperationStatus.UserNotFound, new PasswordChangedModel()); } if (user.Kind != UserKind.Default) { return Attempt.FailWithStatus(UserOperationStatus.InvalidUserType, new PasswordChangedModel()); } IUser? performingUser = await userStore.GetAsync(performingUserKey); if (performingUser is null) { return Attempt.FailWithStatus(UserOperationStatus.MissingUser, new PasswordChangedModel()); } // require old password for self change when outside of invite or resetByToken flows if (performingUser.UserState != UserState.Invited && performingUser.Username == user.Username && string.IsNullOrEmpty(model.OldPassword) && string.IsNullOrEmpty(model.ResetPasswordToken)) { return Attempt.FailWithStatus(UserOperationStatus.SelfOldPasswordRequired, new PasswordChangedModel()); } if (performingUser.IsAdmin() is false && user.IsAdmin()) { return Attempt.FailWithStatus(UserOperationStatus.Forbidden, new PasswordChangedModel()); } if (string.IsNullOrEmpty(model.ResetPasswordToken) is false) { Attempt verifyPasswordResetAsync = await VerifyPasswordResetAsync(model.UserKey, model.ResetPasswordToken); if (verifyPasswordResetAsync.Result != UserOperationStatus.Success) { return Attempt.FailWithStatus(verifyPasswordResetAsync.Result, new PasswordChangedModel()); } } IBackOfficePasswordChanger passwordChanger = serviceScope.ServiceProvider.GetRequiredService(); Attempt result = await passwordChanger.ChangeBackOfficePassword( new ChangeBackOfficeUserPasswordModel { NewPassword = model.NewPassword, OldPassword = model.OldPassword, User = user, ResetPasswordToken = model.ResetPasswordToken, }, performingUser); if (result.Success is false) { return Attempt.FailWithStatus(UserOperationStatus.UnknownFailure, result.Result ?? new PasswordChangedModel()); } scope.Complete(); return Attempt.SucceedWithStatus(UserOperationStatus.Success, result.Result ?? new PasswordChangedModel()); } public async Task?, UserOperationStatus>> GetAllAsync(Guid performingUserKey, int skip, int take) { using ICoreScope scope = ScopeProvider.CreateCoreScope(); IUser? requestingUser = await GetAsync(performingUserKey); if (requestingUser is null) { return Attempt.FailWithStatus?, UserOperationStatus>(UserOperationStatus.MissingUser, null); } UserFilter baseFilter = CreateBaseUserFilter(requestingUser, out IQuery query); PaginationHelper.ConvertSkipTakeToPaging(skip, take, out long pageNumber, out int pageSize); HashSet excludeUserGroupAliases = new(); if (baseFilter.ExcludeUserGroups is not null) { Attempt, UserOperationStatus> userGroupKeyConversionAttempt = GetUserGroupAliasesFromKeys(baseFilter.ExcludeUserGroups); if (userGroupKeyConversionAttempt.Success is false) { return Attempt.FailWithStatus?, UserOperationStatus>(UserOperationStatus.MissingUserGroup, null); } excludeUserGroupAliases = new HashSet(userGroupKeyConversionAttempt.Result); } IEnumerable result = _userRepository.GetPagedResultsByQuery( null, pageNumber, pageSize, out long totalRecords, x => x.Username, excludeUserGroups: excludeUserGroupAliases.ToArray(), filter: query, userState: baseFilter.IncludeUserStates?.ToArray()); var pagedResult = new PagedModel { Items = result, Total = totalRecords }; scope.Complete(); return Attempt.SucceedWithStatus?, UserOperationStatus>(UserOperationStatus.Success, pagedResult); } public async Task, UserOperationStatus>> FilterAsync( Guid userKey, UserFilter filter, int skip = 0, int take = 100, UserOrder orderBy = UserOrder.UserName, Direction orderDirection = Direction.Ascending) { using ICoreScope scope = ScopeProvider.CreateCoreScope(); IUser? requestingUser = await GetAsync(userKey); if (requestingUser is null) { return Attempt.FailWithStatus(UserOperationStatus.MissingUser, new PagedModel()); } UserFilter baseFilter = CreateBaseUserFilter(requestingUser, out IQuery baseQuery); UserFilter mergedFilter = filter.Merge(baseFilter); // TODO: We should have a repository method that accepts keys so we don't have to do this conversion HashSet? excludedUserGroupAliases = null; if (mergedFilter.ExcludeUserGroups is not null) { Attempt, UserOperationStatus> userGroupKeyConversionAttempt = GetUserGroupAliasesFromKeys(mergedFilter.ExcludeUserGroups); if (userGroupKeyConversionAttempt.Success is false) { return Attempt.FailWithStatus(UserOperationStatus.MissingUserGroup, new PagedModel()); } excludedUserGroupAliases = new HashSet(userGroupKeyConversionAttempt.Result); } string[]? includedUserGroupAliases = null; if (mergedFilter.IncludedUserGroups is not null) { Attempt, UserOperationStatus> userGroupKeyConversionAttempt = GetUserGroupAliasesFromKeys(mergedFilter.IncludedUserGroups); if (userGroupKeyConversionAttempt.Success is false) { return Attempt.FailWithStatus(UserOperationStatus.MissingUserGroup, new PagedModel()); } includedUserGroupAliases = userGroupKeyConversionAttempt.Result.ToArray(); } if (mergedFilter.NameFilters is not null) { foreach (var nameFilter in mergedFilter.NameFilters) { baseQuery.Where(x => x.Name!.Contains(nameFilter) || x.Username.Contains(nameFilter)); } } ISet? includeUserStates = null; // The issue is that this is a limiting filter we have to ensure that it still follows our rules // So I'm not allowed to ask for the disabled users if the setting has been flipped if (baseFilter.IncludeUserStates is null || baseFilter.IncludeUserStates.Count == 0) { includeUserStates = filter.IncludeUserStates; } else { includeUserStates = new HashSet(baseFilter.IncludeUserStates); if (filter.IncludeUserStates is not null && filter.IncludeUserStates.Contains(UserState.All) is false) { includeUserStates.IntersectWith(filter.IncludeUserStates); } // This means that we've only chosen to include a user state that is not allowed, so we'll return an empty result if (includeUserStates.Count == 0) { return Attempt.SucceedWithStatus(UserOperationStatus.Success, new PagedModel()); } } PaginationHelper.ConvertSkipTakeToPaging(skip, take, out long pageNumber, out int pageSize); Expression> orderByExpression = GetOrderByExpression(orderBy); // TODO: We should create a Query method on the repo that allows to filter by aliases. IEnumerable result = _userRepository.GetPagedResultsByQuery( null, pageNumber, pageSize, out long totalRecords, orderByExpression, orderDirection, includedUserGroupAliases?.ToArray(), excludedUserGroupAliases?.ToArray(), includeUserStates?.ToArray(), baseQuery); scope.Complete(); var model = new PagedModel { Items = result, Total = totalRecords }; return Attempt.SucceedWithStatus(UserOperationStatus.Success, model); } /// /// Creates a base user filter which ensures our rules are followed, I.E. Only admins can se other admins. /// /// /// We return the query as an out parameter instead of having it in the intermediate object because a two queries cannot be merged into one. /// /// private UserFilter CreateBaseUserFilter(IUser performingUser, out IQuery baseQuery) { var filter = new UserFilter(); baseQuery = Query(); // Only super can see super if (performingUser.IsSuper() is false) { baseQuery.Where(x => x.Key != Constants.Security.SuperUserKey); } // Only admins can see admins if (performingUser.IsAdmin() is false) { filter.ExcludeUserGroups = new HashSet { Constants.Security.AdminGroupKey }; } if (_securitySettings.HideDisabledUsersInBackOffice) { filter.IncludeUserStates = new HashSet { UserState.Active, UserState.Invited, UserState.LockedOut, UserState.Inactive }; } return filter; } private Attempt, UserOperationStatus> GetUserGroupAliasesFromKeys(IEnumerable userGroupKeys) { var aliases = new List(); foreach (Guid key in userGroupKeys) { IUserGroup? group = _userGroupRepository.Get(Query().Where(x => x.Key == key)).FirstOrDefault(); if (group is null) { return Attempt.FailWithStatus(UserOperationStatus.MissingUserGroup, Enumerable.Empty()); } aliases.Add(group.Alias); } return Attempt.SucceedWithStatus, UserOperationStatus>(UserOperationStatus.Success, aliases); } private Expression> GetOrderByExpression(UserOrder orderBy) { return orderBy switch { UserOrder.UserName => x => x.Username, UserOrder.Language => x => x.Language, UserOrder.Name => x => x.Name, UserOrder.Email => x => x.Email, UserOrder.Id => x => x.Id, UserOrder.CreateDate => x => x.CreateDate, UserOrder.UpdateDate => x => x.UpdateDate, UserOrder.IsApproved => x => x.IsApproved, UserOrder.IsLockedOut => x => x.IsLockedOut, UserOrder.LastLoginDate => x => x.LastLoginDate, _ => throw new ArgumentOutOfRangeException(nameof(orderBy), orderBy, null) }; } public async Task DeleteAsync(Guid performingUserKey, ISet keys) { if(keys.Any() is false) { return UserOperationStatus.Success; } using ICoreScope scope = ScopeProvider.CreateCoreScope(); IUser? performingUser = await GetAsync(performingUserKey); if (performingUser is null) { return UserOperationStatus.MissingUser; } if (keys.Contains(performingUser.Key)) { return UserOperationStatus.CannotDeleteSelf; } IServiceScope serviceScope = _serviceScopeFactory.CreateScope(); IBackOfficeUserStore userStore = serviceScope.ServiceProvider.GetRequiredService(); IUser[] usersToDisable = (await userStore.GetUsersAsync(keys.ToArray())).ToArray(); if (usersToDisable.Length != keys.Count) { return UserOperationStatus.UserNotFound; } foreach (IUser user in usersToDisable) { // Check user hasn't logged in. If they have they may have made content changes which will mean // the Id is associated with audit trails, versions etc. and can't be removed. if (user.LastLoginDate is not null && user.LastLoginDate != default(DateTime)) { return UserOperationStatus.CannotDelete; } user.IsApproved = false; user.InvitedDate = null; Delete(user, true); } scope.Complete(); return UserOperationStatus.Success; } public async Task DisableAsync(Guid performingUserKey, ISet keys) { if(keys.Any() is false) { return UserOperationStatus.Success; } using ICoreScope scope = ScopeProvider.CreateCoreScope(); IUser? performingUser = await GetAsync(performingUserKey); if (performingUser is null) { return UserOperationStatus.MissingUser; } if (keys.Contains(performingUser.Key)) { return UserOperationStatus.CannotDisableSelf; } IServiceScope serviceScope = _serviceScopeFactory.CreateScope(); IBackOfficeUserStore userStore = serviceScope.ServiceProvider.GetRequiredService(); IUser[] usersToDisable = (await userStore.GetUsersAsync(keys.ToArray())).ToArray(); if (usersToDisable.Length != keys.Count) { return UserOperationStatus.UserNotFound; } foreach (IUser user in usersToDisable) { if (user.UserState is UserState.Invited) { return UserOperationStatus.CannotDisableInvitedUser; } user.IsApproved = false; user.InvitedDate = null; } Save(usersToDisable); scope.Complete(); return UserOperationStatus.Success; } public async Task EnableAsync(Guid performingUserKey, ISet keys) { if(keys.Any() is false) { return UserOperationStatus.Success; } using ICoreScope scope = ScopeProvider.CreateCoreScope(); IUser? performingUser = await GetAsync(performingUserKey); if (performingUser is null) { return UserOperationStatus.MissingUser; } IServiceScope serviceScope = _serviceScopeFactory.CreateScope(); IBackOfficeUserStore userStore = serviceScope.ServiceProvider.GetRequiredService(); IUser[] usersToEnable = (await userStore.GetUsersAsync(keys.ToArray())).ToArray(); if (usersToEnable.Length != keys.Count) { return UserOperationStatus.UserNotFound; } foreach (IUser user in usersToEnable) { user.IsApproved = true; } Save(usersToEnable); scope.Complete(); return UserOperationStatus.Success; } public async Task ClearAvatarAsync(Guid userKey) { IUser? user = await GetAsync(userKey); if (user is null) { return UserOperationStatus.UserNotFound; } if (string.IsNullOrWhiteSpace(user.Avatar)) { // Nothing to do return UserOperationStatus.Success; } using IServiceScope scope = _serviceScopeFactory.CreateScope(); IBackOfficeUserStore backOfficeUserStore = scope.ServiceProvider.GetRequiredService(); string filePath = user.Avatar; user.Avatar = null; UserOperationStatus result = await backOfficeUserStore.SaveAsync(user); if (result is not UserOperationStatus.Success) { return result; } if (_mediaFileManager.FileSystem.FileExists(filePath)) { _mediaFileManager.FileSystem.DeleteFile(filePath); } return UserOperationStatus.Success; } public async Task> UnlockAsync(Guid performingUserKey, params Guid[] keys) { if (keys.Length == 0) { return Attempt.SucceedWithStatus(UserOperationStatus.Success, new UserUnlockResult()); } using ICoreScope scope = ScopeProvider.CreateCoreScope(); IUser? performingUser = await GetAsync(performingUserKey); if (performingUser is null) { return Attempt.FailWithStatus(UserOperationStatus.MissingUser, new UserUnlockResult()); } IServiceScope serviceScope = _serviceScopeFactory.CreateScope(); ICoreBackOfficeUserManager manager = serviceScope.ServiceProvider.GetRequiredService(); IBackOfficeUserStore userStore = serviceScope.ServiceProvider.GetRequiredService(); IEnumerable usersToUnlock = await userStore.GetUsersAsync(keys); foreach (IUser user in usersToUnlock) { Attempt result = await manager.UnlockUser(user); if (result.Success is false) { return Attempt.FailWithStatus(UserOperationStatus.UnknownFailure, result.Result); } } scope.Complete(); return Attempt.SucceedWithStatus(UserOperationStatus.Success, new UserUnlockResult()); } public IEnumerable GetAll(long pageIndex, int pageSize, out long totalRecords, string orderBy, Direction orderDirection, UserState[]? userState = null, string[]? userGroups = null, string? filter = null) { IQuery? filterQuery = null; if (filter.IsNullOrWhiteSpace() == false) { filterQuery = Query()?.Where(x => (x.Name != null && x.Name.Contains(filter!)) || x.Username.Contains(filter!)); } return GetAll(pageIndex, pageSize, out totalRecords, orderBy, orderDirection, userState, userGroups, null, filterQuery); } public IEnumerable GetAll( long pageIndex, int pageSize, out long totalRecords, string orderBy, Direction orderDirection, UserState[]? userState = null, string[]? includeUserGroups = null, string[]? excludeUserGroups = null, IQuery? filter = null) { using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) { Expression> sort; switch (orderBy.ToUpperInvariant()) { case "USERNAME": sort = member => member.Username; break; case "LANGUAGE": sort = member => member.Language; break; case "NAME": sort = member => member.Name; break; case "EMAIL": sort = member => member.Email; break; case "ID": sort = member => member.Id; break; case "CREATEDATE": sort = member => member.CreateDate; break; case "UPDATEDATE": sort = member => member.UpdateDate; break; case "ISAPPROVED": sort = member => member.IsApproved; break; case "ISLOCKEDOUT": sort = member => member.IsLockedOut; break; case "LASTLOGINDATE": sort = member => member.LastLoginDate; break; default: throw new IndexOutOfRangeException("The orderBy parameter " + orderBy + " is not valid"); } return _userRepository.GetPagedResultsByQuery(null, pageIndex, pageSize, out totalRecords, sort, orderDirection, includeUserGroups, excludeUserGroups, userState, filter); } } /// /// Gets a list of paged objects /// /// Current page index /// Size of the page /// Total number of records found (out) /// /// /// public IEnumerable GetAll(long pageIndex, int pageSize, out long totalRecords) { using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) { return _userRepository.GetPagedResultsByQuery(null, pageIndex, pageSize, out totalRecords, member => member.Name); } } /// /// Gets a list of objects associated with a given group /// /// Id of group /// /// /// public IEnumerable GetAllInGroup(int? groupId) { if (groupId is null) { return Array.Empty(); } using IServiceScope scope = _serviceScopeFactory.CreateScope(); IBackOfficeUserStore backOfficeUserStore = scope.ServiceProvider.GetRequiredService(); return backOfficeUserStore.GetAllInGroupAsync(groupId.Value).GetAwaiter().GetResult(); } /// /// Gets a list of objects not associated with a given group /// /// Id of group /// /// /// public IEnumerable GetAllNotInGroup(int groupId) { using (ICoreScope scope = ScopeProvider.CreateCoreScope()) { return _userRepository.GetAllNotInGroup(groupId); } } #endregion #region Implementation of IUserService /// /// Gets an IProfile by User Id. /// /// Id of the User to retrieve /// /// /// public IProfile? GetProfileById(int id) { // This is called a TON. Go get the full user from cache which should already be IProfile IUser? fullUser = GetUserById(id); if (fullUser == null) { return null; } var asProfile = fullUser as IProfile; return asProfile ?? new UserProfile(fullUser.Id, fullUser.Name); } /// /// Gets a profile by username /// /// Username /// /// /// public IProfile? GetProfileByUserName(string username) { using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) { return _userRepository.GetProfile(username); } } /// /// Gets a user by Id /// /// Id of the user to retrieve. /// /// /// public IUser? GetUserById(int id) { using IServiceScope scope = _serviceScopeFactory.CreateScope(); IBackOfficeUserStore backOfficeUserStore = scope.ServiceProvider.GetRequiredService(); return backOfficeUserStore.GetAsync(id).GetAwaiter().GetResult(); } /// /// Gets a user by it's key. /// /// Key of the user to retrieve. /// Task resolving into an . public Task GetAsync(Guid key) { using IServiceScope scope = _serviceScopeFactory.CreateScope(); IBackOfficeUserStore backOfficeUserStore = scope.ServiceProvider.GetRequiredService(); return backOfficeUserStore.GetAsync(key); } public Task> GetAsync(IEnumerable keys) { using IServiceScope scope = _serviceScopeFactory.CreateScope(); IBackOfficeUserStore backOfficeUserStore = scope.ServiceProvider.GetRequiredService(); return backOfficeUserStore.GetUsersAsync(keys.ToArray()); } public async Task, UserOperationStatus>> GetLinkedLoginsAsync(Guid userKey) { using IServiceScope scope = _serviceScopeFactory.CreateScope(); IBackOfficeUserStore backOfficeUserStore = scope.ServiceProvider.GetRequiredService(); IUser? user = await backOfficeUserStore.GetAsync(userKey); if (user is null) { return Attempt.FailWithStatus, UserOperationStatus>(UserOperationStatus.UserNotFound, Array.Empty()); } ICoreBackOfficeUserManager manager = scope.ServiceProvider.GetRequiredService(); Attempt, UserOperationStatus> loginsAttempt = await manager.GetLoginsAsync(user); return loginsAttempt.Success is false ? Attempt.FailWithStatus, UserOperationStatus>(loginsAttempt.Status, Array.Empty()) : Attempt.SucceedWithStatus(UserOperationStatus.Success, loginsAttempt.Result); } public IEnumerable GetUsersById(params int[]? ids) { using IServiceScope scope = _serviceScopeFactory.CreateScope(); IBackOfficeUserStore backOfficeUserStore = scope.ServiceProvider.GetRequiredService(); return backOfficeUserStore.GetUsersAsync(ids).GetAwaiter().GetResult(); } /// /// Replaces the same permission set for a single group to any number of entities /// /// If no 'entityIds' are specified all permissions will be removed for the specified group. /// Id of the group /// /// Permissions as enumerable list of If nothing is specified all permissions /// are removed. /// /// Specify the nodes to replace permissions for. public void ReplaceUserGroupPermissions(int groupId, ISet permissions, params int[] entityIds) { if (entityIds.Length == 0) { return; } EventMessages evtMsgs = EventMessagesFactory.Get(); using (ICoreScope scope = ScopeProvider.CreateCoreScope()) { _userGroupRepository.ReplaceGroupPermissions(groupId, permissions, entityIds); scope.Complete(); if (permissions is not null) { EntityPermission[] entityPermissions = entityIds.Select(x => new EntityPermission(groupId, x, permissions)).ToArray(); scope.Notifications.Publish(new AssignedUserGroupPermissionsNotification(entityPermissions, evtMsgs)); } } } /// /// Assigns the same permission set for a single user group to any number of entities /// /// Id of the user group /// /// Specify the nodes to replace permissions for public void AssignUserGroupPermission(int groupId, string permission, params int[] entityIds) { if (entityIds.Length == 0) { return; } EventMessages evtMsgs = EventMessagesFactory.Get(); using (ICoreScope scope = ScopeProvider.CreateCoreScope()) { _userGroupRepository.AssignGroupPermission(groupId, permission, entityIds); scope.Complete(); var assigned = new HashSet() { permission }; EntityPermission[] entityPermissions = entityIds.Select(x => new EntityPermission(groupId, x, assigned)).ToArray(); scope.Notifications.Publish(new AssignedUserGroupPermissionsNotification(entityPermissions, evtMsgs)); } } /// /// Gets all UserGroups or those specified as parameters /// /// Optional Ids of UserGroups to retrieve /// An enumerable list of [Obsolete("Use IUserGroupService.GetAsync instead, scheduled for removal in V15.")] public IEnumerable GetAllUserGroups(params int[] ids) { using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) { return _userGroupRepository.GetMany(ids).OrderBy(x => x.Name); } } [Obsolete("Use IUserGroupService.GetAsync instead, scheduled for removal in V15.")] public IEnumerable GetUserGroupsByAlias(params string[] aliases) { if (aliases.Length == 0) { return Enumerable.Empty(); } using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) { IQuery query = Query().Where(x => aliases.SqlIn(x.Alias)); IEnumerable contents = _userGroupRepository.Get(query); return contents?.WhereNotNull().ToArray() ?? Enumerable.Empty(); } } /// /// Gets a UserGroup by its Alias /// /// Alias of the UserGroup to retrieve /// /// /// [Obsolete("Use IUserGroupService.GetAsync instead, scheduled for removal in V15.")] public IUserGroup? GetUserGroupByAlias(string alias) { if (string.IsNullOrWhiteSpace(alias)) { throw new ArgumentException("Value cannot be null or whitespace.", "alias"); } using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) { IQuery query = Query().Where(x => x.Alias == alias); IEnumerable contents = _userGroupRepository.Get(query); return contents?.FirstOrDefault(); } } /// /// Gets a UserGroup by its Id /// /// Id of the UserGroup to retrieve /// /// /// [Obsolete("Use IUserGroupService.GetAsync instead, scheduled for removal in V15.")] public IUserGroup? GetUserGroupById(int id) { using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) { return _userGroupRepository.Get(id); } } /// /// Saves a UserGroup. /// /// UserGroup to save. /// /// If null than no changes are made to the users who are assigned to this group, however if a value is passed in /// than all users will be removed from this group and only these users will be added. [Obsolete("Use IUserGroupService.CreateAsync and IUserGroupService.UpdateAsync instead, scheduled for removal in V15.")] public void Save(IUserGroup userGroup, int[]? userIds = null) { EventMessages evtMsgs = EventMessagesFactory.Get(); using (ICoreScope scope = ScopeProvider.CreateCoreScope()) { // we need to figure out which users have been added / removed, for audit purposes IUser[] empty = Array.Empty(); IUser[] addedUsers = empty; IUser[] removedUsers = empty; if (userIds != null) { IUser[] groupUsers = userGroup.HasIdentity ? _userRepository.GetAllInGroup(userGroup.Id).ToArray() : empty; var xGroupUsers = groupUsers.ToDictionary(x => x.Id, x => x); var groupIds = groupUsers.Select(x => x.Id).ToArray(); var addedUserKeys = new List(); foreach (var userId in userIds.Except(groupIds)) { Guid userKey = _userIdKeyResolver.GetAsync(userId).GetAwaiter().GetResult(); addedUserKeys.Add(userKey); } IEnumerable addedUserIds = userIds.Except(groupIds); addedUsers = addedUserIds.Count() > 0 ? _userRepository.GetMany(addedUserKeys.ToArray()).Where(x => x.Id != 0).ToArray() : new IUser[] { }; removedUsers = groupIds.Except(userIds).Select(x => xGroupUsers[x]).Where(x => x.Id != 0).ToArray(); } var userGroupWithUsers = new UserGroupWithUsers(userGroup, addedUsers, removedUsers); // this is the default/expected notification for the IUserGroup entity being saved var savingNotification = new UserGroupSavingNotification(userGroup, evtMsgs); if (scope.Notifications.PublishCancelable(savingNotification)) { scope.Complete(); return; } // this is an additional notification for special auditing var savingUserGroupWithUsersNotification = new UserGroupWithUsersSavingNotification(userGroupWithUsers, evtMsgs); if (scope.Notifications.PublishCancelable(savingUserGroupWithUsersNotification)) { scope.Complete(); return; } _userGroupRepository.AddOrUpdateGroupWithUsers(userGroup, userIds); scope.Notifications.Publish( new UserGroupSavedNotification(userGroup, evtMsgs).WithStateFrom(savingNotification)); scope.Notifications.Publish( new UserGroupWithUsersSavedNotification(userGroupWithUsers, evtMsgs).WithStateFrom( savingUserGroupWithUsersNotification)); scope.Complete(); } } /// /// Deletes a UserGroup /// /// UserGroup to delete [Obsolete("Use IUserGroupService.DeleteAsync instead, scheduled for removal in V15.")] public void DeleteUserGroup(IUserGroup userGroup) { EventMessages evtMsgs = EventMessagesFactory.Get(); using (ICoreScope scope = ScopeProvider.CreateCoreScope()) { var deletingNotification = new UserGroupDeletingNotification(userGroup, evtMsgs); if (scope.Notifications.PublishCancelable(deletingNotification)) { scope.Complete(); return; } _userGroupRepository.Delete(userGroup); scope.Notifications.Publish( new UserGroupDeletedNotification(userGroup, evtMsgs).WithStateFrom(deletingNotification)); scope.Complete(); } } public async Task> VerifyPasswordResetAsync(Guid userKey, string token) { var decoded = token.FromUrlBase64(); if (decoded is null) { return Attempt.Fail(UserOperationStatus.InvalidPasswordResetToken); } IUser? user = await GetAsync(userKey); if (user is null) { return Attempt.Fail(UserOperationStatus.UserNotFound); } using IServiceScope scope = _serviceScopeFactory.CreateScope(); ICoreBackOfficeUserManager backOfficeUserManager = scope.ServiceProvider.GetRequiredService(); var isValid = await backOfficeUserManager.IsResetPasswordTokenValidAsync(user, decoded); return isValid ? Attempt.Succeed(UserOperationStatus.Success) : Attempt.Fail(UserOperationStatus.InvalidPasswordResetToken); } public async Task> VerifyInviteAsync(Guid userKey, string token) { var decoded = token.FromUrlBase64(); if (decoded is null) { return Attempt.Fail(UserOperationStatus.InvalidInviteToken); } IUser? user = await GetAsync(userKey); if (user is null) { return Attempt.Fail(UserOperationStatus.UserNotFound); } using IServiceScope scope = _serviceScopeFactory.CreateScope(); ICoreBackOfficeUserManager backOfficeUserManager = scope.ServiceProvider.GetRequiredService(); var isValid = await backOfficeUserManager.IsEmailConfirmationTokenValidAsync(user, decoded); return isValid ? Attempt.Succeed(UserOperationStatus.Success) : Attempt.Fail(UserOperationStatus.InvalidInviteToken); } public async Task> CreateInitialPasswordAsync(Guid userKey, string token, string password) { using ICoreScope scope = ScopeProvider.CreateCoreScope(); Attempt verifyInviteAttempt = await VerifyInviteAsync(userKey, token); if (verifyInviteAttempt.Result != UserOperationStatus.Success) { return Attempt.FailWithStatus(verifyInviteAttempt.Result, new PasswordChangedModel()); } Attempt changePasswordAttempt = await ChangePasswordAsync(userKey, new ChangeUserPasswordModel() { NewPassword = password, UserKey = userKey }); Task enableAttempt = EnableAsync(userKey, new HashSet() { userKey }); if (enableAttempt.Result != UserOperationStatus.Success) { return Attempt.FailWithStatus(enableAttempt.Result, new PasswordChangedModel()); } scope.Complete(); return changePasswordAttempt; } public async Task> ResetPasswordAsync(Guid userKey, string token, string password) { using ICoreScope scope = ScopeProvider.CreateCoreScope(); Attempt changePasswordAttempt = await ChangePasswordAsync(userKey, new ChangeUserPasswordModel { NewPassword = password, UserKey = userKey, ResetPasswordToken = token }); scope.Complete(); return changePasswordAttempt; } public async Task> ResetPasswordAsync(Guid performingUserKey, Guid userKey) { if (performingUserKey.Equals(userKey)) { return Attempt.FailWithStatus(UserOperationStatus.SelfPasswordResetNotAllowed, new PasswordChangedModel()); } using ICoreScope scope = ScopeProvider.CreateCoreScope(); using IServiceScope serviceScope = _serviceScopeFactory.CreateScope(); ICoreBackOfficeUserManager backOfficeUserManager = serviceScope.ServiceProvider.GetRequiredService(); var generatedPassword = backOfficeUserManager.GeneratePassword(); Attempt changePasswordAttempt = await ChangePasswordAsync(performingUserKey, new ChangeUserPasswordModel { NewPassword = generatedPassword, UserKey = userKey, }); scope.Complete(); // todo tidy this up // this should be part of the result of the ChangePasswordAsync() method // but the model requires NewPassword // and the passwordChanger does not have a codePath that deals with generating if (changePasswordAttempt.Success) { changePasswordAttempt.Result.ResetPassword = generatedPassword; } return changePasswordAttempt; } /// /// Removes a specific section from all users /// /// This is useful when an entire section is removed from config /// Alias of the section to remove public void DeleteSectionFromAllUserGroups(string sectionAlias) { using (ICoreScope scope = ScopeProvider.CreateCoreScope()) { IEnumerable assignedGroups = _userGroupRepository.GetGroupsAssignedToSection(sectionAlias); foreach (IUserGroup group in assignedGroups) { // now remove the section for each user and commit // now remove the section for each user and commit group.RemoveAllowedSection(sectionAlias); _userGroupRepository.Save(group); } scope.Complete(); } } /// public async Task, UserOperationStatus>> GetMediaPermissionsAsync(Guid userKey, IEnumerable mediaKeys) { using ICoreScope scope = ScopeProvider.CreateCoreScope(); Attempt?> idAttempt = CreateIdKeyMap(mediaKeys, UmbracoObjectTypes.Media); if (idAttempt.Success is false || idAttempt.Result is null) { return Attempt.FailWithStatus(UserOperationStatus.MediaNodeNotFound, Enumerable.Empty()); } Attempt, UserOperationStatus> permissions = await GetPermissionsAsync(userKey, idAttempt.Result); scope.Complete(); return permissions; } /// public async Task, UserOperationStatus>> GetDocumentPermissionsAsync(Guid userKey, IEnumerable contentKeys) { using ICoreScope scope = ScopeProvider.CreateCoreScope(); Attempt?> idAttempt = CreateIdKeyMap(contentKeys, UmbracoObjectTypes.Document); if (idAttempt.Success is false || idAttempt.Result is null) { return Attempt.FailWithStatus(UserOperationStatus.ContentNodeNotFound, Enumerable.Empty()); } Attempt, UserOperationStatus> permissions = await GetPermissionsAsync(userKey, idAttempt.Result); scope.Complete(); return permissions; } private async Task, UserOperationStatus>> GetPermissionsAsync(Guid userKey, Dictionary nodes) { IUser? user = await GetAsync(userKey); if (user is null) { return Attempt.FailWithStatus(UserOperationStatus.UserNotFound, Enumerable.Empty()); } EntityPermissionCollection permissionsCollection = _userGroupRepository.GetPermissions( user.Groups.ToArray(), true, nodes.Select(x => x.Value).ToArray()); var results = new List(); foreach (KeyValuePair node in nodes) { ISet permissions = permissionsCollection.GetAllPermissions(node.Value); results.Add(new NodePermissions { NodeKey = node.Key, Permissions = permissions }); } return Attempt.SucceedWithStatus, UserOperationStatus>(UserOperationStatus.Success, results); } private Attempt?> CreateIdKeyMap(IEnumerable nodeKeys, UmbracoObjectTypes objectType) { // We'll return this as a dictionary we can link the id and key again later. Dictionary idKeys = new(); foreach (Guid key in nodeKeys) { Attempt idAttempt = _entityService.GetId(key, objectType); if (idAttempt.Success is false) { return Attempt.Fail?>(null); } idKeys[key] = idAttempt.Result; } return Attempt.Succeed?>(idKeys); } /// public async Task, UserOperationStatus>> GetPermissionsAsync(Guid userKey, params Guid[] nodeKeys) { using ICoreScope scope = ScopeProvider.CreateCoreScope(); IUser? user = await GetAsync(userKey); if (user is null) { return Attempt.FailWithStatus(UserOperationStatus.UserNotFound, Enumerable.Empty()); } Guid[] keys = nodeKeys.ToArray(); if (keys.Length == 0) { return Attempt.SucceedWithStatus(UserOperationStatus.Success, Enumerable.Empty()); } // We don't know what the entity type may be, so we have to get the entire entity :( Dictionary idKeyMap = new(); foreach (Guid key in keys) { IEntitySlim? entity = _entityService.Get(key); if (entity is null) { return Attempt.FailWithStatus(UserOperationStatus.NodeNotFound, Enumerable.Empty()); } idKeyMap[entity.Id] = key; } EntityPermissionCollection permissionCollection = _userGroupRepository.GetPermissions(user.Groups.ToArray(), true, idKeyMap.Keys.ToArray()); var results = new List(); foreach (int nodeId in idKeyMap.Keys) { ISet permissions = permissionCollection.GetAllPermissions(nodeId); results.Add(new NodePermissions { NodeKey = idKeyMap[nodeId], Permissions = permissions }); } return Attempt.SucceedWithStatus, UserOperationStatus>(UserOperationStatus.Success, results); } /// /// Get explicitly assigned permissions for a user and optional node ids /// /// User to retrieve permissions for /// Specifying nothing will return all permissions for all nodes /// An enumerable list of public EntityPermissionCollection GetPermissions(IUser? user, params int[] nodeIds) { using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) { return _userGroupRepository.GetPermissions(user?.Groups.ToArray(), true, nodeIds); } } /// /// Get explicitly assigned permissions for a group and optional node Ids /// /// /// /// Flag indicating if we want to include the default group permissions for each result if there are not explicit /// permissions set /// /// Specifying nothing will return all permissions for all nodes /// An enumerable list of public EntityPermissionCollection GetPermissions(IUserGroup?[] groups, bool fallbackToDefaultPermissions, params int[] nodeIds) { if (groups == null) { throw new ArgumentNullException(nameof(groups)); } using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) { return _userGroupRepository.GetPermissions( groups.WhereNotNull().Select(x => x.ToReadOnlyGroup()).ToArray(), fallbackToDefaultPermissions, nodeIds); } } /// /// Get explicitly assigned permissions for a group and optional node Ids /// /// Groups to retrieve permissions for /// /// Flag indicating if we want to include the default group permissions for each result if there are not explicit /// permissions set /// /// Specifying nothing will return all permissions for all nodes /// An enumerable list of private IEnumerable GetPermissions(IReadOnlyUserGroup[] groups, bool fallbackToDefaultPermissions, params int[] nodeIds) { if (groups == null) { throw new ArgumentNullException(nameof(groups)); } using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) { return _userGroupRepository.GetPermissions(groups, fallbackToDefaultPermissions, nodeIds); } } /// /// Gets the implicit/inherited permissions for the user for the given path /// /// User to check permissions for /// Path to check permissions for public EntityPermissionSet GetPermissionsForPath(IUser? user, string? path) { var nodeIds = path?.GetIdsFromPathReversed(); if (nodeIds is null || nodeIds.Length == 0 || user is null) { return EntityPermissionSet.Empty(); } // collect all permissions structures for all nodes for all groups belonging to the user EntityPermission[] groupPermissions = GetPermissionsForPath(user.Groups.ToArray(), nodeIds, true).ToArray(); return CalculatePermissionsForPathForUser(groupPermissions, nodeIds); } /// /// Gets the permissions for the provided group and path /// /// /// Path to check permissions for /// /// Flag indicating if we want to include the default group permissions for each result if there are not explicit /// permissions set /// /// String indicating permissions for provided user and path public EntityPermissionSet GetPermissionsForPath(IUserGroup[] groups, string path, bool fallbackToDefaultPermissions = false) { var nodeIds = path.GetIdsFromPathReversed(); if (nodeIds.Length == 0) { return EntityPermissionSet.Empty(); } // collect all permissions structures for all nodes for all groups EntityPermission[] groupPermissions = GetPermissionsForPath(groups.Select(x => x.ToReadOnlyGroup()).ToArray(), nodeIds, true).ToArray(); return CalculatePermissionsForPathForUser(groupPermissions, nodeIds); } public async Task AddClientIdAsync(Guid userKey, string clientId) { if (ValidClientId().IsMatch(clientId) is false) { return UserClientCredentialsOperationStatus.InvalidClientId; } using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); IEnumerable currentClientIds = _userRepository.GetAllClientIds(); if (currentClientIds.InvariantContains(clientId)) { return UserClientCredentialsOperationStatus.DuplicateClientId; } IUser? user = await GetAsync(userKey); if (user is null || user.Kind != UserKind.Api) { return UserClientCredentialsOperationStatus.InvalidUser; } _userRepository.AddClientId(user.Id, clientId); return UserClientCredentialsOperationStatus.Success; } public async Task RemoveClientIdAsync(Guid userKey, string clientId) { using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); var userId = await _userIdKeyResolver.GetAsync(userKey); return _userRepository.RemoveClientId(userId, clientId); } public Task FindByClientIdAsync(string clientId) { using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); IUser? user = _userRepository.GetByClientId(clientId); return Task.FromResult(user?.Kind == UserKind.Api ? user : null); } public async Task> GetClientIdsAsync(Guid userKey) { using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); var userId = await _userIdKeyResolver.GetAsync(userKey); return _userRepository.GetClientIds(userId); } /// /// This performs the calculations for inherited nodes based on this /// http://issues.umbraco.org/issue/U4-10075#comment=67-40085 /// /// /// /// internal static EntityPermissionSet CalculatePermissionsForPathForUser( EntityPermission[] groupPermissions, int[] pathIds) { // not sure this will ever happen, it shouldn't since this should return defaults, but maybe those are empty? if (groupPermissions.Length == 0 || pathIds.Length == 0) { return EntityPermissionSet.Empty(); } // The actual entity id being looked at (deepest part of the path) var entityId = pathIds[0]; var resultPermissions = new EntityPermissionCollection(); // create a grouped by dictionary of another grouped by dictionary var permissionsByGroup = groupPermissions .GroupBy(x => x.UserGroupId) .ToDictionary( x => x.Key, x => x.GroupBy(a => a.EntityId).ToDictionary(a => a.Key, a => a.ToArray())); // iterate through each group foreach (KeyValuePair> byGroup in permissionsByGroup) { var added = false; // iterate deepest to shallowest foreach (var pathId in pathIds) { if (byGroup.Value.TryGetValue(pathId, out EntityPermission[]? permissionsForNodeAndGroup) == false) { continue; } // In theory there will only be one EntityPermission in this group // but there's nothing stopping the logic of this method // from having more so we deal with it here foreach (EntityPermission entityPermission in permissionsForNodeAndGroup) { if (entityPermission.IsDefaultPermissions == false) { // explicit permission found so we'll append it and move on, the collection is a hashset anyways // so only supports adding one element per groupid/contentid resultPermissions.Add(entityPermission); added = true; break; } } // if the permission has been added for this group and this branch then we can exit this loop if (added) { break; } } if (added == false && byGroup.Value.Count > 0) { // if there was no explicit permissions assigned in this branch for this group, then we will // add the group's default permissions resultPermissions.Add(byGroup.Value[entityId][0]); } } var permissionSet = new EntityPermissionSet(entityId, resultPermissions); return permissionSet; } private EntityPermissionCollection GetPermissionsForPath(IReadOnlyUserGroup[] groups, int[] pathIds, bool fallbackToDefaultPermissions = false) { if (pathIds.Length == 0) { return new EntityPermissionCollection([]); } // get permissions for all nodes in the path by group IEnumerable> permissions = GetPermissions(groups, fallbackToDefaultPermissions, pathIds) .GroupBy(x => x.UserGroupId); return new EntityPermissionCollection( permissions.Select(x => GetPermissionsForPathForGroup(x, pathIds, fallbackToDefaultPermissions)) .Where(x => x is not null)!); } /// /// Returns the resulting permission set for a group for the path based on all permissions provided for the branch /// /// /// The collective set of permissions provided to calculate the resulting permissions set for the path /// based on a single group /// /// Must be ordered deepest to shallowest (right to left) /// /// Flag indicating if we want to include the default group permissions for each result if there are not explicit /// permissions set /// /// internal static EntityPermission? GetPermissionsForPathForGroup( IEnumerable pathPermissions, int[] pathIds, bool fallbackToDefaultPermissions = false) { // get permissions for all nodes in the path var permissionsByEntityId = pathPermissions.ToDictionary(x => x.EntityId, x => x); // then the permissions assigned to the path will be the 'deepest' node found that has permissions foreach (var id in pathIds) { if (permissionsByEntityId.TryGetValue(id, out EntityPermission? permission)) { // don't return the default permissions if that is the one assigned here (we'll do that below if nothing was found) if (permission.IsDefaultPermissions == false) { return permission; } } } // if we've made it here it means that no implicit/inherited permissions were found so we return the defaults if that is specified if (fallbackToDefaultPermissions == false) { return null; } return permissionsByEntityId[pathIds[0]]; } private static void AddAdditionalPermissions(ISet assignedPermissions, ISet additionalPermissions) { foreach (var additionalPermission in additionalPermissions) { assignedPermissions.Add(additionalPermission); } } [GeneratedRegex(@"^[\w\d\-\._~]{1,100}$")] private static partial Regex ValidClientId(); #endregion }