#nullable enable using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Pricing; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Business; using Bit.Core.Models.Data; using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; using Bit.Core.Repositories; using Bit.Core.Services; namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers; public class UpdateOrganizationUserCommand : IUpdateOrganizationUserCommand { private readonly IEventService _eventService; private readonly IOrganizationService _organizationService; private readonly IOrganizationRepository _organizationRepository; private readonly IOrganizationUserRepository _organizationUserRepository; private readonly ICountNewSmSeatsRequiredQuery _countNewSmSeatsRequiredQuery; private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand; private readonly ICollectionRepository _collectionRepository; private readonly IGroupRepository _groupRepository; private readonly IHasConfirmedOwnersExceptQuery _hasConfirmedOwnersExceptQuery; private readonly IPricingClient _pricingClient; public UpdateOrganizationUserCommand( IEventService eventService, IOrganizationService organizationService, IOrganizationRepository organizationRepository, IOrganizationUserRepository organizationUserRepository, ICountNewSmSeatsRequiredQuery countNewSmSeatsRequiredQuery, IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand, ICollectionRepository collectionRepository, IGroupRepository groupRepository, IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery, IPricingClient pricingClient) { _eventService = eventService; _organizationService = organizationService; _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; _countNewSmSeatsRequiredQuery = countNewSmSeatsRequiredQuery; _updateSecretsManagerSubscriptionCommand = updateSecretsManagerSubscriptionCommand; _collectionRepository = collectionRepository; _groupRepository = groupRepository; _hasConfirmedOwnersExceptQuery = hasConfirmedOwnersExceptQuery; _pricingClient = pricingClient; } /// /// Update an organization user. /// /// The modified organization user to save. /// The current type (member role) of the user. /// The userId of the currently logged in user who is making the change. /// The user's updated collection access. If set to null, this removes all collection access. /// The user's updated group access. If set to null, groups are not updated. /// public async Task UpdateUserAsync(OrganizationUser organizationUser, OrganizationUserType existingUserType, Guid? savingUserId, List? collectionAccess, IEnumerable? groupAccess) { // Avoid multiple enumeration var collectionAccessList = collectionAccess?.ToList() ?? []; groupAccess = groupAccess?.ToList(); if (organizationUser.Id.Equals(Guid.Empty)) { throw new BadRequestException("Invite the user first."); } var originalOrganizationUser = await _organizationUserRepository.GetByIdAsync(organizationUser.Id); if (originalOrganizationUser == null || organizationUser.OrganizationId != originalOrganizationUser.OrganizationId) { throw new NotFoundException(); } var organization = await _organizationRepository.GetByIdAsync(organizationUser.OrganizationId); if (organization == null) { throw new NotFoundException(); } await EnsureUserCannotBeAdminOrOwnerForMultipleFreeOrganizationAsync(organizationUser, existingUserType, organization); if (collectionAccessList.Count != 0) { collectionAccessList = await ValidateAccessAndFilterDefaultUserCollectionsAsync(originalOrganizationUser, collectionAccessList); } if (groupAccess?.Any() == true) { await ValidateGroupAccessAsync(originalOrganizationUser, groupAccess.ToList()); } if (savingUserId.HasValue) { await _organizationService.ValidateOrganizationUserUpdatePermissions(organizationUser.OrganizationId, organizationUser.Type, originalOrganizationUser.Type, organizationUser.GetPermissions()); } await _organizationService.ValidateOrganizationCustomPermissionsEnabledAsync(organizationUser.OrganizationId, organizationUser.Type); if (organizationUser.Type != OrganizationUserType.Owner && !await _hasConfirmedOwnersExceptQuery.HasConfirmedOwnersExceptAsync(organizationUser.OrganizationId, [organizationUser.Id])) { throw new BadRequestException("Organization must have at least one confirmed owner."); } if (collectionAccessList.Count > 0) { var invalidAssociations = collectionAccessList.Where(cas => cas.Manage && (cas.ReadOnly || cas.HidePasswords)); if (invalidAssociations.Any()) { throw new BadRequestException("The Manage property is mutually exclusive and cannot be true while the ReadOnly or HidePasswords properties are also true."); } } // Only autoscale (if required) after all validation has passed so that we know it's a valid request before // updating Stripe if (!originalOrganizationUser.AccessSecretsManager && organizationUser.AccessSecretsManager) { var additionalSmSeatsRequired = await _countNewSmSeatsRequiredQuery.CountNewSmSeatsRequiredAsync(organizationUser.OrganizationId, 1); if (additionalSmSeatsRequired > 0) { // TODO: https://bitwarden.atlassian.net/browse/PM-17012 var plan = await _pricingClient.GetPlanOrThrow(organization.PlanType); var update = new SecretsManagerSubscriptionUpdate(organization, plan, true) .AdjustSeats(additionalSmSeatsRequired); await _updateSecretsManagerSubscriptionCommand.UpdateSubscriptionAsync(update); } } await _organizationUserRepository.ReplaceAsync(organizationUser, collectionAccessList); if (groupAccess != null) { await _organizationUserRepository.UpdateGroupsAsync(organizationUser.Id, groupAccess); } await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Updated); } private async Task EnsureUserCannotBeAdminOrOwnerForMultipleFreeOrganizationAsync(OrganizationUser updatedOrgUser, OrganizationUserType existingUserType, Entities.Organization organization) { if (organization.PlanType != PlanType.Free) { return; } if (!updatedOrgUser.UserId.HasValue) { return; } if (updatedOrgUser.Type is not (OrganizationUserType.Admin or OrganizationUserType.Owner)) { return; } // Since free organizations only supports a few users there is not much point in avoiding N+1 queries for this. var adminCount = await _organizationUserRepository.GetCountByFreeOrganizationAdminUserAsync(updatedOrgUser.UserId!.Value); var isCurrentAdminOrOwner = existingUserType is OrganizationUserType.Admin or OrganizationUserType.Owner; if (isCurrentAdminOrOwner && adminCount <= 1) { return; } if (!isCurrentAdminOrOwner && adminCount == 0) { return; } throw new BadRequestException("User can only be an admin of one free organization."); } private async Task> ValidateAccessAndFilterDefaultUserCollectionsAsync( OrganizationUser originalUser, List collectionAccess) { var collections = await _collectionRepository .GetManyByManyIdsAsync(collectionAccess.Select(c => c.Id)); ValidateCollections(originalUser, collectionAccess, collections); return ExcludeDefaultUserCollections(collectionAccess, collections); } private static void ValidateCollections(OrganizationUser originalUser, List collectionAccess, ICollection collections) { var collectionIds = collections.Select(c => c.Id); var missingCollection = collectionAccess .FirstOrDefault(cas => !collectionIds.Contains(cas.Id)); if (missingCollection != default) { throw new NotFoundException(); } var invalidCollection = collections.FirstOrDefault(c => c.OrganizationId != originalUser.OrganizationId); if (invalidCollection != default) { // Use generic error message to avoid enumeration throw new NotFoundException(); } } private static List ExcludeDefaultUserCollections( List collectionAccess, ICollection collections) => collectionAccess .Where(cas => collections.Any(c => c.Id == cas.Id && c.Type != CollectionType.DefaultUserCollection)) .ToList(); private async Task ValidateGroupAccessAsync(OrganizationUser originalUser, ICollection groupAccess) { var groups = await _groupRepository.GetManyByManyIds(groupAccess); var groupIds = groups.Select(g => g.Id); var missingGroupId = groupAccess.FirstOrDefault(gId => !groupIds.Contains(gId)); if (missingGroupId != default) { throw new NotFoundException(); } var invalidGroup = groups.FirstOrDefault(g => g.OrganizationId != originalUser.OrganizationId); if (invalidGroup != default) { // Use generic error message to avoid enumeration throw new NotFoundException(); } } }