diff --git a/.github/workflows/test-database.yml b/.github/workflows/test-database.yml index a9c4737acc..26db5ea0a4 100644 --- a/.github/workflows/test-database.yml +++ b/.github/workflows/test-database.yml @@ -32,7 +32,6 @@ on: - "src/**/Entities/**/*.cs" # Database entity definitions jobs: - test: name: Run tests runs-on: ubuntu-22.04 @@ -148,7 +147,7 @@ jobs: run: 'docker logs $(docker ps --quiet --filter "name=mssql")' - name: Report test results - uses: dorny/test-reporter@31a54ee7ebcacc03a09ea97a7e5465a47b84aea5 # v1.9.1 + uses: dorny/test-reporter@6e6a65b7a0bd2c9197df7d0ae36ac5cee784230c # v2.0.0 if: ${{ github.event.pull_request.head.repo.full_name == github.repository && !cancelled() }} with: name: Test Results diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8d115b39cd..e44d7aa8b8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,7 +13,6 @@ env: _AZ_REGISTRY: "bitwardenprod.azurecr.io" jobs: - testing: name: Run tests if: ${{ startsWith(github.head_ref, 'version_bump_') == false }} @@ -50,7 +49,7 @@ jobs: run: dotnet test ./bitwarden_license/test --configuration Debug --logger "trx;LogFileName=bw-test-results.trx" /p:CoverletOutputFormatter="cobertura" --collect:"XPlat Code Coverage" - name: Report test results - uses: dorny/test-reporter@31a54ee7ebcacc03a09ea97a7e5465a47b84aea5 # v1.9.1 + uses: dorny/test-reporter@6e6a65b7a0bd2c9197df7d0ae36ac5cee784230c # v2.0.0 if: ${{ github.event.pull_request.head.repo.full_name == github.repository && !cancelled() }} with: name: Test Results 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/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs b/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs index 294a926022..74cfc1f916 100644 --- a/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs +++ b/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs @@ -628,6 +628,19 @@ public class ProviderBillingService( } } + public async Task UpdatePaymentMethod( + Provider provider, + TokenizedPaymentSource tokenizedPaymentSource, + TaxInformation taxInformation) + { + await Task.WhenAll( + subscriberService.UpdatePaymentSource(provider, tokenizedPaymentSource), + subscriberService.UpdateTaxInformation(provider, taxInformation)); + + await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId, + new SubscriptionUpdateOptions { CollectionMethod = StripeConstants.CollectionMethod.ChargeAutomatically }); + } + public async Task UpdateSeatMinimums(UpdateProviderSeatMinimumsCommand command) { if (command.Configuration.Any(x => x.SeatsMinimum < 0)) diff --git a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs index 5a73e57204..cc7f2314fd 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,6 +57,7 @@ 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; @@ -79,6 +82,7 @@ public class OrganizationUsersController : Controller IRemoveOrganizationUserCommand removeOrganizationUserCommand, IDeleteManagedOrganizationUserAccountCommand deleteManagedOrganizationUserAccountCommand, IGetOrganizationUsersManagementStatusQuery getOrganizationUsersManagementStatusQuery, + IPolicyRequirementQuery policyRequirementQuery, IFeatureService featureService, IPricingClient pricingClient) { @@ -102,6 +106,7 @@ public class OrganizationUsersController : Controller _removeOrganizationUserCommand = removeOrganizationUserCommand; _deleteManagedOrganizationUserAccountCommand = deleteManagedOrganizationUserAccountCommand; _getOrganizationUsersManagementStatusQuery = getOrganizationUsersManagementStatusQuery; + _policyRequirementQuery = policyRequirementQuery; _featureService = featureService; _pricingClient = pricingClient; } @@ -315,11 +320,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); 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/Billing/Controllers/ProviderBillingController.cs b/src/Api/Billing/Controllers/ProviderBillingController.cs index 73c992040c..bb1fd7bb25 100644 --- a/src/Api/Billing/Controllers/ProviderBillingController.cs +++ b/src/Api/Billing/Controllers/ProviderBillingController.cs @@ -1,5 +1,6 @@ using Bit.Api.Billing.Models.Requests; using Bit.Api.Billing.Models.Responses; +using Bit.Core; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Models; using Bit.Core.Billing.Pricing; @@ -20,6 +21,7 @@ namespace Bit.Api.Billing.Controllers; [Authorize("Application")] public class ProviderBillingController( ICurrentContext currentContext, + IFeatureService featureService, ILogger logger, IPricingClient pricingClient, IProviderBillingService providerBillingService, @@ -71,6 +73,65 @@ public class ProviderBillingController( "text/csv"); } + [HttpPut("payment-method")] + public async Task UpdatePaymentMethodAsync( + [FromRoute] Guid providerId, + [FromBody] UpdatePaymentMethodRequestBody requestBody) + { + var allowProviderPaymentMethod = featureService.IsEnabled(FeatureFlagKeys.PM18794_ProviderPaymentMethod); + + if (!allowProviderPaymentMethod) + { + return TypedResults.NotFound(); + } + + var (provider, result) = await TryGetBillableProviderForAdminOperation(providerId); + + if (provider == null) + { + return result; + } + + var tokenizedPaymentSource = requestBody.PaymentSource.ToDomain(); + var taxInformation = requestBody.TaxInformation.ToDomain(); + + await providerBillingService.UpdatePaymentMethod( + provider, + tokenizedPaymentSource, + taxInformation); + + return TypedResults.Ok(); + } + + [HttpPost("payment-method/verify-bank-account")] + public async Task VerifyBankAccountAsync( + [FromRoute] Guid providerId, + [FromBody] VerifyBankAccountRequestBody requestBody) + { + var allowProviderPaymentMethod = featureService.IsEnabled(FeatureFlagKeys.PM18794_ProviderPaymentMethod); + + if (!allowProviderPaymentMethod) + { + return TypedResults.NotFound(); + } + + var (provider, result) = await TryGetBillableProviderForAdminOperation(providerId); + + if (provider == null) + { + return result; + } + + if (requestBody.DescriptorCode.Length != 6 || !requestBody.DescriptorCode.StartsWith("SM")) + { + return Error.BadRequest("Statement descriptor should be a 6-character value that starts with 'SM'"); + } + + await subscriberService.VerifyBankAccount(provider, requestBody.DescriptorCode); + + return TypedResults.Ok(); + } + [HttpGet("subscription")] public async Task GetSubscriptionAsync([FromRoute] Guid providerId) { @@ -102,12 +163,32 @@ public class ProviderBillingController( var subscriptionSuspension = await GetSubscriptionSuspensionAsync(stripeAdapter, subscription); + var paymentSource = await subscriberService.GetPaymentSource(provider); + var response = ProviderSubscriptionResponse.From( subscription, configuredProviderPlans, taxInformation, subscriptionSuspension, - provider); + provider, + paymentSource); + + return TypedResults.Ok(response); + } + + [HttpGet("tax-information")] + public async Task GetTaxInformationAsync([FromRoute] Guid providerId) + { + var (provider, result) = await TryGetBillableProviderForAdminOperation(providerId); + + if (provider == null) + { + return result; + } + + var taxInformation = await subscriberService.GetTaxInformation(provider); + + var response = TaxInformationResponse.From(taxInformation); return TypedResults.Ok(response); } diff --git a/src/Api/Billing/Models/Responses/ProviderSubscriptionResponse.cs b/src/Api/Billing/Models/Responses/ProviderSubscriptionResponse.cs index 34c3817e51..ea1479c9df 100644 --- a/src/Api/Billing/Models/Responses/ProviderSubscriptionResponse.cs +++ b/src/Api/Billing/Models/Responses/ProviderSubscriptionResponse.cs @@ -16,7 +16,8 @@ public record ProviderSubscriptionResponse( TaxInformation TaxInformation, DateTime? CancelAt, SubscriptionSuspension Suspension, - ProviderType ProviderType) + ProviderType ProviderType, + PaymentSource PaymentSource) { private const string _annualCadence = "Annual"; private const string _monthlyCadence = "Monthly"; @@ -26,7 +27,8 @@ public record ProviderSubscriptionResponse( ICollection providerPlans, TaxInformation taxInformation, SubscriptionSuspension subscriptionSuspension, - Provider provider) + Provider provider, + PaymentSource paymentSource) { var providerPlanResponses = providerPlans .Select(providerPlan => @@ -57,7 +59,8 @@ public record ProviderSubscriptionResponse( taxInformation, subscription.CancelAt, subscriptionSuspension, - provider.Type); + provider.Type, + paymentSource); } } diff --git a/src/Api/Utilities/CommandResultExtensions.cs b/src/Api/Utilities/CommandResultExtensions.cs index 39104db7ff..c7315a0fa0 100644 --- a/src/Api/Utilities/CommandResultExtensions.cs +++ b/src/Api/Utilities/CommandResultExtensions.cs @@ -12,7 +12,7 @@ public static class CommandResultExtensions NoRecordFoundFailure failure => new ObjectResult(failure.ErrorMessages) { StatusCode = StatusCodes.Status404NotFound }, BadRequestFailure failure => new ObjectResult(failure.ErrorMessages) { StatusCode = StatusCodes.Status400BadRequest }, Failure failure => new ObjectResult(failure.ErrorMessages) { StatusCode = StatusCodes.Status400BadRequest }, - Success success => new ObjectResult(success.Data) { StatusCode = StatusCodes.Status200OK }, + Success success => new ObjectResult(success.Value) { StatusCode = StatusCodes.Status200OK }, _ => throw new InvalidOperationException($"Unhandled commandResult type: {commandResult.GetType().Name}") }; } 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/Core/AdminConsole/Errors/Error.cs b/src/Core/AdminConsole/Errors/Error.cs new file mode 100644 index 0000000000..6c8eed41a4 --- /dev/null +++ b/src/Core/AdminConsole/Errors/Error.cs @@ -0,0 +1,3 @@ +namespace Bit.Core.AdminConsole.Errors; + +public record Error(string Message, T ErroredValue); diff --git a/src/Core/AdminConsole/Errors/InsufficientPermissionsError.cs b/src/Core/AdminConsole/Errors/InsufficientPermissionsError.cs new file mode 100644 index 0000000000..d04ceba7c9 --- /dev/null +++ b/src/Core/AdminConsole/Errors/InsufficientPermissionsError.cs @@ -0,0 +1,11 @@ +namespace Bit.Core.AdminConsole.Errors; + +public record InsufficientPermissionsError(string Message, T ErroredValue) : Error(Message, ErroredValue) +{ + public const string Code = "Insufficient Permissions"; + + public InsufficientPermissionsError(T ErroredValue) : this(Code, ErroredValue) + { + + } +} diff --git a/src/Core/AdminConsole/Errors/RecordNotFoundError.cs b/src/Core/AdminConsole/Errors/RecordNotFoundError.cs new file mode 100644 index 0000000000..25a169efe1 --- /dev/null +++ b/src/Core/AdminConsole/Errors/RecordNotFoundError.cs @@ -0,0 +1,11 @@ +namespace Bit.Core.AdminConsole.Errors; + +public record RecordNotFoundError(string Message, T ErroredValue) : Error(Message, ErroredValue) +{ + public const string Code = "Record Not Found"; + + public RecordNotFoundError(T ErroredValue) : this(Code, ErroredValue) + { + + } +} 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/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index 1b44eea496..772b407951 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, @@ -1353,13 +1358,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."); + } } } diff --git a/src/Core/AdminConsole/Shared/Validation/IValidator.cs b/src/Core/AdminConsole/Shared/Validation/IValidator.cs new file mode 100644 index 0000000000..d90386f00e --- /dev/null +++ b/src/Core/AdminConsole/Shared/Validation/IValidator.cs @@ -0,0 +1,6 @@ +namespace Bit.Core.AdminConsole.Shared.Validation; + +public interface IValidator +{ + public Task> ValidateAsync(T value); +} diff --git a/src/Core/AdminConsole/Shared/Validation/ValidationResult.cs b/src/Core/AdminConsole/Shared/Validation/ValidationResult.cs new file mode 100644 index 0000000000..e25103e701 --- /dev/null +++ b/src/Core/AdminConsole/Shared/Validation/ValidationResult.cs @@ -0,0 +1,15 @@ +using Bit.Core.AdminConsole.Errors; + +namespace Bit.Core.AdminConsole.Shared.Validation; + +public abstract record ValidationResult; + +public record Valid : ValidationResult +{ + public T Value { get; init; } +} + +public record Invalid : ValidationResult +{ + public IEnumerable> Errors { get; init; } +} diff --git a/src/Core/Billing/Services/IProviderBillingService.cs b/src/Core/Billing/Services/IProviderBillingService.cs index d6983da03e..64585f3361 100644 --- a/src/Core/Billing/Services/IProviderBillingService.cs +++ b/src/Core/Billing/Services/IProviderBillingService.cs @@ -95,5 +95,16 @@ public interface IProviderBillingService Task SetupSubscription( Provider provider); + /// + /// Updates the 's payment source and tax information and then sets their subscription's collection_method to be "charge_automatically". + /// + /// The to update the payment source and tax information for. + /// The tokenized payment source (ex. Credit Card) to attach to the . + /// The 's updated tax information. + Task UpdatePaymentMethod( + Provider provider, + TokenizedPaymentSource tokenizedPaymentSource, + TaxInformation taxInformation); + Task UpdateSeatMinimums(UpdateProviderSeatMinimumsCommand command); } diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 1068daa878..ec8edb24ca 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -115,6 +115,19 @@ public static class FeatureFlagKeys public const string RiskInsightsCriticalApplication = "pm-14466-risk-insights-critical-application"; public const string EnableRiskInsightsNotifications = "enable-risk-insights-notifications"; public const string DesktopSendUIRefresh = "desktop-send-ui-refresh"; + 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"; + public const string VaultBulkManagementAction = "vault-bulk-management-action"; + 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"; @@ -122,9 +135,7 @@ public static class FeatureFlagKeys public const string AC2101UpdateTrialInitiationEmail = "AC-2101-update-trial-initiation-email"; public const string EmailVerification = "email-verification"; public const string EmailVerificationDisableTimingDelays = "email-verification-disable-timing-delays"; - public const string RestrictProviderAccess = "restrict-provider-access"; public const string PM4154BulkEncryptionService = "PM-4154-bulk-encryption-service"; - public const string VaultBulkManagementAction = "vault-bulk-management-action"; public const string InlineMenuFieldQualification = "inline-menu-field-qualification"; public const string InlineMenuPositioningImprovements = "inline-menu-positioning-improvements"; public const string DeviceTrustLogging = "pm-8285-device-trust-logging"; @@ -149,11 +160,7 @@ public static class FeatureFlagKeys public const string RemoveServerVersionHeader = "remove-server-version-header"; public const string GeneratorToolsModernization = "generator-tools-modernization"; public const string NewDeviceVerification = "new-device-verification"; - public const string NewDeviceVerificationTemporaryDismiss = "new-device-temporary-dismiss"; - public const string NewDeviceVerificationPermanentDismiss = "new-device-permanent-dismiss"; - public const string SecurityTasks = "security-tasks"; public const string MacOsNativeCredentialSync = "macos-native-credential-sync"; - public const string PM9111ExtensionPersistAddEditForm = "pm-9111-extension-persist-add-edit-form"; public const string InlineMenuTotp = "inline-menu-totp"; public const string PrivateKeyRegeneration = "pm-12241-private-key-regeneration"; public const string AppReviewPrompt = "app-review-prompt"; @@ -172,6 +179,10 @@ public static class FeatureFlagKeys public const string WebPush = "web-push"; public const string AndroidImportLoginsFlow = "import-logins-flow"; 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 const string ArchiveVaultItems = "pm-19148-innovation-archive"; public static List GetAllKeys() diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index 8a8de3d77d..2a3edcdc00 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -36,7 +36,7 @@ - + diff --git a/src/Core/MailTemplates/Handlebars/AdminConsole/OrganizationUserRevokedForSingleOrgPolicy.html.hbs b/src/Core/MailTemplates/Handlebars/AdminConsole/OrganizationUserRevokedForSingleOrgPolicy.html.hbs index d04abe86c9..5b2b1a70c5 100644 --- a/src/Core/MailTemplates/Handlebars/AdminConsole/OrganizationUserRevokedForSingleOrgPolicy.html.hbs +++ b/src/Core/MailTemplates/Handlebars/AdminConsole/OrganizationUserRevokedForSingleOrgPolicy.html.hbs @@ -7,7 +7,7 @@ - To leave an organization, first log into the web app, select the three dot menu next to the organization name, and select Leave. + To leave an organization, first log into the web app, select the three dot menu next to the organization name, and select Leave. diff --git a/src/Core/MailTemplates/Handlebars/AdminConsole/OrganizationUserRevokedForSingleOrgPolicy.text.hbs b/src/Core/MailTemplates/Handlebars/AdminConsole/OrganizationUserRevokedForSingleOrgPolicy.text.hbs index f933e8cf62..6a4b48006b 100644 --- a/src/Core/MailTemplates/Handlebars/AdminConsole/OrganizationUserRevokedForSingleOrgPolicy.text.hbs +++ b/src/Core/MailTemplates/Handlebars/AdminConsole/OrganizationUserRevokedForSingleOrgPolicy.text.hbs @@ -1,5 +1,5 @@ {{#>BasicTextLayout}} Your user account has been revoked from the {{OrganizationName}} organization because your account is part of multiple organizations. Before you can rejoin {{OrganizationName}}, you must first leave all other organizations. -To leave an organization, first log in the web app (https://vault.bitwarden.com/#/login), select the three dot menu next to the organization name, and select Leave. +To leave an organization, first log in the web app ({{{WebVaultUrl}}}/login), select the three dot menu next to the organization name, and select Leave. {{/BasicTextLayout}} diff --git a/src/Core/MailTemplates/Handlebars/OrganizationSeatsAutoscaled.html.hbs b/src/Core/MailTemplates/Handlebars/OrganizationSeatsAutoscaled.html.hbs index 8277e3894a..6bdb982194 100644 --- a/src/Core/MailTemplates/Handlebars/OrganizationSeatsAutoscaled.html.hbs +++ b/src/Core/MailTemplates/Handlebars/OrganizationSeatsAutoscaled.html.hbs @@ -26,7 +26,7 @@ - + Manage subscription
diff --git a/src/Core/MailTemplates/Handlebars/OrganizationSeatsMaxReached.html.hbs b/src/Core/MailTemplates/Handlebars/OrganizationSeatsMaxReached.html.hbs index 6ac2ee74a5..49dbe41c72 100644 --- a/src/Core/MailTemplates/Handlebars/OrganizationSeatsMaxReached.html.hbs +++ b/src/Core/MailTemplates/Handlebars/OrganizationSeatsMaxReached.html.hbs @@ -24,7 +24,7 @@ - + Manage subscription
diff --git a/src/Core/MailTemplates/Handlebars/OrganizationSmSeatsMaxReached.html.hbs b/src/Core/MailTemplates/Handlebars/OrganizationSmSeatsMaxReached.html.hbs index a6db21effc..2ef6707f1f 100644 --- a/src/Core/MailTemplates/Handlebars/OrganizationSmSeatsMaxReached.html.hbs +++ b/src/Core/MailTemplates/Handlebars/OrganizationSmSeatsMaxReached.html.hbs @@ -24,7 +24,7 @@ - + Manage subscription
diff --git a/src/Core/MailTemplates/Handlebars/OrganizationSmServiceAccountsMaxReached.html.hbs b/src/Core/MailTemplates/Handlebars/OrganizationSmServiceAccountsMaxReached.html.hbs index 507fdc33a9..1f4300c23e 100644 --- a/src/Core/MailTemplates/Handlebars/OrganizationSmServiceAccountsMaxReached.html.hbs +++ b/src/Core/MailTemplates/Handlebars/OrganizationSmServiceAccountsMaxReached.html.hbs @@ -24,7 +24,7 @@ - + Manage subscription
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/Commands/CommandResult.cs b/src/Core/Models/Commands/CommandResult.cs index ae14b7d2f9..a8ec772fc1 100644 --- a/src/Core/Models/Commands/CommandResult.cs +++ b/src/Core/Models/Commands/CommandResult.cs @@ -1,5 +1,7 @@ #nullable enable +using Bit.Core.AdminConsole.Errors; + namespace Bit.Core.Models.Commands; public class CommandResult(IEnumerable errors) @@ -9,7 +11,6 @@ public class CommandResult(IEnumerable errors) public bool Success => ErrorMessages.Count == 0; public bool HasErrors => ErrorMessages.Count > 0; public List ErrorMessages { get; } = errors.ToList(); - public CommandResult() : this(Array.Empty()) { } } @@ -29,22 +30,30 @@ public class Success : CommandResult { } -public abstract class CommandResult -{ +public abstract class CommandResult; +public class Success(T value) : CommandResult +{ + public T Value { get; } = value; } -public class Success(T data) : CommandResult +public class Failure(IEnumerable errorMessages) : CommandResult { - public T? Data { get; init; } = data; + public List ErrorMessages { get; } = errorMessages.ToList(); + + public string ErrorMessage => string.Join(" ", ErrorMessages); + + public Failure(string error) : this([error]) { } } -public class Failure(IEnumerable errorMessage) : CommandResult +public class Partial : CommandResult { - public IEnumerable ErrorMessages { get; init; } = errorMessage; + public T[] Successes { get; set; } = []; + public Error[] Failures { get; set; } = []; - public Failure(string errorMessage) : this(new[] { errorMessage }) + public Partial(IEnumerable successfulItems, IEnumerable> failedItems) { + Successes = successfulItems.ToArray(); + Failures = failedItems.ToArray(); } } - diff --git a/src/Core/Models/Mail/OrganizationSeatsAutoscaledViewModel.cs b/src/Core/Models/Mail/OrganizationSeatsAutoscaledViewModel.cs index 87f87b1c69..425b853d3e 100644 --- a/src/Core/Models/Mail/OrganizationSeatsAutoscaledViewModel.cs +++ b/src/Core/Models/Mail/OrganizationSeatsAutoscaledViewModel.cs @@ -2,7 +2,7 @@ public class OrganizationSeatsAutoscaledViewModel : BaseMailModel { - public Guid OrganizationId { get; set; } public int InitialSeatCount { get; set; } public int CurrentSeatCount { get; set; } + public string VaultSubscriptionUrl { get; set; } } diff --git a/src/Core/Models/Mail/OrganizationSeatsMaxReachedViewModel.cs b/src/Core/Models/Mail/OrganizationSeatsMaxReachedViewModel.cs index cdfb57b2dc..ad9c48ab31 100644 --- a/src/Core/Models/Mail/OrganizationSeatsMaxReachedViewModel.cs +++ b/src/Core/Models/Mail/OrganizationSeatsMaxReachedViewModel.cs @@ -2,6 +2,6 @@ public class OrganizationSeatsMaxReachedViewModel : BaseMailModel { - public Guid OrganizationId { get; set; } public int MaxSeatCount { get; set; } + public string VaultSubscriptionUrl { get; set; } } diff --git a/src/Core/Models/Mail/OrganizationServiceAccountsMaxReachedViewModel.cs b/src/Core/Models/Mail/OrganizationServiceAccountsMaxReachedViewModel.cs index 1b9c925720..c814a3e564 100644 --- a/src/Core/Models/Mail/OrganizationServiceAccountsMaxReachedViewModel.cs +++ b/src/Core/Models/Mail/OrganizationServiceAccountsMaxReachedViewModel.cs @@ -2,6 +2,6 @@ public class OrganizationServiceAccountsMaxReachedViewModel { - public Guid OrganizationId { get; set; } public int MaxServiceAccountsCount { get; set; } + public string VaultSubscriptionUrl { get; set; } } 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/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 c598a9d432..edb99809f7 100644 --- a/src/Core/Services/Implementations/HandlebarsMailService.cs +++ b/src/Core/Services/Implementations/HandlebarsMailService.cs @@ -214,9 +214,9 @@ public class HandlebarsMailService : IMailService var message = CreateDefaultMessage($"{organization.DisplayName()} Seat Count Has Increased", ownerEmails); var model = new OrganizationSeatsAutoscaledViewModel { - OrganizationId = organization.Id, InitialSeatCount = initialSeatCount, CurrentSeatCount = organization.Seats.Value, + VaultSubscriptionUrl = GetCloudVaultSubscriptionUrl(organization.Id) }; await AddMessageContentAsync(message, "OrganizationSeatsAutoscaled", model); @@ -229,8 +229,8 @@ public class HandlebarsMailService : IMailService var message = CreateDefaultMessage($"{organization.DisplayName()} Seat Limit Reached", ownerEmails); var model = new OrganizationSeatsMaxReachedViewModel { - OrganizationId = organization.Id, MaxSeatCount = maxSeatCount, + VaultSubscriptionUrl = GetCloudVaultSubscriptionUrl(organization.Id) }; await AddMessageContentAsync(message, "OrganizationSeatsMaxReached", model); @@ -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) @@ -1103,8 +1142,8 @@ public class HandlebarsMailService : IMailService var message = CreateDefaultMessage($"{organization.DisplayName()} Secrets Manager Seat Limit Reached", ownerEmails); var model = new OrganizationSeatsMaxReachedViewModel { - OrganizationId = organization.Id, MaxSeatCount = maxSeatCount, + VaultSubscriptionUrl = GetCloudVaultSubscriptionUrl(organization.Id) }; await AddMessageContentAsync(message, "OrganizationSmSeatsMaxReached", model); @@ -1118,8 +1157,8 @@ public class HandlebarsMailService : IMailService var message = CreateDefaultMessage($"{organization.DisplayName()} Secrets Manager Machine Accounts Limit Reached", ownerEmails); var model = new OrganizationServiceAccountsMaxReachedViewModel { - OrganizationId = organization.Id, MaxServiceAccountsCount = maxSeatCount, + VaultSubscriptionUrl = GetCloudVaultSubscriptionUrl(organization.Id) }; await AddMessageContentAsync(message, "OrganizationSmServiceAccountsMaxReached", model); @@ -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()); } @@ -1223,4 +1264,11 @@ public class HandlebarsMailService : IMailService { return string.IsNullOrEmpty(userName) ? email : CoreHelpers.SanitizeForEmail(userName, false); } + + private string GetCloudVaultSubscriptionUrl(Guid organizationId) + => _globalSettings.BaseServiceUri.CloudRegion?.ToLower() switch + { + "eu" => $"https://vault.bitwarden.eu/#/organizations/{organizationId}/billing/subscription", + _ => $"https://vault.bitwarden.com/#/organizations/{organizationId}/billing/subscription" + }; } 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/Icons/Icons.csproj b/src/Icons/Icons.csproj index 1674e2f877..455c8b3155 100644 --- a/src/Icons/Icons.csproj +++ b/src/Icons/Icons.csproj @@ -7,7 +7,7 @@ - + diff --git a/src/Identity/IdentityServer/RequestValidators/DeviceValidator.cs b/src/Identity/IdentityServer/RequestValidators/DeviceValidator.cs index 4744f4aca3..36a08326ab 100644 --- a/src/Identity/IdentityServer/RequestValidators/DeviceValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/DeviceValidator.cs @@ -250,21 +250,26 @@ public class DeviceValidator( var customResponse = new Dictionary(); switch (errorType) { + /* + * The ErrorMessage is brittle and is used to control the flow in the clients. Do not change them without updating the client as well. + * There is a backwards compatibility issue as well: if you make a change on the clients then ensure that they are backwards + * compatible. + */ case DeviceValidationResultType.InvalidUser: result.ErrorDescription = "Invalid user"; - customResponse.Add("ErrorModel", new ErrorResponseModel("Invalid user.")); + customResponse.Add("ErrorModel", new ErrorResponseModel("invalid user")); break; case DeviceValidationResultType.InvalidNewDeviceOtp: result.ErrorDescription = "Invalid New Device OTP"; - customResponse.Add("ErrorModel", new ErrorResponseModel("Invalid new device OTP. Try again.")); + customResponse.Add("ErrorModel", new ErrorResponseModel("invalid new device otp")); break; case DeviceValidationResultType.NewDeviceVerificationRequired: result.ErrorDescription = "New device verification required"; - customResponse.Add("ErrorModel", new ErrorResponseModel("New device verification required.")); + customResponse.Add("ErrorModel", new ErrorResponseModel("new device verification required")); break; case DeviceValidationResultType.NoDeviceInformationProvided: result.ErrorDescription = "No device information provided"; - customResponse.Add("ErrorModel", new ErrorResponseModel("No device information provided.")); + customResponse.Add("ErrorModel", new ErrorResponseModel("no device information provided")); break; } return (result, customResponse); diff --git a/src/Identity/IdentityServer/RequestValidators/TwoFactorAuthenticationValidator.cs b/src/Identity/IdentityServer/RequestValidators/TwoFactorAuthenticationValidator.cs index 856846cdd6..e733d4f410 100644 --- a/src/Identity/IdentityServer/RequestValidators/TwoFactorAuthenticationValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/TwoFactorAuthenticationValidator.cs @@ -1,5 +1,4 @@ using System.Text.Json; -using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Identity.TokenProviders; @@ -155,12 +154,9 @@ public class TwoFactorAuthenticationValidator( return false; } - if (_featureService.IsEnabled(FeatureFlagKeys.RecoveryCodeLogin)) + if (type is TwoFactorProviderType.RecoveryCode) { - if (type is TwoFactorProviderType.RecoveryCode) - { - return await _userService.RecoverTwoFactorAsync(user, token); - } + return await _userService.RecoverTwoFactorAsync(user, token); } // These cases we want to always return false, U2f is deprecated and OrganizationDuo diff --git a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs index 9b77fb216e..07b55aa44a 100644 --- a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs +++ b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs @@ -563,8 +563,8 @@ public class OrganizationUserRepository : Repository, IO await using var connection = new SqlConnection(ConnectionString); await connection.ExecuteAsync( - "[dbo].[OrganizationUser_SetStatusForUsersById]", - new { OrganizationUserIds = JsonSerializer.Serialize(organizationUserIds), Status = OrganizationUserStatusType.Revoked }, + "[dbo].[OrganizationUser_SetStatusForUsersByGuidIdArray]", + new { OrganizationUserIds = organizationUserIds.ToGuidIdArrayTVP(), Status = OrganizationUserStatusType.Revoked }, commandType: CommandType.StoredProcedure); } diff --git a/src/Sql/dbo/Stored Procedures/OrganizationUser_SetStatusForUsersByGuidIdArray.sql b/src/Sql/dbo/Stored Procedures/OrganizationUser_SetStatusForUsersByGuidIdArray.sql new file mode 100644 index 0000000000..7843748d72 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/OrganizationUser_SetStatusForUsersByGuidIdArray.sql @@ -0,0 +1,14 @@ +CREATE PROCEDURE [dbo].[OrganizationUser_SetStatusForUsersByGuidIdArray] + @OrganizationUserIds AS [dbo].[GuidIdArray] READONLY, + @Status SMALLINT +AS +BEGIN + SET NOCOUNT ON + + UPDATE OU + SET OU.[Status] = @Status + FROM [dbo].[OrganizationUser] OU + INNER JOIN @OrganizationUserIds OUI ON OUI.[Id] = OU.[Id] + + EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationUserIds] @OrganizationUserIds +END 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/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/Shared/IValidatorTests.cs b/test/Core.Test/AdminConsole/Shared/IValidatorTests.cs new file mode 100644 index 0000000000..abb49c25c6 --- /dev/null +++ b/test/Core.Test/AdminConsole/Shared/IValidatorTests.cs @@ -0,0 +1,58 @@ +using Bit.Core.AdminConsole.Errors; +using Bit.Core.AdminConsole.Shared.Validation; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.Shared; + +public class IValidatorTests +{ + public class TestClass + { + public string Name { get; set; } = string.Empty; + } + + public record InvalidRequestError(T ErroredValue) : Error(Code, ErroredValue) + { + public const string Code = "InvalidRequest"; + } + + public class TestClassValidator : IValidator + { + public Task> ValidateAsync(TestClass value) + { + if (string.IsNullOrWhiteSpace(value.Name)) + { + return Task.FromResult>(new Invalid + { + Errors = [new InvalidRequestError(value)] + }); + } + + return Task.FromResult>(new Valid { Value = value }); + } + } + + [Fact] + public async Task ValidateAsync_WhenSomethingIsInvalid_ReturnsInvalidWithError() + { + var example = new TestClass(); + + var result = await new TestClassValidator().ValidateAsync(example); + + Assert.IsType>(result); + var invalidResult = result as Invalid; + Assert.Equal(InvalidRequestError.Code, invalidResult.Errors.First().Message); + } + + [Fact] + public async Task ValidateAsync_WhenIsValid_ReturnsValid() + { + var example = new TestClass { Name = "Valid" }; + + var result = await new TestClassValidator().ValidateAsync(example); + + Assert.IsType>(result); + var validResult = result as Valid; + Assert.Equal(example.Name, validResult.Value.Name); + } +} diff --git a/test/Core.Test/Models/Commands/CommandResultTests.cs b/test/Core.Test/Models/Commands/CommandResultTests.cs new file mode 100644 index 0000000000..c500fef4f5 --- /dev/null +++ b/test/Core.Test/Models/Commands/CommandResultTests.cs @@ -0,0 +1,53 @@ +using Bit.Core.AdminConsole.Errors; +using Bit.Core.Models.Commands; +using Bit.Test.Common.AutoFixture.Attributes; +using Xunit; + +namespace Bit.Core.Test.Models.Commands; + +public class CommandResultTests +{ + public class TestItem + { + public Guid Id { get; set; } + public string Value { get; set; } + } + + public CommandResult BulkAction(IEnumerable items) + { + var itemList = items.ToList(); + var successfulItems = items.Where(x => x.Value == "SuccessfulRequest").ToArray(); + + var failedItems = itemList.Except(successfulItems).ToArray(); + + var notFound = failedItems.First(x => x.Value == "Failed due to not found"); + var invalidPermissions = failedItems.First(x => x.Value == "Failed due to invalid permissions"); + + var notFoundError = new RecordNotFoundError(notFound); + var insufficientPermissionsError = new InsufficientPermissionsError(invalidPermissions); + + return new Partial(successfulItems.ToArray(), [notFoundError, insufficientPermissionsError]); + } + + [Theory] + [BitAutoData] + public void Partial_CommandResult_BulkRequestWithSuccessAndFailures(Guid successId1, Guid failureId1, Guid failureId2) + { + var listOfRecords = new List + { + new TestItem() { Id = successId1, Value = "SuccessfulRequest" }, + new TestItem() { Id = failureId1, Value = "Failed due to not found" }, + new TestItem() { Id = failureId2, Value = "Failed due to invalid permissions" } + }; + + var result = BulkAction(listOfRecords); + + Assert.IsType>(result); + + var failures = (result as Partial).Failures.ToArray(); + var success = (result as Partial).Successes.First(); + + Assert.Equal(listOfRecords.First(), success); + Assert.Equal(2, failures.Length); + } +} 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( diff --git a/test/Identity.Test/IdentityServer/DeviceValidatorTests.cs b/test/Identity.Test/IdentityServer/DeviceValidatorTests.cs index bcb357d640..b71dd6c230 100644 --- a/test/Identity.Test/IdentityServer/DeviceValidatorTests.cs +++ b/test/Identity.Test/IdentityServer/DeviceValidatorTests.cs @@ -172,7 +172,7 @@ public class DeviceValidatorTests Assert.False(result); Assert.NotNull(context.CustomResponse["ErrorModel"]); - var expectedErrorModel = new ErrorResponseModel("No device information provided."); + var expectedErrorModel = new ErrorResponseModel("no device information provided"); var actualResponse = (ErrorResponseModel)context.CustomResponse["ErrorModel"]; Assert.Equal(expectedErrorModel.Message, actualResponse.Message); } @@ -418,7 +418,7 @@ public class DeviceValidatorTests Assert.False(result); Assert.NotNull(context.CustomResponse["ErrorModel"]); // PM-13340: The error message should be "invalid user" instead of "no device information provided" - var expectedErrorMessage = "No device information provided."; + var expectedErrorMessage = "no device information provided"; var actualResponse = (ErrorResponseModel)context.CustomResponse["ErrorModel"]; Assert.Equal(expectedErrorMessage, actualResponse.Message); } @@ -552,7 +552,7 @@ public class DeviceValidatorTests Assert.False(result); Assert.NotNull(context.CustomResponse["ErrorModel"]); - var expectedErrorMessage = "Invalid new device OTP. Try again."; + var expectedErrorMessage = "invalid new device otp"; var actualResponse = (ErrorResponseModel)context.CustomResponse["ErrorModel"]; Assert.Equal(expectedErrorMessage, actualResponse.Message); } @@ -604,7 +604,7 @@ public class DeviceValidatorTests Assert.False(result); Assert.NotNull(context.CustomResponse["ErrorModel"]); - var expectedErrorMessage = "New device verification required."; + var expectedErrorMessage = "new device verification required"; var actualResponse = (ErrorResponseModel)context.CustomResponse["ErrorModel"]; Assert.Equal(expectedErrorMessage, actualResponse.Message); } diff --git a/test/Identity.Test/IdentityServer/TwoFactorAuthenticationValidatorTests.cs b/test/Identity.Test/IdentityServer/TwoFactorAuthenticationValidatorTests.cs index e59a66a9e7..fb4d7c321a 100644 --- a/test/Identity.Test/IdentityServer/TwoFactorAuthenticationValidatorTests.cs +++ b/test/Identity.Test/IdentityServer/TwoFactorAuthenticationValidatorTests.cs @@ -1,5 +1,4 @@ -using Bit.Core; -using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Identity.TokenProviders; using Bit.Core.Auth.Models.Business.Tokenables; @@ -464,7 +463,6 @@ public class TwoFactorAuthenticationValidatorTests user.TwoFactorRecoveryCode = token; _userService.RecoverTwoFactorAsync(Arg.Is(user), Arg.Is(token)).Returns(true); - _featureService.IsEnabled(FeatureFlagKeys.RecoveryCodeLogin).Returns(true); // Act var result = await _sut.VerifyTwoFactorAsync( @@ -486,7 +484,6 @@ public class TwoFactorAuthenticationValidatorTests user.TwoFactorRecoveryCode = token; _userService.RecoverTwoFactorAsync(Arg.Is(user), Arg.Is(token)).Returns(false); - _featureService.IsEnabled(FeatureFlagKeys.RecoveryCodeLogin).Returns(true); // Act var result = await _sut.VerifyTwoFactorAsync( diff --git a/util/Attachments/Dockerfile b/util/Attachments/Dockerfile index 2d99aa5911..37b23a1b95 100644 --- a/util/Attachments/Dockerfile +++ b/util/Attachments/Dockerfile @@ -1,4 +1,4 @@ -FROM bitwarden/server:latest +FROM ghcr.io/bitwarden/server LABEL com.bitwarden.product="bitwarden" diff --git a/util/Migrator/DbScripts/2025-03-13-00_AddOrgUserSetStatusForUsersByGuidIdArray.sql b/util/Migrator/DbScripts/2025-03-13-00_AddOrgUserSetStatusForUsersByGuidIdArray.sql new file mode 100644 index 0000000000..e7c0477710 --- /dev/null +++ b/util/Migrator/DbScripts/2025-03-13-00_AddOrgUserSetStatusForUsersByGuidIdArray.sql @@ -0,0 +1,15 @@ +CREATE OR ALTER PROCEDURE [dbo].[OrganizationUser_SetStatusForUsersByGuidIdArray] + @OrganizationUserIds AS [dbo].[GuidIdArray] READONLY, + @Status SMALLINT +AS +BEGIN + SET NOCOUNT ON + + UPDATE OU + SET OU.[Status] = @Status + FROM [dbo].[OrganizationUser] OU + INNER JOIN @OrganizationUserIds OUI ON OUI.[Id] = OU.[Id] + + EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationUserIds] @OrganizationUserIds +END +GO