diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8185b42e7f..212f86b4fa 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -202,6 +202,8 @@ jobs: uses: github/codeql-action/upload-sarif@dd746615b3b9d728a6a37ca2045b68ca76d4841a # v3.28.8 with: sarif_file: ${{ steps.container-scan.outputs.sarif }} + sha: ${{ contains(github.event_name, 'pull_request') && github.event.pull_request.head.sha || github.sha }} + ref: ${{ contains(github.event_name, 'pull_request') && format('refs/pull/{0}/head', github.event.pull_request.number) || github.ref }} build-stub-swagger: name: Build Docker-Stub/Swagger diff --git a/.github/workflows/scan.yml b/.github/workflows/scan.yml index 1fa5c9587c..fe88782e35 100644 --- a/.github/workflows/scan.yml +++ b/.github/workflows/scan.yml @@ -49,6 +49,8 @@ jobs: uses: github/codeql-action/upload-sarif@dd746615b3b9d728a6a37ca2045b68ca76d4841a # v3.28.8 with: sarif_file: cx_result.sarif + sha: ${{ contains(github.event_name, 'pull_request') && github.event.pull_request.head.sha || github.sha }} + ref: ${{ contains(github.event_name, 'pull_request') && format('refs/pull/{0}/head', github.event.pull_request.number) || github.ref }} quality: name: Quality scan diff --git a/Directory.Build.props b/Directory.Build.props index a994b2196e..2ede6ad8d1 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,7 +3,7 @@ net8.0 - 2025.3.0 + 2025.3.3 Bit.$(MSBuildProjectName) enable diff --git a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs index 5a73e57204..cfe93e87ce 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs @@ -8,6 +8,8 @@ using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Enums; @@ -55,8 +57,10 @@ public class OrganizationUsersController : Controller private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; private readonly IDeleteManagedOrganizationUserAccountCommand _deleteManagedOrganizationUserAccountCommand; private readonly IGetOrganizationUsersManagementStatusQuery _getOrganizationUsersManagementStatusQuery; + private readonly IPolicyRequirementQuery _policyRequirementQuery; private readonly IFeatureService _featureService; private readonly IPricingClient _pricingClient; + private readonly IConfirmOrganizationUserCommand _confirmOrganizationUserCommand; public OrganizationUsersController( IOrganizationRepository organizationRepository, @@ -79,8 +83,10 @@ public class OrganizationUsersController : Controller IRemoveOrganizationUserCommand removeOrganizationUserCommand, IDeleteManagedOrganizationUserAccountCommand deleteManagedOrganizationUserAccountCommand, IGetOrganizationUsersManagementStatusQuery getOrganizationUsersManagementStatusQuery, + IPolicyRequirementQuery policyRequirementQuery, IFeatureService featureService, - IPricingClient pricingClient) + IPricingClient pricingClient, + IConfirmOrganizationUserCommand confirmOrganizationUserCommand) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; @@ -102,8 +108,10 @@ public class OrganizationUsersController : Controller _removeOrganizationUserCommand = removeOrganizationUserCommand; _deleteManagedOrganizationUserAccountCommand = deleteManagedOrganizationUserAccountCommand; _getOrganizationUsersManagementStatusQuery = getOrganizationUsersManagementStatusQuery; + _policyRequirementQuery = policyRequirementQuery; _featureService = featureService; _pricingClient = pricingClient; + _confirmOrganizationUserCommand = confirmOrganizationUserCommand; } [HttpGet("{id}")] @@ -303,7 +311,7 @@ public class OrganizationUsersController : Controller await _organizationService.InitPendingOrganization(user.Id, orgId, organizationUserId, model.Keys.PublicKey, model.Keys.EncryptedPrivateKey, model.CollectionName); await _acceptOrgUserCommand.AcceptOrgUserByEmailTokenAsync(organizationUserId, user, model.Token, _userService); - await _organizationService.ConfirmUserAsync(orgId, organizationUserId, model.Key, user.Id); + await _confirmOrganizationUserCommand.ConfirmUserAsync(orgId, organizationUserId, model.Key, user.Id); } [HttpPost("{organizationUserId}/accept")] @@ -315,11 +323,13 @@ public class OrganizationUsersController : Controller throw new UnauthorizedAccessException(); } - var useMasterPasswordPolicy = await ShouldHandleResetPasswordAsync(orgId); + var useMasterPasswordPolicy = _featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements) + ? (await _policyRequirementQuery.GetAsync(user.Id)).AutoEnrollEnabled(orgId) + : await ShouldHandleResetPasswordAsync(orgId); if (useMasterPasswordPolicy && string.IsNullOrWhiteSpace(model.ResetPasswordKey)) { - throw new BadRequestException(string.Empty, "Master Password reset is required, but not provided."); + throw new BadRequestException("Master Password reset is required, but not provided."); } await _acceptOrgUserCommand.AcceptOrgUserByEmailTokenAsync(organizationUserId, user, model.Token, _userService); @@ -357,7 +367,7 @@ public class OrganizationUsersController : Controller } var userId = _userService.GetProperUserId(User); - var result = await _organizationService.ConfirmUserAsync(orgGuidId, new Guid(id), model.Key, userId.Value); + var result = await _confirmOrganizationUserCommand.ConfirmUserAsync(orgGuidId, new Guid(id), model.Key, userId.Value); } [HttpPost("confirm")] @@ -371,7 +381,7 @@ public class OrganizationUsersController : Controller } var userId = _userService.GetProperUserId(User); - var results = await _organizationService.ConfirmUsersAsync(orgGuidId, model.ToDictionary(), userId.Value); + var results = await _confirmOrganizationUserCommand.ConfirmUsersAsync(orgGuidId, model.ToDictionary(), userId.Value); return new ListResponseModel(results.Select(r => new OrganizationUserBulkResponseModel(r.Item1.Id, r.Item2))); diff --git a/src/Api/AdminConsole/Controllers/OrganizationsController.cs b/src/Api/AdminConsole/Controllers/OrganizationsController.cs index 34da3de10c..9fa9cb6672 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationsController.cs @@ -16,6 +16,8 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationApiKeys.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Repositories; @@ -61,6 +63,7 @@ public class OrganizationsController : Controller private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; private readonly ICloudOrganizationSignUpCommand _cloudOrganizationSignUpCommand; private readonly IOrganizationDeleteCommand _organizationDeleteCommand; + private readonly IPolicyRequirementQuery _policyRequirementQuery; private readonly IPricingClient _pricingClient; public OrganizationsController( @@ -84,6 +87,7 @@ public class OrganizationsController : Controller IRemoveOrganizationUserCommand removeOrganizationUserCommand, ICloudOrganizationSignUpCommand cloudOrganizationSignUpCommand, IOrganizationDeleteCommand organizationDeleteCommand, + IPolicyRequirementQuery policyRequirementQuery, IPricingClient pricingClient) { _organizationRepository = organizationRepository; @@ -106,6 +110,7 @@ public class OrganizationsController : Controller _removeOrganizationUserCommand = removeOrganizationUserCommand; _cloudOrganizationSignUpCommand = cloudOrganizationSignUpCommand; _organizationDeleteCommand = organizationDeleteCommand; + _policyRequirementQuery = policyRequirementQuery; _pricingClient = pricingClient; } @@ -163,8 +168,13 @@ public class OrganizationsController : Controller throw new NotFoundException(); } - var resetPasswordPolicy = - await _policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword); + if (_featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements)) + { + var resetPasswordPolicyRequirement = await _policyRequirementQuery.GetAsync(user.Id); + return new OrganizationAutoEnrollStatusResponseModel(organization.Id, resetPasswordPolicyRequirement.AutoEnrollEnabled(organization.Id)); + } + + var resetPasswordPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword); if (resetPasswordPolicy == null || !resetPasswordPolicy.Enabled || resetPasswordPolicy.Data == null) { return new OrganizationAutoEnrollStatusResponseModel(organization.Id, false); @@ -172,6 +182,7 @@ public class OrganizationsController : Controller var data = JsonSerializer.Deserialize(resetPasswordPolicy.Data, JsonHelpers.IgnoreCase); return new OrganizationAutoEnrollStatusResponseModel(organization.Id, data?.AutoEnrollEnabled ?? false); + } [HttpPost("")] diff --git a/src/Api/Vault/Controllers/CiphersController.cs b/src/Api/Vault/Controllers/CiphersController.cs index 62f07005ee..daaf8a03fb 100644 --- a/src/Api/Vault/Controllers/CiphersController.cs +++ b/src/Api/Vault/Controllers/CiphersController.cs @@ -127,9 +127,9 @@ public class CiphersController : Controller public async Task> Get() { var user = await _userService.GetUserByPrincipalAsync(User); - var hasOrgs = _currentContext.Organizations?.Any() ?? false; + var hasOrgs = _currentContext.Organizations.Count != 0; // TODO: Use hasOrgs proper for cipher listing here? - var ciphers = await _cipherRepository.GetManyByUserIdAsync(user.Id, withOrganizations: true || hasOrgs); + var ciphers = await _cipherRepository.GetManyByUserIdAsync(user.Id, withOrganizations: true); Dictionary> collectionCiphersGroupDict = null; if (hasOrgs) { diff --git a/src/Billing/Billing.csproj b/src/Billing/Billing.csproj index 50e372791f..f32eccfe8c 100644 --- a/src/Billing/Billing.csproj +++ b/src/Billing/Billing.csproj @@ -3,8 +3,6 @@ bitwarden-Billing false - - $(WarningsNotAsErrors);CS9113 diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs new file mode 100644 index 0000000000..9bfe8f791e --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs @@ -0,0 +1,186 @@ +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.Services; +using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; +using Bit.Core.Billing.Enums; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Platform.Push; +using Bit.Core.Repositories; +using Bit.Core.Services; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers; + +public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand +{ + private readonly IOrganizationRepository _organizationRepository; + private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly IUserRepository _userRepository; + private readonly IEventService _eventService; + private readonly IMailService _mailService; + private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; + private readonly IPushNotificationService _pushNotificationService; + private readonly IPushRegistrationService _pushRegistrationService; + private readonly IPolicyService _policyService; + private readonly IDeviceRepository _deviceRepository; + + public ConfirmOrganizationUserCommand( + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + IUserRepository userRepository, + IEventService eventService, + IMailService mailService, + ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, + IPushNotificationService pushNotificationService, + IPushRegistrationService pushRegistrationService, + IPolicyService policyService, + IDeviceRepository deviceRepository) + { + _organizationRepository = organizationRepository; + _organizationUserRepository = organizationUserRepository; + _userRepository = userRepository; + _eventService = eventService; + _mailService = mailService; + _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; + _pushNotificationService = pushNotificationService; + _pushRegistrationService = pushRegistrationService; + _policyService = policyService; + _deviceRepository = deviceRepository; + } + + public async Task ConfirmUserAsync(Guid organizationId, Guid organizationUserId, string key, + Guid confirmingUserId) + { + var result = await ConfirmUsersAsync( + organizationId, + new Dictionary() { { organizationUserId, key } }, + confirmingUserId); + + if (!result.Any()) + { + throw new BadRequestException("User not valid."); + } + + var (orgUser, error) = result[0]; + if (error != "") + { + throw new BadRequestException(error); + } + return orgUser; + } + + public async Task>> ConfirmUsersAsync(Guid organizationId, Dictionary keys, + Guid confirmingUserId) + { + var selectedOrganizationUsers = await _organizationUserRepository.GetManyAsync(keys.Keys); + var validSelectedOrganizationUsers = selectedOrganizationUsers + .Where(u => u.Status == OrganizationUserStatusType.Accepted && u.OrganizationId == organizationId && u.UserId != null) + .ToList(); + + if (!validSelectedOrganizationUsers.Any()) + { + return new List>(); + } + + var validSelectedUserIds = validSelectedOrganizationUsers.Select(u => u.UserId.Value).ToList(); + + var organization = await _organizationRepository.GetByIdAsync(organizationId); + var allUsersOrgs = await _organizationUserRepository.GetManyByManyUsersAsync(validSelectedUserIds); + var users = await _userRepository.GetManyAsync(validSelectedUserIds); + var usersTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(validSelectedUserIds); + + var keyedFilteredUsers = validSelectedOrganizationUsers.ToDictionary(u => u.UserId.Value, u => u); + var keyedOrganizationUsers = allUsersOrgs.GroupBy(u => u.UserId.Value) + .ToDictionary(u => u.Key, u => u.ToList()); + + var succeededUsers = new List(); + var result = new List>(); + + foreach (var user in users) + { + if (!keyedFilteredUsers.ContainsKey(user.Id)) + { + continue; + } + var orgUser = keyedFilteredUsers[user.Id]; + var orgUsers = keyedOrganizationUsers.GetValueOrDefault(user.Id, new List()); + try + { + if (organization.PlanType == PlanType.Free && (orgUser.Type == OrganizationUserType.Admin + || orgUser.Type == OrganizationUserType.Owner)) + { + // 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(user.Id); + if (adminCount > 0) + { + throw new BadRequestException("User can only be an admin of one free organization."); + } + } + + var twoFactorEnabled = usersTwoFactorEnabled.FirstOrDefault(tuple => tuple.userId == user.Id).twoFactorIsEnabled; + await CheckPoliciesAsync(organizationId, user, orgUsers, twoFactorEnabled); + orgUser.Status = OrganizationUserStatusType.Confirmed; + orgUser.Key = keys[orgUser.Id]; + orgUser.Email = null; + + await _eventService.LogOrganizationUserEventAsync(orgUser, EventType.OrganizationUser_Confirmed); + await _mailService.SendOrganizationConfirmedEmailAsync(organization.DisplayName(), user.Email, orgUser.AccessSecretsManager); + await DeleteAndPushUserRegistrationAsync(organizationId, user.Id); + succeededUsers.Add(orgUser); + result.Add(Tuple.Create(orgUser, "")); + } + catch (BadRequestException e) + { + result.Add(Tuple.Create(orgUser, e.Message)); + } + } + + await _organizationUserRepository.ReplaceManyAsync(succeededUsers); + + return result; + } + + private async Task CheckPoliciesAsync(Guid organizationId, User user, + ICollection userOrgs, bool twoFactorEnabled) + { + // Enforce Two Factor Authentication Policy for this organization + var orgRequiresTwoFactor = (await _policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.TwoFactorAuthentication)) + .Any(p => p.OrganizationId == organizationId); + if (orgRequiresTwoFactor && !twoFactorEnabled) + { + throw new BadRequestException("User does not have two-step login enabled."); + } + + var hasOtherOrgs = userOrgs.Any(ou => ou.OrganizationId != organizationId); + var singleOrgPolicies = await _policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.SingleOrg); + var otherSingleOrgPolicies = + singleOrgPolicies.Where(p => p.OrganizationId != organizationId); + // Enforce Single Organization Policy for this organization + if (hasOtherOrgs && singleOrgPolicies.Any(p => p.OrganizationId == organizationId)) + { + throw new BadRequestException("Cannot confirm this member to the organization until they leave or remove all other organizations."); + } + // Enforce Single Organization Policy of other organizations user is a member of + if (otherSingleOrgPolicies.Any()) + { + throw new BadRequestException("Cannot confirm this member to the organization because they are in another organization which forbids it."); + } + } + + private async Task DeleteAndPushUserRegistrationAsync(Guid organizationId, Guid userId) + { + var devices = await GetUserDeviceIdsAsync(userId); + await _pushRegistrationService.DeleteUserRegistrationOrganizationAsync(devices, + organizationId.ToString()); + await _pushNotificationService.PushSyncOrgKeysAsync(userId); + } + + private async Task> GetUserDeviceIdsAsync(Guid userId) + { + var devices = await _deviceRepository.GetManyByUserIdAsync(userId); + return devices + .Where(d => !string.IsNullOrWhiteSpace(d.PushToken)) + .Select(d => d.Id.ToString()); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IConfirmOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IConfirmOrganizationUserCommand.cs new file mode 100644 index 0000000000..302ee0901d --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IConfirmOrganizationUserCommand.cs @@ -0,0 +1,30 @@ +using Bit.Core.Entities; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; + +/// +/// Command to confirm organization users who have accepted their invitations. +/// +public interface IConfirmOrganizationUserCommand +{ + /// + /// Confirms a single organization user who has accepted their invitation. + /// + /// The ID of the organization. + /// The ID of the organization user to confirm. + /// The encrypted organization key for the user. + /// The ID of the user performing the confirmation. + /// The confirmed organization user. + /// Thrown when the user is not valid or cannot be confirmed. + Task ConfirmUserAsync(Guid organizationId, Guid organizationUserId, string key, Guid confirmingUserId); + + /// + /// Confirms multiple organization users who have accepted their invitations. + /// + /// The ID of the organization. + /// A dictionary mapping organization user IDs to their encrypted organization keys. + /// The ID of the user performing the confirmation. + /// A list of tuples containing the organization user and an error message (if any). + Task>> ConfirmUsersAsync(Guid organizationId, Dictionary keys, + Guid confirmingUserId); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/ResetPasswordPolicyRequirement.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/ResetPasswordPolicyRequirement.cs new file mode 100644 index 0000000000..4feef1b088 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/ResetPasswordPolicyRequirement.cs @@ -0,0 +1,46 @@ +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.Enums; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; + +/// +/// Policy requirements for the Account recovery administration policy. +/// +public class ResetPasswordPolicyRequirement : IPolicyRequirement +{ + /// + /// List of Organization Ids that require automatic enrollment in password recovery. + /// + private IEnumerable _autoEnrollOrganizations; + public IEnumerable AutoEnrollOrganizations { init => _autoEnrollOrganizations = value; } + + /// + /// Returns true if provided organizationId requires automatic enrollment in password recovery. + /// + public bool AutoEnrollEnabled(Guid organizationId) + { + return _autoEnrollOrganizations.Contains(organizationId); + } + + +} + +public class ResetPasswordPolicyRequirementFactory : BasePolicyRequirementFactory +{ + public override PolicyType PolicyType => PolicyType.ResetPassword; + + protected override bool ExemptProviders => false; + + protected override IEnumerable ExemptRoles => []; + + public override ResetPasswordPolicyRequirement Create(IEnumerable policyDetails) + { + var result = policyDetails + .Where(p => p.GetDataModel().AutoEnrollEnabled) + .Select(p => p.OrganizationId) + .ToHashSet(); + + return new ResetPasswordPolicyRequirement() { AutoEnrollOrganizations = result }; + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs index 6c698f9ffc..d386006ad2 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs @@ -33,5 +33,6 @@ public static class PolicyServiceCollectionExtensions { services.AddScoped, DisableSendPolicyRequirementFactory>(); services.AddScoped, SendOptionsPolicyRequirementFactory>(); + services.AddScoped, ResetPasswordPolicyRequirementFactory>(); } } diff --git a/src/Core/AdminConsole/Services/IOrganizationService.cs b/src/Core/AdminConsole/Services/IOrganizationService.cs index dacb2ab162..476fccb480 100644 --- a/src/Core/AdminConsole/Services/IOrganizationService.cs +++ b/src/Core/AdminConsole/Services/IOrganizationService.cs @@ -38,9 +38,6 @@ public interface IOrganizationService IEnumerable<(OrganizationUserInvite invite, string externalId)> invites); Task>> ResendInvitesAsync(Guid organizationId, Guid? invitingUserId, IEnumerable organizationUsersId); Task ResendInviteAsync(Guid organizationId, Guid? invitingUserId, Guid organizationUserId, bool initOrganization = false); - Task ConfirmUserAsync(Guid organizationId, Guid organizationUserId, string key, Guid confirmingUserId); - Task>> ConfirmUsersAsync(Guid organizationId, Dictionary keys, - Guid confirmingUserId); Task UpdateUserResetPasswordEnrollmentAsync(Guid organizationId, Guid userId, string resetPasswordKey, Guid? callingUserId); Task ImportAsync(Guid organizationId, IEnumerable groups, IEnumerable newUsers, IEnumerable removeUserExternalIds, diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index 1b44eea496..ab5703eaa1 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -6,6 +6,8 @@ using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Models.Business; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.Enums; @@ -76,6 +78,7 @@ public class OrganizationService : IOrganizationService private readonly IOrganizationBillingService _organizationBillingService; private readonly IHasConfirmedOwnersExceptQuery _hasConfirmedOwnersExceptQuery; private readonly IPricingClient _pricingClient; + private readonly IPolicyRequirementQuery _policyRequirementQuery; public OrganizationService( IOrganizationRepository organizationRepository, @@ -111,7 +114,8 @@ public class OrganizationService : IOrganizationService ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, IOrganizationBillingService organizationBillingService, IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery, - IPricingClient pricingClient) + IPricingClient pricingClient, + IPolicyRequirementQuery policyRequirementQuery) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; @@ -147,6 +151,7 @@ public class OrganizationService : IOrganizationService _organizationBillingService = organizationBillingService; _hasConfirmedOwnersExceptQuery = hasConfirmedOwnersExceptQuery; _pricingClient = pricingClient; + _policyRequirementQuery = policyRequirementQuery; } public async Task ReplacePaymentMethodAsync(Guid organizationId, string paymentToken, @@ -1122,98 +1127,6 @@ public class OrganizationService : IOrganizationService ); } - public async Task ConfirmUserAsync(Guid organizationId, Guid organizationUserId, string key, - Guid confirmingUserId) - { - var result = await ConfirmUsersAsync( - organizationId, - new Dictionary() { { organizationUserId, key } }, - confirmingUserId); - - if (!result.Any()) - { - throw new BadRequestException("User not valid."); - } - - var (orgUser, error) = result[0]; - if (error != "") - { - throw new BadRequestException(error); - } - return orgUser; - } - - public async Task>> ConfirmUsersAsync(Guid organizationId, Dictionary keys, - Guid confirmingUserId) - { - var selectedOrganizationUsers = await _organizationUserRepository.GetManyAsync(keys.Keys); - var validSelectedOrganizationUsers = selectedOrganizationUsers - .Where(u => u.Status == OrganizationUserStatusType.Accepted && u.OrganizationId == organizationId && u.UserId != null) - .ToList(); - - if (!validSelectedOrganizationUsers.Any()) - { - return new List>(); - } - - var validSelectedUserIds = validSelectedOrganizationUsers.Select(u => u.UserId.Value).ToList(); - - var organization = await GetOrgById(organizationId); - var allUsersOrgs = await _organizationUserRepository.GetManyByManyUsersAsync(validSelectedUserIds); - var users = await _userRepository.GetManyAsync(validSelectedUserIds); - var usersTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(validSelectedUserIds); - - var keyedFilteredUsers = validSelectedOrganizationUsers.ToDictionary(u => u.UserId.Value, u => u); - var keyedOrganizationUsers = allUsersOrgs.GroupBy(u => u.UserId.Value) - .ToDictionary(u => u.Key, u => u.ToList()); - - var succeededUsers = new List(); - var result = new List>(); - - foreach (var user in users) - { - if (!keyedFilteredUsers.ContainsKey(user.Id)) - { - continue; - } - var orgUser = keyedFilteredUsers[user.Id]; - var orgUsers = keyedOrganizationUsers.GetValueOrDefault(user.Id, new List()); - try - { - if (organization.PlanType == PlanType.Free && (orgUser.Type == OrganizationUserType.Admin - || orgUser.Type == OrganizationUserType.Owner)) - { - // 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(user.Id); - if (adminCount > 0) - { - throw new BadRequestException("User can only be an admin of one free organization."); - } - } - - var twoFactorEnabled = usersTwoFactorEnabled.FirstOrDefault(tuple => tuple.userId == user.Id).twoFactorIsEnabled; - await CheckPoliciesAsync(organizationId, user, orgUsers, twoFactorEnabled); - orgUser.Status = OrganizationUserStatusType.Confirmed; - orgUser.Key = keys[orgUser.Id]; - orgUser.Email = null; - - await _eventService.LogOrganizationUserEventAsync(orgUser, EventType.OrganizationUser_Confirmed); - await _mailService.SendOrganizationConfirmedEmailAsync(organization.DisplayName(), user.Email, orgUser.AccessSecretsManager); - await DeleteAndPushUserRegistrationAsync(organizationId, user.Id); - succeededUsers.Add(orgUser); - result.Add(Tuple.Create(orgUser, "")); - } - catch (BadRequestException e) - { - result.Add(Tuple.Create(orgUser, e.Message)); - } - } - - await _organizationUserRepository.ReplaceManyAsync(succeededUsers); - - return result; - } - internal async Task<(bool canScale, string failureReason)> CanScaleAsync( Organization organization, int seatsToAdd) @@ -1300,32 +1213,7 @@ public class OrganizationService : IOrganizationService } } - private async Task CheckPoliciesAsync(Guid organizationId, User user, - ICollection userOrgs, bool twoFactorEnabled) - { - // Enforce Two Factor Authentication Policy for this organization - var orgRequiresTwoFactor = (await _policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.TwoFactorAuthentication)) - .Any(p => p.OrganizationId == organizationId); - if (orgRequiresTwoFactor && !twoFactorEnabled) - { - throw new BadRequestException("User does not have two-step login enabled."); - } - var hasOtherOrgs = userOrgs.Any(ou => ou.OrganizationId != organizationId); - var singleOrgPolicies = await _policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.SingleOrg); - var otherSingleOrgPolicies = - singleOrgPolicies.Where(p => p.OrganizationId != organizationId); - // Enforce Single Organization Policy for this organization - if (hasOtherOrgs && singleOrgPolicies.Any(p => p.OrganizationId == organizationId)) - { - throw new BadRequestException("Cannot confirm this member to the organization until they leave or remove all other organizations."); - } - // Enforce Single Organization Policy of other organizations user is a member of - if (otherSingleOrgPolicies.Any()) - { - throw new BadRequestException("Cannot confirm this member to the organization because they are in another organization which forbids it."); - } - } public async Task UpdateUserResetPasswordEnrollmentAsync(Guid organizationId, Guid userId, string resetPasswordKey, Guid? callingUserId) { @@ -1353,13 +1241,25 @@ public class OrganizationService : IOrganizationService } // Block the user from withdrawal if auto enrollment is enabled - if (resetPasswordKey == null && resetPasswordPolicy.Data != null) + if (_featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements)) { - var data = JsonSerializer.Deserialize(resetPasswordPolicy.Data, JsonHelpers.IgnoreCase); - - if (data?.AutoEnrollEnabled ?? false) + var resetPasswordPolicyRequirement = await _policyRequirementQuery.GetAsync(userId); + if (resetPasswordKey == null && resetPasswordPolicyRequirement.AutoEnrollEnabled(organizationId)) { - throw new BadRequestException("Due to an Enterprise Policy, you are not allowed to withdraw from Password Reset."); + throw new BadRequestException("Due to an Enterprise Policy, you are not allowed to withdraw from account recovery."); + } + + } + else + { + if (resetPasswordKey == null && resetPasswordPolicy.Data != null) + { + var data = JsonSerializer.Deserialize(resetPasswordPolicy.Data, JsonHelpers.IgnoreCase); + + if (data?.AutoEnrollEnabled ?? false) + { + throw new BadRequestException("Due to an Enterprise Policy, you are not allowed to withdraw from account recovery."); + } } } @@ -1623,15 +1523,6 @@ public class OrganizationService : IOrganizationService await _groupRepository.UpdateUsersAsync(group.Id, users); } - private async Task DeleteAndPushUserRegistrationAsync(Guid organizationId, Guid userId) - { - var devices = await GetUserDeviceIdsAsync(userId); - await _pushRegistrationService.DeleteUserRegistrationOrganizationAsync(devices, - organizationId.ToString()); - await _pushNotificationService.PushSyncOrgKeysAsync(userId); - } - - private async Task> GetUserDeviceIdsAsync(Guid userId) { var devices = await _deviceRepository.GetManyByUserIdAsync(userId); diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index e09422871d..0ae9f1d8d7 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -118,6 +118,7 @@ public static class FeatureFlagKeys public const string ExportAttachments = "export-attachments"; /* Vault Team */ + public const string PM8851_BrowserOnboardingNudge = "pm-8851-browser-onboarding-nudge"; public const string PM9111ExtensionPersistAddEditForm = "pm-9111-extension-persist-add-edit-form"; public const string NewDeviceVerificationPermanentDismiss = "new-device-permanent-dismiss"; public const string NewDeviceVerificationTemporaryDismiss = "new-device-temporary-dismiss"; @@ -125,6 +126,9 @@ public static class FeatureFlagKeys public const string RestrictProviderAccess = "restrict-provider-access"; public const string SecurityTasks = "security-tasks"; + /* Auth Team */ + public const string PM9112DeviceApprovalPersistence = "pm-9112-device-approval-persistence"; + public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair"; public const string UseTreeWalkerApiForPageDetailsCollection = "use-tree-walker-api-for-page-details-collection"; public const string DuoRedirect = "duo-redirect"; @@ -177,6 +181,8 @@ public static class FeatureFlagKeys public const string PM12276Breadcrumbing = "pm-12276-breadcrumbing-for-business-features"; public const string PM18794_ProviderPaymentMethod = "pm-18794-provider-payment-method"; public const string PM3553_MobileSimpleLoginSelfHostAlias = "simple-login-self-host-alias"; + public const string SetInitialPasswordRefactor = "pm-16117-set-initial-password-refactor"; + public const string ChangeExistingPasswordRefactor = "pm-16117-change-existing-password-refactor"; public static List GetAllKeys() { diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index 2a3edcdc00..ea72f3c785 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -4,7 +4,7 @@ false bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml - $(WarningsNotAsErrors);CS1570;CS1574;CS8602;CS9113;CS1998;CS8604 + $(WarningsNotAsErrors);CS1570;CS1574;CS9113;CS1998 diff --git a/src/Core/MailTemplates/Handlebars/SecurityTasksNotification.html.hbs b/src/Core/MailTemplates/Handlebars/SecurityTasksNotification.html.hbs index 039806f44b..ca015e3e83 100644 --- a/src/Core/MailTemplates/Handlebars/SecurityTasksNotification.html.hbs +++ b/src/Core/MailTemplates/Handlebars/SecurityTasksNotification.html.hbs @@ -15,14 +15,21 @@ + style="display: table; width:100%; padding-bottom: 24px; text-align: center;" align="center"> +
+ style="display: inline-block; font-weight: bold; color: #ffffff; text-decoration: none; text-align: center; cursor: pointer; border-radius: 999px; background-color: #175DDC; border-color: #175DDC; border-style: solid; border-width: 10px 20px; margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;"> Review at-risk passwords
+ + +
+ {{formatAdminOwnerEmails AdminOwnerEmails}} +
{{/SecurityTasksHtmlLayout}} diff --git a/src/Core/MailTemplates/Handlebars/SecurityTasksNotification.text.hbs b/src/Core/MailTemplates/Handlebars/SecurityTasksNotification.text.hbs index ba8650ad10..f5493e4503 100644 --- a/src/Core/MailTemplates/Handlebars/SecurityTasksNotification.text.hbs +++ b/src/Core/MailTemplates/Handlebars/SecurityTasksNotification.text.hbs @@ -5,4 +5,13 @@ breach. Launch the Bitwarden extension to review your at-risk passwords. Review at-risk passwords ({{{ReviewPasswordsUrl}}}) + +{{#if (eq (length AdminOwnerEmails) 1)}} +This request was initiated by {{AdminOwnerEmails.[0]}}. +{{else}} +This request was initiated by +{{#each AdminOwnerEmails}} + {{#if @last}}and {{/if}}{{this}}{{#unless @last}}, {{/unless}} +{{/each}}. +{{/if}} {{/SecurityTasksHtmlLayout}} diff --git a/src/Core/Models/Mail/SecurityTaskNotificationViewModel.cs b/src/Core/Models/Mail/SecurityTaskNotificationViewModel.cs index 7f93ac2439..8871a53424 100644 --- a/src/Core/Models/Mail/SecurityTaskNotificationViewModel.cs +++ b/src/Core/Models/Mail/SecurityTaskNotificationViewModel.cs @@ -8,5 +8,7 @@ public class SecurityTaskNotificationViewModel : BaseMailModel public bool TaskCountPlural => TaskCount != 1; + public IEnumerable AdminOwnerEmails { get; set; } + public string ReviewPasswordsUrl => $"{WebVaultUrl}/browser-extension-prompt"; } diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index 232e04fbd0..e13a06f660 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -116,6 +116,7 @@ public static class OrganizationServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); } private static void AddOrganizationApiKeyCommandsQueries(this IServiceCollection services) diff --git a/src/Core/Services/IMailService.cs b/src/Core/Services/IMailService.cs index b0b884eb3e..e61127c57a 100644 --- a/src/Core/Services/IMailService.cs +++ b/src/Core/Services/IMailService.cs @@ -99,5 +99,5 @@ public interface IMailService string organizationName); Task SendClaimedDomainUserEmailAsync(ManagedUserDomainClaimedEmails emailList); Task SendDeviceApprovalRequestedNotificationEmailAsync(IEnumerable adminEmails, Guid organizationId, string email, string userName); - Task SendBulkSecurityTaskNotificationsAsync(string orgName, IEnumerable securityTaskNotificaitons); + Task SendBulkSecurityTaskNotificationsAsync(Organization org, IEnumerable securityTaskNotifications, IEnumerable adminOwnerEmails); } diff --git a/src/Core/Services/Implementations/HandlebarsMailService.cs b/src/Core/Services/Implementations/HandlebarsMailService.cs index d96f69b0a6..edb99809f7 100644 --- a/src/Core/Services/Implementations/HandlebarsMailService.cs +++ b/src/Core/Services/Implementations/HandlebarsMailService.cs @@ -740,6 +740,45 @@ public class HandlebarsMailService : IMailService var clickTrackingText = (clickTrackingOff ? "clicktracking=off" : string.Empty); writer.WriteSafeString($"{text}"); }); + + // Construct markup for admin and owner email addresses. + // Using conditionals within the handlebar syntax was including extra spaces around + // concatenated strings, which this helper avoids. + Handlebars.RegisterHelper("formatAdminOwnerEmails", (writer, context, parameters) => + { + if (parameters.Length == 0) + { + writer.WriteSafeString(string.Empty); + return; + } + + var emailList = ((IEnumerable)parameters[0]).ToList(); + if (emailList.Count == 0) + { + writer.WriteSafeString(string.Empty); + return; + } + + string constructAnchorElement(string email) + { + return $"{email}"; + } + + var outputMessage = "This request was initiated by "; + + if (emailList.Count == 1) + { + outputMessage += $"{constructAnchorElement(emailList[0])}."; + } + else + { + outputMessage += string.Join(", ", emailList.Take(emailList.Count - 1) + .Select(email => constructAnchorElement(email))); + outputMessage += $", and {constructAnchorElement(emailList.Last())}."; + } + + writer.WriteSafeString($"{outputMessage}"); + }); } public async Task SendEmergencyAccessInviteEmailAsync(EmergencyAccess emergencyAccess, string name, string token) @@ -1201,21 +1240,23 @@ public class HandlebarsMailService : IMailService await _mailDeliveryService.SendEmailAsync(message); } - public async Task SendBulkSecurityTaskNotificationsAsync(string orgName, IEnumerable securityTaskNotificaitons) + public async Task SendBulkSecurityTaskNotificationsAsync(Organization org, IEnumerable securityTaskNotifications, IEnumerable adminOwnerEmails) { MailQueueMessage CreateMessage(UserSecurityTasksCount notification) { - var message = CreateDefaultMessage($"{orgName} has identified {notification.TaskCount} at-risk password{(notification.TaskCount.Equals(1) ? "" : "s")}", notification.Email); + var sanitizedOrgName = CoreHelpers.SanitizeForEmail(org.DisplayName(), false); + var message = CreateDefaultMessage($"{sanitizedOrgName} has identified {notification.TaskCount} at-risk password{(notification.TaskCount.Equals(1) ? "" : "s")}", notification.Email); var model = new SecurityTaskNotificationViewModel { - OrgName = orgName, + OrgName = CoreHelpers.SanitizeForEmail(sanitizedOrgName, false), TaskCount = notification.TaskCount, + AdminOwnerEmails = adminOwnerEmails, WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash, }; message.Category = "SecurityTasksNotification"; return new MailQueueMessage(message, "SecurityTasksNotification", model); } - var messageModels = securityTaskNotificaitons.Select(CreateMessage); + var messageModels = securityTaskNotifications.Select(CreateMessage); await EnqueueMailAsync(messageModels.ToList()); } diff --git a/src/Core/Services/NoopImplementations/NoopMailService.cs b/src/Core/Services/NoopImplementations/NoopMailService.cs index 5fba545903..d829fbbacb 100644 --- a/src/Core/Services/NoopImplementations/NoopMailService.cs +++ b/src/Core/Services/NoopImplementations/NoopMailService.cs @@ -324,7 +324,7 @@ public class NoopMailService : IMailService return Task.FromResult(0); } - public Task SendBulkSecurityTaskNotificationsAsync(string orgName, IEnumerable securityTaskNotificaitons) + public Task SendBulkSecurityTaskNotificationsAsync(Organization org, IEnumerable securityTaskNotifications, IEnumerable adminOwnerEmails) { return Task.FromResult(0); } diff --git a/src/Core/Vault/Commands/CreateManyTaskNotificationsCommand.cs b/src/Core/Vault/Commands/CreateManyTaskNotificationsCommand.cs index 58b5f65e0f..a335b059a4 100644 --- a/src/Core/Vault/Commands/CreateManyTaskNotificationsCommand.cs +++ b/src/Core/Vault/Commands/CreateManyTaskNotificationsCommand.cs @@ -17,19 +17,22 @@ public class CreateManyTaskNotificationsCommand : ICreateManyTaskNotificationsCo private readonly IMailService _mailService; private readonly ICreateNotificationCommand _createNotificationCommand; private readonly IPushNotificationService _pushNotificationService; + private readonly IOrganizationUserRepository _organizationUserRepository; public CreateManyTaskNotificationsCommand( IGetSecurityTasksNotificationDetailsQuery getSecurityTasksNotificationDetailsQuery, IOrganizationRepository organizationRepository, IMailService mailService, ICreateNotificationCommand createNotificationCommand, - IPushNotificationService pushNotificationService) + IPushNotificationService pushNotificationService, + IOrganizationUserRepository organizationUserRepository) { _getSecurityTasksNotificationDetailsQuery = getSecurityTasksNotificationDetailsQuery; _organizationRepository = organizationRepository; _mailService = mailService; _createNotificationCommand = createNotificationCommand; _pushNotificationService = pushNotificationService; + _organizationUserRepository = organizationUserRepository; } public async Task CreateAsync(Guid orgId, IEnumerable securityTasks) @@ -45,8 +48,11 @@ public class CreateManyTaskNotificationsCommand : ICreateManyTaskNotificationsCo }).ToList(); var organization = await _organizationRepository.GetByIdAsync(orgId); + var orgAdminEmails = await _organizationUserRepository.GetManyDetailsByRoleAsync(orgId, OrganizationUserType.Admin); + var orgOwnerEmails = await _organizationUserRepository.GetManyDetailsByRoleAsync(orgId, OrganizationUserType.Owner); + var orgAdminAndOwnerEmails = orgAdminEmails.Concat(orgOwnerEmails).Select(x => x.Email).Distinct().ToList(); - await _mailService.SendBulkSecurityTaskNotificationsAsync(organization.Name, userTaskCount); + await _mailService.SendBulkSecurityTaskNotificationsAsync(organization, userTaskCount, orgAdminAndOwnerEmails); // Break securityTaskCiphers into separate lists by user Id var securityTaskCiphersByUser = securityTaskCiphers.GroupBy(x => x.UserId) diff --git a/src/Core/Vault/Services/Implementations/CipherService.cs b/src/Core/Vault/Services/Implementations/CipherService.cs index 90c03df90b..a315528e59 100644 --- a/src/Core/Vault/Services/Implementations/CipherService.cs +++ b/src/Core/Vault/Services/Implementations/CipherService.cs @@ -13,7 +13,9 @@ using Bit.Core.Tools.Models.Business; using Bit.Core.Tools.Services; using Bit.Core.Utilities; using Bit.Core.Vault.Entities; +using Bit.Core.Vault.Enums; using Bit.Core.Vault.Models.Data; +using Bit.Core.Vault.Queries; using Bit.Core.Vault.Repositories; namespace Bit.Core.Vault.Services; @@ -38,6 +40,7 @@ public class CipherService : ICipherService private const long _fileSizeLeeway = 1024L * 1024L; // 1MB private readonly IReferenceEventService _referenceEventService; private readonly ICurrentContext _currentContext; + private readonly IGetCipherPermissionsForUserQuery _getCipherPermissionsForUserQuery; public CipherService( ICipherRepository cipherRepository, @@ -54,7 +57,8 @@ public class CipherService : ICipherService IPolicyService policyService, GlobalSettings globalSettings, IReferenceEventService referenceEventService, - ICurrentContext currentContext) + ICurrentContext currentContext, + IGetCipherPermissionsForUserQuery getCipherPermissionsForUserQuery) { _cipherRepository = cipherRepository; _folderRepository = folderRepository; @@ -71,6 +75,7 @@ public class CipherService : ICipherService _globalSettings = globalSettings; _referenceEventService = referenceEventService; _currentContext = currentContext; + _getCipherPermissionsForUserQuery = getCipherPermissionsForUserQuery; } public async Task SaveAsync(Cipher cipher, Guid savingUserId, DateTime? lastKnownRevisionDate, @@ -161,6 +166,7 @@ public class CipherService : ICipherService { ValidateCipherLastKnownRevisionDateAsync(cipher, lastKnownRevisionDate); cipher.RevisionDate = DateTime.UtcNow; + await ValidateViewPasswordUserAsync(cipher); await _cipherRepository.ReplaceAsync(cipher); await _eventService.LogCipherEventAsync(cipher, Bit.Core.Enums.EventType.Cipher_Updated); @@ -966,4 +972,32 @@ public class CipherService : ICipherService ValidateCipherLastKnownRevisionDateAsync(cipher, lastKnownRevisionDate); } + + private async Task ValidateViewPasswordUserAsync(Cipher cipher) + { + if (cipher.Type != CipherType.Login || cipher.Data == null || !cipher.OrganizationId.HasValue) + { + return; + } + var existingCipher = await _cipherRepository.GetByIdAsync(cipher.Id); + if (existingCipher == null) return; + + var cipherPermissions = await _getCipherPermissionsForUserQuery.GetByOrganization(cipher.OrganizationId.Value); + // Check if user is a "hidden password" user + if (!cipherPermissions.TryGetValue(cipher.Id, out var permission) || !(permission.ViewPassword && permission.Edit)) + { + // "hidden password" users may not add cipher key encryption + if (existingCipher.Key == null && cipher.Key != null) + { + throw new BadRequestException("You do not have permission to add cipher key encryption."); + } + // "hidden password" users may not change passwords, TOTP codes, or passkeys, so we need to set them back to the original values + var existingCipherData = JsonSerializer.Deserialize(existingCipher.Data); + var newCipherData = JsonSerializer.Deserialize(cipher.Data); + newCipherData.Fido2Credentials = existingCipherData.Fido2Credentials; + newCipherData.Totp = existingCipherData.Totp; + newCipherData.Password = existingCipherData.Password; + cipher.Data = JsonSerializer.Serialize(newCipherData); + } + } } diff --git a/src/Infrastructure.Dapper/Infrastructure.Dapper.csproj b/src/Infrastructure.Dapper/Infrastructure.Dapper.csproj index 19512670ce..c51af39824 100644 --- a/src/Infrastructure.Dapper/Infrastructure.Dapper.csproj +++ b/src/Infrastructure.Dapper/Infrastructure.Dapper.csproj @@ -2,7 +2,7 @@ - $(WarningsNotAsErrors);CS8618;CS4014 + $(WarningsNotAsErrors);CS8618 diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs index e3071bd227..a19560ecee 100644 --- a/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs @@ -7,6 +7,8 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Repositories; @@ -424,4 +426,93 @@ public class OrganizationUsersControllerTests .GetManyDetailsByOrganizationAsync(organizationAbility.Id, Arg.Any(), Arg.Any()) .Returns(organizationUsers); } + + [Theory] + [BitAutoData] + public async Task Accept_WhenOrganizationUsePoliciesIsEnabledAndResetPolicyIsEnabled_WithPolicyRequirementsEnabled_ShouldHandleResetPassword(Guid orgId, Guid orgUserId, + OrganizationUserAcceptRequestModel model, User user, SutProvider sutProvider) + { + // Arrange + var applicationCacheService = sutProvider.GetDependency(); + applicationCacheService.GetOrganizationAbilityAsync(orgId).Returns(new OrganizationAbility { UsePolicies = true }); + + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true); + + var policy = new Policy + { + Enabled = true, + Data = CoreHelpers.ClassToJsonData(new ResetPasswordDataModel { AutoEnrollEnabled = true, }), + }; + var userService = sutProvider.GetDependency(); + userService.GetUserByPrincipalAsync(default).ReturnsForAnyArgs(user); + + var policyRequirementQuery = sutProvider.GetDependency(); + + var policyRepository = sutProvider.GetDependency(); + + var policyRequirement = new ResetPasswordPolicyRequirement { AutoEnrollOrganizations = [orgId] }; + + policyRequirementQuery.GetAsync(user.Id).Returns(policyRequirement); + + // Act + await sutProvider.Sut.Accept(orgId, orgUserId, model); + + // Assert + await sutProvider.GetDependency().Received(1) + .AcceptOrgUserByEmailTokenAsync(orgUserId, user, model.Token, userService); + await sutProvider.GetDependency().Received(1) + .UpdateUserResetPasswordEnrollmentAsync(orgId, user.Id, model.ResetPasswordKey, user.Id); + + await userService.Received(1).GetUserByPrincipalAsync(default); + await applicationCacheService.Received(0).GetOrganizationAbilityAsync(orgId); + await policyRepository.Received(0).GetByOrganizationIdTypeAsync(orgId, PolicyType.ResetPassword); + await policyRequirementQuery.Received(1).GetAsync(user.Id); + Assert.True(policyRequirement.AutoEnrollEnabled(orgId)); + } + + [Theory] + [BitAutoData] + public async Task Accept_WithInvalidModelResetPasswordKey_WithPolicyRequirementsEnabled_ThrowsBadRequestException(Guid orgId, Guid orgUserId, + OrganizationUserAcceptRequestModel model, User user, SutProvider sutProvider) + { + // Arrange + model.ResetPasswordKey = " "; + var applicationCacheService = sutProvider.GetDependency(); + applicationCacheService.GetOrganizationAbilityAsync(orgId).Returns(new OrganizationAbility { UsePolicies = true }); + + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true); + + var policy = new Policy + { + Enabled = true, + Data = CoreHelpers.ClassToJsonData(new ResetPasswordDataModel { AutoEnrollEnabled = true, }), + }; + var userService = sutProvider.GetDependency(); + userService.GetUserByPrincipalAsync(default).ReturnsForAnyArgs(user); + + var policyRepository = sutProvider.GetDependency(); + + var policyRequirementQuery = sutProvider.GetDependency(); + + var policyRequirement = new ResetPasswordPolicyRequirement { AutoEnrollOrganizations = [orgId] }; + + policyRequirementQuery.GetAsync(user.Id).Returns(policyRequirement); + + // Act + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.Accept(orgId, orgUserId, model)); + + // Assert + await sutProvider.GetDependency().Received(0) + .AcceptOrgUserByEmailTokenAsync(orgUserId, user, model.Token, userService); + await sutProvider.GetDependency().Received(0) + .UpdateUserResetPasswordEnrollmentAsync(orgId, user.Id, model.ResetPasswordKey, user.Id); + + await userService.Received(1).GetUserByPrincipalAsync(default); + await applicationCacheService.Received(0).GetOrganizationAbilityAsync(orgId); + await policyRepository.Received(0).GetByOrganizationIdTypeAsync(orgId, PolicyType.ResetPassword); + await policyRequirementQuery.Received(1).GetAsync(user.Id); + + Assert.Equal("Master Password reset is required, but not provided.", exception.Message); + } } diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs index b0906ddc43..8e6d2ce27b 100644 --- a/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs @@ -4,12 +4,15 @@ using Bit.Api.AdminConsole.Controllers; using Bit.Api.Auth.Models.Request.Accounts; using Bit.Core; using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Models.Business.Tokenables; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationApiKeys.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; @@ -55,6 +58,7 @@ public class OrganizationsControllerTests : IDisposable private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; private readonly ICloudOrganizationSignUpCommand _cloudOrganizationSignUpCommand; private readonly IOrganizationDeleteCommand _organizationDeleteCommand; + private readonly IPolicyRequirementQuery _policyRequirementQuery; private readonly IPricingClient _pricingClient; private readonly OrganizationsController _sut; @@ -80,6 +84,7 @@ public class OrganizationsControllerTests : IDisposable _removeOrganizationUserCommand = Substitute.For(); _cloudOrganizationSignUpCommand = Substitute.For(); _organizationDeleteCommand = Substitute.For(); + _policyRequirementQuery = Substitute.For(); _pricingClient = Substitute.For(); _sut = new OrganizationsController( @@ -103,6 +108,7 @@ public class OrganizationsControllerTests : IDisposable _removeOrganizationUserCommand, _cloudOrganizationSignUpCommand, _organizationDeleteCommand, + _policyRequirementQuery, _pricingClient); } @@ -236,4 +242,55 @@ public class OrganizationsControllerTests : IDisposable await _organizationDeleteCommand.Received(1).DeleteAsync(organization); } + + [Theory, AutoData] + public async Task GetAutoEnrollStatus_WithPolicyRequirementsEnabled_ReturnsOrganizationAutoEnrollStatus_WithResetPasswordEnabledTrue( + User user, + Organization organization, + OrganizationUser organizationUser + ) + { + var policyRequirement = new ResetPasswordPolicyRequirement() { AutoEnrollOrganizations = [organization.Id] }; + + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + _organizationRepository.GetByIdentifierAsync(organization.Id.ToString()).Returns(organization); + _featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true); + _organizationUserRepository.GetByOrganizationAsync(organization.Id, user.Id).Returns(organizationUser); + _policyRequirementQuery.GetAsync(user.Id).Returns(policyRequirement); + + var result = await _sut.GetAutoEnrollStatus(organization.Id.ToString()); + + await _userService.Received(1).GetUserByPrincipalAsync(Arg.Any()); + await _organizationRepository.Received(1).GetByIdentifierAsync(organization.Id.ToString()); + await _policyRequirementQuery.Received(1).GetAsync(user.Id); + + Assert.True(result.ResetPasswordEnabled); + Assert.Equal(result.Id, organization.Id); + } + + [Theory, AutoData] + public async Task GetAutoEnrollStatus_WithPolicyRequirementsDisabled_ReturnsOrganizationAutoEnrollStatus_WithResetPasswordEnabledTrue( + User user, + Organization organization, + OrganizationUser organizationUser +) + { + + var policy = new Policy() { Type = PolicyType.ResetPassword, Enabled = true, Data = "{\"AutoEnrollEnabled\": true}", OrganizationId = organization.Id }; + + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + _organizationRepository.GetByIdentifierAsync(organization.Id.ToString()).Returns(organization); + _featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(false); + _organizationUserRepository.GetByOrganizationAsync(organization.Id, user.Id).Returns(organizationUser); + _policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword).Returns(policy); + + var result = await _sut.GetAutoEnrollStatus(organization.Id.ToString()); + + await _userService.Received(1).GetUserByPrincipalAsync(Arg.Any()); + await _organizationRepository.Received(1).GetByIdentifierAsync(organization.Id.ToString()); + await _policyRequirementQuery.Received(0).GetAsync(user.Id); + await _policyRepository.Received(1).GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword); + + Assert.True(result.ResetPasswordEnabled); + } } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommandTests.cs new file mode 100644 index 0000000000..06335f668d --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommandTests.cs @@ -0,0 +1,324 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers; +using Bit.Core.AdminConsole.Services; +using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; +using Bit.Core.Billing.Enums; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Test.AdminConsole.AutoFixture; +using Bit.Core.Test.AutoFixture.OrganizationUserFixtures; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers; + +[SutProviderCustomize] +public class ConfirmOrganizationUserCommandTests +{ + [Theory, BitAutoData] + public async Task ConfirmUserAsync_WithInvalidStatus_ThrowsBadRequestException(OrganizationUser confirmingUser, + [OrganizationUser(OrganizationUserStatusType.Invited)] OrganizationUser orgUser, string key, + SutProvider sutProvider) + { + var organizationUserRepository = sutProvider.GetDependency(); + + organizationUserRepository.GetByIdAsync(orgUser.Id).Returns(orgUser); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id)); + Assert.Contains("User not valid.", exception.Message); + } + + [Theory, BitAutoData] + public async Task ConfirmUserAsync_WithWrongOrganization_ThrowsBadRequestException(OrganizationUser confirmingUser, + [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, string key, + SutProvider sutProvider) + { + var organizationUserRepository = sutProvider.GetDependency(); + + organizationUserRepository.GetByIdAsync(orgUser.Id).Returns(orgUser); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.ConfirmUserAsync(confirmingUser.OrganizationId, orgUser.Id, key, confirmingUser.Id)); + Assert.Contains("User not valid.", exception.Message); + } + + [Theory] + [BitAutoData(OrganizationUserType.Admin)] + [BitAutoData(OrganizationUserType.Owner)] + public async Task ConfirmUserAsync_ToFree_WithExistingAdminOrOwner_ThrowsBadRequestException(OrganizationUserType userType, Organization org, OrganizationUser confirmingUser, + [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user, + string key, SutProvider sutProvider) + { + var organizationUserRepository = sutProvider.GetDependency(); + var organizationRepository = sutProvider.GetDependency(); + var userRepository = sutProvider.GetDependency(); + + org.PlanType = PlanType.Free; + orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id; + orgUser.UserId = user.Id; + orgUser.Type = userType; + organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser }); + organizationUserRepository.GetCountByFreeOrganizationAdminUserAsync(orgUser.UserId.Value).Returns(1); + organizationRepository.GetByIdAsync(org.Id).Returns(org); + userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user }); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id)); + Assert.Contains("User can only be an admin of one free organization.", exception.Message); + } + + [Theory] + [BitAutoData(PlanType.Custom, OrganizationUserType.Admin)] + [BitAutoData(PlanType.Custom, OrganizationUserType.Owner)] + [BitAutoData(PlanType.EnterpriseAnnually, OrganizationUserType.Admin)] + [BitAutoData(PlanType.EnterpriseAnnually, OrganizationUserType.Owner)] + [BitAutoData(PlanType.EnterpriseAnnually2020, OrganizationUserType.Admin)] + [BitAutoData(PlanType.EnterpriseAnnually2020, OrganizationUserType.Owner)] + [BitAutoData(PlanType.EnterpriseAnnually2019, OrganizationUserType.Admin)] + [BitAutoData(PlanType.EnterpriseAnnually2019, OrganizationUserType.Owner)] + [BitAutoData(PlanType.EnterpriseMonthly, OrganizationUserType.Admin)] + [BitAutoData(PlanType.EnterpriseMonthly, OrganizationUserType.Owner)] + [BitAutoData(PlanType.EnterpriseMonthly2020, OrganizationUserType.Admin)] + [BitAutoData(PlanType.EnterpriseMonthly2020, OrganizationUserType.Owner)] + [BitAutoData(PlanType.EnterpriseMonthly2019, OrganizationUserType.Admin)] + [BitAutoData(PlanType.EnterpriseMonthly2019, OrganizationUserType.Owner)] + [BitAutoData(PlanType.FamiliesAnnually, OrganizationUserType.Admin)] + [BitAutoData(PlanType.FamiliesAnnually, OrganizationUserType.Owner)] + [BitAutoData(PlanType.FamiliesAnnually2019, OrganizationUserType.Admin)] + [BitAutoData(PlanType.FamiliesAnnually2019, OrganizationUserType.Owner)] + [BitAutoData(PlanType.TeamsAnnually, OrganizationUserType.Admin)] + [BitAutoData(PlanType.TeamsAnnually, OrganizationUserType.Owner)] + [BitAutoData(PlanType.TeamsAnnually2020, OrganizationUserType.Admin)] + [BitAutoData(PlanType.TeamsAnnually2020, OrganizationUserType.Owner)] + [BitAutoData(PlanType.TeamsAnnually2019, OrganizationUserType.Admin)] + [BitAutoData(PlanType.TeamsAnnually2019, OrganizationUserType.Owner)] + [BitAutoData(PlanType.TeamsMonthly, OrganizationUserType.Admin)] + [BitAutoData(PlanType.TeamsMonthly, OrganizationUserType.Owner)] + [BitAutoData(PlanType.TeamsMonthly2020, OrganizationUserType.Admin)] + [BitAutoData(PlanType.TeamsMonthly2020, OrganizationUserType.Owner)] + [BitAutoData(PlanType.TeamsMonthly2019, OrganizationUserType.Admin)] + [BitAutoData(PlanType.TeamsMonthly2019, OrganizationUserType.Owner)] + public async Task ConfirmUserAsync_ToNonFree_WithExistingFreeAdminOrOwner_Succeeds(PlanType planType, OrganizationUserType orgUserType, Organization org, OrganizationUser confirmingUser, + [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user, + string key, SutProvider sutProvider) + { + var organizationUserRepository = sutProvider.GetDependency(); + var organizationRepository = sutProvider.GetDependency(); + var userRepository = sutProvider.GetDependency(); + + org.PlanType = planType; + orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id; + orgUser.UserId = user.Id; + orgUser.Type = orgUserType; + orgUser.AccessSecretsManager = false; + organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser }); + organizationUserRepository.GetCountByFreeOrganizationAdminUserAsync(orgUser.UserId.Value).Returns(1); + organizationRepository.GetByIdAsync(org.Id).Returns(org); + userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user }); + + await sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id); + + await sutProvider.GetDependency().Received(1).LogOrganizationUserEventAsync(orgUser, EventType.OrganizationUser_Confirmed); + await sutProvider.GetDependency().Received(1).SendOrganizationConfirmedEmailAsync(org.DisplayName(), user.Email); + await organizationUserRepository.Received(1).ReplaceManyAsync(Arg.Is>(users => users.Contains(orgUser) && users.Count == 1)); + } + + + [Theory, BitAutoData] + public async Task ConfirmUserAsync_AsUser_WithSingleOrgPolicyAppliedFromConfirmingOrg_ThrowsBadRequestException(Organization org, OrganizationUser confirmingUser, + [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user, + OrganizationUser orgUserAnotherOrg, [OrganizationUserPolicyDetails(PolicyType.SingleOrg)] OrganizationUserPolicyDetails singleOrgPolicy, + string key, SutProvider sutProvider) + { + var organizationUserRepository = sutProvider.GetDependency(); + var organizationRepository = sutProvider.GetDependency(); + var userRepository = sutProvider.GetDependency(); + var policyService = sutProvider.GetDependency(); + + org.PlanType = PlanType.EnterpriseAnnually; + orgUser.Status = OrganizationUserStatusType.Accepted; + orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id; + orgUser.UserId = orgUserAnotherOrg.UserId = user.Id; + organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser }); + organizationUserRepository.GetManyByManyUsersAsync(default).ReturnsForAnyArgs(new[] { orgUserAnotherOrg }); + organizationRepository.GetByIdAsync(org.Id).Returns(org); + userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user }); + singleOrgPolicy.OrganizationId = org.Id; + policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.SingleOrg).Returns(new[] { singleOrgPolicy }); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id)); + Assert.Contains("Cannot confirm this member to the organization until they leave or remove all other organizations.", exception.Message); + } + + [Theory, BitAutoData] + public async Task ConfirmUserAsync_AsUser_WithSingleOrgPolicyAppliedFromOtherOrg_ThrowsBadRequestException(Organization org, OrganizationUser confirmingUser, + [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user, + OrganizationUser orgUserAnotherOrg, [OrganizationUserPolicyDetails(PolicyType.SingleOrg)] OrganizationUserPolicyDetails singleOrgPolicy, + string key, SutProvider sutProvider) + { + var organizationUserRepository = sutProvider.GetDependency(); + var organizationRepository = sutProvider.GetDependency(); + var userRepository = sutProvider.GetDependency(); + var policyService = sutProvider.GetDependency(); + + org.PlanType = PlanType.EnterpriseAnnually; + orgUser.Status = OrganizationUserStatusType.Accepted; + orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id; + orgUser.UserId = orgUserAnotherOrg.UserId = user.Id; + organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser }); + organizationUserRepository.GetManyByManyUsersAsync(default).ReturnsForAnyArgs(new[] { orgUserAnotherOrg }); + organizationRepository.GetByIdAsync(org.Id).Returns(org); + userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user }); + singleOrgPolicy.OrganizationId = orgUserAnotherOrg.Id; + policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.SingleOrg).Returns(new[] { singleOrgPolicy }); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id)); + Assert.Contains("Cannot confirm this member to the organization because they are in another organization which forbids it.", exception.Message); + } + + [Theory] + [BitAutoData(OrganizationUserType.Admin)] + [BitAutoData(OrganizationUserType.Owner)] + public async Task ConfirmUserAsync_AsOwnerOrAdmin_WithSingleOrgPolicy_ExcludedViaUserType_Success( + OrganizationUserType userType, Organization org, OrganizationUser confirmingUser, + [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user, + OrganizationUser orgUserAnotherOrg, + string key, SutProvider sutProvider) + { + var organizationUserRepository = sutProvider.GetDependency(); + var organizationRepository = sutProvider.GetDependency(); + var userRepository = sutProvider.GetDependency(); + + org.PlanType = PlanType.EnterpriseAnnually; + orgUser.Type = userType; + orgUser.Status = OrganizationUserStatusType.Accepted; + orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id; + orgUser.UserId = orgUserAnotherOrg.UserId = user.Id; + orgUser.AccessSecretsManager = true; + organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser }); + organizationUserRepository.GetManyByManyUsersAsync(default).ReturnsForAnyArgs(new[] { orgUserAnotherOrg }); + organizationRepository.GetByIdAsync(org.Id).Returns(org); + userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user }); + + await sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id); + + await sutProvider.GetDependency().Received(1).LogOrganizationUserEventAsync(orgUser, EventType.OrganizationUser_Confirmed); + await sutProvider.GetDependency().Received(1).SendOrganizationConfirmedEmailAsync(org.DisplayName(), user.Email, true); + await organizationUserRepository.Received(1).ReplaceManyAsync(Arg.Is>(users => users.Contains(orgUser) && users.Count == 1)); + } + + [Theory, BitAutoData] + public async Task ConfirmUserAsync_WithTwoFactorPolicyAndTwoFactorDisabled_ThrowsBadRequestException(Organization org, OrganizationUser confirmingUser, + [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user, + OrganizationUser orgUserAnotherOrg, + [OrganizationUserPolicyDetails(PolicyType.TwoFactorAuthentication)] OrganizationUserPolicyDetails twoFactorPolicy, + string key, SutProvider sutProvider) + { + var organizationUserRepository = sutProvider.GetDependency(); + var organizationRepository = sutProvider.GetDependency(); + var userRepository = sutProvider.GetDependency(); + var policyService = sutProvider.GetDependency(); + var twoFactorIsEnabledQuery = sutProvider.GetDependency(); + + org.PlanType = PlanType.EnterpriseAnnually; + orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id; + orgUser.UserId = orgUserAnotherOrg.UserId = user.Id; + organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser }); + organizationUserRepository.GetManyByManyUsersAsync(default).ReturnsForAnyArgs(new[] { orgUserAnotherOrg }); + organizationRepository.GetByIdAsync(org.Id).Returns(org); + userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user }); + twoFactorPolicy.OrganizationId = org.Id; + policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.TwoFactorAuthentication).Returns(new[] { twoFactorPolicy }); + twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(Arg.Is>(ids => ids.Contains(user.Id))) + .Returns(new List<(Guid userId, bool twoFactorIsEnabled)>() { (user.Id, false) }); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id)); + Assert.Contains("User does not have two-step login enabled.", exception.Message); + } + + [Theory, BitAutoData] + public async Task ConfirmUserAsync_WithTwoFactorPolicyAndTwoFactorEnabled_Succeeds(Organization org, OrganizationUser confirmingUser, + [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user, + [OrganizationUserPolicyDetails(PolicyType.TwoFactorAuthentication)] OrganizationUserPolicyDetails twoFactorPolicy, + string key, SutProvider sutProvider) + { + var organizationUserRepository = sutProvider.GetDependency(); + var organizationRepository = sutProvider.GetDependency(); + var userRepository = sutProvider.GetDependency(); + var policyService = sutProvider.GetDependency(); + var twoFactorIsEnabledQuery = sutProvider.GetDependency(); + + org.PlanType = PlanType.EnterpriseAnnually; + orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id; + orgUser.UserId = user.Id; + organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser }); + organizationRepository.GetByIdAsync(org.Id).Returns(org); + userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user }); + twoFactorPolicy.OrganizationId = org.Id; + policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.TwoFactorAuthentication).Returns(new[] { twoFactorPolicy }); + twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(Arg.Is>(ids => ids.Contains(user.Id))) + .Returns(new List<(Guid userId, bool twoFactorIsEnabled)>() { (user.Id, true) }); + + await sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id); + } + + [Theory, BitAutoData] + public async Task ConfirmUsersAsync_WithMultipleUsers_ReturnsExpectedMixedResults(Organization org, + OrganizationUser confirmingUser, + [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser1, + [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser2, + [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser3, + OrganizationUser anotherOrgUser, User user1, User user2, User user3, + [OrganizationUserPolicyDetails(PolicyType.TwoFactorAuthentication)] OrganizationUserPolicyDetails twoFactorPolicy, + [OrganizationUserPolicyDetails(PolicyType.SingleOrg)] OrganizationUserPolicyDetails singleOrgPolicy, + string key, SutProvider sutProvider) + { + var organizationUserRepository = sutProvider.GetDependency(); + var organizationRepository = sutProvider.GetDependency(); + var userRepository = sutProvider.GetDependency(); + var policyService = sutProvider.GetDependency(); + var twoFactorIsEnabledQuery = sutProvider.GetDependency(); + + org.PlanType = PlanType.EnterpriseAnnually; + orgUser1.OrganizationId = orgUser2.OrganizationId = orgUser3.OrganizationId = confirmingUser.OrganizationId = org.Id; + orgUser1.UserId = user1.Id; + orgUser2.UserId = user2.Id; + orgUser3.UserId = user3.Id; + anotherOrgUser.UserId = user3.Id; + var orgUsers = new[] { orgUser1, orgUser2, orgUser3 }; + organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(orgUsers); + organizationRepository.GetByIdAsync(org.Id).Returns(org); + userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user1, user2, user3 }); + twoFactorPolicy.OrganizationId = org.Id; + policyService.GetPoliciesApplicableToUserAsync(Arg.Any(), PolicyType.TwoFactorAuthentication).Returns(new[] { twoFactorPolicy }); + twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(Arg.Is>(ids => ids.Contains(user1.Id) && ids.Contains(user2.Id) && ids.Contains(user3.Id))) + .Returns(new List<(Guid userId, bool twoFactorIsEnabled)>() + { + (user1.Id, true), + (user2.Id, false), + (user3.Id, true) + }); + singleOrgPolicy.OrganizationId = org.Id; + policyService.GetPoliciesApplicableToUserAsync(user3.Id, PolicyType.SingleOrg) + .Returns(new[] { singleOrgPolicy }); + organizationUserRepository.GetManyByManyUsersAsync(default) + .ReturnsForAnyArgs(new[] { orgUser1, orgUser2, orgUser3, anotherOrgUser }); + + var keys = orgUsers.ToDictionary(ou => ou.Id, _ => key); + var result = await sutProvider.Sut.ConfirmUsersAsync(confirmingUser.OrganizationId, keys, confirmingUser.Id); + Assert.Contains("", result[0].Item2); + Assert.Contains("User does not have two-step login enabled.", result[1].Item2); + Assert.Contains("Cannot confirm this member to the organization until they leave or remove all other organizations.", result[2].Item2); + } +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/ResetPasswordPolicyRequirementFactoryTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/ResetPasswordPolicyRequirementFactoryTests.cs new file mode 100644 index 0000000000..181f4f170e --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/ResetPasswordPolicyRequirementFactoryTests.cs @@ -0,0 +1,37 @@ +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; +using Bit.Core.Test.AdminConsole.AutoFixture; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; + +[SutProviderCustomize] +public class ResetPasswordPolicyRequirementFactoryTests +{ + [Theory, BitAutoData] + public void AutoEnroll_WithNoPolicies_IsEmpty(SutProvider sutProvider, Guid orgId) + { + var actual = sutProvider.Sut.Create([]); + + Assert.False(actual.AutoEnrollEnabled(orgId)); + } + + [Theory, BitAutoData] + public void AutoEnrollAdministration_WithAnyResetPasswordPolices_ReturnsEnabledOrganizationIds( + [PolicyDetails(PolicyType.ResetPassword)] PolicyDetails[] policies, + SutProvider sutProvider) + { + policies[0].SetDataModel(new ResetPasswordDataModel { AutoEnrollEnabled = true }); + policies[1].SetDataModel(new ResetPasswordDataModel { AutoEnrollEnabled = false }); + policies[2].SetDataModel(new ResetPasswordDataModel { AutoEnrollEnabled = true }); + + var actual = sutProvider.Sut.Create(policies); + + Assert.True(actual.AutoEnrollEnabled(policies[0].OrganizationId)); + Assert.False(actual.AutoEnrollEnabled(policies[1].OrganizationId)); + Assert.True(actual.AutoEnrollEnabled(policies[2].OrganizationId)); + } +} diff --git a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs index 4c42fdfeb9..82dc0e2ebe 100644 --- a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs +++ b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs @@ -24,7 +24,6 @@ using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; -using Bit.Core.Test.AdminConsole.AutoFixture; using Bit.Core.Test.AutoFixture.OrganizationFixtures; using Bit.Core.Test.AutoFixture.OrganizationUserFixtures; using Bit.Core.Tokens; @@ -978,306 +977,6 @@ OrganizationUserInvite invite, SutProvider sutProvider) sutProvider.GetDependency().ManageUsers(organization.Id).Returns(true); } - [Theory, BitAutoData] - public async Task ConfirmUser_InvalidStatus(OrganizationUser confirmingUser, - [OrganizationUser(OrganizationUserStatusType.Invited)] OrganizationUser orgUser, string key, - SutProvider sutProvider) - { - var organizationUserRepository = sutProvider.GetDependency(); - - organizationUserRepository.GetByIdAsync(orgUser.Id).Returns(orgUser); - - var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id)); - Assert.Contains("User not valid.", exception.Message); - } - - [Theory, BitAutoData] - public async Task ConfirmUser_WrongOrganization(OrganizationUser confirmingUser, - [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, string key, - SutProvider sutProvider) - { - var organizationUserRepository = sutProvider.GetDependency(); - - organizationUserRepository.GetByIdAsync(orgUser.Id).Returns(orgUser); - - var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.ConfirmUserAsync(confirmingUser.OrganizationId, orgUser.Id, key, confirmingUser.Id)); - Assert.Contains("User not valid.", exception.Message); - } - - [Theory] - [BitAutoData(OrganizationUserType.Admin)] - [BitAutoData(OrganizationUserType.Owner)] - public async Task ConfirmUserToFree_AlreadyFreeAdminOrOwner_Throws(OrganizationUserType userType, Organization org, OrganizationUser confirmingUser, - [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user, - string key, SutProvider sutProvider) - { - var organizationUserRepository = sutProvider.GetDependency(); - var organizationRepository = sutProvider.GetDependency(); - var userRepository = sutProvider.GetDependency(); - - org.PlanType = PlanType.Free; - orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id; - orgUser.UserId = user.Id; - orgUser.Type = userType; - organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser }); - organizationUserRepository.GetCountByFreeOrganizationAdminUserAsync(orgUser.UserId.Value).Returns(1); - organizationRepository.GetByIdAsync(org.Id).Returns(org); - userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user }); - - var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id)); - Assert.Contains("User can only be an admin of one free organization.", exception.Message); - } - - [Theory] - [BitAutoData(PlanType.Custom, OrganizationUserType.Admin)] - [BitAutoData(PlanType.Custom, OrganizationUserType.Owner)] - [BitAutoData(PlanType.EnterpriseAnnually, OrganizationUserType.Admin)] - [BitAutoData(PlanType.EnterpriseAnnually, OrganizationUserType.Owner)] - [BitAutoData(PlanType.EnterpriseAnnually2020, OrganizationUserType.Admin)] - [BitAutoData(PlanType.EnterpriseAnnually2020, OrganizationUserType.Owner)] - [BitAutoData(PlanType.EnterpriseAnnually2019, OrganizationUserType.Admin)] - [BitAutoData(PlanType.EnterpriseAnnually2019, OrganizationUserType.Owner)] - [BitAutoData(PlanType.EnterpriseMonthly, OrganizationUserType.Admin)] - [BitAutoData(PlanType.EnterpriseMonthly, OrganizationUserType.Owner)] - [BitAutoData(PlanType.EnterpriseMonthly2020, OrganizationUserType.Admin)] - [BitAutoData(PlanType.EnterpriseMonthly2020, OrganizationUserType.Owner)] - [BitAutoData(PlanType.EnterpriseMonthly2019, OrganizationUserType.Admin)] - [BitAutoData(PlanType.EnterpriseMonthly2019, OrganizationUserType.Owner)] - [BitAutoData(PlanType.FamiliesAnnually, OrganizationUserType.Admin)] - [BitAutoData(PlanType.FamiliesAnnually, OrganizationUserType.Owner)] - [BitAutoData(PlanType.FamiliesAnnually2019, OrganizationUserType.Admin)] - [BitAutoData(PlanType.FamiliesAnnually2019, OrganizationUserType.Owner)] - [BitAutoData(PlanType.TeamsAnnually, OrganizationUserType.Admin)] - [BitAutoData(PlanType.TeamsAnnually, OrganizationUserType.Owner)] - [BitAutoData(PlanType.TeamsAnnually2020, OrganizationUserType.Admin)] - [BitAutoData(PlanType.TeamsAnnually2020, OrganizationUserType.Owner)] - [BitAutoData(PlanType.TeamsAnnually2019, OrganizationUserType.Admin)] - [BitAutoData(PlanType.TeamsAnnually2019, OrganizationUserType.Owner)] - [BitAutoData(PlanType.TeamsMonthly, OrganizationUserType.Admin)] - [BitAutoData(PlanType.TeamsMonthly, OrganizationUserType.Owner)] - [BitAutoData(PlanType.TeamsMonthly2020, OrganizationUserType.Admin)] - [BitAutoData(PlanType.TeamsMonthly2020, OrganizationUserType.Owner)] - [BitAutoData(PlanType.TeamsMonthly2019, OrganizationUserType.Admin)] - [BitAutoData(PlanType.TeamsMonthly2019, OrganizationUserType.Owner)] - public async Task ConfirmUserToNonFree_AlreadyFreeAdminOrOwner_DoesNotThrow(PlanType planType, OrganizationUserType orgUserType, Organization org, OrganizationUser confirmingUser, - [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user, - string key, SutProvider sutProvider) - { - var organizationUserRepository = sutProvider.GetDependency(); - var organizationRepository = sutProvider.GetDependency(); - var userRepository = sutProvider.GetDependency(); - - org.PlanType = planType; - orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id; - orgUser.UserId = user.Id; - orgUser.Type = orgUserType; - orgUser.AccessSecretsManager = false; - organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser }); - organizationUserRepository.GetCountByFreeOrganizationAdminUserAsync(orgUser.UserId.Value).Returns(1); - organizationRepository.GetByIdAsync(org.Id).Returns(org); - userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user }); - - await sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id); - - await sutProvider.GetDependency().Received(1).LogOrganizationUserEventAsync(orgUser, EventType.OrganizationUser_Confirmed); - await sutProvider.GetDependency().Received(1).SendOrganizationConfirmedEmailAsync(org.DisplayName(), user.Email); - await organizationUserRepository.Received(1).ReplaceManyAsync(Arg.Is>(users => users.Contains(orgUser) && users.Count == 1)); - } - - - [Theory, BitAutoData] - public async Task ConfirmUser_AsUser_SingleOrgPolicy_AppliedFromConfirmingOrg_Throws(Organization org, OrganizationUser confirmingUser, - [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user, - OrganizationUser orgUserAnotherOrg, [OrganizationUserPolicyDetails(PolicyType.SingleOrg)] OrganizationUserPolicyDetails singleOrgPolicy, - string key, SutProvider sutProvider) - { - var organizationUserRepository = sutProvider.GetDependency(); - var organizationRepository = sutProvider.GetDependency(); - var userRepository = sutProvider.GetDependency(); - var policyService = sutProvider.GetDependency(); - - org.PlanType = PlanType.EnterpriseAnnually; - orgUser.Status = OrganizationUserStatusType.Accepted; - orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id; - orgUser.UserId = orgUserAnotherOrg.UserId = user.Id; - organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser }); - organizationUserRepository.GetManyByManyUsersAsync(default).ReturnsForAnyArgs(new[] { orgUserAnotherOrg }); - organizationRepository.GetByIdAsync(org.Id).Returns(org); - userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user }); - singleOrgPolicy.OrganizationId = org.Id; - policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.SingleOrg).Returns(new[] { singleOrgPolicy }); - - var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id)); - Assert.Contains("Cannot confirm this member to the organization until they leave or remove all other organizations.", exception.Message); - } - - [Theory, BitAutoData] - public async Task ConfirmUser_AsUser_SingleOrgPolicy_AppliedFromOtherOrg_Throws(Organization org, OrganizationUser confirmingUser, - [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user, - OrganizationUser orgUserAnotherOrg, [OrganizationUserPolicyDetails(PolicyType.SingleOrg)] OrganizationUserPolicyDetails singleOrgPolicy, - string key, SutProvider sutProvider) - { - var organizationUserRepository = sutProvider.GetDependency(); - var organizationRepository = sutProvider.GetDependency(); - var userRepository = sutProvider.GetDependency(); - var policyService = sutProvider.GetDependency(); - - org.PlanType = PlanType.EnterpriseAnnually; - orgUser.Status = OrganizationUserStatusType.Accepted; - orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id; - orgUser.UserId = orgUserAnotherOrg.UserId = user.Id; - organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser }); - organizationUserRepository.GetManyByManyUsersAsync(default).ReturnsForAnyArgs(new[] { orgUserAnotherOrg }); - organizationRepository.GetByIdAsync(org.Id).Returns(org); - userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user }); - singleOrgPolicy.OrganizationId = orgUserAnotherOrg.Id; - policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.SingleOrg).Returns(new[] { singleOrgPolicy }); - - var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id)); - Assert.Contains("Cannot confirm this member to the organization because they are in another organization which forbids it.", exception.Message); - } - - [Theory] - [BitAutoData(OrganizationUserType.Admin)] - [BitAutoData(OrganizationUserType.Owner)] - public async Task ConfirmUser_AsOwnerOrAdmin_SingleOrgPolicy_ExcludedViaUserType_Success( - OrganizationUserType userType, Organization org, OrganizationUser confirmingUser, - [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user, - OrganizationUser orgUserAnotherOrg, - string key, SutProvider sutProvider) - { - var organizationUserRepository = sutProvider.GetDependency(); - var organizationRepository = sutProvider.GetDependency(); - var userRepository = sutProvider.GetDependency(); - - org.PlanType = PlanType.EnterpriseAnnually; - orgUser.Type = userType; - orgUser.Status = OrganizationUserStatusType.Accepted; - orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id; - orgUser.UserId = orgUserAnotherOrg.UserId = user.Id; - orgUser.AccessSecretsManager = true; - organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser }); - organizationUserRepository.GetManyByManyUsersAsync(default).ReturnsForAnyArgs(new[] { orgUserAnotherOrg }); - organizationRepository.GetByIdAsync(org.Id).Returns(org); - userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user }); - - await sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id); - - await sutProvider.GetDependency().Received(1).LogOrganizationUserEventAsync(orgUser, EventType.OrganizationUser_Confirmed); - await sutProvider.GetDependency().Received(1).SendOrganizationConfirmedEmailAsync(org.DisplayName(), user.Email, true); - await organizationUserRepository.Received(1).ReplaceManyAsync(Arg.Is>(users => users.Contains(orgUser) && users.Count == 1)); - } - - [Theory, BitAutoData] - public async Task ConfirmUser_TwoFactorPolicy_NotEnabled_Throws(Organization org, OrganizationUser confirmingUser, - [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user, - OrganizationUser orgUserAnotherOrg, - [OrganizationUserPolicyDetails(PolicyType.TwoFactorAuthentication)] OrganizationUserPolicyDetails twoFactorPolicy, - string key, SutProvider sutProvider) - { - var organizationUserRepository = sutProvider.GetDependency(); - var organizationRepository = sutProvider.GetDependency(); - var userRepository = sutProvider.GetDependency(); - var policyService = sutProvider.GetDependency(); - var twoFactorIsEnabledQuery = sutProvider.GetDependency(); - - org.PlanType = PlanType.EnterpriseAnnually; - orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id; - orgUser.UserId = orgUserAnotherOrg.UserId = user.Id; - organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser }); - organizationUserRepository.GetManyByManyUsersAsync(default).ReturnsForAnyArgs(new[] { orgUserAnotherOrg }); - organizationRepository.GetByIdAsync(org.Id).Returns(org); - userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user }); - twoFactorPolicy.OrganizationId = org.Id; - policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.TwoFactorAuthentication).Returns(new[] { twoFactorPolicy }); - twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(Arg.Is>(ids => ids.Contains(user.Id))) - .Returns(new List<(Guid userId, bool twoFactorIsEnabled)>() { (user.Id, false) }); - - var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id)); - Assert.Contains("User does not have two-step login enabled.", exception.Message); - } - - [Theory, BitAutoData] - public async Task ConfirmUser_TwoFactorPolicy_Enabled_Success(Organization org, OrganizationUser confirmingUser, - [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user, - [OrganizationUserPolicyDetails(PolicyType.TwoFactorAuthentication)] OrganizationUserPolicyDetails twoFactorPolicy, - string key, SutProvider sutProvider) - { - var organizationUserRepository = sutProvider.GetDependency(); - var organizationRepository = sutProvider.GetDependency(); - var userRepository = sutProvider.GetDependency(); - var policyService = sutProvider.GetDependency(); - var twoFactorIsEnabledQuery = sutProvider.GetDependency(); - - org.PlanType = PlanType.EnterpriseAnnually; - orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id; - orgUser.UserId = user.Id; - organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser }); - organizationRepository.GetByIdAsync(org.Id).Returns(org); - userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user }); - twoFactorPolicy.OrganizationId = org.Id; - policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.TwoFactorAuthentication).Returns(new[] { twoFactorPolicy }); - twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(Arg.Is>(ids => ids.Contains(user.Id))) - .Returns(new List<(Guid userId, bool twoFactorIsEnabled)>() { (user.Id, true) }); - - await sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id); - } - - [Theory, BitAutoData] - public async Task ConfirmUsers_Success(Organization org, - OrganizationUser confirmingUser, - [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser1, - [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser2, - [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser3, - OrganizationUser anotherOrgUser, User user1, User user2, User user3, - [OrganizationUserPolicyDetails(PolicyType.TwoFactorAuthentication)] OrganizationUserPolicyDetails twoFactorPolicy, - [OrganizationUserPolicyDetails(PolicyType.SingleOrg)] OrganizationUserPolicyDetails singleOrgPolicy, - string key, SutProvider sutProvider) - { - var organizationUserRepository = sutProvider.GetDependency(); - var organizationRepository = sutProvider.GetDependency(); - var userRepository = sutProvider.GetDependency(); - var policyService = sutProvider.GetDependency(); - var twoFactorIsEnabledQuery = sutProvider.GetDependency(); - - org.PlanType = PlanType.EnterpriseAnnually; - orgUser1.OrganizationId = orgUser2.OrganizationId = orgUser3.OrganizationId = confirmingUser.OrganizationId = org.Id; - orgUser1.UserId = user1.Id; - orgUser2.UserId = user2.Id; - orgUser3.UserId = user3.Id; - anotherOrgUser.UserId = user3.Id; - var orgUsers = new[] { orgUser1, orgUser2, orgUser3 }; - organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(orgUsers); - organizationRepository.GetByIdAsync(org.Id).Returns(org); - userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user1, user2, user3 }); - twoFactorPolicy.OrganizationId = org.Id; - policyService.GetPoliciesApplicableToUserAsync(Arg.Any(), PolicyType.TwoFactorAuthentication).Returns(new[] { twoFactorPolicy }); - twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(Arg.Is>(ids => ids.Contains(user1.Id) && ids.Contains(user2.Id) && ids.Contains(user3.Id))) - .Returns(new List<(Guid userId, bool twoFactorIsEnabled)>() - { - (user1.Id, true), - (user2.Id, false), - (user3.Id, true) - }); - singleOrgPolicy.OrganizationId = org.Id; - policyService.GetPoliciesApplicableToUserAsync(user3.Id, PolicyType.SingleOrg) - .Returns(new[] { singleOrgPolicy }); - organizationUserRepository.GetManyByManyUsersAsync(default) - .ReturnsForAnyArgs(new[] { orgUser1, orgUser2, orgUser3, anotherOrgUser }); - - var keys = orgUsers.ToDictionary(ou => ou.Id, _ => key); - var result = await sutProvider.Sut.ConfirmUsersAsync(confirmingUser.OrganizationId, keys, confirmingUser.Id); - Assert.Contains("", result[0].Item2); - Assert.Contains("User does not have two-step login enabled.", result[1].Item2); - Assert.Contains("Cannot confirm this member to the organization until they leave or remove all other organizations.", result[2].Item2); - } - [Theory, BitAutoData] public async Task UpdateOrganizationKeysAsync_WithoutManageResetPassword_Throws(Guid orgId, string publicKey, string privateKey, SutProvider sutProvider) diff --git a/test/Core.Test/Vault/Services/CipherServiceTests.cs b/test/Core.Test/Vault/Services/CipherServiceTests.cs index 1803c980c2..3ef29146c2 100644 --- a/test/Core.Test/Vault/Services/CipherServiceTests.cs +++ b/test/Core.Test/Vault/Services/CipherServiceTests.cs @@ -1,4 +1,5 @@ -using Bit.Core.AdminConsole.Entities; +using System.Text.Json; +using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Enums; using Bit.Core.Entities; using Bit.Core.Enums; @@ -9,7 +10,9 @@ using Bit.Core.Services; using Bit.Core.Test.AutoFixture.CipherFixtures; using Bit.Core.Utilities; using Bit.Core.Vault.Entities; +using Bit.Core.Vault.Enums; using Bit.Core.Vault.Models.Data; +using Bit.Core.Vault.Queries; using Bit.Core.Vault.Repositories; using Bit.Core.Vault.Services; using Bit.Test.Common.AutoFixture; @@ -797,6 +800,233 @@ public class CipherServiceTests Arg.Is>(arg => !arg.Except(ciphers).Any())); } + private class SaveDetailsAsyncDependencies + { + public CipherDetails CipherDetails { get; set; } + public SutProvider SutProvider { get; set; } + } + + private static SaveDetailsAsyncDependencies GetSaveDetailsAsyncDependencies( + SutProvider sutProvider, + string newPassword, + bool viewPassword, + bool editPermission, + string? key = null, + string? totp = null, + CipherLoginFido2CredentialData[]? passkeys = null + ) + { + var cipherDetails = new CipherDetails + { + Id = Guid.NewGuid(), + OrganizationId = Guid.NewGuid(), + Type = CipherType.Login, + UserId = Guid.NewGuid(), + RevisionDate = DateTime.UtcNow, + Key = key, + }; + + var newLoginData = new CipherLoginData { Username = "user", Password = newPassword, Totp = totp, Fido2Credentials = passkeys }; + cipherDetails.Data = JsonSerializer.Serialize(newLoginData); + + var existingCipher = new Cipher + { + Id = cipherDetails.Id, + Data = JsonSerializer.Serialize( + new CipherLoginData + { + Username = "user", + Password = "OriginalPassword", + Totp = "OriginalTotp", + Fido2Credentials = [] + } + ), + }; + + sutProvider.GetDependency() + .GetByIdAsync(cipherDetails.Id) + .Returns(existingCipher); + + sutProvider.GetDependency() + .ReplaceAsync(Arg.Any()) + .Returns(Task.CompletedTask); + + var permissions = new Dictionary + { + { cipherDetails.Id, new OrganizationCipherPermission { ViewPassword = viewPassword, Edit = editPermission } } + }; + + sutProvider.GetDependency() + .GetByOrganization(cipherDetails.OrganizationId.Value) + .Returns(permissions); + + return new SaveDetailsAsyncDependencies + { + CipherDetails = cipherDetails, + SutProvider = sutProvider, + }; + } + + [Theory, BitAutoData] + public async Task SaveDetailsAsync_PasswordNotChangedWithoutViewPasswordPermission(string _, SutProvider sutProvider) + { + var deps = GetSaveDetailsAsyncDependencies(sutProvider, "NewPassword", viewPassword: false, editPermission: true); + + await deps.SutProvider.Sut.SaveDetailsAsync( + deps.CipherDetails, + deps.CipherDetails.UserId.Value, + deps.CipherDetails.RevisionDate, + null, + true); + + var updatedLoginData = JsonSerializer.Deserialize(deps.CipherDetails.Data); + Assert.Equal("OriginalPassword", updatedLoginData.Password); + } + + [Theory, BitAutoData] + public async Task SaveDetailsAsync_PasswordNotChangedWithoutEditPermission(string _, SutProvider sutProvider) + { + var deps = GetSaveDetailsAsyncDependencies(sutProvider, "NewPassword", viewPassword: true, editPermission: false); + + await deps.SutProvider.Sut.SaveDetailsAsync( + deps.CipherDetails, + deps.CipherDetails.UserId.Value, + deps.CipherDetails.RevisionDate, + null, + true); + + var updatedLoginData = JsonSerializer.Deserialize(deps.CipherDetails.Data); + Assert.Equal("OriginalPassword", updatedLoginData.Password); + } + + [Theory, BitAutoData] + public async Task SaveDetailsAsync_PasswordChangedWithPermission(string _, SutProvider sutProvider) + { + var deps = GetSaveDetailsAsyncDependencies(sutProvider, "NewPassword", viewPassword: true, editPermission: true); + + await deps.SutProvider.Sut.SaveDetailsAsync( + deps.CipherDetails, + deps.CipherDetails.UserId.Value, + deps.CipherDetails.RevisionDate, + null, + true); + + var updatedLoginData = JsonSerializer.Deserialize(deps.CipherDetails.Data); + Assert.Equal("NewPassword", updatedLoginData.Password); + } + + [Theory, BitAutoData] + public async Task SaveDetailsAsync_CipherKeyChangedWithPermission(string _, SutProvider sutProvider) + { + var deps = GetSaveDetailsAsyncDependencies(sutProvider, "NewPassword", viewPassword: true, editPermission: true, "NewKey"); + + await deps.SutProvider.Sut.SaveDetailsAsync( + deps.CipherDetails, + deps.CipherDetails.UserId.Value, + deps.CipherDetails.RevisionDate, + null, + true); + + Assert.Equal("NewKey", deps.CipherDetails.Key); + } + + [Theory, BitAutoData] + public async Task SaveDetailsAsync_CipherKeyChangedWithoutPermission(string _, SutProvider sutProvider) + { + var deps = GetSaveDetailsAsyncDependencies(sutProvider, "NewPassword", viewPassword: true, editPermission: false, "NewKey"); + + var exception = await Assert.ThrowsAsync(() => deps.SutProvider.Sut.SaveDetailsAsync( + deps.CipherDetails, + deps.CipherDetails.UserId.Value, + deps.CipherDetails.RevisionDate, + null, + true)); + + Assert.Contains("do not have permission", exception.Message); + } + + [Theory, BitAutoData] + public async Task SaveDetailsAsync_TotpChangedWithoutPermission(string _, SutProvider sutProvider) + { + var deps = GetSaveDetailsAsyncDependencies(sutProvider, "NewPassword", viewPassword: true, editPermission: false, totp: "NewTotp"); + + await deps.SutProvider.Sut.SaveDetailsAsync( + deps.CipherDetails, + deps.CipherDetails.UserId.Value, + deps.CipherDetails.RevisionDate, + null, + true); + + var updatedLoginData = JsonSerializer.Deserialize(deps.CipherDetails.Data); + Assert.Equal("OriginalTotp", updatedLoginData.Totp); + } + + [Theory, BitAutoData] + public async Task SaveDetailsAsync_TotpChangedWithPermission(string _, SutProvider sutProvider) + { + var deps = GetSaveDetailsAsyncDependencies(sutProvider, "NewPassword", viewPassword: true, editPermission: true, totp: "NewTotp"); + + await deps.SutProvider.Sut.SaveDetailsAsync( + deps.CipherDetails, + deps.CipherDetails.UserId.Value, + deps.CipherDetails.RevisionDate, + null, + true); + + var updatedLoginData = JsonSerializer.Deserialize(deps.CipherDetails.Data); + Assert.Equal("NewTotp", updatedLoginData.Totp); + } + + [Theory, BitAutoData] + public async Task SaveDetailsAsync_Fido2CredentialsChangedWithoutPermission(string _, SutProvider sutProvider) + { + var passkeys = new[] + { + new CipherLoginFido2CredentialData + { + CredentialId = "CredentialId", + UserHandle = "UserHandle", + } + }; + + var deps = GetSaveDetailsAsyncDependencies(sutProvider, "NewPassword", viewPassword: true, editPermission: false, passkeys: passkeys); + + await deps.SutProvider.Sut.SaveDetailsAsync( + deps.CipherDetails, + deps.CipherDetails.UserId.Value, + deps.CipherDetails.RevisionDate, + null, + true); + + var updatedLoginData = JsonSerializer.Deserialize(deps.CipherDetails.Data); + Assert.Empty(updatedLoginData.Fido2Credentials); + } + + [Theory, BitAutoData] + public async Task SaveDetailsAsync_Fido2CredentialsChangedWithPermission(string _, SutProvider sutProvider) + { + var passkeys = new[] + { + new CipherLoginFido2CredentialData + { + CredentialId = "CredentialId", + UserHandle = "UserHandle", + } + }; + + var deps = GetSaveDetailsAsyncDependencies(sutProvider, "NewPassword", viewPassword: true, editPermission: true, passkeys: passkeys); + + await deps.SutProvider.Sut.SaveDetailsAsync( + deps.CipherDetails, + deps.CipherDetails.UserId.Value, + deps.CipherDetails.RevisionDate, + null, + true); + + var updatedLoginData = JsonSerializer.Deserialize(deps.CipherDetails.Data); + Assert.Equal(passkeys.Length, updatedLoginData.Fido2Credentials.Length); + } + [Theory] [BitAutoData] public async Task DeleteAsync_WithPersonalCipherOwner_DeletesCipher(