1
0
mirror of https://github.com/bitwarden/server.git synced 2025-04-04 20:50:21 -05:00

Merge branch 'main' into innovation/opaque

This commit is contained in:
Bernd Schoolmann 2025-03-21 17:49:16 +01:00 committed by GitHub
commit 8073d0e0c3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
73 changed files with 2740 additions and 406 deletions

View File

@ -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

View File

@ -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

View File

@ -3,7 +3,7 @@
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Version>2025.3.0</Version>
<Version>2025.3.3</Version>
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>

View File

@ -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))

View File

@ -11,6 +11,7 @@ using Bit.Core.AdminConsole.Services;
using Bit.Core.Billing.Entities;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Repositories;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Services.Contracts;
@ -42,6 +43,7 @@ public class ProvidersController : Controller
private readonly IFeatureService _featureService;
private readonly IProviderPlanRepository _providerPlanRepository;
private readonly IProviderBillingService _providerBillingService;
private readonly IPricingClient _pricingClient;
private readonly string _stripeUrl;
private readonly string _braintreeMerchantUrl;
private readonly string _braintreeMerchantId;
@ -60,7 +62,8 @@ public class ProvidersController : Controller
IFeatureService featureService,
IProviderPlanRepository providerPlanRepository,
IProviderBillingService providerBillingService,
IWebHostEnvironment webHostEnvironment)
IWebHostEnvironment webHostEnvironment,
IPricingClient pricingClient)
{
_organizationRepository = organizationRepository;
_organizationService = organizationService;
@ -75,6 +78,7 @@ public class ProvidersController : Controller
_featureService = featureService;
_providerPlanRepository = providerPlanRepository;
_providerBillingService = providerBillingService;
_pricingClient = pricingClient;
_stripeUrl = webHostEnvironment.GetStripeUrl();
_braintreeMerchantUrl = webHostEnvironment.GetBraintreeMerchantUrl();
_braintreeMerchantId = globalSettings.Braintree.MerchantId;
@ -415,7 +419,9 @@ public class ProvidersController : Controller
return RedirectToAction("Index");
}
return View(new OrganizationEditModel(provider));
var plans = await _pricingClient.ListPlans();
return View(new OrganizationEditModel(provider, plans));
}
[HttpPost]

View File

@ -22,13 +22,14 @@ public class OrganizationEditModel : OrganizationViewModel
public OrganizationEditModel() { }
public OrganizationEditModel(Provider provider)
public OrganizationEditModel(Provider provider, List<Plan> plans)
{
Provider = provider;
BillingEmail = provider.Type == ProviderType.Reseller ? provider.BillingEmail : string.Empty;
PlanType = Core.Billing.Enums.PlanType.TeamsMonthly;
Plan = Core.Billing.Enums.PlanType.TeamsMonthly.GetDisplayAttribute()?.GetName();
LicenseKey = RandomLicenseKey;
_plans = plans;
}
public OrganizationEditModel(

View File

@ -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<ResetPasswordPolicyRequirement>(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);

View File

@ -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<ResetPasswordPolicyRequirement>(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<ResetPasswordDataModel>(resetPasswordPolicy.Data, JsonHelpers.IgnoreCase);
return new OrganizationAutoEnrollStatusResponseModel(organization.Id, data?.AutoEnrollEnabled ?? false);
}
[HttpPost("")]

View File

@ -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<BaseProviderController> logger,
IPricingClient pricingClient,
IProviderBillingService providerBillingService,
@ -71,6 +73,65 @@ public class ProviderBillingController(
"text/csv");
}
[HttpPut("payment-method")]
public async Task<IResult> 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<IResult> 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<IResult> 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<IResult> 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);
}

View File

@ -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<ConfiguredProviderPlan> 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);
}
}

View File

@ -12,7 +12,7 @@ public static class CommandResultExtensions
NoRecordFoundFailure<T> failure => new ObjectResult(failure.ErrorMessages) { StatusCode = StatusCodes.Status404NotFound },
BadRequestFailure<T> failure => new ObjectResult(failure.ErrorMessages) { StatusCode = StatusCodes.Status400BadRequest },
Failure<T> failure => new ObjectResult(failure.ErrorMessages) { StatusCode = StatusCodes.Status400BadRequest },
Success<T> success => new ObjectResult(success.Data) { StatusCode = StatusCodes.Status200OK },
Success<T> success => new ObjectResult(success.Value) { StatusCode = StatusCodes.Status200OK },
_ => throw new InvalidOperationException($"Unhandled commandResult type: {commandResult.GetType().Name}")
};
}

View File

@ -127,9 +127,9 @@ public class CiphersController : Controller
public async Task<ListResponseModel<CipherDetailsResponseModel>> 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<Guid, IGrouping<Guid, CollectionCipher>> collectionCiphersGroupDict = null;
if (hasOrgs)
{

View File

@ -0,0 +1,3 @@
namespace Bit.Core.AdminConsole.Errors;
public record Error<T>(string Message, T ErroredValue);

View File

@ -0,0 +1,11 @@
namespace Bit.Core.AdminConsole.Errors;
public record InsufficientPermissionsError<T>(string Message, T ErroredValue) : Error<T>(Message, ErroredValue)
{
public const string Code = "Insufficient Permissions";
public InsufficientPermissionsError(T ErroredValue) : this(Code, ErroredValue)
{
}
}

View File

@ -0,0 +1,11 @@
namespace Bit.Core.AdminConsole.Errors;
public record RecordNotFoundError<T>(string Message, T ErroredValue) : Error<T>(Message, ErroredValue)
{
public const string Code = "Record Not Found";
public RecordNotFoundError(T ErroredValue) : this(Code, ErroredValue)
{
}
}

View File

@ -8,21 +8,25 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.Implementations;
public class PolicyRequirementQuery(
IPolicyRepository policyRepository,
IEnumerable<RequirementFactory<IPolicyRequirement>> factories)
IEnumerable<IPolicyRequirementFactory<IPolicyRequirement>> factories)
: IPolicyRequirementQuery
{
public async Task<T> GetAsync<T>(Guid userId) where T : IPolicyRequirement
{
var factory = factories.OfType<RequirementFactory<T>>().SingleOrDefault();
var factory = factories.OfType<IPolicyRequirementFactory<T>>().SingleOrDefault();
if (factory is null)
{
throw new NotImplementedException("No Policy Requirement found for " + typeof(T));
throw new NotImplementedException("No Requirement Factory found for " + typeof(T));
}
return factory(await GetPolicyDetails(userId));
var policyDetails = await GetPolicyDetails(userId);
var filteredPolicies = policyDetails
.Where(p => p.PolicyType == factory.PolicyType)
.Where(factory.Enforce);
var requirement = factory.Create(filteredPolicies);
return requirement;
}
private Task<IEnumerable<PolicyDetails>> GetPolicyDetails(Guid userId) =>
policyRepository.GetPolicyDetailsByUserId(userId);
private Task<IEnumerable<PolicyDetails>> GetPolicyDetails(Guid userId)
=> policyRepository.GetPolicyDetailsByUserId(userId);
}

View File

@ -0,0 +1,44 @@
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.Enums;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
/// <summary>
/// A simple base implementation of <see cref="IPolicyRequirementFactory{T}"/> which will be suitable for most policies.
/// It provides sensible defaults to help teams to implement their own Policy Requirements.
/// </summary>
/// <typeparam name="T"></typeparam>
public abstract class BasePolicyRequirementFactory<T> : IPolicyRequirementFactory<T> where T : IPolicyRequirement
{
/// <summary>
/// User roles that are exempt from policy enforcement.
/// Owners and Admins are exempt by default but this may be overridden.
/// </summary>
protected virtual IEnumerable<OrganizationUserType> ExemptRoles { get; } =
[OrganizationUserType.Owner, OrganizationUserType.Admin];
/// <summary>
/// User statuses that are exempt from policy enforcement.
/// Invited and Revoked users are exempt by default, which is appropriate in the majority of cases.
/// </summary>
protected virtual IEnumerable<OrganizationUserStatusType> ExemptStatuses { get; } =
[OrganizationUserStatusType.Invited, OrganizationUserStatusType.Revoked];
/// <summary>
/// Whether a Provider User for the organization is exempt from policy enforcement.
/// Provider Users are exempt by default, which is appropriate in the majority of cases.
/// </summary>
protected virtual bool ExemptProviders { get; } = true;
/// <inheritdoc />
public abstract PolicyType PolicyType { get; }
public bool Enforce(PolicyDetails policyDetails)
=> !policyDetails.HasRole(ExemptRoles) &&
!policyDetails.HasStatus(ExemptStatuses) &&
(!policyDetails.IsProvider || !ExemptProviders);
/// <inheritdoc />
public abstract T Create(IEnumerable<PolicyDetails> policyDetails);
}

View File

@ -0,0 +1,27 @@
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
/// <summary>
/// Policy requirements for the Disable Send policy.
/// </summary>
public class DisableSendPolicyRequirement : IPolicyRequirement
{
/// <summary>
/// Indicates whether Send is disabled for the user. If true, the user should not be able to create or edit Sends.
/// They may still delete existing Sends.
/// </summary>
public bool DisableSend { get; init; }
}
public class DisableSendPolicyRequirementFactory : BasePolicyRequirementFactory<DisableSendPolicyRequirement>
{
public override PolicyType PolicyType => PolicyType.DisableSend;
public override DisableSendPolicyRequirement Create(IEnumerable<PolicyDetails> policyDetails)
{
var result = new DisableSendPolicyRequirement { DisableSend = policyDetails.Any() };
return result;
}
}

View File

@ -1,24 +1,11 @@
#nullable enable
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
/// <summary>
/// Represents the business requirements of how one or more enterprise policies will be enforced against a user.
/// The implementation of this interface will depend on how the policies are enforced in the relevant domain.
/// An object that represents how a <see cref="PolicyType"/> will be enforced against a user.
/// This acts as a bridge between the <see cref="Policy"/> entity saved to the database and the domain that the policy
/// affects. You may represent the impact of the policy in any way that makes sense for the domain.
/// </summary>
public interface IPolicyRequirement;
/// <summary>
/// A factory function that takes a sequence of <see cref="PolicyDetails"/> and transforms them into a single
/// <see cref="IPolicyRequirement"/> for consumption by the relevant domain. This will receive *all* policy types
/// that may be enforced against a user; when implementing this delegate, you must filter out irrelevant policy types
/// as well as policies that should not be enforced against a user (e.g. due to the user's role or status).
/// </summary>
/// <remarks>
/// See <see cref="PolicyRequirementHelpers"/> for extension methods to handle common requirements when implementing
/// this delegate.
/// </remarks>
public delegate T RequirementFactory<out T>(IEnumerable<PolicyDetails> policyDetails)
where T : IPolicyRequirement;

