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

Merge branch 'main' into add-docker-arm64-builds

This commit is contained in:
Vince Grassia 2025-03-19 11:25:59 -04:00
commit 1f1ed1c343
No known key found for this signature in database
GPG Key ID: 9AD7505E8448CC08
56 changed files with 2113 additions and 381 deletions

View File

@ -32,7 +32,6 @@ on:
- "src/**/Entities/**/*.cs" # Database entity definitions - "src/**/Entities/**/*.cs" # Database entity definitions
jobs: jobs:
test: test:
name: Run tests name: Run tests
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
@ -148,8 +147,8 @@ jobs:
run: 'docker logs $(docker ps --quiet --filter "name=mssql")' run: 'docker logs $(docker ps --quiet --filter "name=mssql")'
- name: Report test results - 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() }} if: ${{ github.event.pull_request.head.repo.full_name == github.repository && !cancelled() }}
with: with:
name: Test Results name: Test Results
path: "**/*-test-results.trx" path: "**/*-test-results.trx"

View File

@ -13,7 +13,6 @@ env:
_AZ_REGISTRY: "bitwardenprod.azurecr.io" _AZ_REGISTRY: "bitwardenprod.azurecr.io"
jobs: jobs:
testing: testing:
name: Run tests name: Run tests
if: ${{ startsWith(github.head_ref, 'version_bump_') == false }} 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" 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 - 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() }} if: ${{ github.event.pull_request.head.repo.full_name == github.repository && !cancelled() }}
with: with:
name: Test Results name: Test Results

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

View File

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

View File