View File

@ -0,0 +1,39 @@
#nullable enable
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
/// <summary>
/// An interface that defines how to create a single <see cref="IPolicyRequirement"/> from a sequence of
/// <see cref="PolicyDetails"/>.
/// </summary>
/// <typeparam name="T">The <see cref="IPolicyRequirement"/> that the factory produces.</typeparam>
/// <remarks>
/// See <see cref="BasePolicyRequirementFactory{T}"/> for a simple base implementation suitable for most policies.
/// </remarks>
public interface IPolicyRequirementFactory<out T> where T : IPolicyRequirement
{
/// <summary>
/// The <see cref="PolicyType"/> that the requirement relates to.
/// </summary>
PolicyType PolicyType { get; }
/// <summary>
/// A predicate that determines whether a policy should be enforced against the user.
/// </summary>
/// <remarks>Use this to exempt users based on their role, status or other attributes.</remarks>
/// <param name="policyDetails">Policy details for the defined PolicyType.</param>
/// <returns>True if the policy should be enforced against the user, false otherwise.</returns>
bool Enforce(PolicyDetails policyDetails);
/// <summary>
/// A reducer method that creates a single <see cref="IPolicyRequirement"/> from a set of PolicyDetails.
/// </summary>
/// <param name="policyDetails">
/// PolicyDetails for the specified PolicyType, after they have been filtered by the Enforce predicate. That is,
/// this is the final interface to be called.
/// </param>
T Create(IEnumerable<PolicyDetails> policyDetails);
}

View File

@ -1,5 +1,4 @@
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.Enums;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
@ -7,35 +6,16 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements
public static class PolicyRequirementHelpers
{
/// <summary>
/// Filters the PolicyDetails by PolicyType. This is generally required to only get the PolicyDetails that your
/// IPolicyRequirement relates to.
/// Returns true if the <see cref="PolicyDetails"/> is for one of the specified roles, false otherwise.
/// </summary>
public static IEnumerable<PolicyDetails> GetPolicyType(
this IEnumerable<PolicyDetails> policyDetails,
PolicyType type)
=> policyDetails.Where(x => x.PolicyType == type);
/// <summary>
/// Filters the PolicyDetails to remove the specified user roles. This can be used to exempt
/// owners and admins from policy enforcement.
/// </summary>
public static IEnumerable<PolicyDetails> ExemptRoles(
this IEnumerable<PolicyDetails> policyDetails,
public static bool HasRole(
this PolicyDetails policyDetails,
IEnumerable<OrganizationUserType> roles)
=> policyDetails.Where(x => !roles.Contains(x.OrganizationUserType));
=> roles.Contains(policyDetails.OrganizationUserType);
/// <summary>
/// Filters the PolicyDetails to remove organization users who are also provider users for the organization.
/// This can be used to exempt provider users from policy enforcement.
/// Returns true if the <see cref="PolicyDetails"/> relates to one of the specified statuses, false otherwise.
/// </summary>
public static IEnumerable<PolicyDetails> ExemptProviders(this IEnumerable<PolicyDetails> policyDetails)
=> policyDetails.Where(x => !x.IsProvider);
/// <summary>
/// Filters the PolicyDetails to remove the specified organization user statuses. For example, this can be used
/// to exempt users in the invited and revoked statuses from policy enforcement.
/// </summary>
public static IEnumerable<PolicyDetails> ExemptStatus(
this IEnumerable<PolicyDetails> policyDetails, IEnumerable<OrganizationUserStatusType> status)
=> policyDetails.Where(x => !status.Contains(x.OrganizationUserStatus));
public static bool HasStatus(this PolicyDetails policyDetails, IEnumerable<OrganizationUserStatusType> status)
=> status.Contains(policyDetails.OrganizationUserStatus);
}

View File

@ -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;
/// <summary>
/// Policy requirements for the Account recovery administration policy.
/// </summary>
public class ResetPasswordPolicyRequirement : IPolicyRequirement
{
/// <summary>
/// List of Organization Ids that require automatic enrollment in password recovery.
/// </summary>
private IEnumerable<Guid> _autoEnrollOrganizations;
public IEnumerable<Guid> AutoEnrollOrganizations { init => _autoEnrollOrganizations = value; }
/// <summary>
/// Returns true if provided organizationId requires automatic enrollment in password recovery.
/// </summary>
public bool AutoEnrollEnabled(Guid organizationId)
{
return _autoEnrollOrganizations.Contains(organizationId);
}
}
public class ResetPasswordPolicyRequirementFactory : BasePolicyRequirementFactory<ResetPasswordPolicyRequirement>
{
public override PolicyType PolicyType => PolicyType.ResetPassword;
protected override bool ExemptProviders => false;
protected override IEnumerable<OrganizationUserType> ExemptRoles => [];
public override ResetPasswordPolicyRequirement Create(IEnumerable<PolicyDetails> policyDetails)
{
var result = policyDetails
.Where(p => p.GetDataModel<ResetPasswordDataModel>().AutoEnrollEnabled)
.Select(p => p.OrganizationId)
.ToHashSet();
return new ResetPasswordPolicyRequirement() { AutoEnrollOrganizations = result };
}
}

View File

@ -0,0 +1,34 @@
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
/// <summary>
/// Policy requirements for the Send Options policy.
/// </summary>
public class SendOptionsPolicyRequirement : IPolicyRequirement
{
/// <summary>
/// Indicates whether the user is prohibited from hiding their email from the recipient of a Send.
/// </summary>
public bool DisableHideEmail { get; init; }
}
public class SendOptionsPolicyRequirementFactory : BasePolicyRequirementFactory<SendOptionsPolicyRequirement>
{
public override PolicyType PolicyType => PolicyType.SendOptions;
public override SendOptionsPolicyRequirement Create(IEnumerable<PolicyDetails> policyDetails)
{
var result = policyDetails
.Select(p => p.GetDataModel<SendOptionsPolicyData>())
.Aggregate(
new SendOptionsPolicyRequirement(),
(result, data) => new SendOptionsPolicyRequirement
{
DisableHideEmail = result.DisableHideEmail || data.DisableHideEmail
});
return result;
}
}

View File

@ -1,54 +0,0 @@
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.Enums;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
/// <summary>
/// Policy requirements for the Disable Send and Send Options policies.
/// </summary>
public class SendPolicyRequirement : IPolicyRequirement
{
/// <summary>
/// Indicates whether Send is disabled for the user. If true, the user should not be able to create or edit Sends.
/// They may still delete existing Sends.
/// </summary>
public bool DisableSend { get; init; }
/// <summary>
/// Indicates whether the user is prohibited from hiding their email from the recipient of a Send.
/// </summary>
public bool DisableHideEmail { get; init; }
/// <summary>
/// Create a new SendPolicyRequirement.
/// </summary>
/// <param name="policyDetails">All PolicyDetails relating to the user.</param>
/// <remarks>
/// This is a <see cref="RequirementFactory{T}"/> for the SendPolicyRequirement.
/// </remarks>
public static SendPolicyRequirement Create(IEnumerable<PolicyDetails> policyDetails)
{
var filteredPolicies = policyDetails
.ExemptRoles([OrganizationUserType.Owner, OrganizationUserType.Admin])
.ExemptStatus([OrganizationUserStatusType.Invited, OrganizationUserStatusType.Revoked])
.ExemptProviders()
.ToList();
var result = filteredPolicies
.GetPolicyType(PolicyType.SendOptions)
.Select(p => p.GetDataModel<SendOptionsPolicyData>())
.Aggregate(
new SendPolicyRequirement
{
// Set Disable Send requirement in the initial seed
DisableSend = filteredPolicies.GetPolicyType(PolicyType.DisableSend).Any()
},
(result, data) => new SendPolicyRequirement
{
DisableSend = result.DisableSend,
DisableHideEmail = result.DisableHideEmail || data.DisableHideEmail
});
return result;
}
}

View File

@ -31,32 +31,8 @@ public static class PolicyServiceCollectionExtensions
private static void AddPolicyRequirements(this IServiceCollection services)
{
// Register policy requirement factories here
services.AddPolicyRequirement(SendPolicyRequirement.Create);
services.AddScoped<IPolicyRequirementFactory<IPolicyRequirement>, DisableSendPolicyRequirementFactory>();
services.AddScoped<IPolicyRequirementFactory<IPolicyRequirement>, SendOptionsPolicyRequirementFactory>();
services.AddScoped<IPolicyRequirementFactory<IPolicyRequirement>, ResetPasswordPolicyRequirementFactory>();
}
/// <summary>
/// Used to register simple policy requirements where its factory method implements CreateRequirement.
/// This MUST be used rather than calling AddScoped directly, because it will ensure the factory method has
/// the correct type to be injected and then identified by <see cref="PolicyRequirementQuery"/> at runtime.
/// </summary>
/// <typeparam name="T">The specific PolicyRequirement being registered.</typeparam>
private static void AddPolicyRequirement<T>(this IServiceCollection serviceCollection, RequirementFactory<T> factory)
where T : class, IPolicyRequirement
=> serviceCollection.AddPolicyRequirement(_ => factory);
/// <summary>
/// Used to register policy requirements where you need to access additional dependencies (usually to return a
/// curried factory method).
/// This MUST be used rather than calling AddScoped directly, because it will ensure the factory method has
/// the correct type to be injected and then identified by <see cref="PolicyRequirementQuery"/> at runtime.
/// </summary>
/// <typeparam name="T">
/// A callback that takes IServiceProvider and returns a RequirementFactory for
/// your policy requirement.
/// </typeparam>
private static void AddPolicyRequirement<T>(this IServiceCollection serviceCollection,
Func<IServiceProvider, RequirementFactory<T>> factory)
where T : class, IPolicyRequirement
=> serviceCollection.AddScoped<RequirementFactory<IPolicyRequirement>>(factory);
}

View File

@ -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<ResetPasswordDataModel>(resetPasswordPolicy.Data, JsonHelpers.IgnoreCase);
if (data?.AutoEnrollEnabled ?? false)
var resetPasswordPolicyRequirement = await _policyRequirementQuery.GetAsync<ResetPasswordPolicyRequirement>(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<ResetPasswordDataModel>(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.");
}
}
}

View File

@ -0,0 +1,6 @@
namespace Bit.Core.AdminConsole.Shared.Validation;
public interface IValidator<T>
{
public Task<ValidationResult<T>> ValidateAsync(T value);
}

View File

@ -0,0 +1,15 @@
using Bit.Core.AdminConsole.Errors;
namespace Bit.Core.AdminConsole.Shared.Validation;
public abstract record ValidationResult<T>;
public record Valid<T> : ValidationResult<T>
{
public T Value { get; init; }
}
public record Invalid<T> : ValidationResult<T>
{
public IEnumerable<Error<T>> Errors { get; init; }
}

View File

@ -95,5 +95,16 @@ public interface IProviderBillingService
Task<Subscription> SetupSubscription(
Provider provider);
/// <summary>
/// Updates the <paramref name="provider"/>'s payment source and tax information and then sets their subscription's collection_method to be "charge_automatically".
/// </summary>
/// <param name="provider">The <paramref name="provider"/> to update the payment source and tax information for.</param>
/// <param name="tokenizedPaymentSource">The tokenized payment source (ex. Credit Card) to attach to the <paramref name="provider"/>.</param>
/// <param name="taxInformation">The <paramref name="provider"/>'s updated tax information.</param>
Task UpdatePaymentMethod(
Provider provider,
TokenizedPaymentSource tokenizedPaymentSource,
TaxInformation taxInformation);
Task UpdateSeatMinimums(UpdateProviderSeatMinimumsCommand command);
}

View File

@ -114,6 +114,20 @@ public static class FeatureFlagKeys
public const string ItemShare = "item-share";
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";
@ -121,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";
@ -148,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 +180,10 @@ public static class FeatureFlagKeys
public const string AndroidImportLoginsFlow = "import-logins-flow";
public const string PM12276Breadcrumbing = "pm-12276-breadcrumbing-for-business-features";
public const string OpaqueKeyExchange = "opaque-key-exchange";
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<string> GetAllKeys()
{

View File

@ -37,7 +37,7 @@
<PackageReference Include="DnsClient" Version="1.8.0" />
<PackageReference Include="Fido2.AspNet" Version="3.0.1" />
<PackageReference Include="Handlebars.Net" Version="2.1.6" />
<PackageReference Include="MailKit" Version="4.10.0" />
<PackageReference Include="MailKit" Version="4.11.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.10" />
<PackageReference Include="Microsoft.Azure.Cosmos" Version="3.46.1" />
<PackageReference Include="Microsoft.Azure.NotificationHubs" Version="4.2.0" />

View File

@ -7,7 +7,7 @@
</tr>
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none;" valign="top" align="left">
To leave an organization, first log into the <a href="https://vault.bitwarden.com/#/login">web app</a>, select the three dot menu next to the organization name, and select Leave.
To leave an organization, first log into the <a href="{{{WebVaultUrl}}}/login">web app</a>, select the three dot menu next to the organization name, and select Leave.
</td>
</tr>
</table>

View File

@ -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}}

View File

@ -26,7 +26,7 @@
</tr>
<tr style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none; text-align: center;" valign="top" align="center">
<a href="https://vault.bitwarden.com/#/organizations/{{{OrganizationId}}}/billing/subscription" clicktracking=off target="_blank" style="color: #ffffff; text-decoration: none; text-align: center; cursor: pointer; display: inline-block; border-radius: 5px; 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;">
<a href="{{{VaultSubscriptionUrl}}}" clicktracking=off target="_blank" style="color: #ffffff; text-decoration: none; text-align: center; cursor: pointer; display: inline-block; border-radius: 5px; 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;">
Manage subscription
</a>
<br class="line-break" />

View File

@ -24,7 +24,7 @@
</tr>
<tr style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none; text-align: center;" valign="top" align="center">
<a href="https://vault.bitwarden.com/#/organizations/{{{OrganizationId}}}/billing/subscription" clicktracking=off target="_blank" style="color: #ffffff; text-decoration: none; text-align: center; cursor: pointer; display: inline-block; border-radius: 5px; 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;">
<a href="{{{VaultSubscriptionUrl}}}" clicktracking=off target="_blank" style="color: #ffffff; text-decoration: none; text-align: center; cursor: pointer; display: inline-block; border-radius: 5px; 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;">
Manage subscription
</a>
<br class="line-break" />

View File

@ -24,7 +24,7 @@
</tr>
<tr style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none; text-align: center;" valign="top" align="center">
<a href="https://vault.bitwarden.com/#/organizations/{{{OrganizationId}}}/billing/subscription" clicktracking=off target="_blank" rel="noopener noreferrer" style="color: #ffffff; text-decoration: none; text-align: center; cursor: pointer; display: inline-block; border-radius: 5px; 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;">
<a href="{{{VaultSubscriptionUrl}}}" clicktracking=off target="_blank" rel="noopener noreferrer" style="color: #ffffff; text-decoration: none; text-align: center; cursor: pointer; display: inline-block; border-radius: 5px; 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;">
Manage subscription
</a>
<br class="line-break" />

View File

@ -24,7 +24,7 @@
</tr>
<tr style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none; text-align: center;" valign="top" align="center">
<a href="https://vault.bitwarden.com/#/organizations/{{{OrganizationId}}}/billing/subscription" clicktracking=off target="_blank" rel="noopener noreferrer" style="color: #ffffff; text-decoration: none; text-align: center; cursor: pointer; display: inline-block; border-radius: 5px; 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;">
<a href="{{{VaultSubscriptionUrl}}}" clicktracking=off target="_blank" rel="noopener noreferrer" style="color: #ffffff; text-decoration: none; text-align: center; cursor: pointer; display: inline-block; border-radius: 5px; 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;">
Manage subscription
</a>
<br class="line-break" />

View File

@ -15,14 +15,21 @@
</tr>
</table>
<table width="100%" border="0" cellpadding="0" cellspacing="0"
style="display: table; width:100%; padding-bottom: 35px; text-align: center;" align="center">
style="display: table; width:100%; padding-bottom: 24px; text-align: center;" align="center">
<tr>
<td display="display: table-cell">
<a href="{{ReviewPasswordsUrl}}" clicktracking=off target="_blank"
style="display: inline-block; 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;">
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
</a>
</td>
</tr>
<table width="100%" border="0" cellpadding="0" cellspacing="0"
style="display: table; width:100%; padding-bottom: 24px; text-align: center;" align="center">
<tr>
<td display="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-style: normal; font-weight: 400; font-size: 12px; line-height: 16px;">
{{formatAdminOwnerEmails AdminOwnerEmails}}
</td>
</tr>
</table>
{{/SecurityTasksHtmlLayout}}

View File

@ -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}}

View File

@ -1,5 +1,7 @@
#nullable enable
using Bit.Core.AdminConsole.Errors;
namespace Bit.Core.Models.Commands;
public class CommandResult(IEnumerable<string> errors)
@ -9,7 +11,6 @@ public class CommandResult(IEnumerable<string> errors)
public bool Success => ErrorMessages.Count == 0;
public bool HasErrors => ErrorMessages.Count > 0;
public List<string> ErrorMessages { get; } = errors.ToList();
public CommandResult() : this(Array.Empty<string>()) { }
}
@ -29,22 +30,30 @@ public class Success : CommandResult
{
}
public abstract class CommandResult<T>
{
public abstract class CommandResult<T>;
public class Success<T>(T value) : CommandResult<T>
{
public T Value { get; } = value;
}
public class Success<T>(T data) : CommandResult<T>
public class Failure<T>(IEnumerable<string> errorMessages) : CommandResult<T>
{
public T? Data { get; init; } = data;
public List<string> ErrorMessages { get; } = errorMessages.ToList();
public string ErrorMessage => string.Join(" ", ErrorMessages);
public Failure(string error) : this([error]) { }
}
public class Failure<T>(IEnumerable<string> errorMessage) : CommandResult<T>
public class Partial<T> : CommandResult<T>
{
public IEnumerable<string> ErrorMessages { get; init; } = errorMessage;
public T[] Successes { get; set; } = [];
public Error<T>[] Failures { get; set; } = [];
public Failure(string errorMessage) : this(new[] { errorMessage })
public Partial(IEnumerable<T> successfulItems, IEnumerable<Error<T>> failedItems)
{
Successes = successfulItems.ToArray();
Failures = failedItems.ToArray();
}
}

View File

@ -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; }
}

View File

@ -2,6 +2,6 @@
public class OrganizationSeatsMaxReachedViewModel : BaseMailModel
{
public Guid OrganizationId { get; set; }
public int MaxSeatCount { get; set; }
public string VaultSubscriptionUrl { get; set; }
}

View File

@ -2,6 +2,6 @@
public class OrganizationServiceAccountsMaxReachedViewModel
{
public Guid OrganizationId { get; set; }
public int MaxServiceAccountsCount { get; set; }
public string VaultSubscriptionUrl { get; set; }
}

View File

@ -8,5 +8,7 @@ public class SecurityTaskNotificationViewModel : BaseMailModel
public bool TaskCountPlural => TaskCount != 1;
public IEnumerable<string> AdminOwnerEmails { get; set; }
public string ReviewPasswordsUrl => $"{WebVaultUrl}/browser-extension-prompt";
}

View File

@ -99,5 +99,5 @@ public interface IMailService
string organizationName);
Task SendClaimedDomainUserEmailAsync(ManagedUserDomainClaimedEmails emailList);
Task SendDeviceApprovalRequestedNotificationEmailAsync(IEnumerable<string> adminEmails, Guid organizationId, string email, string userName);
Task SendBulkSecurityTaskNotificationsAsync(string orgName, IEnumerable<UserSecurityTasksCount> securityTaskNotificaitons);
Task SendBulkSecurityTaskNotificationsAsync(Organization org, IEnumerable<UserSecurityTasksCount> securityTaskNotifications, IEnumerable<string> adminOwnerEmails);
}