@ -1,5 +1,6 @@
using Bit.Api.Billing.Models.Requests; using Bit.Api.Billing.Models.Requests;
using Bit.Api.Billing.Models.Responses; using Bit.Api.Billing.Models.Responses;
using Bit.Core;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Models; using Bit.Core.Billing.Models;
using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Pricing;
@ -20,6 +21,7 @@ namespace Bit.Api.Billing.Controllers;
[Authorize("Application")] [Authorize("Application")]
public class ProviderBillingController( public class ProviderBillingController(
ICurrentContext currentContext, ICurrentContext currentContext,
IFeatureService featureService,
ILogger<BaseProviderController> logger, ILogger<BaseProviderController> logger,
IPricingClient pricingClient, IPricingClient pricingClient,
IProviderBillingService providerBillingService, IProviderBillingService providerBillingService,
@ -71,6 +73,65 @@ public class ProviderBillingController(
"text/csv"); "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")] [HttpGet("subscription")]
public async Task<IResult> GetSubscriptionAsync([FromRoute] Guid providerId) public async Task<IResult> GetSubscriptionAsync([FromRoute] Guid providerId)
{ {
@ -102,12 +163,32 @@ public class ProviderBillingController(
var subscriptionSuspension = await GetSubscriptionSuspensionAsync(stripeAdapter, subscription); var subscriptionSuspension = await GetSubscriptionSuspensionAsync(stripeAdapter, subscription);
var paymentSource = await subscriberService.GetPaymentSource(provider);
var response = ProviderSubscriptionResponse.From( var response = ProviderSubscriptionResponse.From(
subscription, subscription,
configuredProviderPlans, configuredProviderPlans,
taxInformation, taxInformation,
subscriptionSuspension, 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); return TypedResults.Ok(response);
} }

View File

@ -16,7 +16,8 @@ public record ProviderSubscriptionResponse(
TaxInformation TaxInformation, TaxInformation TaxInformation,
DateTime? CancelAt, DateTime? CancelAt,
SubscriptionSuspension Suspension, SubscriptionSuspension Suspension,
ProviderType ProviderType) ProviderType ProviderType,
PaymentSource PaymentSource)
{ {
private const string _annualCadence = "Annual"; private const string _annualCadence = "Annual";
private const string _monthlyCadence = "Monthly"; private const string _monthlyCadence = "Monthly";
@ -26,7 +27,8 @@ public record ProviderSubscriptionResponse(
ICollection<ConfiguredProviderPlan> providerPlans, ICollection<ConfiguredProviderPlan> providerPlans,
TaxInformation taxInformation, TaxInformation taxInformation,
SubscriptionSuspension subscriptionSuspension, SubscriptionSuspension subscriptionSuspension,
Provider provider) Provider provider,
PaymentSource paymentSource)
{ {
var providerPlanResponses = providerPlans var providerPlanResponses = providerPlans
.Select(providerPlan => .Select(providerPlan =>
@ -57,7 +59,8 @@ public record ProviderSubscriptionResponse(
taxInformation, taxInformation,
subscription.CancelAt, subscription.CancelAt,
subscriptionSuspension, 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 }, NoRecordFoundFailure<T> failure => new ObjectResult(failure.ErrorMessages) { StatusCode = StatusCodes.Status404NotFound },
BadRequestFailure<T> failure => new ObjectResult(failure.ErrorMessages) { StatusCode = StatusCodes.Status400BadRequest }, BadRequestFailure<T> failure => new ObjectResult(failure.ErrorMessages) { StatusCode = StatusCodes.Status400BadRequest },
Failure<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}") _ => throw new InvalidOperationException($"Unhandled commandResult type: {commandResult.GetType().Name}")
}; };
} }

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( public class PolicyRequirementQuery(
IPolicyRepository policyRepository, IPolicyRepository policyRepository,
IEnumerable<RequirementFactory<IPolicyRequirement>> factories) IEnumerable<IPolicyRequirementFactory<IPolicyRequirement>> factories)
: IPolicyRequirementQuery : IPolicyRequirementQuery
{ {
public async Task<T> GetAsync<T>(Guid userId) where T : IPolicyRequirement 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) 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) => private Task<IEnumerable<PolicyDetails>> GetPolicyDetails(Guid userId)
policyRepository.GetPolicyDetailsByUserId(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.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
/// <summary> /// <summary>
/// Represents the business requirements of how one or more enterprise policies will be enforced against a user. /// An object that represents how a <see cref="PolicyType"/> will be enforced against a user.
/// The implementation of this interface will depend on how the policies are enforced in the relevant domain. /// 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> /// </summary>
public interface IPolicyRequirement; 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; using Bit.Core.Enums;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
@ -7,35 +6,16 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements
public static class PolicyRequirementHelpers public static class PolicyRequirementHelpers
{ {
/// <summary> /// <summary>
/// Filters the PolicyDetails by PolicyType. This is generally required to only get the PolicyDetails that your /// Returns true if the <see cref="PolicyDetails"/> is for one of the specified roles, false otherwise.
/// IPolicyRequirement relates to.
/// </summary> /// </summary>
public static IEnumerable<PolicyDetails> GetPolicyType( public static bool HasRole(
this IEnumerable<PolicyDetails> policyDetails, this 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,
IEnumerable<OrganizationUserType> roles) IEnumerable<OrganizationUserType> roles)
=> policyDetails.Where(x => !roles.Contains(x.OrganizationUserType)); => roles.Contains(policyDetails.OrganizationUserType);
/// <summary> /// <summary>
/// Filters the PolicyDetails to remove organization users who are also provider users for the organization. /// Returns true if the <see cref="PolicyDetails"/> relates to one of the specified statuses, false otherwise.
/// This can be used to exempt provider users from policy enforcement.
/// </summary> /// </summary>
public static IEnumerable<PolicyDetails> ExemptProviders(this IEnumerable<PolicyDetails> policyDetails) public static bool HasStatus(this PolicyDetails policyDetails, IEnumerable<OrganizationUserStatusType> status)
=> policyDetails.Where(x => !x.IsProvider); => status.Contains(policyDetails.OrganizationUserStatus);
/// <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));
} }

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,7 @@ public static class PolicyServiceCollectionExtensions
private static void AddPolicyRequirements(this IServiceCollection services) private static void AddPolicyRequirements(this IServiceCollection services)
{ {
// Register policy requirement factories here services.AddScoped<IPolicyRequirementFactory<IPolicyRequirement>, DisableSendPolicyRequirementFactory>();
services.AddPolicyRequirement(SendPolicyRequirement.Create); services.AddScoped<IPolicyRequirementFactory<IPolicyRequirement>, SendOptionsPolicyRequirementFactory>();
} }
/// <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

@ -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( Task<Subscription> SetupSubscription(
Provider provider); 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); Task UpdateSeatMinimums(UpdateProviderSeatMinimumsCommand command);
} }