View File

@ -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($"<a href=\"{href}\" target=\"_blank\" {clickTrackingText}>{text}</a>");
});
// 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<string>)parameters[0]).ToList();
if (emailList.Count == 0)
{
writer.WriteSafeString(string.Empty);
return;
}
string constructAnchorElement(string email)
{
return $"<a style=\"color: #175DDC\" href=\"mailto:{email}\">{email}</a>";
}
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<UserSecurityTasksCount> securityTaskNotificaitons)
public async Task SendBulkSecurityTaskNotificationsAsync(Organization org, IEnumerable<UserSecurityTasksCount> securityTaskNotifications, IEnumerable<string> 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"
};
}

View File

@ -324,7 +324,7 @@ public class NoopMailService : IMailService
return Task.FromResult(0);
}
public Task SendBulkSecurityTaskNotificationsAsync(string orgName, IEnumerable<UserSecurityTasksCount> securityTaskNotificaitons)
public Task SendBulkSecurityTaskNotificationsAsync(Organization org, IEnumerable<UserSecurityTasksCount> securityTaskNotifications, IEnumerable<string> adminOwnerEmails)
{
return Task.FromResult(0);
}

View File

@ -326,14 +326,14 @@ public class SendService : ISendService
return;
}
var sendPolicyRequirement = await _policyRequirementQuery.GetAsync<SendPolicyRequirement>(userId.Value);
if (sendPolicyRequirement.DisableSend)
var disableSendRequirement = await _policyRequirementQuery.GetAsync<DisableSendPolicyRequirement>(userId.Value);
if (disableSendRequirement.DisableSend)
{
throw new BadRequestException("Due to an Enterprise Policy, you are only able to delete an existing Send.");
}
if (sendPolicyRequirement.DisableHideEmail && send.HideEmail.GetValueOrDefault())
var sendOptionsRequirement = await _policyRequirementQuery.GetAsync<SendOptionsPolicyRequirement>(userId.Value);
if (sendOptionsRequirement.DisableHideEmail && send.HideEmail.GetValueOrDefault())
{
throw new BadRequestException("Due to an Enterprise Policy, you are not allowed to hide your email address from recipients when creating or editing a Send.");
}

View File

@ -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<SecurityTask> 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)

View File

@ -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<CipherLoginData>(existingCipher.Data);
var newCipherData = JsonSerializer.Deserialize<CipherLoginData>(cipher.Data);
newCipherData.Fido2Credentials = existingCipherData.Fido2Credentials;
newCipherData.Totp = existingCipherData.Totp;
newCipherData.Password = existingCipherData.Password;
cipher.Data = JsonSerializer.Serialize(newCipherData);
}
}
}

View File

@ -7,7 +7,7 @@
<PropertyGroup Condition=" '$(RunConfiguration)' == 'Icons' " />
<ItemGroup>
<PackageReference Include="AngleSharp" Version="1.1.2" />
<PackageReference Include="AngleSharp" Version="1.2.0" />
</ItemGroup>
<ItemGroup>

View File

@ -250,6 +250,11 @@ public class DeviceValidator(
var customResponse = new Dictionary<string, object>();
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"));

View File

@ -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

View File

@ -563,8 +563,8 @@ public class OrganizationUserRepository : Repository<OrganizationUser, Guid>, 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);
}

View File

@ -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

View File

@ -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<bool>(), Arg.Any<bool>())
.Returns(organizationUsers);
}
[Theory]
[BitAutoData]
public async Task Accept_WhenOrganizationUsePoliciesIsEnabledAndResetPolicyIsEnabled_WithPolicyRequirementsEnabled_ShouldHandleResetPassword(Guid orgId, Guid orgUserId,
OrganizationUserAcceptRequestModel model, User user, SutProvider<OrganizationUsersController> sutProvider)
{
// Arrange
var applicationCacheService = sutProvider.GetDependency<IApplicationCacheService>();
applicationCacheService.GetOrganizationAbilityAsync(orgId).Returns(new OrganizationAbility { UsePolicies = true });
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true);
var policy = new Policy
{
Enabled = true,
Data = CoreHelpers.ClassToJsonData(new ResetPasswordDataModel { AutoEnrollEnabled = true, }),
};
var userService = sutProvider.GetDependency<IUserService>();
userService.GetUserByPrincipalAsync(default).ReturnsForAnyArgs(user);
var policyRequirementQuery = sutProvider.GetDependency<IPolicyRequirementQuery>();
var policyRepository = sutProvider.GetDependency<IPolicyRepository>();
var policyRequirement = new ResetPasswordPolicyRequirement { AutoEnrollOrganizations = [orgId] };
policyRequirementQuery.GetAsync<ResetPasswordPolicyRequirement>(user.Id).Returns(policyRequirement);
// Act
await sutProvider.Sut.Accept(orgId, orgUserId, model);
// Assert
await sutProvider.GetDependency<IAcceptOrgUserCommand>().Received(1)
.AcceptOrgUserByEmailTokenAsync(orgUserId, user, model.Token, userService);
await sutProvider.GetDependency<IOrganizationService>().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<ResetPasswordPolicyRequirement>(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<OrganizationUsersController> sutProvider)
{
// Arrange
model.ResetPasswordKey = " ";
var applicationCacheService = sutProvider.GetDependency<IApplicationCacheService>();
applicationCacheService.GetOrganizationAbilityAsync(orgId).Returns(new OrganizationAbility { UsePolicies = true });
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true);
var policy = new Policy
{
Enabled = true,
Data = CoreHelpers.ClassToJsonData(new ResetPasswordDataModel { AutoEnrollEnabled = true, }),
};
var userService = sutProvider.GetDependency<IUserService>();
userService.GetUserByPrincipalAsync(default).ReturnsForAnyArgs(user);
var policyRepository = sutProvider.GetDependency<IPolicyRepository>();
var policyRequirementQuery = sutProvider.GetDependency<IPolicyRequirementQuery>();
var policyRequirement = new ResetPasswordPolicyRequirement { AutoEnrollOrganizations = [orgId] };
policyRequirementQuery.GetAsync<ResetPasswordPolicyRequirement>(user.Id).Returns(policyRequirement);
// Act
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
sutProvider.Sut.Accept(orgId, orgUserId, model));
// Assert
await sutProvider.GetDependency<IAcceptOrgUserCommand>().Received(0)
.AcceptOrgUserByEmailTokenAsync(orgUserId, user, model.Token, userService);
await sutProvider.GetDependency<IOrganizationService>().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<ResetPasswordPolicyRequirement>(user.Id);
Assert.Equal("Master Password reset is required, but not provided.", exception.Message);
}
}

View File

@ -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<IRemoveOrganizationUserCommand>();
_cloudOrganizationSignUpCommand = Substitute.For<ICloudOrganizationSignUpCommand>();
_organizationDeleteCommand = Substitute.For<IOrganizationDeleteCommand>();
_policyRequirementQuery = Substitute.For<IPolicyRequirementQuery>();
_pricingClient = Substitute.For<IPricingClient>();
_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<ClaimsPrincipal>()).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<ResetPasswordPolicyRequirement>(user.Id).Returns(policyRequirement);
var result = await _sut.GetAutoEnrollStatus(organization.Id.ToString());
await _userService.Received(1).GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>());
await _organizationRepository.Received(1).GetByIdentifierAsync(organization.Id.ToString());
await _policyRequirementQuery.Received(1).GetAsync<ResetPasswordPolicyRequirement>(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<ClaimsPrincipal>()).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<ClaimsPrincipal>());
await _organizationRepository.Received(1).GetByIdentifierAsync(organization.Id.ToString());
await _policyRequirementQuery.Received(0).GetAsync<ResetPasswordPolicyRequirement>(user.Id);
await _policyRepository.Received(1).GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword);
Assert.True(result.ResetPasswordEnabled);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,23 @@
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies;
/// <summary>
/// Intentionally simplified PolicyRequirement that just holds the input PolicyDetails for us to assert against.
/// </summary>
public class TestPolicyRequirement : IPolicyRequirement
{
public IEnumerable<PolicyDetails> Policies { get; init; } = [];
}
public class TestPolicyRequirementFactory(Func<PolicyDetails, bool> enforce) : IPolicyRequirementFactory<TestPolicyRequirement>
{
public PolicyType PolicyType => PolicyType.SingleOrg;
public bool Enforce(PolicyDetails policyDetails) => enforce(policyDetails);
public TestPolicyRequirement Create(IEnumerable<PolicyDetails> policyDetails)
=> new() { Policies = policyDetails };
}

View File

@ -1,6 +1,6 @@
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Implementations;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
using Bit.Core.AdminConsole.Repositories;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
@ -11,50 +11,72 @@ namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies;
[SutProviderCustomize]
public class PolicyRequirementQueryTests
{
/// <summary>
/// Tests that the query correctly registers, retrieves and instantiates arbitrary IPolicyRequirements
/// according to their provided CreateRequirement delegate.
/// </summary>
[Theory, BitAutoData]
public async Task GetAsync_Works(Guid userId, Guid organizationId)
public async Task GetAsync_IgnoresOtherPolicyTypes(Guid userId)
{
var thisPolicy = new PolicyDetails { PolicyType = PolicyType.SingleOrg };
var otherPolicy = new PolicyDetails { PolicyType = PolicyType.RequireSso };
var policyRepository = Substitute.For<IPolicyRepository>();
var factories = new List<RequirementFactory<IPolicyRequirement>>
{
// In prod this cast is handled when the CreateRequirement delegate is registered in DI
(RequirementFactory<TestPolicyRequirement>)TestPolicyRequirement.Create
};
policyRepository.GetPolicyDetailsByUserId(userId).Returns([otherPolicy, thisPolicy]);
var sut = new PolicyRequirementQuery(policyRepository, factories);
policyRepository.GetPolicyDetailsByUserId(userId).Returns([
new PolicyDetails
{
OrganizationId = organizationId
}
]);
var factory = new TestPolicyRequirementFactory(_ => true);
var sut = new PolicyRequirementQuery(policyRepository, [factory]);
var requirement = await sut.GetAsync<TestPolicyRequirement>(userId);
Assert.Equal(organizationId, requirement.OrganizationId);
Assert.Contains(thisPolicy, requirement.Policies);
Assert.DoesNotContain(otherPolicy, requirement.Policies);
}
[Theory, BitAutoData]
public async Task GetAsync_ThrowsIfNoRequirementRegistered(Guid userId)
public async Task GetAsync_CallsEnforceCallback(Guid userId)
{
// Arrange policies
var policyRepository = Substitute.For<IPolicyRepository>();
var thisPolicy = new PolicyDetails { PolicyType = PolicyType.SingleOrg };
var otherPolicy = new PolicyDetails { PolicyType = PolicyType.SingleOrg };
policyRepository.GetPolicyDetailsByUserId(userId).Returns([thisPolicy, otherPolicy]);
// Arrange a substitute Enforce function so that we can inspect the received calls
var callback = Substitute.For<Func<PolicyDetails, bool>>();
callback(Arg.Any<PolicyDetails>()).Returns(x => x.Arg<PolicyDetails>() == thisPolicy);
// Arrange the sut
var factory = new TestPolicyRequirementFactory(callback);
var sut = new PolicyRequirementQuery(policyRepository, [factory]);
// Act
var requirement = await sut.GetAsync<TestPolicyRequirement>(userId);
// Assert
Assert.Contains(thisPolicy, requirement.Policies);
Assert.DoesNotContain(otherPolicy, requirement.Policies);
callback.Received()(Arg.Is(thisPolicy));
callback.Received()(Arg.Is(otherPolicy));
}
[Theory, BitAutoData]
public async Task GetAsync_ThrowsIfNoFactoryRegistered(Guid userId)
{
var policyRepository = Substitute.For<IPolicyRepository>();
var sut = new PolicyRequirementQuery(policyRepository, []);
var exception = await Assert.ThrowsAsync<NotImplementedException>(()
=> sut.GetAsync<TestPolicyRequirement>(userId));
Assert.Contains("No Policy Requirement found", exception.Message);
Assert.Contains("No Requirement Factory found", exception.Message);
}
/// <summary>
/// Intentionally simplified PolicyRequirement that just holds the Policy.OrganizationId for us to assert against.
/// </summary>
private class TestPolicyRequirement : IPolicyRequirement
[Theory, BitAutoData]
public async Task GetAsync_HandlesNoPolicies(Guid userId)
{
public Guid OrganizationId { get; init; }
public static TestPolicyRequirement Create(IEnumerable<PolicyDetails> policyDetails)
=> new() { OrganizationId = policyDetails.Single().OrganizationId };
var policyRepository = Substitute.For<IPolicyRepository>();
policyRepository.GetPolicyDetailsByUserId(userId).Returns([]);
var factory = new TestPolicyRequirementFactory(x => x.IsProvider);
var sut = new PolicyRequirementQuery(policyRepository, [factory]);
var requirement = await sut.GetAsync<TestPolicyRequirement>(userId);
Assert.Empty(requirement.Policies);
}
}

View File

@ -0,0 +1,90 @@
using AutoFixture.Xunit2;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
using Bit.Core.Enums;
using Bit.Core.Test.AdminConsole.AutoFixture;
using Xunit;
namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
public class BasePolicyRequirementFactoryTests
{
[Theory, AutoData]
public void ExemptRoles_DoesNotEnforceAgainstThoseRoles(
[PolicyDetails(PolicyType.SingleOrg, OrganizationUserType.Owner)] PolicyDetails ownerPolicy,
[PolicyDetails(PolicyType.SingleOrg, OrganizationUserType.Admin)] PolicyDetails adminPolicy,
[PolicyDetails(PolicyType.SingleOrg, OrganizationUserType.Custom)] PolicyDetails customPolicy,
[PolicyDetails(PolicyType.SingleOrg)] PolicyDetails userPolicy)
{
var sut = new TestPolicyRequirementFactory(
// These exempt roles are intentionally unusual to make sure we're properly testing the sut
[OrganizationUserType.User, OrganizationUserType.Custom],
[],
false);
Assert.True(sut.Enforce(ownerPolicy));
Assert.True(sut.Enforce(adminPolicy));
Assert.False(sut.Enforce(customPolicy));
Assert.False(sut.Enforce(userPolicy));
}
[Theory, AutoData]
public void ExemptStatuses_DoesNotEnforceAgainstThoseStatuses(
[PolicyDetails(PolicyType.SingleOrg, userStatus: OrganizationUserStatusType.Invited)] PolicyDetails invitedPolicy,
[PolicyDetails(PolicyType.SingleOrg, userStatus: OrganizationUserStatusType.Accepted)] PolicyDetails acceptedPolicy,
[PolicyDetails(PolicyType.SingleOrg, userStatus: OrganizationUserStatusType.Confirmed)] PolicyDetails confirmedPolicy,
[PolicyDetails(PolicyType.SingleOrg, userStatus: OrganizationUserStatusType.Revoked)] PolicyDetails revokedPolicy)
{
var sut = new TestPolicyRequirementFactory(
[],
// These exempt statuses are intentionally unusual to make sure we're properly testing the sut
[OrganizationUserStatusType.Confirmed, OrganizationUserStatusType.Accepted],
false);
Assert.True(sut.Enforce(invitedPolicy));
Assert.True(sut.Enforce(revokedPolicy));
Assert.False(sut.Enforce(confirmedPolicy));
Assert.False(sut.Enforce(acceptedPolicy));
}
[Theory, AutoData]
public void ExemptProviders_DoesNotEnforceAgainstProviders(
[PolicyDetails(PolicyType.SingleOrg, isProvider: true)] PolicyDetails policy)
{
var sut = new TestPolicyRequirementFactory(
[],
[],
true);
Assert.False(sut.Enforce(policy));
}
[Theory, AutoData]
public void NoExemptions_EnforcesAgainstAdminsAndProviders(
[PolicyDetails(PolicyType.SingleOrg, OrganizationUserType.Owner, isProvider: true)] PolicyDetails policy)
{
var sut = new TestPolicyRequirementFactory(
[],
[],
false);
Assert.True(sut.Enforce(policy));
}
private class TestPolicyRequirementFactory(
IEnumerable<OrganizationUserType> exemptRoles,
IEnumerable<OrganizationUserStatusType> exemptStatuses,
bool exemptProviders
) : BasePolicyRequirementFactory<TestPolicyRequirement>
{
public override PolicyType PolicyType => PolicyType.SingleOrg;
protected override IEnumerable<OrganizationUserType> ExemptRoles => exemptRoles;
protected override IEnumerable<OrganizationUserStatusType> ExemptStatuses => exemptStatuses;
protected override bool ExemptProviders => exemptProviders;
public override TestPolicyRequirement Create(IEnumerable<PolicyDetails> policyDetails)
=> new() { Policies = policyDetails };
}
}

View File

@ -0,0 +1,32 @@
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 DisableSendPolicyRequirementFactoryTests
{
[Theory, BitAutoData]
public void DisableSend_IsFalse_IfNoPolicies(SutProvider<DisableSendPolicyRequirementFactory> sutProvider)
{
var actual = sutProvider.Sut.Create([]);
Assert.False(actual.DisableSend);
}
[Theory, BitAutoData]
public void DisableSend_IsTrue_IfAnyDisableSendPolicies(
[PolicyDetails(PolicyType.DisableSend)] PolicyDetails[] policies,
SutProvider<DisableSendPolicyRequirementFactory> sutProvider
)
{
var actual = sutProvider.Sut.Create(policies);
Assert.True(actual.DisableSend);
}
}

View File

@ -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<ResetPasswordPolicyRequirementFactory> 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<ResetPasswordPolicyRequirementFactory> 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));
}
}

View File

@ -0,0 +1,49 @@
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 SendOptionsPolicyRequirementFactoryTests
{
[Theory, BitAutoData]
public void DisableHideEmail_IsFalse_IfNoPolicies(SutProvider<SendOptionsPolicyRequirementFactory> sutProvider)
{
var actual = sutProvider.Sut.Create([]);
Assert.False(actual.DisableHideEmail);
}
[Theory, BitAutoData]
public void DisableHideEmail_IsFalse_IfNotConfigured(
[PolicyDetails(PolicyType.SendOptions)] PolicyDetails[] policies,
SutProvider<SendOptionsPolicyRequirementFactory> sutProvider
)
{
policies[0].SetDataModel(new SendOptionsPolicyData { DisableHideEmail = false });
policies[1].SetDataModel(new SendOptionsPolicyData { DisableHideEmail = false });
var actual = sutProvider.Sut.Create(policies);
Assert.False(actual.DisableHideEmail);
}
[Theory, BitAutoData]
public void DisableHideEmail_IsTrue_IfAnyConfigured(
[PolicyDetails(PolicyType.SendOptions)] PolicyDetails[] policies,
SutProvider<SendOptionsPolicyRequirementFactory> sutProvider
)
{
policies[0].SetDataModel(new SendOptionsPolicyData { DisableHideEmail = true });
policies[1].SetDataModel(new SendOptionsPolicyData { DisableHideEmail = false });
var actual = sutProvider.Sut.Create(policies);
Assert.True(actual.DisableHideEmail);
}
}

View File