View File

@ -114,6 +114,16 @@ public static class FeatureFlagKeys
public const string ItemShare = "item-share"; public const string ItemShare = "item-share";
public const string RiskInsightsCriticalApplication = "pm-14466-risk-insights-critical-application"; public const string RiskInsightsCriticalApplication = "pm-14466-risk-insights-critical-application";
public const string EnableRiskInsightsNotifications = "enable-risk-insights-notifications"; 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 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";
public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair"; public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair";
public const string UseTreeWalkerApiForPageDetailsCollection = "use-tree-walker-api-for-page-details-collection"; public const string UseTreeWalkerApiForPageDetailsCollection = "use-tree-walker-api-for-page-details-collection";
@ -121,9 +131,7 @@ public static class FeatureFlagKeys
public const string AC2101UpdateTrialInitiationEmail = "AC-2101-update-trial-initiation-email"; public const string AC2101UpdateTrialInitiationEmail = "AC-2101-update-trial-initiation-email";
public const string EmailVerification = "email-verification"; public const string EmailVerification = "email-verification";
public const string EmailVerificationDisableTimingDelays = "email-verification-disable-timing-delays"; 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 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 InlineMenuFieldQualification = "inline-menu-field-qualification";
public const string InlineMenuPositioningImprovements = "inline-menu-positioning-improvements"; public const string InlineMenuPositioningImprovements = "inline-menu-positioning-improvements";
public const string DeviceTrustLogging = "pm-8285-device-trust-logging"; public const string DeviceTrustLogging = "pm-8285-device-trust-logging";
@ -148,11 +156,7 @@ public static class FeatureFlagKeys
public const string RemoveServerVersionHeader = "remove-server-version-header"; public const string RemoveServerVersionHeader = "remove-server-version-header";
public const string GeneratorToolsModernization = "generator-tools-modernization"; public const string GeneratorToolsModernization = "generator-tools-modernization";
public const string NewDeviceVerification = "new-device-verification"; 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 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 InlineMenuTotp = "inline-menu-totp";
public const string PrivateKeyRegeneration = "pm-12241-private-key-regeneration"; public const string PrivateKeyRegeneration = "pm-12241-private-key-regeneration";
public const string AppReviewPrompt = "app-review-prompt"; public const string AppReviewPrompt = "app-review-prompt";
@ -171,6 +175,8 @@ public static class FeatureFlagKeys
public const string WebPush = "web-push"; public const string WebPush = "web-push";
public const string AndroidImportLoginsFlow = "import-logins-flow"; public const string AndroidImportLoginsFlow = "import-logins-flow";
public const string PM12276Breadcrumbing = "pm-12276-breadcrumbing-for-business-features"; public const string PM12276Breadcrumbing = "pm-12276-breadcrumbing-for-business-features";
public const string PM18794_ProviderPaymentMethod = "pm-18794-provider-payment-method";
public const string PM3553_MobileSimpleLoginSelfHostAlias = "simple-login-self-host-alias";
public static List<string> GetAllKeys() public static List<string> GetAllKeys()
{ {

View File

@ -36,7 +36,7 @@
<PackageReference Include="DnsClient" Version="1.8.0" /> <PackageReference Include="DnsClient" Version="1.8.0" />
<PackageReference Include="Fido2.AspNet" Version="3.0.1" /> <PackageReference Include="Fido2.AspNet" Version="3.0.1" />
<PackageReference Include="Handlebars.Net" Version="2.1.6" /> <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.AspNetCore.Authentication.JwtBearer" Version="8.0.10" />
<PackageReference Include="Microsoft.Azure.Cosmos" Version="3.46.1" /> <PackageReference Include="Microsoft.Azure.Cosmos" Version="3.46.1" />
<PackageReference Include="Microsoft.Azure.NotificationHubs" Version="4.2.0" /> <PackageReference Include="Microsoft.Azure.NotificationHubs" Version="4.2.0" />

View File

@ -7,7 +7,7 @@
</tr> </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;"> <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"> <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> </td>
</tr> </tr>
</table> </table>

View File

@ -1,5 +1,5 @@
{{#>BasicTextLayout}} {{#>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. 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}} {{/BasicTextLayout}}

View File

@ -26,7 +26,7 @@
</tr> </tr>
<tr style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;"> <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"> <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 Manage subscription
</a> </a>
<br class="line-break" /> <br class="line-break" />

View File

@ -24,7 +24,7 @@
</tr> </tr>
<tr style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;"> <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"> <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 Manage subscription
</a> </a>
<br class="line-break" /> <br class="line-break" />

View File

@ -24,7 +24,7 @@
</tr> </tr>
<tr style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;"> <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"> <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 Manage subscription
</a> </a>
<br class="line-break" /> <br class="line-break" />

View File

@ -24,7 +24,7 @@
</tr> </tr>
<tr style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;"> <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"> <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 Manage subscription
</a> </a>
<br class="line-break" /> <br class="line-break" />

View File

@ -1,5 +1,7 @@
#nullable enable #nullable enable
using Bit.Core.AdminConsole.Errors;
namespace Bit.Core.Models.Commands; namespace Bit.Core.Models.Commands;
public class CommandResult(IEnumerable<string> errors) public class CommandResult(IEnumerable<string> errors)
@ -9,7 +11,6 @@ public class CommandResult(IEnumerable<string> errors)
public bool Success => ErrorMessages.Count == 0; public bool Success => ErrorMessages.Count == 0;
public bool HasErrors => ErrorMessages.Count > 0; public bool HasErrors => ErrorMessages.Count > 0;
public List<string> ErrorMessages { get; } = errors.ToList(); public List<string> ErrorMessages { get; } = errors.ToList();
public CommandResult() : this(Array.Empty<string>()) { } 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 class OrganizationSeatsAutoscaledViewModel : BaseMailModel
{ {
public Guid OrganizationId { get; set; }
public int InitialSeatCount { get; set; } public int InitialSeatCount { get; set; }
public int CurrentSeatCount { get; set; } public int CurrentSeatCount { get; set; }
public string VaultSubscriptionUrl { get; set; }
} }

View File

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

View File

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

View File

@ -214,9 +214,9 @@ public class HandlebarsMailService : IMailService
var message = CreateDefaultMessage($"{organization.DisplayName()} Seat Count Has Increased", ownerEmails); var message = CreateDefaultMessage($"{organization.DisplayName()} Seat Count Has Increased", ownerEmails);
var model = new OrganizationSeatsAutoscaledViewModel var model = new OrganizationSeatsAutoscaledViewModel
{ {
OrganizationId = organization.Id,
InitialSeatCount = initialSeatCount, InitialSeatCount = initialSeatCount,
CurrentSeatCount = organization.Seats.Value, CurrentSeatCount = organization.Seats.Value,
VaultSubscriptionUrl = GetCloudVaultSubscriptionUrl(organization.Id)
}; };
await AddMessageContentAsync(message, "OrganizationSeatsAutoscaled", model); await AddMessageContentAsync(message, "OrganizationSeatsAutoscaled", model);
@ -229,8 +229,8 @@ public class HandlebarsMailService : IMailService
var message = CreateDefaultMessage($"{organization.DisplayName()} Seat Limit Reached", ownerEmails); var message = CreateDefaultMessage($"{organization.DisplayName()} Seat Limit Reached", ownerEmails);
var model = new OrganizationSeatsMaxReachedViewModel var model = new OrganizationSeatsMaxReachedViewModel
{ {
OrganizationId = organization.Id,
MaxSeatCount = maxSeatCount, MaxSeatCount = maxSeatCount,
VaultSubscriptionUrl = GetCloudVaultSubscriptionUrl(organization.Id)
}; };
await AddMessageContentAsync(message, "OrganizationSeatsMaxReached", model); await AddMessageContentAsync(message, "OrganizationSeatsMaxReached", model);
@ -1103,8 +1103,8 @@ public class HandlebarsMailService : IMailService
var message = CreateDefaultMessage($"{organization.DisplayName()} Secrets Manager Seat Limit Reached", ownerEmails); var message = CreateDefaultMessage($"{organization.DisplayName()} Secrets Manager Seat Limit Reached", ownerEmails);
var model = new OrganizationSeatsMaxReachedViewModel var model = new OrganizationSeatsMaxReachedViewModel
{ {
OrganizationId = organization.Id,
MaxSeatCount = maxSeatCount, MaxSeatCount = maxSeatCount,
VaultSubscriptionUrl = GetCloudVaultSubscriptionUrl(organization.Id)
}; };
await AddMessageContentAsync(message, "OrganizationSmSeatsMaxReached", model); await AddMessageContentAsync(message, "OrganizationSmSeatsMaxReached", model);
@ -1118,8 +1118,8 @@ public class HandlebarsMailService : IMailService
var message = CreateDefaultMessage($"{organization.DisplayName()} Secrets Manager Machine Accounts Limit Reached", ownerEmails); var message = CreateDefaultMessage($"{organization.DisplayName()} Secrets Manager Machine Accounts Limit Reached", ownerEmails);
var model = new OrganizationServiceAccountsMaxReachedViewModel var model = new OrganizationServiceAccountsMaxReachedViewModel
{ {
OrganizationId = organization.Id,
MaxServiceAccountsCount = maxSeatCount, MaxServiceAccountsCount = maxSeatCount,
VaultSubscriptionUrl = GetCloudVaultSubscriptionUrl(organization.Id)
}; };
await AddMessageContentAsync(message, "OrganizationSmServiceAccountsMaxReached", model); await AddMessageContentAsync(message, "OrganizationSmServiceAccountsMaxReached", model);
@ -1223,4 +1223,11 @@ public class HandlebarsMailService : IMailService
{ {
return string.IsNullOrEmpty(userName) ? email : CoreHelpers.SanitizeForEmail(userName, false); 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

@ -326,14 +326,14 @@ public class SendService : ISendService
return; return;
} }
var sendPolicyRequirement = await _policyRequirementQuery.GetAsync<SendPolicyRequirement>(userId.Value); var disableSendRequirement = await _policyRequirementQuery.GetAsync<DisableSendPolicyRequirement>(userId.Value);
if (disableSendRequirement.DisableSend)
if (sendPolicyRequirement.DisableSend)
{ {
throw new BadRequestException("Due to an Enterprise Policy, you are only able to delete an existing Send."); 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."); 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

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

View File

@ -250,6 +250,11 @@ public class DeviceValidator(
var customResponse = new Dictionary<string, object>(); var customResponse = new Dictionary<string, object>();
switch (errorType) 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: case DeviceValidationResultType.InvalidUser:
result.ErrorDescription = "Invalid user"; result.ErrorDescription = "Invalid user";
customResponse.Add("ErrorModel", new ErrorResponseModel("invalid user")); customResponse.Add("ErrorModel", new ErrorResponseModel("invalid user"));

View File

@ -1,5 +1,4 @@
using System.Text.Json; using System.Text.Json;
using Bit.Core;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.Auth.Enums; using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Identity.TokenProviders; using Bit.Core.Auth.Identity.TokenProviders;
@ -155,12 +154,9 @@ public class TwoFactorAuthenticationValidator(
return false; 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 // 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 using var connection = new SqlConnection(ConnectionString);
await connection.ExecuteAsync( await connection.ExecuteAsync(
"[dbo].[OrganizationUser_SetStatusForUsersById]", "[dbo].[OrganizationUser_SetStatusForUsersByGuidIdArray]",
new { OrganizationUserIds = JsonSerializer.Serialize(organizationUserIds), Status = OrganizationUserStatusType.Revoked }, new { OrganizationUserIds = organizationUserIds.ToGuidIdArrayTVP(), Status = OrganizationUserStatusType.Revoked },
commandType: CommandType.StoredProcedure); 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

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.Implementations;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Test.Common.AutoFixture.Attributes; using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute; using NSubstitute;
@ -11,50 +11,72 @@ namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies;
[SutProviderCustomize] [SutProviderCustomize]
public class PolicyRequirementQueryTests public class PolicyRequirementQueryTests
{ {
/// <summary>
/// Tests that the query correctly registers, retrieves and instantiates arbitrary IPolicyRequirements
/// according to their provided CreateRequirement delegate.
/// </summary>
[Theory, BitAutoData] [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 policyRepository = Substitute.For<IPolicyRepository>();
var factories = new List<RequirementFactory<IPolicyRequirement>> policyRepository.GetPolicyDetailsByUserId(userId).Returns([otherPolicy, thisPolicy]);
{
// In prod this cast is handled when the CreateRequirement delegate is registered in DI
(RequirementFactory<TestPolicyRequirement>)TestPolicyRequirement.Create
};
var sut = new PolicyRequirementQuery(policyRepository, factories); var factory = new TestPolicyRequirementFactory(_ => true);
policyRepository.GetPolicyDetailsByUserId(userId).Returns([ var sut = new PolicyRequirementQuery(policyRepository, [factory]);
new PolicyDetails
{
OrganizationId = organizationId
}
]);
var requirement = await sut.GetAsync<TestPolicyRequirement>(userId); var requirement = await sut.GetAsync<TestPolicyRequirement>(userId);
Assert.Equal(organizationId, requirement.OrganizationId);
Assert.Contains(thisPolicy, requirement.Policies);
Assert.DoesNotContain(otherPolicy, requirement.Policies);
} }
[Theory, BitAutoData] [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 policyRepository = Substitute.For<IPolicyRepository>();
var sut = new PolicyRequirementQuery(policyRepository, []); var sut = new PolicyRequirementQuery(policyRepository, []);
var exception = await Assert.ThrowsAsync<NotImplementedException>(() var exception = await Assert.ThrowsAsync<NotImplementedException>(()
=> sut.GetAsync<TestPolicyRequirement>(userId)); => sut.GetAsync<TestPolicyRequirement>(userId));
Assert.Contains("No Policy Requirement found", exception.Message); Assert.Contains("No Requirement Factory found", exception.Message);
} }
/// <summary> [Theory, BitAutoData]
/// Intentionally simplified PolicyRequirement that just holds the Policy.OrganizationId for us to assert against. public async Task GetAsync_HandlesNoPolicies(Guid userId)
/// </summary>
private class TestPolicyRequirement : IPolicyRequirement
{ {
public Guid OrganizationId { get; init; } var policyRepository = Substitute.For<IPolicyRepository>();
public static TestPolicyRequirement Create(IEnumerable<PolicyDetails> policyDetails) policyRepository.GetPolicyDetailsByUserId(userId).Returns([]);
=> new() { OrganizationId = policyDetails.Single().OrganizationId };
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,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 // Disable Send policy check - vNext
private void SaveSendAsync_Setup_vNext(SutProvider<SendService> sutProvider, Send send, private void SaveSendAsync_Setup_vNext(SutProvider<SendService> sutProvider, Send send,
SendPolicyRequirement sendPolicyRequirement) DisableSendPolicyRequirement disableSendPolicyRequirement, SendOptionsPolicyRequirement sendOptionsPolicyRequirement)
{ {
sutProvider.GetDependency<IPolicyRequirementQuery>().GetAsync<SendPolicyRequirement>(send.UserId!.Value) sutProvider.GetDependency<IPolicyRequirementQuery>().GetAsync<DisableSendPolicyRequirement>(send.UserId!.Value)
.Returns(sendPolicyRequirement); .Returns(disableSendPolicyRequirement);
sutProvider.GetDependency<IPolicyRequirementQuery>().GetAsync<SendOptionsPolicyRequirement>(send.UserId!.Value)
.Returns(sendOptionsPolicyRequirement);
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true); sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true);
// Should not be called in these tests // Should not be called in these tests
@ -141,7 +143,7 @@ public class SendServiceTests
SutProvider<SendService> sutProvider, [NewUserSendCustomize] Send send) SutProvider<SendService> sutProvider, [NewUserSendCustomize] Send send)
{ {
send.Type = sendType; 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)); 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.", 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) SutProvider<SendService> sutProvider, [NewUserSendCustomize] Send send)
{ {
send.Type = sendType; send.Type = sendType;
SaveSendAsync_Setup_vNext(sutProvider, send, new SendPolicyRequirement()); SaveSendAsync_Setup_vNext(sutProvider, send, new DisableSendPolicyRequirement(), new SendOptionsPolicyRequirement());
await sutProvider.Sut.SaveSendAsync(send); await sutProvider.Sut.SaveSendAsync(send);
@ -171,7 +173,7 @@ public class SendServiceTests
SutProvider<SendService> sutProvider, [NewUserSendCustomize] Send send) SutProvider<SendService> sutProvider, [NewUserSendCustomize] Send send)
{ {
send.Type = sendType; 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; send.HideEmail = true;
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.SaveSendAsync(send)); var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.SaveSendAsync(send));
@ -185,7 +187,7 @@ public class SendServiceTests
SutProvider<SendService> sutProvider, [NewUserSendCustomize] Send send) SutProvider<SendService> sutProvider, [NewUserSendCustomize] Send send)
{ {
send.Type = sendType; 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; send.HideEmail = false;
await sutProvider.Sut.SaveSendAsync(send); await sutProvider.Sut.SaveSendAsync(send);
@ -200,7 +202,7 @@ public class SendServiceTests
SutProvider<SendService> sutProvider, [NewUserSendCustomize] Send send) SutProvider<SendService> sutProvider, [NewUserSendCustomize] Send send)
{ {
send.Type = sendType; send.Type = sendType;
SaveSendAsync_Setup_vNext(sutProvider, send, new SendPolicyRequirement()); SaveSendAsync_Setup_vNext(sutProvider, send, new DisableSendPolicyRequirement(), new SendOptionsPolicyRequirement());
send.HideEmail = true; send.HideEmail = true;
await sutProvider.Sut.SaveSendAsync(send); await sutProvider.Sut.SaveSendAsync(send);

View File

@ -602,6 +602,78 @@ public class CipherServiceTests
Assert.NotEqual(initialRevisionDate, cipher.RevisionDate); 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] [Theory]
[BitAutoData] [BitAutoData]
public async Task RestoreManyAsync_UpdatesCiphers(ICollection<CipherDetails> ciphers, public async Task RestoreManyAsync_UpdatesCiphers(ICollection<CipherDetails> ciphers,
@ -725,6 +797,188 @@ public class CipherServiceTests
Arg.Is<IEnumerable<Cipher>>(arg => !arg.Except(ciphers).Any())); Arg.Is<IEnumerable<Cipher>>(arg => !arg.Except(ciphers).Any()));
} }
[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) private async Task AssertNoActionsAsync(SutProvider<CipherService> sutProvider)
{ {
await sutProvider.GetDependency<ICipherRepository>().DidNotReceiveWithAnyArgs().GetManyOrganizationDetailsByOrganizationIdAsync(default); 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.Enums;
using Bit.Core.Auth.Identity.TokenProviders; using Bit.Core.Auth.Identity.TokenProviders;
using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Models.Business.Tokenables;
@ -464,7 +463,6 @@ public class TwoFactorAuthenticationValidatorTests
user.TwoFactorRecoveryCode = token; user.TwoFactorRecoveryCode = token;
_userService.RecoverTwoFactorAsync(Arg.Is(user), Arg.Is(token)).Returns(true); _userService.RecoverTwoFactorAsync(Arg.Is(user), Arg.Is(token)).Returns(true);
_featureService.IsEnabled(FeatureFlagKeys.RecoveryCodeLogin).Returns(true);
// Act // Act
var result = await _sut.VerifyTwoFactorAsync( var result = await _sut.VerifyTwoFactorAsync(
@ -486,7 +484,6 @@ public class TwoFactorAuthenticationValidatorTests
user.TwoFactorRecoveryCode = token; user.TwoFactorRecoveryCode = token;
_userService.RecoverTwoFactorAsync(Arg.Is(user), Arg.Is(token)).Returns(false); _userService.RecoverTwoFactorAsync(Arg.Is(user), Arg.Is(token)).Returns(false);
_featureService.IsEnabled(FeatureFlagKeys.RecoveryCodeLogin).Returns(true);
// Act // Act
var result = await _sut.VerifyTwoFactorAsync( var result = await _sut.VerifyTwoFactorAsync(

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