@ -1,138 +0,0 @@
using AutoFixture.Xunit2;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
using Bit.Core.Enums;
using Bit.Core.Test.AdminConsole.AutoFixture;
using Xunit;
namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
public class SendPolicyRequirementTests
{
[Theory, AutoData]
public void DisableSend_IsFalse_IfNoDisableSendPolicies(
[PolicyDetails(PolicyType.RequireSso)] PolicyDetails otherPolicy1,
[PolicyDetails(PolicyType.SendOptions)] PolicyDetails otherPolicy2)
{
EnableDisableHideEmail(otherPolicy2);
var actual = SendPolicyRequirement.Create([otherPolicy1, otherPolicy2]);
Assert.False(actual.DisableSend);
}
[Theory]
[InlineAutoData(OrganizationUserType.Owner, false)]
[InlineAutoData(OrganizationUserType.Admin, false)]
[InlineAutoData(OrganizationUserType.User, true)]
[InlineAutoData(OrganizationUserType.Custom, true)]
public void DisableSend_TestRoles(
OrganizationUserType userType,
bool shouldBeEnforced,
[PolicyDetails(PolicyType.DisableSend)] PolicyDetails policyDetails)
{
policyDetails.OrganizationUserType = userType;
var actual = SendPolicyRequirement.Create([policyDetails]);
Assert.Equal(shouldBeEnforced, actual.DisableSend);
}
[Theory, AutoData]
public void DisableSend_Not_EnforcedAgainstProviders(
[PolicyDetails(PolicyType.DisableSend, isProvider: true)] PolicyDetails policyDetails)
{
var actual = SendPolicyRequirement.Create([policyDetails]);
Assert.False(actual.DisableSend);
}
[Theory]
[InlineAutoData(OrganizationUserStatusType.Confirmed, true)]
[InlineAutoData(OrganizationUserStatusType.Accepted, true)]
[InlineAutoData(OrganizationUserStatusType.Invited, false)]
[InlineAutoData(OrganizationUserStatusType.Revoked, false)]
public void DisableSend_TestStatuses(
OrganizationUserStatusType userStatus,
bool shouldBeEnforced,
[PolicyDetails(PolicyType.DisableSend)] PolicyDetails policyDetails)
{
policyDetails.OrganizationUserStatus = userStatus;
var actual = SendPolicyRequirement.Create([policyDetails]);
Assert.Equal(shouldBeEnforced, actual.DisableSend);
}
[Theory, AutoData]
public void DisableHideEmail_IsFalse_IfNoSendOptionsPolicies(
[PolicyDetails(PolicyType.RequireSso)] PolicyDetails otherPolicy1,
[PolicyDetails(PolicyType.DisableSend)] PolicyDetails otherPolicy2)
{
var actual = SendPolicyRequirement.Create([otherPolicy1, otherPolicy2]);
Assert.False(actual.DisableHideEmail);
}
[Theory]
[InlineAutoData(OrganizationUserType.Owner, false)]
[InlineAutoData(OrganizationUserType.Admin, false)]
[InlineAutoData(OrganizationUserType.User, true)]
[InlineAutoData(OrganizationUserType.Custom, true)]
public void DisableHideEmail_TestRoles(
OrganizationUserType userType,
bool shouldBeEnforced,
[PolicyDetails(PolicyType.SendOptions)] PolicyDetails policyDetails)
{
EnableDisableHideEmail(policyDetails);
policyDetails.OrganizationUserType = userType;
var actual = SendPolicyRequirement.Create([policyDetails]);
Assert.Equal(shouldBeEnforced, actual.DisableHideEmail);
}
[Theory, AutoData]
public void DisableHideEmail_Not_EnforcedAgainstProviders(
[PolicyDetails(PolicyType.SendOptions, isProvider: true)] PolicyDetails policyDetails)
{
EnableDisableHideEmail(policyDetails);
var actual = SendPolicyRequirement.Create([policyDetails]);
Assert.False(actual.DisableHideEmail);
}
[Theory]
[InlineAutoData(OrganizationUserStatusType.Confirmed, true)]
[InlineAutoData(OrganizationUserStatusType.Accepted, true)]
[InlineAutoData(OrganizationUserStatusType.Invited, false)]
[InlineAutoData(OrganizationUserStatusType.Revoked, false)]
public void DisableHideEmail_TestStatuses(
OrganizationUserStatusType userStatus,
bool shouldBeEnforced,
[PolicyDetails(PolicyType.SendOptions)] PolicyDetails policyDetails)
{
EnableDisableHideEmail(policyDetails);
policyDetails.OrganizationUserStatus = userStatus;
var actual = SendPolicyRequirement.Create([policyDetails]);
Assert.Equal(shouldBeEnforced, actual.DisableHideEmail);
}
[Theory, AutoData]
public void DisableHideEmail_HandlesNullData(
[PolicyDetails(PolicyType.SendOptions)] PolicyDetails policyDetails)
{
policyDetails.PolicyData = null;
var actual = SendPolicyRequirement.Create([policyDetails]);
Assert.False(actual.DisableHideEmail);
}
private static void EnableDisableHideEmail(PolicyDetails policyDetails)
=> policyDetails.SetDataModel(new SendOptionsPolicyData { DisableHideEmail = true });
}

View File

@ -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>(T ErroredValue) : Error<T>(Code, ErroredValue)
{
public const string Code = "InvalidRequest";
}
public class TestClassValidator : IValidator<TestClass>
{
public Task<ValidationResult<TestClass>> ValidateAsync(TestClass value)
{
if (string.IsNullOrWhiteSpace(value.Name))
{
return Task.FromResult<ValidationResult<TestClass>>(new Invalid<TestClass>
{
Errors = [new InvalidRequestError<TestClass>(value)]
});
}
return Task.FromResult<ValidationResult<TestClass>>(new Valid<TestClass> { Value = value });
}
}
[Fact]
public async Task ValidateAsync_WhenSomethingIsInvalid_ReturnsInvalidWithError()
{
var example = new TestClass();
var result = await new TestClassValidator().ValidateAsync(example);
Assert.IsType<Invalid<TestClass>>(result);
var invalidResult = result as Invalid<TestClass>;
Assert.Equal(InvalidRequestError<TestClass>.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<Valid<TestClass>>(result);
var validResult = result as Valid<TestClass>;
Assert.Equal(example.Name, validResult.Value.Name);
}
}

View File

@ -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<TestItem> BulkAction(IEnumerable<TestItem> 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<TestItem>(notFound);
var insufficientPermissionsError = new InsufficientPermissionsError<TestItem>(invalidPermissions);
return new Partial<TestItem>(successfulItems.ToArray(), [notFoundError, insufficientPermissionsError]);
}
[Theory]
[BitAutoData]
public void Partial_CommandResult_BulkRequestWithSuccessAndFailures(Guid successId1, Guid failureId1, Guid failureId2)
{
var listOfRecords = new List<TestItem>
{
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<Partial<TestItem>>(result);
var failures = (result as Partial<TestItem>).Failures.ToArray();
var success = (result as Partial<TestItem>).Successes.First();
Assert.Equal(listOfRecords.First(), success);
Assert.Equal(2, failures.Length);
}
}

View File

@ -123,10 +123,12 @@ public class SendServiceTests
// Disable Send policy check - vNext
private void SaveSendAsync_Setup_vNext(SutProvider<SendService> sutProvider, Send send,
SendPolicyRequirement sendPolicyRequirement)
DisableSendPolicyRequirement disableSendPolicyRequirement, SendOptionsPolicyRequirement sendOptionsPolicyRequirement)
{
sutProvider.GetDependency<IPolicyRequirementQuery>().GetAsync<SendPolicyRequirement>(send.UserId!.Value)
.Returns(sendPolicyRequirement);
sutProvider.GetDependency<IPolicyRequirementQuery>().GetAsync<DisableSendPolicyRequirement>(send.UserId!.Value)
.Returns(disableSendPolicyRequirement);
sutProvider.GetDependency<IPolicyRequirementQuery>().GetAsync<SendOptionsPolicyRequirement>(send.UserId!.Value)
.Returns(sendOptionsPolicyRequirement);
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true);
// Should not be called in these tests
@ -141,7 +143,7 @@ public class SendServiceTests
SutProvider<SendService> sutProvider, [NewUserSendCustomize] Send send)
{
send.Type = sendType;
SaveSendAsync_Setup_vNext(sutProvider, send, new SendPolicyRequirement { DisableSend = true });
SaveSendAsync_Setup_vNext(sutProvider, send, new DisableSendPolicyRequirement { DisableSend = true }, new SendOptionsPolicyRequirement());
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.SaveSendAsync(send));
Assert.Contains("Due to an Enterprise Policy, you are only able to delete an existing Send.",
@ -155,7 +157,7 @@ public class SendServiceTests
SutProvider<SendService> sutProvider, [NewUserSendCustomize] Send send)
{
send.Type = sendType;
SaveSendAsync_Setup_vNext(sutProvider, send, new SendPolicyRequirement());
SaveSendAsync_Setup_vNext(sutProvider, send, new DisableSendPolicyRequirement(), new SendOptionsPolicyRequirement());
await sutProvider.Sut.SaveSendAsync(send);
@ -171,7 +173,7 @@ public class SendServiceTests
SutProvider<SendService> sutProvider, [NewUserSendCustomize] Send send)
{
send.Type = sendType;
SaveSendAsync_Setup_vNext(sutProvider, send, new SendPolicyRequirement { DisableHideEmail = true });
SaveSendAsync_Setup_vNext(sutProvider, send, new DisableSendPolicyRequirement(), new SendOptionsPolicyRequirement { DisableHideEmail = true });
send.HideEmail = true;
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.SaveSendAsync(send));
@ -185,7 +187,7 @@ public class SendServiceTests
SutProvider<SendService> sutProvider, [NewUserSendCustomize] Send send)
{
send.Type = sendType;
SaveSendAsync_Setup_vNext(sutProvider, send, new SendPolicyRequirement { DisableHideEmail = true });
SaveSendAsync_Setup_vNext(sutProvider, send, new DisableSendPolicyRequirement(), new SendOptionsPolicyRequirement { DisableHideEmail = true });
send.HideEmail = false;
await sutProvider.Sut.SaveSendAsync(send);
@ -200,7 +202,7 @@ public class SendServiceTests
SutProvider<SendService> sutProvider, [NewUserSendCustomize] Send send)
{
send.Type = sendType;
SaveSendAsync_Setup_vNext(sutProvider, send, new SendPolicyRequirement());
SaveSendAsync_Setup_vNext(sutProvider, send, new DisableSendPolicyRequirement(), new SendOptionsPolicyRequirement());
send.HideEmail = true;
await sutProvider.Sut.SaveSendAsync(send);

View File

@ -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;
@ -602,6 +605,78 @@ public class CipherServiceTests
Assert.NotEqual(initialRevisionDate, cipher.RevisionDate);
}
[Theory]
[BitAutoData]
public async Task RestoreAsync_WithAlreadyRestoredCipher_SkipsOperation(
Guid restoringUserId, Cipher cipher, SutProvider<CipherService> sutProvider)
{
cipher.DeletedDate = null;
await sutProvider.Sut.RestoreAsync(cipher, restoringUserId, true);
await sutProvider.GetDependency<ICipherRepository>().DidNotReceiveWithAnyArgs().UpsertAsync(default);
await sutProvider.GetDependency<IEventService>().DidNotReceiveWithAnyArgs().LogCipherEventAsync(default, default);
await sutProvider.GetDependency<IPushNotificationService>().DidNotReceiveWithAnyArgs().PushSyncCipherUpdateAsync(default, default);
}
[Theory]
[BitAutoData]
public async Task RestoreAsync_WithPersonalCipherBelongingToDifferentUser_ThrowsBadRequestException(
Guid restoringUserId, Cipher cipher, SutProvider<CipherService> sutProvider)
{
cipher.UserId = Guid.NewGuid();
cipher.OrganizationId = null;
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.RestoreAsync(cipher, restoringUserId));
Assert.Contains("do not have permissions", exception.Message);
await sutProvider.GetDependency<ICipherRepository>().DidNotReceiveWithAnyArgs().UpsertAsync(default);
await sutProvider.GetDependency<IEventService>().DidNotReceiveWithAnyArgs().LogCipherEventAsync(default, default);
await sutProvider.GetDependency<IPushNotificationService>().DidNotReceiveWithAnyArgs().PushSyncCipherUpdateAsync(default, default);
}
[Theory]
[OrganizationCipherCustomize]
[BitAutoData]
public async Task RestoreAsync_WithOrgCipherLackingEditPermission_ThrowsBadRequestException(
Guid restoringUserId, Cipher cipher, SutProvider<CipherService> sutProvider)
{
sutProvider.GetDependency<ICipherRepository>()
.GetCanEditByIdAsync(restoringUserId, cipher.Id)
.Returns(false);
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.RestoreAsync(cipher, restoringUserId));
Assert.Contains("do not have permissions", exception.Message);
await sutProvider.GetDependency<ICipherRepository>().DidNotReceiveWithAnyArgs().UpsertAsync(default);
await sutProvider.GetDependency<IEventService>().DidNotReceiveWithAnyArgs().LogCipherEventAsync(default, default);
await sutProvider.GetDependency<IPushNotificationService>().DidNotReceiveWithAnyArgs().PushSyncCipherUpdateAsync(default, default);
}
[Theory]
[BitAutoData]
public async Task RestoreAsync_WithCipherDetailsType_RestoresCipherDetails(
Guid restoringUserId, CipherDetails cipherDetails, SutProvider<CipherService> sutProvider)
{
sutProvider.GetDependency<ICipherRepository>()
.GetCanEditByIdAsync(restoringUserId, cipherDetails.Id)
.Returns(true);
var initialRevisionDate = new DateTime(1970, 1, 1, 0, 0, 0);
cipherDetails.DeletedDate = initialRevisionDate;
cipherDetails.RevisionDate = initialRevisionDate;
await sutProvider.Sut.RestoreAsync(cipherDetails, restoringUserId);
Assert.Null(cipherDetails.DeletedDate);
Assert.NotEqual(initialRevisionDate, cipherDetails.RevisionDate);
await sutProvider.GetDependency<ICipherRepository>().Received(1).UpsertAsync(cipherDetails);
await sutProvider.GetDependency<IEventService>().Received(1).LogCipherEventAsync(cipherDetails, EventType.Cipher_Restored);
await sutProvider.GetDependency<IPushNotificationService>().Received(1).PushSyncCipherUpdateAsync(cipherDetails, null);
}
[Theory]
[BitAutoData]
public async Task RestoreManyAsync_UpdatesCiphers(ICollection<CipherDetails> ciphers,
@ -725,6 +800,415 @@ public class CipherServiceTests
Arg.Is<IEnumerable<Cipher>>(arg => !arg.Except(ciphers).Any()));
}
private class SaveDetailsAsyncDependencies
{
public CipherDetails CipherDetails { get; set; }
public SutProvider<CipherService> SutProvider { get; set; }
}
private static SaveDetailsAsyncDependencies GetSaveDetailsAsyncDependencies(
SutProvider<CipherService> 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<ICipherRepository>()
.GetByIdAsync(cipherDetails.Id)
.Returns(existingCipher);
sutProvider.GetDependency<ICipherRepository>()
.ReplaceAsync(Arg.Any<CipherDetails>())
.Returns(Task.CompletedTask);
var permissions = new Dictionary<Guid, OrganizationCipherPermission>
{
{ cipherDetails.Id, new OrganizationCipherPermission { ViewPassword = viewPassword, Edit = editPermission } }
};
sutProvider.GetDependency<IGetCipherPermissionsForUserQuery>()
.GetByOrganization(cipherDetails.OrganizationId.Value)
.Returns(permissions);
return new SaveDetailsAsyncDependencies
{
CipherDetails = cipherDetails,
SutProvider = sutProvider,
};
}
[Theory, BitAutoData]
public async Task SaveDetailsAsync_PasswordNotChangedWithoutViewPasswordPermission(string _, SutProvider<CipherService> 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<CipherLoginData>(deps.CipherDetails.Data);
Assert.Equal("OriginalPassword", updatedLoginData.Password);
}
[Theory, BitAutoData]
public async Task SaveDetailsAsync_PasswordNotChangedWithoutEditPermission(string _, SutProvider<CipherService> 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<CipherLoginData>(deps.CipherDetails.Data);
Assert.Equal("OriginalPassword", updatedLoginData.Password);
}
[Theory, BitAutoData]
public async Task SaveDetailsAsync_PasswordChangedWithPermission(string _, SutProvider<CipherService> 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<CipherLoginData>(deps.CipherDetails.Data);
Assert.Equal("NewPassword", updatedLoginData.Password);
}
[Theory, BitAutoData]
public async Task SaveDetailsAsync_CipherKeyChangedWithPermission(string _, SutProvider<CipherService> 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<CipherService> sutProvider)
{
var deps = GetSaveDetailsAsyncDependencies(sutProvider, "NewPassword", viewPassword: true, editPermission: false, "NewKey");
var exception = await Assert.ThrowsAsync<BadRequestException>(() => 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<CipherService> 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<CipherLoginData>(deps.CipherDetails.Data);
Assert.Equal("OriginalTotp", updatedLoginData.Totp);
}
[Theory, BitAutoData]
public async Task SaveDetailsAsync_TotpChangedWithPermission(string _, SutProvider<CipherService> 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<CipherLoginData>(deps.CipherDetails.Data);
Assert.Equal("NewTotp", updatedLoginData.Totp);
}
[Theory, BitAutoData]
public async Task SaveDetailsAsync_Fido2CredentialsChangedWithoutPermission(string _, SutProvider<CipherService> 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<CipherLoginData>(deps.CipherDetails.Data);
Assert.Empty(updatedLoginData.Fido2Credentials);
}
[Theory, BitAutoData]
public async Task SaveDetailsAsync_Fido2CredentialsChangedWithPermission(string _, SutProvider<CipherService> 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<CipherLoginData>(deps.CipherDetails.Data);
Assert.Equal(passkeys.Length, updatedLoginData.Fido2Credentials.Length);
}
[Theory]
[BitAutoData]
public async Task DeleteAsync_WithPersonalCipherOwner_DeletesCipher(
Guid deletingUserId, Cipher cipher, SutProvider<CipherService> sutProvider)
{
cipher.UserId = deletingUserId;
cipher.OrganizationId = null;
await sutProvider.Sut.DeleteAsync(cipher, deletingUserId);
await sutProvider.GetDependency<ICipherRepository>().Received(1).DeleteAsync(cipher);
await sutProvider.GetDependency<IAttachmentStorageService>().Received(1).DeleteAttachmentsForCipherAsync(cipher.Id);
await sutProvider.GetDependency<IEventService>().Received(1).LogCipherEventAsync(cipher, EventType.Cipher_Deleted);
await sutProvider.GetDependency<IPushNotificationService>().Received(1).PushSyncCipherDeleteAsync(cipher);
}
[Theory]
[OrganizationCipherCustomize]
[BitAutoData]
public async Task DeleteAsync_WithOrgCipherAndEditPermission_DeletesCipher(
Guid deletingUserId, Cipher cipher, SutProvider<CipherService> sutProvider)
{
sutProvider.GetDependency<ICipherRepository>()
.GetCanEditByIdAsync(deletingUserId, cipher.Id)
.Returns(true);
await sutProvider.Sut.DeleteAsync(cipher, deletingUserId);
await sutProvider.GetDependency<ICipherRepository>().Received(1).DeleteAsync(cipher);
await sutProvider.GetDependency<IAttachmentStorageService>().Received(1).DeleteAttachmentsForCipherAsync(cipher.Id);
await sutProvider.GetDependency<IEventService>().Received(1).LogCipherEventAsync(cipher, EventType.Cipher_Deleted);
await sutProvider.GetDependency<IPushNotificationService>().Received(1).PushSyncCipherDeleteAsync(cipher);
}
[Theory]
[BitAutoData]
public async Task DeleteAsync_WithPersonalCipherBelongingToDifferentUser_ThrowsBadRequestException(
Guid deletingUserId, Cipher cipher, SutProvider<CipherService> sutProvider)
{
cipher.UserId = Guid.NewGuid();
cipher.OrganizationId = null;
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.DeleteAsync(cipher, deletingUserId));
Assert.Contains("do not have permissions", exception.Message);
await sutProvider.GetDependency<ICipherRepository>().DidNotReceiveWithAnyArgs().DeleteAsync(default);
await sutProvider.GetDependency<IAttachmentStorageService>().DidNotReceiveWithAnyArgs().DeleteAttachmentsForCipherAsync(default);
await sutProvider.GetDependency<IEventService>().DidNotReceiveWithAnyArgs().LogCipherEventAsync(default, default);
await sutProvider.GetDependency<IPushNotificationService>().DidNotReceiveWithAnyArgs().PushSyncCipherDeleteAsync(default);
}
[Theory]
[OrganizationCipherCustomize]
[BitAutoData]
public async Task DeleteAsync_WithOrgCipherLackingEditPermission_ThrowsBadRequestException(
Guid deletingUserId, Cipher cipher, SutProvider<CipherService> sutProvider)
{
sutProvider.GetDependency<ICipherRepository>()
.GetCanEditByIdAsync(deletingUserId, cipher.Id)
.Returns(false);
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.DeleteAsync(cipher, deletingUserId));
Assert.Contains("do not have permissions", exception.Message);
await sutProvider.GetDependency<ICipherRepository>().DidNotReceiveWithAnyArgs().DeleteAsync(default);
await sutProvider.GetDependency<IAttachmentStorageService>().DidNotReceiveWithAnyArgs().DeleteAttachmentsForCipherAsync(default);
await sutProvider.GetDependency<IEventService>().DidNotReceiveWithAnyArgs().LogCipherEventAsync(default, default);
await sutProvider.GetDependency<IPushNotificationService>().DidNotReceiveWithAnyArgs().PushSyncCipherDeleteAsync(default);
}
[Theory]
[BitAutoData]
public async Task SoftDeleteAsync_WithPersonalCipherOwner_SoftDeletesCipher(
Guid deletingUserId, Cipher cipher, SutProvider<CipherService> sutProvider)
{
cipher.UserId = deletingUserId;
cipher.OrganizationId = null;
cipher.DeletedDate = null;
sutProvider.GetDependency<ICipherRepository>()
.GetCanEditByIdAsync(deletingUserId, cipher.Id)
.Returns(true);
await sutProvider.Sut.SoftDeleteAsync(cipher, deletingUserId);
Assert.NotNull(cipher.DeletedDate);
Assert.Equal(cipher.RevisionDate, cipher.DeletedDate);
await sutProvider.GetDependency<ICipherRepository>().Received(1).UpsertAsync(cipher);
await sutProvider.GetDependency<IEventService>().Received(1).LogCipherEventAsync(cipher, EventType.Cipher_SoftDeleted);
await sutProvider.GetDependency<IPushNotificationService>().Received(1).PushSyncCipherUpdateAsync(cipher, null);
}
[Theory]
[OrganizationCipherCustomize]
[BitAutoData]
public async Task SoftDeleteAsync_WithOrgCipherAndEditPermission_SoftDeletesCipher(
Guid deletingUserId, Cipher cipher, SutProvider<CipherService> sutProvider)
{
cipher.DeletedDate = null;
sutProvider.GetDependency<ICipherRepository>()
.GetCanEditByIdAsync(deletingUserId, cipher.Id)
.Returns(true);
await sutProvider.Sut.SoftDeleteAsync(cipher, deletingUserId);
Assert.NotNull(cipher.DeletedDate);
Assert.Equal(cipher.DeletedDate, cipher.RevisionDate);
await sutProvider.GetDependency<ICipherRepository>().Received(1).UpsertAsync(cipher);
await sutProvider.GetDependency<IEventService>().Received(1).LogCipherEventAsync(cipher, EventType.Cipher_SoftDeleted);
await sutProvider.GetDependency<IPushNotificationService>().Received(1).PushSyncCipherUpdateAsync(cipher, null);
}
[Theory]
[BitAutoData]
public async Task SoftDeleteAsync_WithPersonalCipherBelongingToDifferentUser_ThrowsBadRequestException(
Guid deletingUserId, Cipher cipher, SutProvider<CipherService> sutProvider)
{
cipher.UserId = Guid.NewGuid();
cipher.OrganizationId = null;
sutProvider.GetDependency<ICipherRepository>()
.GetCanEditByIdAsync(deletingUserId, cipher.Id)
.Returns(false);
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.SoftDeleteAsync(cipher, deletingUserId));
Assert.Contains("do not have permissions", exception.Message);
}
[Theory]
[OrganizationCipherCustomize]
[BitAutoData]
public async Task SoftDeleteAsync_WithOrgCipherLackingEditPermission_ThrowsBadRequestException(
Guid deletingUserId, Cipher cipher, SutProvider<CipherService> sutProvider)
{
sutProvider.GetDependency<ICipherRepository>()
.GetCanEditByIdAsync(deletingUserId, cipher.Id)
.Returns(false);
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.SoftDeleteAsync(cipher, deletingUserId));
Assert.Contains("do not have permissions", exception.Message);
}
[Theory]
[BitAutoData]
public async Task SoftDeleteAsync_WithCipherDetailsType_SoftDeletesCipherDetails(
Guid deletingUserId, CipherDetails cipher, SutProvider<CipherService> sutProvider)
{
cipher.DeletedDate = null;
await sutProvider.Sut.SoftDeleteAsync(cipher, deletingUserId, true);
Assert.NotNull(cipher.DeletedDate);
Assert.Equal(cipher.DeletedDate, cipher.RevisionDate);
await sutProvider.GetDependency<ICipherRepository>().Received(1).UpsertAsync(cipher);
await sutProvider.GetDependency<IEventService>().Received(1).LogCipherEventAsync(cipher, EventType.Cipher_SoftDeleted);
await sutProvider.GetDependency<IPushNotificationService>().Received(1).PushSyncCipherUpdateAsync(cipher, null);
}
[Theory]
[BitAutoData]
public async Task SoftDeleteAsync_WithAlreadySoftDeletedCipher_SkipsOperation(
Guid deletingUserId, Cipher cipher, SutProvider<CipherService> sutProvider)
{
sutProvider.GetDependency<ICipherRepository>()
.GetCanEditByIdAsync(deletingUserId, cipher.Id)
.Returns(true);
cipher.DeletedDate = DateTime.UtcNow.AddDays(-1);
await sutProvider.Sut.SoftDeleteAsync(cipher, deletingUserId);
await sutProvider.GetDependency<ICipherRepository>().DidNotReceive().UpsertAsync(Arg.Any<Cipher>());
await sutProvider.GetDependency<IEventService>().DidNotReceive().LogCipherEventAsync(Arg.Any<Cipher>(), Arg.Any<EventType>());
await sutProvider.GetDependency<IPushNotificationService>().DidNotReceive().PushSyncCipherUpdateAsync(Arg.Any<Cipher>(), Arg.Any<IEnumerable<Guid>>());
}
private async Task AssertNoActionsAsync(SutProvider<CipherService> sutProvider)
{
await sutProvider.GetDependency<ICipherRepository>().DidNotReceiveWithAnyArgs().GetManyOrganizationDetailsByOrganizationIdAsync(default);

View File

@ -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(

View File

@ -1,4 +1,4 @@
FROM bitwarden/server:latest
FROM ghcr.io/bitwarden/server
LABEL com.bitwarden.product="bitwarden"

View File

@ -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

View File

@ -15,7 +15,7 @@
services:
mssql:
image: bitwarden/mssql:{{{CoreVersion}}}
image: ghcr.io/bitwarden/mssql:{{{CoreVersion}}}
container_name: bitwarden-mssql
restart: always
stop_grace_period: 60s
@ -33,7 +33,7 @@ services:
- ../env/mssql.override.env
web:
image: bitwarden/web:{{{WebVersion}}}
image: ghcr.io/bitwarden/web:{{{WebVersion}}}
container_name: bitwarden-web
restart: always
volumes:
@ -43,7 +43,7 @@ services:
- ../env/uid.env
attachments:
image: bitwarden/attachments:{{{CoreVersion}}}
image: ghcr.io/bitwarden/attachments:{{{CoreVersion}}}
container_name: bitwarden-attachments
restart: always
volumes:
@ -53,7 +53,7 @@ services:
- ../env/uid.env
api:
image: bitwarden/api:{{{CoreVersion}}}
image: ghcr.io/bitwarden/api:{{{CoreVersion}}}
container_name: bitwarden-api
restart: always
volumes:
@ -69,7 +69,7 @@ services:
- public
identity:
image: bitwarden/identity:{{{CoreVersion}}}
image: ghcr.io/bitwarden/identity:{{{CoreVersion}}}
container_name: bitwarden-identity
restart: always
volumes:
@ -86,7 +86,7 @@ services:
- public
sso:
image: bitwarden/sso:{{{CoreVersion}}}
image: ghcr.io/bitwarden/sso:{{{CoreVersion}}}
container_name: bitwarden-sso
restart: always
volumes:
@ -103,7 +103,7 @@ services:
- public
admin:
image: bitwarden/admin:{{{CoreVersion}}}
image: ghcr.io/bitwarden/admin:{{{CoreVersion}}}
container_name: bitwarden-admin
restart: always
depends_on:
@ -121,7 +121,7 @@ services:
- public
icons:
image: bitwarden/icons:{{{CoreVersion}}}
image: ghcr.io/bitwarden/icons:{{{CoreVersion}}}
container_name: bitwarden-icons
restart: always
volumes:
@ -135,7 +135,7 @@ services:
- public
notifications:
image: bitwarden/notifications:{{{CoreVersion}}}
image: ghcr.io/bitwarden/notifications:{{{CoreVersion}}}
container_name: bitwarden-notifications
restart: always
volumes:
@ -150,7 +150,7 @@ services:
- public
events:
image: bitwarden/events:{{{CoreVersion}}}
image: ghcr.io/bitwarden/events:{{{CoreVersion}}}
container_name: bitwarden-events
restart: always
volumes:
@ -165,7 +165,7 @@ services:
- public
nginx:
image: bitwarden/nginx:{{{CoreVersion}}}
image: ghcr.io/bitwarden/nginx:{{{CoreVersion}}}
container_name: bitwarden-nginx
restart: always
depends_on:
@ -195,7 +195,7 @@ services:
{{#if EnableKeyConnector}}
key-connector:
image: bitwarden/key-connector:{{{KeyConnectorVersion}}}
image: ghcr.io/bitwarden/key-connector:{{{KeyConnectorVersion}}}
container_name: bitwarden-key-connector
restart: always
volumes:
@ -212,7 +212,7 @@ services:
{{#if EnableScim}}
scim:
image: bitwarden/scim:{{{CoreVersion}}}
image: ghcr.io/bitwarden/scim:{{{CoreVersion}}}
container_name: bitwarden-scim
restart: always
volumes: