From cfa8d4a16540615fe735fdf59bba5063dfbd2374 Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Mon, 12 Jan 2026 10:45:41 -0600 Subject: [PATCH 01/49] [PM-29604] [PM-29605] [PM-29606] Support premium subscription page redesign (#6821) * feat(get-subscription): Add EnumMemberJsonConverter * feat(get-subscription): Add BitwardenDiscount model * feat(get-subscription): Add Cart model * feat(get-subscription): Add Storage model * feat(get-subscription): Add BitwardenSubscription model * feat(get-subscription): Add DiscountExtensions * feat(get-subscription): Add error code to StripeConstants * feat(get-subscription): Add GetBitwardenSubscriptionQuery * feat(get-subscription): Expose GET /account/billing/vnext/subscription * feat(reinstate-subscription): Add ReinstateSubscriptionCommand * feat(reinstate-subscription): Expose POST /account/billing/vnext/subscription/reinstate * feat(pay-with-paypal-immediately): Add SubscriberId union * feat(pay-with-paypal-immediately): Add BraintreeService with PayInvoice method * feat(pay-with-paypal-immediately): Pay PayPal invoice immediately when starting premium subscription * feat(pay-with-paypal-immediately): Pay invoice with Braintree on invoice.created for subscription cycles only * fix(update-storage): Always invoice for premium storage update * fix(update-storage): Move endpoint to subscription path * docs: Note FF removal POIs * (format): Run dotnet format --- .../Billing/Controllers/AccountsController.cs | 12 +- .../VNext/AccountBillingVNextController.cs | 28 +- .../Requests/Storage/StorageUpdateRequest.cs | 5 +- .../Response/SubscriptionResponseModel.cs | 1 + .../Implementations/InvoiceCreatedHandler.cs | 12 +- src/Core/Billing/Constants/StripeConstants.cs | 3 + src/Core/Billing/Enums/PlanCadenceType.cs | 6 +- .../Billing/Extensions/DiscountExtensions.cs | 12 + .../Extensions/ServiceCollectionExtensions.cs | 6 + ...tePremiumCloudHostedSubscriptionCommand.cs | 17 +- .../Commands/UpdatePremiumStorageCommand.cs | 24 +- .../Commands/ReinstateSubscriptionCommand.cs | 42 ++ .../Subscriptions/Models/BitwardenDiscount.cs | 61 ++ .../Models/BitwardenSubscription.cs | 52 ++ src/Core/Billing/Subscriptions/Models/Cart.cs | 83 +++ .../Billing/Subscriptions/Models/Storage.cs | 52 ++ .../Subscriptions/Models/SubscriberId.cs | 43 ++ .../Queries/GetBitwardenSubscriptionQuery.cs | 201 ++++++ src/Core/Services/IBraintreeService.cs | 11 + .../Implementations/BraintreeService.cs | 107 +++ .../Services/Implementations/UserService.cs | 2 + src/Core/Utilities/EnumMemberJsonConverter.cs | 52 ++ .../AccountBillingVNextControllerTests.cs | 24 +- ...miumCloudHostedSubscriptionCommandTests.cs | 30 +- .../UpdatePremiumStorageCommandTests.cs | 31 +- .../GetBitwardenSubscriptionQueryTests.cs | 607 ++++++++++++++++++ .../Utilities/EnumMemberJsonConverterTests.cs | 219 +++++++ 27 files changed, 1676 insertions(+), 67 deletions(-) create mode 100644 src/Core/Billing/Extensions/DiscountExtensions.cs create mode 100644 src/Core/Billing/Subscriptions/Commands/ReinstateSubscriptionCommand.cs create mode 100644 src/Core/Billing/Subscriptions/Models/BitwardenDiscount.cs create mode 100644 src/Core/Billing/Subscriptions/Models/BitwardenSubscription.cs create mode 100644 src/Core/Billing/Subscriptions/Models/Cart.cs create mode 100644 src/Core/Billing/Subscriptions/Models/Storage.cs create mode 100644 src/Core/Billing/Subscriptions/Models/SubscriberId.cs create mode 100644 src/Core/Billing/Subscriptions/Queries/GetBitwardenSubscriptionQuery.cs create mode 100644 src/Core/Services/IBraintreeService.cs create mode 100644 src/Core/Services/Implementations/BraintreeService.cs create mode 100644 src/Core/Utilities/EnumMemberJsonConverter.cs create mode 100644 test/Core.Test/Billing/Subscriptions/Queries/GetBitwardenSubscriptionQueryTests.cs create mode 100644 test/Core.Test/Utilities/EnumMemberJsonConverterTests.cs diff --git a/src/Api/Billing/Controllers/AccountsController.cs b/src/Api/Billing/Controllers/AccountsController.cs index e3410de503..c90b927bee 100644 --- a/src/Api/Billing/Controllers/AccountsController.cs +++ b/src/Api/Billing/Controllers/AccountsController.cs @@ -22,7 +22,7 @@ public class AccountsController( IFeatureService featureService, ILicensingService licensingService) : Controller { - // TODO: Migrate to Query / AccountBillingVNextController as part of Premium -> Organization upgrade work. + // TODO: Remove with deletion of pm-29594-update-individual-subscription-page [HttpGet("subscription")] public async Task GetSubscriptionAsync( [FromServices] GlobalSettings globalSettings, @@ -61,7 +61,7 @@ public class AccountsController( } } - // TODO: Migrate to Command / AccountBillingVNextController as PUT /account/billing/vnext/subscription + // TODO: Remove with deletion of pm-29594-update-individual-subscription-page [HttpPost("storage")] [SelfHosted(NotSelfHostedOnly = true)] public async Task PostStorageAsync([FromBody] StorageRequestModel model) @@ -118,7 +118,7 @@ public class AccountsController( user.IsExpired()); } - // TODO: Migrate to Command / AccountBillingVNextController as POST /account/billing/vnext/subscription/reinstate + // TODO: Remove with deletion of pm-29594-update-individual-subscription-page [HttpPost("reinstate-premium")] [SelfHosted(NotSelfHostedOnly = true)] public async Task PostReinstateAsync() @@ -131,10 +131,4 @@ public class AccountsController( await userService.ReinstatePremiumAsync(user); } - - private async Task> GetOrganizationIdsClaimingUserAsync(Guid userId) - { - var organizationsClaimingUser = await userService.GetOrganizationsClaimingUserAsync(userId); - return organizationsClaimingUser.Select(o => o.Id); - } } diff --git a/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs b/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs index d1e9b9206a..6c56d6db3a 100644 --- a/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs +++ b/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs @@ -7,6 +7,8 @@ using Bit.Core.Billing.Licenses.Queries; using Bit.Core.Billing.Payment.Commands; using Bit.Core.Billing.Payment.Queries; using Bit.Core.Billing.Premium.Commands; +using Bit.Core.Billing.Subscriptions.Commands; +using Bit.Core.Billing.Subscriptions.Queries; using Bit.Core.Entities; using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; @@ -21,9 +23,11 @@ namespace Bit.Api.Billing.Controllers.VNext; public class AccountBillingVNextController( ICreateBitPayInvoiceForCreditCommand createBitPayInvoiceForCreditCommand, ICreatePremiumCloudHostedSubscriptionCommand createPremiumCloudHostedSubscriptionCommand, + IGetBitwardenSubscriptionQuery getBitwardenSubscriptionQuery, IGetCreditQuery getCreditQuery, IGetPaymentMethodQuery getPaymentMethodQuery, IGetUserLicenseQuery getUserLicenseQuery, + IReinstateSubscriptionCommand reinstateSubscriptionCommand, IUpdatePaymentMethodCommand updatePaymentMethodCommand, IUpdatePremiumStorageCommand updatePremiumStorageCommand, IUpgradePremiumToOrganizationCommand upgradePremiumToOrganizationCommand) : BaseBillingController @@ -91,10 +95,30 @@ public class AccountBillingVNextController( return TypedResults.Ok(response); } - [HttpPut("storage")] + [HttpGet("subscription")] [RequireFeature(FeatureFlagKeys.PM29594_UpdateIndividualSubscriptionPage)] [InjectUser] - public async Task UpdateStorageAsync( + public async Task GetSubscriptionAsync( + [BindNever] User user) + { + var subscription = await getBitwardenSubscriptionQuery.Run(user); + return TypedResults.Ok(subscription); + } + + [HttpPost("subscription/reinstate")] + [RequireFeature(FeatureFlagKeys.PM29594_UpdateIndividualSubscriptionPage)] + [InjectUser] + public async Task ReinstateSubscriptionAsync( + [BindNever] User user) + { + var result = await reinstateSubscriptionCommand.Run(user); + return Handle(result); + } + + [HttpPut("subscription/storage")] + [RequireFeature(FeatureFlagKeys.PM29594_UpdateIndividualSubscriptionPage)] + [InjectUser] + public async Task UpdateSubscriptionStorageAsync( [BindNever] User user, [FromBody] StorageUpdateRequest request) { diff --git a/src/Api/Billing/Models/Requests/Storage/StorageUpdateRequest.cs b/src/Api/Billing/Models/Requests/Storage/StorageUpdateRequest.cs index 0b18fc1e6f..fe0c8e9e17 100644 --- a/src/Api/Billing/Models/Requests/Storage/StorageUpdateRequest.cs +++ b/src/Api/Billing/Models/Requests/Storage/StorageUpdateRequest.cs @@ -13,7 +13,6 @@ public class StorageUpdateRequest : IValidatableObject /// Must be between 0 and the maximum allowed (minus base storage). /// [Required] - [Range(0, 99)] public short AdditionalStorageGb { get; set; } public IEnumerable Validate(ValidationContext validationContext) @@ -22,14 +21,14 @@ public class StorageUpdateRequest : IValidatableObject { yield return new ValidationResult( "Additional storage cannot be negative.", - new[] { nameof(AdditionalStorageGb) }); + [nameof(AdditionalStorageGb)]); } if (AdditionalStorageGb > 99) { yield return new ValidationResult( "Maximum additional storage is 99 GB.", - new[] { nameof(AdditionalStorageGb) }); + [nameof(AdditionalStorageGb)]); } } } diff --git a/src/Api/Models/Response/SubscriptionResponseModel.cs b/src/Api/Models/Response/SubscriptionResponseModel.cs index 32d12aa416..a357264081 100644 --- a/src/Api/Models/Response/SubscriptionResponseModel.cs +++ b/src/Api/Models/Response/SubscriptionResponseModel.cs @@ -10,6 +10,7 @@ using Bit.Core.Utilities; namespace Bit.Api.Models.Response; +// TODO: Remove with deletion of pm-29594-update-individual-subscription-page public class SubscriptionResponseModel : ResponseModel { diff --git a/src/Billing/Services/Implementations/InvoiceCreatedHandler.cs b/src/Billing/Services/Implementations/InvoiceCreatedHandler.cs index 101b0e26b9..0db498844e 100644 --- a/src/Billing/Services/Implementations/InvoiceCreatedHandler.cs +++ b/src/Billing/Services/Implementations/InvoiceCreatedHandler.cs @@ -1,12 +1,13 @@ using Bit.Core.Billing.Constants; +using Bit.Core.Services; using Event = Stripe.Event; namespace Bit.Billing.Services.Implementations; public class InvoiceCreatedHandler( + IBraintreeService braintreeService, ILogger logger, IStripeEventService stripeEventService, - IStripeEventUtilityService stripeEventUtilityService, IProviderEventService providerEventService) : IInvoiceCreatedHandler { @@ -29,9 +30,9 @@ public class InvoiceCreatedHandler( { try { - var invoice = await stripeEventService.GetInvoice(parsedEvent, true, ["customer"]); + var invoice = await stripeEventService.GetInvoice(parsedEvent, true, ["customer", "parent.subscription_details.subscription"]); - var usingPayPal = invoice.Customer?.Metadata.ContainsKey("btCustomerId") ?? false; + var usingPayPal = invoice.Customer.Metadata.ContainsKey("btCustomerId"); if (usingPayPal && invoice is { @@ -39,13 +40,12 @@ public class InvoiceCreatedHandler( Status: not StripeConstants.InvoiceStatus.Paid, CollectionMethod: "charge_automatically", BillingReason: - "subscription_create" or "subscription_cycle" or "automatic_pending_invoice_item_invoice", - Parent.SubscriptionDetails: not null + Parent.SubscriptionDetails.Subscription: not null }) { - await stripeEventUtilityService.AttemptToPayInvoiceAsync(invoice); + await braintreeService.PayInvoice(invoice.Parent.SubscriptionDetails.Subscription, invoice); } } catch (Exception exception) diff --git a/src/Core/Billing/Constants/StripeConstants.cs b/src/Core/Billing/Constants/StripeConstants.cs index d25962a7ba..e9c34d7e06 100644 --- a/src/Core/Billing/Constants/StripeConstants.cs +++ b/src/Core/Billing/Constants/StripeConstants.cs @@ -42,6 +42,7 @@ public static class StripeConstants public static class ErrorCodes { public const string CustomerTaxLocationInvalid = "customer_tax_location_invalid"; + public const string InvoiceUpcomingNone = "invoice_upcoming_none"; public const string PaymentMethodMicroDepositVerificationAttemptsExceeded = "payment_method_microdeposit_verification_attempts_exceeded"; public const string PaymentMethodMicroDepositVerificationDescriptorCodeMismatch = "payment_method_microdeposit_verification_descriptor_code_mismatch"; public const string PaymentMethodMicroDepositVerificationTimeout = "payment_method_microdeposit_verification_timeout"; @@ -65,8 +66,10 @@ public static class StripeConstants public static class MetadataKeys { public const string BraintreeCustomerId = "btCustomerId"; + public const string BraintreeTransactionId = "btTransactionId"; public const string InvoiceApproved = "invoice_approved"; public const string OrganizationId = "organizationId"; + public const string PayPalTransactionId = "btPayPalTransactionId"; public const string PreviousAdditionalStorage = "previous_additional_storage"; public const string PreviousPeriodEndDate = "previous_period_end_date"; public const string PreviousPremiumPriceId = "previous_premium_price_id"; diff --git a/src/Core/Billing/Enums/PlanCadenceType.cs b/src/Core/Billing/Enums/PlanCadenceType.cs index 9e6fa69832..20421bc2af 100644 --- a/src/Core/Billing/Enums/PlanCadenceType.cs +++ b/src/Core/Billing/Enums/PlanCadenceType.cs @@ -1,7 +1,11 @@ -namespace Bit.Core.Billing.Enums; +using System.Runtime.Serialization; + +namespace Bit.Core.Billing.Enums; public enum PlanCadenceType { + [EnumMember(Value = "annually")] Annually, + [EnumMember(Value = "monthly")] Monthly } diff --git a/src/Core/Billing/Extensions/DiscountExtensions.cs b/src/Core/Billing/Extensions/DiscountExtensions.cs new file mode 100644 index 0000000000..6d5b91bd89 --- /dev/null +++ b/src/Core/Billing/Extensions/DiscountExtensions.cs @@ -0,0 +1,12 @@ +using Stripe; + +namespace Bit.Core.Billing.Extensions; + +public static class DiscountExtensions +{ + public static bool AppliesTo(this Discount discount, SubscriptionItem subscriptionItem) + => discount.Coupon.AppliesTo.Products.Contains(subscriptionItem.Price.Product.Id); + + public static bool IsValid(this Discount? discount) + => discount?.Coupon?.Valid ?? false; +} diff --git a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs index d121ab04aa..c61c4e6279 100644 --- a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs +++ b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs @@ -12,8 +12,11 @@ using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Billing.Services.Implementations; using Bit.Core.Billing.Subscriptions.Commands; +using Bit.Core.Billing.Subscriptions.Queries; using Bit.Core.Billing.Tax.Services; using Bit.Core.Billing.Tax.Services.Implementations; +using Bit.Core.Services; +using Bit.Core.Services.Implementations; namespace Bit.Core.Billing.Extensions; @@ -39,6 +42,9 @@ public static class ServiceCollectionExtensions services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); } private static void AddOrganizationLicenseCommandsQueries(this IServiceCollection services) diff --git a/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs b/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs index ed60e2f11c..d52c79c1ee 100644 --- a/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs +++ b/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs @@ -7,6 +7,7 @@ using Bit.Core.Billing.Payment.Models; using Bit.Core.Billing.Payment.Queries; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; +using Bit.Core.Billing.Subscriptions.Models; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Platform.Push; @@ -49,6 +50,7 @@ public interface ICreatePremiumCloudHostedSubscriptionCommand public class CreatePremiumCloudHostedSubscriptionCommand( IBraintreeGateway braintreeGateway, + IBraintreeService braintreeService, IGlobalSettings globalSettings, ISetupIntentCache setupIntentCache, IStripeAdapter stripeAdapter, @@ -300,6 +302,7 @@ public class CreatePremiumCloudHostedSubscriptionCommand( ValidateLocation = ValidateTaxLocationTiming.Immediately } }; + return await stripeAdapter.UpdateCustomerAsync(customer.Id, options); } @@ -351,14 +354,18 @@ public class CreatePremiumCloudHostedSubscriptionCommand( var subscription = await stripeAdapter.CreateSubscriptionAsync(subscriptionCreateOptions); - if (usingPayPal) + if (!usingPayPal) { - await stripeAdapter.UpdateInvoiceAsync(subscription.LatestInvoiceId, new InvoiceUpdateOptions - { - AutoAdvance = false - }); + return subscription; } + var invoice = await stripeAdapter.UpdateInvoiceAsync(subscription.LatestInvoiceId, new InvoiceUpdateOptions + { + AutoAdvance = false + }); + + await braintreeService.PayInvoice(new UserId(userId), invoice); + return subscription; } } diff --git a/src/Core/Billing/Premium/Commands/UpdatePremiumStorageCommand.cs b/src/Core/Billing/Premium/Commands/UpdatePremiumStorageCommand.cs index 610c112e08..176c77bf57 100644 --- a/src/Core/Billing/Premium/Commands/UpdatePremiumStorageCommand.cs +++ b/src/Core/Billing/Premium/Commands/UpdatePremiumStorageCommand.cs @@ -1,4 +1,5 @@ using Bit.Core.Billing.Commands; +using Bit.Core.Billing.Constants; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Entities; @@ -10,6 +11,8 @@ using Stripe; namespace Bit.Core.Billing.Premium.Commands; +using static StripeConstants; + /// /// Updates the storage allocation for a premium user's subscription. /// Handles both increases and decreases in storage in an idempotent manner. @@ -34,14 +37,14 @@ public class UpdatePremiumStorageCommand( { public Task> Run(User user, short additionalStorageGb) => HandleAsync(async () => { - if (!user.Premium) + if (user is not { Premium: true, GatewaySubscriptionId: not null and not "" }) { return new BadRequest("User does not have a premium subscription."); } if (!user.MaxStorageGb.HasValue) { - return new BadRequest("No access to storage."); + return new BadRequest("User has no access to storage."); } // Fetch all premium plans and the user's subscription to find which plan they're on @@ -54,7 +57,7 @@ public class UpdatePremiumStorageCommand( if (passwordManagerItem == null) { - return new BadRequest("Premium subscription item not found."); + return new Conflict("Premium subscription does not have a Password Manager line item."); } var premiumPlan = premiumPlans.First(p => p.Seat.StripePriceId == passwordManagerItem.Price.Id); @@ -66,20 +69,20 @@ public class UpdatePremiumStorageCommand( return new BadRequest("Additional storage cannot be negative."); } - var newTotalStorageGb = (short)(baseStorageGb + additionalStorageGb); + var maxStorageGb = (short)(baseStorageGb + additionalStorageGb); - if (newTotalStorageGb > 100) + if (maxStorageGb > 100) { return new BadRequest("Maximum storage is 100 GB."); } // Idempotency check: if user already has the requested storage, return success - if (user.MaxStorageGb == newTotalStorageGb) + if (user.MaxStorageGb == maxStorageGb) { return new None(); } - var remainingStorage = user.StorageBytesRemaining(newTotalStorageGb); + var remainingStorage = user.StorageBytesRemaining(maxStorageGb); if (remainingStorage < 0) { return new BadRequest( @@ -124,21 +127,18 @@ public class UpdatePremiumStorageCommand( }); } - // Update subscription with prorations - // Storage is billed annually, so we create prorations and invoice immediately var subscriptionUpdateOptions = new SubscriptionUpdateOptions { Items = subscriptionItemOptions, - ProrationBehavior = Core.Constants.CreateProrations + ProrationBehavior = ProrationBehavior.AlwaysInvoice }; await stripeAdapter.UpdateSubscriptionAsync(subscription.Id, subscriptionUpdateOptions); // Update the user's max storage - user.MaxStorageGb = newTotalStorageGb; + user.MaxStorageGb = maxStorageGb; await userService.SaveUserAsync(user); - // No payment intent needed - the subscription update will automatically create and finalize the invoice return new None(); }); } diff --git a/src/Core/Billing/Subscriptions/Commands/ReinstateSubscriptionCommand.cs b/src/Core/Billing/Subscriptions/Commands/ReinstateSubscriptionCommand.cs new file mode 100644 index 0000000000..e7d988a107 --- /dev/null +++ b/src/Core/Billing/Subscriptions/Commands/ReinstateSubscriptionCommand.cs @@ -0,0 +1,42 @@ +using Bit.Core.Billing.Commands; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Services; +using Bit.Core.Entities; +using Microsoft.Extensions.Logging; +using OneOf.Types; +using Stripe; + +namespace Bit.Core.Billing.Subscriptions.Commands; + +using static StripeConstants; + +public interface IReinstateSubscriptionCommand +{ + Task> Run(ISubscriber subscriber); +} + +public class ReinstateSubscriptionCommand( + ILogger logger, + IStripeAdapter stripeAdapter) : BaseBillingCommand(logger), IReinstateSubscriptionCommand +{ + public Task> Run(ISubscriber subscriber) => HandleAsync(async () => + { + var subscription = await stripeAdapter.GetSubscriptionAsync(subscriber.GatewaySubscriptionId); + + if (subscription is not + { + Status: SubscriptionStatus.Trialing or SubscriptionStatus.Active, + CancelAt: not null + }) + { + return new BadRequest("Subscription is not pending cancellation."); + } + + await stripeAdapter.UpdateSubscriptionAsync(subscription.Id, new SubscriptionUpdateOptions + { + CancelAtPeriodEnd = false + }); + + return new None(); + }); +} diff --git a/src/Core/Billing/Subscriptions/Models/BitwardenDiscount.cs b/src/Core/Billing/Subscriptions/Models/BitwardenDiscount.cs new file mode 100644 index 0000000000..dde005b7bd --- /dev/null +++ b/src/Core/Billing/Subscriptions/Models/BitwardenDiscount.cs @@ -0,0 +1,61 @@ +using System.Runtime.Serialization; +using System.Text.Json.Serialization; +using Bit.Core.Utilities; +using Stripe; + +namespace Bit.Core.Billing.Subscriptions.Models; + +/// +/// The type of discounts Bitwarden supports. +/// +public enum BitwardenDiscountType +{ + [EnumMember(Value = "amount-off")] + AmountOff, + + [EnumMember(Value = "percent-off")] + PercentOff +} + +/// +/// A record representing a discount applied to a Bitwarden subscription. +/// +public record BitwardenDiscount +{ + /// + /// The type of the discount. + /// + [JsonConverter(typeof(EnumMemberJsonConverter))] + public required BitwardenDiscountType Type { get; init; } + + /// + /// The value of the discount. + /// + public required decimal Value { get; init; } + + public static implicit operator BitwardenDiscount(Discount? discount) + { + if (discount is not + { + Coupon.Valid: true + }) + { + return null!; + } + + return discount.Coupon switch + { + { AmountOff: > 0 } => new BitwardenDiscount + { + Type = BitwardenDiscountType.AmountOff, + Value = discount.Coupon.AmountOff.Value + }, + { PercentOff: > 0 } => new BitwardenDiscount + { + Type = BitwardenDiscountType.PercentOff, + Value = discount.Coupon.PercentOff.Value + }, + _ => null! + }; + } +} diff --git a/src/Core/Billing/Subscriptions/Models/BitwardenSubscription.cs b/src/Core/Billing/Subscriptions/Models/BitwardenSubscription.cs new file mode 100644 index 0000000000..5643b35cda --- /dev/null +++ b/src/Core/Billing/Subscriptions/Models/BitwardenSubscription.cs @@ -0,0 +1,52 @@ +namespace Bit.Core.Billing.Subscriptions.Models; + +public record BitwardenSubscription +{ + /// + /// The status of the subscription. + /// + public required string Status { get; init; } + + /// + /// The subscription's cart, including line items, any discounts, and estimated tax. + /// + public required Cart Cart { get; init; } + + /// + /// The amount of storage available and used for the subscription. + /// Allowed Subscribers: User, Organization + /// + public Storage? Storage { get; init; } + + /// + /// If the subscription is pending cancellation, the date at which the + /// subscription will be canceled. + /// Allowed Statuses: 'trialing', 'active' + /// + public DateTime? CancelAt { get; init; } + + /// + /// The date the subscription was canceled. + /// Allowed Statuses: 'canceled' + /// + public DateTime? Canceled { get; init; } + + /// + /// The date of the next charge for the subscription. + /// Allowed Statuses: 'trialing', 'active' + /// + public DateTime? NextCharge { get; init; } + + /// + /// The date the subscription will be or was suspended due to lack of payment. + /// Allowed Statuses: 'incomplete', 'incomplete_expired', 'past_due', 'unpaid' + /// + public DateTime? Suspension { get; init; } + + /// + /// The number of days after the subscription goes 'past_due' the subscriber has to resolve their + /// open invoices before the subscription is suspended. + /// Allowed Statuses: 'past_due' + /// + public int? GracePeriod { get; init; } +} diff --git a/src/Core/Billing/Subscriptions/Models/Cart.cs b/src/Core/Billing/Subscriptions/Models/Cart.cs new file mode 100644 index 0000000000..e7c08919d9 --- /dev/null +++ b/src/Core/Billing/Subscriptions/Models/Cart.cs @@ -0,0 +1,83 @@ +using System.Text.Json.Serialization; +using Bit.Core.Billing.Enums; +using Bit.Core.Utilities; + +namespace Bit.Core.Billing.Subscriptions.Models; + +public record CartItem +{ + /// + /// The client-side translation key for the name of the cart item. + /// + public required string TranslationKey { get; init; } + + /// + /// The quantity of the cart item. + /// + public required long Quantity { get; init; } + + /// + /// The unit-cost of the cart item. + /// + public required decimal Cost { get; init; } + + /// + /// An optional discount applied specifically to this cart item. + /// + public BitwardenDiscount? Discount { get; init; } +} + +public record PasswordManagerCartItems +{ + /// + /// The Password Manager seats in the cart. + /// + public required CartItem Seats { get; init; } + + /// + /// The additional storage in the cart. + /// + public CartItem? AdditionalStorage { get; init; } +} + +public record SecretsManagerCartItems +{ + /// + /// The Secrets Manager seats in the cart. + /// + public required CartItem Seats { get; init; } + + /// + /// The additional service accounts in the cart. + /// + public CartItem? AdditionalServiceAccounts { get; init; } +} + +public record Cart +{ + /// + /// The Password Manager items in the cart. + /// + public required PasswordManagerCartItems PasswordManager { get; init; } + + /// + /// The Secrets Manager items in the cart. + /// + public SecretsManagerCartItems? SecretsManager { get; init; } + + /// + /// The cart's billing cadence. + /// + [JsonConverter(typeof(EnumMemberJsonConverter))] + public PlanCadenceType Cadence { get; init; } + + /// + /// An optional discount applied to the entire cart. + /// + public BitwardenDiscount? Discount { get; init; } + + /// + /// The estimated tax for the cart. + /// + public required decimal EstimatedTax { get; init; } +} diff --git a/src/Core/Billing/Subscriptions/Models/Storage.cs b/src/Core/Billing/Subscriptions/Models/Storage.cs new file mode 100644 index 0000000000..cd26579bee --- /dev/null +++ b/src/Core/Billing/Subscriptions/Models/Storage.cs @@ -0,0 +1,52 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Entities; +using Bit.Core.Utilities; +using OneOf; + +namespace Bit.Core.Billing.Subscriptions.Models; + +public record Storage +{ + private const double _bytesPerGibibyte = 1073741824D; + + /// + /// The amount of storage the subscriber has available. + /// + public required short Available { get; init; } + + /// + /// The amount of storage the subscriber has used. + /// + public required double Used { get; init; } + + /// + /// The amount of storage the subscriber has used, formatted as a human-readable string. + /// + public required string ReadableUsed { get; init; } + + public static implicit operator Storage(User user) => From(user); + public static implicit operator Storage(Organization organization) => From(organization); + + private static Storage From(OneOf subscriber) + { + var maxStorageGB = subscriber.Match( + user => user.MaxStorageGb, + organization => organization.MaxStorageGb); + + if (maxStorageGB == null) + { + return null!; + } + + var storage = subscriber.Match( + user => user.Storage, + organization => organization.Storage); + + return new Storage + { + Available = maxStorageGB.Value, + Used = Math.Round((storage ?? 0) / _bytesPerGibibyte, 2), + ReadableUsed = CoreHelpers.ReadableBytesSize(storage ?? 0) + }; + } +} diff --git a/src/Core/Billing/Subscriptions/Models/SubscriberId.cs b/src/Core/Billing/Subscriptions/Models/SubscriberId.cs new file mode 100644 index 0000000000..1ea842b0e6 --- /dev/null +++ b/src/Core/Billing/Subscriptions/Models/SubscriberId.cs @@ -0,0 +1,43 @@ +using Bit.Core.Billing.Constants; +using Bit.Core.Exceptions; +using OneOf; +using Stripe; + +namespace Bit.Core.Billing.Subscriptions.Models; + +using static StripeConstants; + +public record UserId(Guid Value); + +public record OrganizationId(Guid Value); + +public record ProviderId(Guid Value); + +public class SubscriberId : OneOfBase +{ + private SubscriberId(OneOf input) : base(input) { } + + public static implicit operator SubscriberId(UserId value) => new(value); + public static implicit operator SubscriberId(OrganizationId value) => new(value); + public static implicit operator SubscriberId(ProviderId value) => new(value); + + public static implicit operator SubscriberId(Subscription subscription) + { + if (subscription.Metadata.TryGetValue(MetadataKeys.UserId, out var userIdValue) + && Guid.TryParse(userIdValue, out var userId)) + { + return new UserId(userId); + } + + if (subscription.Metadata.TryGetValue(MetadataKeys.OrganizationId, out var organizationIdValue) + && Guid.TryParse(organizationIdValue, out var organizationId)) + { + return new OrganizationId(organizationId); + } + + return subscription.Metadata.TryGetValue(MetadataKeys.ProviderId, out var providerIdValue) && + Guid.TryParse(providerIdValue, out var providerId) + ? new ProviderId(providerId) + : throw new ConflictException("Subscription does not have a valid subscriber ID"); + } +} diff --git a/src/Core/Billing/Subscriptions/Queries/GetBitwardenSubscriptionQuery.cs b/src/Core/Billing/Subscriptions/Queries/GetBitwardenSubscriptionQuery.cs new file mode 100644 index 0000000000..cd7fa91fff --- /dev/null +++ b/src/Core/Billing/Subscriptions/Queries/GetBitwardenSubscriptionQuery.cs @@ -0,0 +1,201 @@ +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Extensions; +using Bit.Core.Billing.Pricing; +using Bit.Core.Billing.Services; +using Bit.Core.Billing.Subscriptions.Models; +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Microsoft.Extensions.Logging; +using OneOf; +using Stripe; + +namespace Bit.Core.Billing.Subscriptions.Queries; + +using static StripeConstants; +using static Utilities; + +public interface IGetBitwardenSubscriptionQuery +{ + /// + /// Retrieves detailed subscription information for a user, including subscription status, + /// cart items, discounts, and billing details. + /// + /// The user whose subscription information to retrieve. + /// + /// A containing the subscription details, or null if no + /// subscription is found or the subscription status is not recognized. + /// + /// + /// Currently only supports subscribers. Future versions will support all + /// types (User and Organization). + /// + Task Run(User user); +} + +public class GetBitwardenSubscriptionQuery( + ILogger logger, + IPricingClient pricingClient, + IStripeAdapter stripeAdapter) : IGetBitwardenSubscriptionQuery +{ + public async Task Run(User user) + { + var subscription = await stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, new SubscriptionGetOptions + { + Expand = + [ + "customer.discount.coupon.applies_to", + "discounts.coupon.applies_to", + "items.data.price.product", + "test_clock" + ] + }); + + var cart = await GetPremiumCartAsync(subscription); + + var baseSubscription = new BitwardenSubscription { Status = subscription.Status, Cart = cart, Storage = user }; + + switch (subscription.Status) + { + case SubscriptionStatus.Incomplete: + case SubscriptionStatus.IncompleteExpired: + return baseSubscription with { Suspension = subscription.Created.AddHours(23), GracePeriod = 1 }; + + case SubscriptionStatus.Trialing: + case SubscriptionStatus.Active: + return baseSubscription with + { + NextCharge = subscription.GetCurrentPeriodEnd(), + CancelAt = subscription.CancelAt + }; + + case SubscriptionStatus.PastDue: + case SubscriptionStatus.Unpaid: + var suspension = await GetSubscriptionSuspensionAsync(stripeAdapter, subscription); + if (suspension == null) + { + return baseSubscription; + } + return baseSubscription with { Suspension = suspension.SuspensionDate, GracePeriod = suspension.GracePeriod }; + + case SubscriptionStatus.Canceled: + return baseSubscription with { Canceled = subscription.CanceledAt }; + + default: + { + logger.LogError("Subscription ({SubscriptionID}) has an unmanaged status ({Status})", subscription.Id, subscription.Status); + throw new ConflictException("Subscription is in an invalid state. Please contact support for assistance."); + } + } + } + + private async Task GetPremiumCartAsync( + Subscription subscription) + { + var plans = await pricingClient.ListPremiumPlans(); + + var passwordManagerSeatsItem = subscription.Items.FirstOrDefault(item => + plans.Any(plan => plan.Seat.StripePriceId == item.Price.Id)); + + if (passwordManagerSeatsItem == null) + { + throw new ConflictException("Premium subscription does not have a Password Manager line item."); + } + + var additionalStorageItem = subscription.Items.FirstOrDefault(item => + plans.Any(plan => plan.Storage.StripePriceId == item.Price.Id)); + + var (cartLevelDiscount, productLevelDiscounts) = GetStripeDiscounts(subscription); + + var passwordManagerSeats = new CartItem + { + TranslationKey = "premiumMembership", + Quantity = passwordManagerSeatsItem.Quantity, + Cost = GetCost(passwordManagerSeatsItem), + Discount = productLevelDiscounts.FirstOrDefault(discount => discount.AppliesTo(passwordManagerSeatsItem)) + }; + + var additionalStorage = additionalStorageItem != null + ? new CartItem + { + TranslationKey = "additionalStorageGB", + Quantity = additionalStorageItem.Quantity, + Cost = GetCost(additionalStorageItem), + Discount = productLevelDiscounts.FirstOrDefault(discount => discount.AppliesTo(additionalStorageItem)) + } + : null; + + var estimatedTax = await EstimateTaxAsync(subscription); + + return new Cart + { + PasswordManager = new PasswordManagerCartItems + { + Seats = passwordManagerSeats, + AdditionalStorage = additionalStorage + }, + Cadence = PlanCadenceType.Annually, + Discount = cartLevelDiscount, + EstimatedTax = estimatedTax + }; + } + + #region Utilities + + private async Task EstimateTaxAsync(Subscription subscription) + { + try + { + var invoice = await stripeAdapter.CreateInvoicePreviewAsync(new InvoiceCreatePreviewOptions + { + Customer = subscription.Customer.Id, + Subscription = subscription.Id + }); + + return GetCost(invoice.TotalTaxes); + } + catch (StripeException stripeException) when + (stripeException.StripeError.Code == ErrorCodes.InvoiceUpcomingNone) + { + return 0; + } + } + + private static decimal GetCost(OneOf> value) => + value.Match( + item => (item.Price.UnitAmountDecimal ?? 0) / 100M, + taxes => taxes.Sum(invoiceTotalTax => invoiceTotalTax.Amount) / 100M); + + private static (Discount? CartLevel, List ProductLevel) GetStripeDiscounts( + Subscription subscription) + { + var discounts = new List(); + + if (subscription.Customer.Discount.IsValid()) + { + discounts.Add(subscription.Customer.Discount); + } + + discounts.AddRange(subscription.Discounts.Where(discount => discount.IsValid())); + + var cartLevel = new List(); + var productLevel = new List(); + + foreach (var discount in discounts) + { + switch (discount) + { + case { Coupon.AppliesTo.Products: null or { Count: 0 } }: + cartLevel.Add(discount); + break; + case { Coupon.AppliesTo.Products.Count: > 0 }: + productLevel.Add(discount); + break; + } + } + + return (cartLevel.FirstOrDefault(), productLevel); + } + + #endregion +} diff --git a/src/Core/Services/IBraintreeService.cs b/src/Core/Services/IBraintreeService.cs new file mode 100644 index 0000000000..166d285908 --- /dev/null +++ b/src/Core/Services/IBraintreeService.cs @@ -0,0 +1,11 @@ +using Bit.Core.Billing.Subscriptions.Models; +using Stripe; + +namespace Bit.Core.Services; + +public interface IBraintreeService +{ + Task PayInvoice( + SubscriberId subscriberId, + Invoice invoice); +} diff --git a/src/Core/Services/Implementations/BraintreeService.cs b/src/Core/Services/Implementations/BraintreeService.cs new file mode 100644 index 0000000000..e3630ed888 --- /dev/null +++ b/src/Core/Services/Implementations/BraintreeService.cs @@ -0,0 +1,107 @@ +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Services; +using Bit.Core.Billing.Subscriptions.Models; +using Bit.Core.Exceptions; +using Bit.Core.Settings; +using Braintree; +using Microsoft.Extensions.Logging; +using Stripe; + +namespace Bit.Core.Services.Implementations; + +using static StripeConstants; + +public class BraintreeService( + IBraintreeGateway braintreeGateway, + IGlobalSettings globalSettings, + ILogger logger, + IMailService mailService, + IStripeAdapter stripeAdapter) : IBraintreeService +{ + private readonly ConflictException _problemPayingInvoice = new("There was a problem paying for your invoice. Please contact customer support."); + + public async Task PayInvoice( + SubscriberId subscriberId, + Invoice invoice) + { + if (invoice.Customer == null) + { + logger.LogError("Invoice's ({InvoiceID}) `customer` property must be expanded to be paid with Braintree", + invoice.Id); + throw _problemPayingInvoice; + } + + if (!invoice.Customer.Metadata.TryGetValue(MetadataKeys.BraintreeCustomerId, out var braintreeCustomerId)) + { + logger.LogError( + "Cannot pay invoice ({InvoiceID}) with Braintree for Customer ({CustomerID}) that does not have a Braintree Customer ID", + invoice.Id, invoice.Customer.Id); + throw _problemPayingInvoice; + } + + if (invoice is not + { + AmountDue: > 0, + Status: not InvoiceStatus.Paid, + CollectionMethod: CollectionMethod.ChargeAutomatically + }) + { + logger.LogWarning("Attempted to pay invoice ({InvoiceID}) with Braintree that is not eligible for payment", invoice.Id); + return; + } + + var amount = Math.Round(invoice.AmountDue / 100M, 2); + + var idKey = subscriberId.Match( + _ => "user_id", + _ => "organization_id", + _ => "provider_id"); + + var idValue = subscriberId.Match( + userId => userId.Value, + organizationId => organizationId.Value, + providerId => providerId.Value); + + var request = new TransactionRequest + { + Amount = amount, + CustomerId = braintreeCustomerId, + Options = new TransactionOptionsRequest + { + SubmitForSettlement = true, + PayPal = new TransactionOptionsPayPalRequest + { + CustomField = $"{idKey}:{idValue},region:{globalSettings.BaseServiceUri.CloudRegion}" + } + }, + CustomFields = new Dictionary + { + [idKey] = idValue.ToString(), + ["region"] = globalSettings.BaseServiceUri.CloudRegion + } + }; + + var result = await braintreeGateway.Transaction.SaleAsync(request); + + if (!result.IsSuccess()) + { + if (invoice.AttemptCount < 4) + { + await mailService.SendPaymentFailedAsync(invoice.Customer.Email, amount, true); + } + + return; + } + + await stripeAdapter.UpdateInvoiceAsync(invoice.Id, new InvoiceUpdateOptions + { + Metadata = new Dictionary + { + [MetadataKeys.BraintreeTransactionId] = result.Target.Id, + [MetadataKeys.PayPalTransactionId] = result.Target.PayPalDetails.AuthorizationId + } + }); + + await stripeAdapter.PayInvoiceAsync(invoice.Id, new InvoicePayOptions { PaidOutOfBand = true }); + } +} diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index 498721238b..763f70dd0c 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -995,6 +995,7 @@ public class UserService : UserManager, IUserService await SaveUserAsync(user); } + // TODO: Remove with deletion of pm-29594-update-individual-subscription-page public async Task AdjustStorageAsync(User user, short storageAdjustmentGb) { if (user == null) @@ -1040,6 +1041,7 @@ public class UserService : UserManager, IUserService await _paymentService.CancelSubscriptionAsync(user, eop); } + // TODO: Remove with deletion of pm-29594-update-individual-subscription-page public async Task ReinstatePremiumAsync(User user) { await _paymentService.ReinstateSubscriptionAsync(user); diff --git a/src/Core/Utilities/EnumMemberJsonConverter.cs b/src/Core/Utilities/EnumMemberJsonConverter.cs new file mode 100644 index 0000000000..63bebf9cca --- /dev/null +++ b/src/Core/Utilities/EnumMemberJsonConverter.cs @@ -0,0 +1,52 @@ +using System.Reflection; +using System.Runtime.Serialization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Bit.Core.Utilities; + +/// +/// A custom JSON converter for enum types that respects the when serializing and deserializing. +/// +/// The enum type to convert. Must be a struct and implement Enum. +/// +/// This converter builds lookup dictionaries at initialization to efficiently map between enum values and their +/// string representations. If an enum value has an , the attribute's Value +/// property is used as the JSON string; otherwise, the enum's ToString() value is used. +/// +public class EnumMemberJsonConverter : JsonConverter where T : struct, Enum +{ + private readonly Dictionary _enumToString = new(); + private readonly Dictionary _stringToEnum = new(); + + public EnumMemberJsonConverter() + { + var type = typeof(T); + var values = Enum.GetValues(); + + foreach (var value in values) + { + var fieldInfo = type.GetField(value.ToString()); + var attribute = fieldInfo?.GetCustomAttribute(); + + var stringValue = attribute?.Value ?? value.ToString(); + _enumToString[value] = stringValue; + _stringToEnum[stringValue] = value; + } + } + + public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var stringValue = reader.GetString(); + + if (!string.IsNullOrEmpty(stringValue) && _stringToEnum.TryGetValue(stringValue, out var enumValue)) + { + return enumValue; + } + + throw new JsonException($"Unable to convert '{stringValue}' to {typeof(T).Name}"); + } + + public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) + => writer.WriteStringValue(_enumToString[value]); +} diff --git a/test/Api.Test/Billing/Controllers/VNext/AccountBillingVNextControllerTests.cs b/test/Api.Test/Billing/Controllers/VNext/AccountBillingVNextControllerTests.cs index 653785b143..5b14608fc0 100644 --- a/test/Api.Test/Billing/Controllers/VNext/AccountBillingVNextControllerTests.cs +++ b/test/Api.Test/Billing/Controllers/VNext/AccountBillingVNextControllerTests.cs @@ -3,6 +3,8 @@ using Bit.Api.Billing.Models.Requests.Storage; using Bit.Core.Billing.Commands; using Bit.Core.Billing.Licenses.Queries; using Bit.Core.Billing.Premium.Commands; +using Bit.Core.Billing.Subscriptions.Commands; +using Bit.Core.Billing.Subscriptions.Queries; using Bit.Core.Entities; using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.AspNetCore.Http; @@ -29,9 +31,11 @@ public class AccountBillingVNextControllerTests _sut = new AccountBillingVNextController( Substitute.For(), Substitute.For(), + Substitute.For(), Substitute.For(), Substitute.For(), _getUserLicenseQuery, + Substitute.For(), Substitute.For(), _updatePremiumStorageCommand, _upgradePremiumToOrganizationCommand); @@ -63,7 +67,7 @@ public class AccountBillingVNextControllerTests .Returns(new BillingCommandResult(new None())); // Act - var result = await _sut.UpdateStorageAsync(user, request); + var result = await _sut.UpdateSubscriptionStorageAsync(user, request); // Assert var okResult = Assert.IsAssignableFrom(result); @@ -83,7 +87,7 @@ public class AccountBillingVNextControllerTests .Returns(new BadRequest(errorMessage)); // Act - var result = await _sut.UpdateStorageAsync(user, request); + var result = await _sut.UpdateSubscriptionStorageAsync(user, request); // Assert var badRequestResult = Assert.IsAssignableFrom(result); @@ -103,7 +107,7 @@ public class AccountBillingVNextControllerTests .Returns(new BadRequest(errorMessage)); // Act - var result = await _sut.UpdateStorageAsync(user, request); + var result = await _sut.UpdateSubscriptionStorageAsync(user, request); // Assert var badRequestResult = Assert.IsAssignableFrom(result); @@ -123,7 +127,7 @@ public class AccountBillingVNextControllerTests .Returns(new BadRequest(errorMessage)); // Act - var result = await _sut.UpdateStorageAsync(user, request); + var result = await _sut.UpdateSubscriptionStorageAsync(user, request); // Assert var badRequestResult = Assert.IsAssignableFrom(result); @@ -143,7 +147,7 @@ public class AccountBillingVNextControllerTests .Returns(new BadRequest(errorMessage)); // Act - var result = await _sut.UpdateStorageAsync(user, request); + var result = await _sut.UpdateSubscriptionStorageAsync(user, request); // Assert var badRequestResult = Assert.IsAssignableFrom(result); @@ -163,7 +167,7 @@ public class AccountBillingVNextControllerTests .Returns(new BadRequest(errorMessage)); // Act - var result = await _sut.UpdateStorageAsync(user, request); + var result = await _sut.UpdateSubscriptionStorageAsync(user, request); // Assert var badRequestResult = Assert.IsAssignableFrom(result); @@ -182,7 +186,7 @@ public class AccountBillingVNextControllerTests .Returns(new BillingCommandResult(new None())); // Act - var result = await _sut.UpdateStorageAsync(user, request); + var result = await _sut.UpdateSubscriptionStorageAsync(user, request); // Assert var okResult = Assert.IsAssignableFrom(result); @@ -201,7 +205,7 @@ public class AccountBillingVNextControllerTests .Returns(new BillingCommandResult(new None())); // Act - var result = await _sut.UpdateStorageAsync(user, request); + var result = await _sut.UpdateSubscriptionStorageAsync(user, request); // Assert var okResult = Assert.IsAssignableFrom(result); @@ -220,7 +224,7 @@ public class AccountBillingVNextControllerTests .Returns(new BillingCommandResult(new None())); // Act - var result = await _sut.UpdateStorageAsync(user, request); + var result = await _sut.UpdateSubscriptionStorageAsync(user, request); // Assert var okResult = Assert.IsAssignableFrom(result); @@ -239,7 +243,7 @@ public class AccountBillingVNextControllerTests .Returns(new BillingCommandResult(new None())); // Act - var result = await _sut.UpdateStorageAsync(user, request); + var result = await _sut.UpdateSubscriptionStorageAsync(user, request); // Assert var okResult = Assert.IsAssignableFrom(result); diff --git a/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs b/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs index b58b5cd250..55eb69cc64 100644 --- a/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs +++ b/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs @@ -8,6 +8,7 @@ using Bit.Core.Billing.Payment.Queries; using Bit.Core.Billing.Premium.Commands; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; +using Bit.Core.Billing.Subscriptions.Models; using Bit.Core.Entities; using Bit.Core.Platform.Push; using Bit.Core.Services; @@ -29,6 +30,7 @@ namespace Bit.Core.Test.Billing.Premium.Commands; public class CreatePremiumCloudHostedSubscriptionCommandTests { private readonly IBraintreeGateway _braintreeGateway = Substitute.For(); + private readonly IBraintreeService _braintreeService = Substitute.For(); private readonly IGlobalSettings _globalSettings = Substitute.For(); private readonly ISetupIntentCache _setupIntentCache = Substitute.For(); private readonly IStripeAdapter _stripeAdapter = Substitute.For(); @@ -59,6 +61,7 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests _command = new CreatePremiumCloudHostedSubscriptionCommand( _braintreeGateway, + _braintreeService, _globalSettings, _setupIntentCache, _stripeAdapter, @@ -235,11 +238,15 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests var mockCustomer = Substitute.For(); mockCustomer.Id = "cust_123"; mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" }; - mockCustomer.Metadata = new Dictionary(); + mockCustomer.Metadata = new Dictionary + { + [Core.Billing.Utilities.BraintreeCustomerIdKey] = "bt_customer_123" + }; var mockSubscription = Substitute.For(); mockSubscription.Id = "sub_123"; mockSubscription.Status = "active"; + mockSubscription.LatestInvoiceId = "in_123"; var mockInvoice = Substitute.For(); @@ -258,6 +265,9 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests await _stripeAdapter.Received(1).CreateCustomerAsync(Arg.Any()); await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Any()); await _subscriberService.Received(1).CreateBraintreeCustomer(user, paymentMethod.Token); + await _stripeAdapter.Received(1).UpdateInvoiceAsync(mockSubscription.LatestInvoiceId, + Arg.Is(opts => opts.AutoAdvance == false)); + await _braintreeService.Received(1).PayInvoice(Arg.Any(), mockInvoice); await _userService.Received(1).SaveUserAsync(user); await _pushNotificationService.Received(1).PushSyncVaultAsync(user.Id); } @@ -456,11 +466,15 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests var mockCustomer = Substitute.For(); mockCustomer.Id = "cust_123"; mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" }; - mockCustomer.Metadata = new Dictionary(); + mockCustomer.Metadata = new Dictionary + { + [Core.Billing.Utilities.BraintreeCustomerIdKey] = "bt_customer_123" + }; var mockSubscription = Substitute.For(); mockSubscription.Id = "sub_123"; mockSubscription.Status = "incomplete"; + mockSubscription.LatestInvoiceId = "in_123"; mockSubscription.Items = new StripeList { Data = @@ -487,6 +501,9 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests Assert.True(result.IsT0); Assert.True(user.Premium); Assert.Equal(mockSubscription.GetCurrentPeriodEnd(), user.PremiumExpirationDate); + await _stripeAdapter.Received(1).UpdateInvoiceAsync(mockSubscription.LatestInvoiceId, + Arg.Is(opts => opts.AutoAdvance == false)); + await _braintreeService.Received(1).PayInvoice(Arg.Any(), mockInvoice); } [Theory, BitAutoData] @@ -559,11 +576,15 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests var mockCustomer = Substitute.For(); mockCustomer.Id = "cust_123"; mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" }; - mockCustomer.Metadata = new Dictionary(); + mockCustomer.Metadata = new Dictionary + { + [Core.Billing.Utilities.BraintreeCustomerIdKey] = "bt_customer_123" + }; var mockSubscription = Substitute.For(); mockSubscription.Id = "sub_123"; mockSubscription.Status = "active"; // PayPal + active doesn't match pattern + mockSubscription.LatestInvoiceId = "in_123"; mockSubscription.Items = new StripeList { Data = @@ -590,6 +611,9 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests Assert.True(result.IsT0); Assert.False(user.Premium); Assert.Null(user.PremiumExpirationDate); + await _stripeAdapter.Received(1).UpdateInvoiceAsync(mockSubscription.LatestInvoiceId, + Arg.Is(opts => opts.AutoAdvance == false)); + await _braintreeService.Received(1).PayInvoice(Arg.Any(), mockInvoice); } [Theory, BitAutoData] diff --git a/test/Core.Test/Billing/Premium/Commands/UpdatePremiumStorageCommandTests.cs b/test/Core.Test/Billing/Premium/Commands/UpdatePremiumStorageCommandTests.cs index 7e3ea562d6..7b9b68c757 100644 --- a/test/Core.Test/Billing/Premium/Commands/UpdatePremiumStorageCommandTests.cs +++ b/test/Core.Test/Billing/Premium/Commands/UpdatePremiumStorageCommandTests.cs @@ -18,13 +18,11 @@ public class UpdatePremiumStorageCommandTests private readonly IStripeAdapter _stripeAdapter = Substitute.For(); private readonly IUserService _userService = Substitute.For(); private readonly IPricingClient _pricingClient = Substitute.For(); - private readonly PremiumPlan _premiumPlan; private readonly UpdatePremiumStorageCommand _command; public UpdatePremiumStorageCommandTests() { - // Setup default premium plan with standard pricing - _premiumPlan = new PremiumPlan + var premiumPlan = new PremiumPlan { Name = "Premium", Available = true, @@ -32,7 +30,7 @@ public class UpdatePremiumStorageCommandTests Seat = new PremiumPurchasable { Price = 10M, StripePriceId = "price_premium", Provided = 1 }, Storage = new PremiumPurchasable { Price = 4M, StripePriceId = "price_storage", Provided = 1 } }; - _pricingClient.ListPremiumPlans().Returns(new List { _premiumPlan }); + _pricingClient.ListPremiumPlans().Returns([premiumPlan]); _command = new UpdatePremiumStorageCommand( _stripeAdapter, @@ -43,18 +41,19 @@ public class UpdatePremiumStorageCommandTests private Subscription CreateMockSubscription(string subscriptionId, int? storageQuantity = null) { - var items = new List(); - - // Always add the seat item - items.Add(new SubscriptionItem + var items = new List { - Id = "si_seat", - Price = new Price { Id = "price_premium" }, - Quantity = 1 - }); + // Always add the seat item + new() + { + Id = "si_seat", + Price = new Price { Id = "price_premium" }, + Quantity = 1 + } + }; // Add storage item if quantity is provided - if (storageQuantity.HasValue && storageQuantity.Value > 0) + if (storageQuantity is > 0) { items.Add(new SubscriptionItem { @@ -142,7 +141,7 @@ public class UpdatePremiumStorageCommandTests // Assert Assert.True(result.IsT1); var badRequest = result.AsT1; - Assert.Equal("No access to storage.", badRequest.Response); + Assert.Equal("User has no access to storage.", badRequest.Response); } [Theory, BitAutoData] @@ -216,7 +215,7 @@ public class UpdatePremiumStorageCommandTests opts.Items.Count == 1 && opts.Items[0].Id == "si_storage" && opts.Items[0].Quantity == 9 && - opts.ProrationBehavior == "create_prorations")); + opts.ProrationBehavior == "always_invoice")); // Verify user was saved await _userService.Received(1).SaveUserAsync(Arg.Is(u => @@ -233,7 +232,7 @@ public class UpdatePremiumStorageCommandTests user.Storage = 500L * 1024 * 1024; user.GatewaySubscriptionId = "sub_123"; - var subscription = CreateMockSubscription("sub_123", null); + var subscription = CreateMockSubscription("sub_123"); _stripeAdapter.GetSubscriptionAsync("sub_123").Returns(subscription); // Act diff --git a/test/Core.Test/Billing/Subscriptions/Queries/GetBitwardenSubscriptionQueryTests.cs b/test/Core.Test/Billing/Subscriptions/Queries/GetBitwardenSubscriptionQueryTests.cs new file mode 100644 index 0000000000..a12a0e4cb0 --- /dev/null +++ b/test/Core.Test/Billing/Subscriptions/Queries/GetBitwardenSubscriptionQueryTests.cs @@ -0,0 +1,607 @@ +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Pricing; +using Bit.Core.Billing.Services; +using Bit.Core.Billing.Subscriptions.Models; +using Bit.Core.Billing.Subscriptions.Queries; +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Microsoft.Extensions.Logging; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Stripe; +using Xunit; + +namespace Bit.Core.Test.Billing.Subscriptions.Queries; + +using static StripeConstants; + +public class GetBitwardenSubscriptionQueryTests +{ + private readonly ILogger _logger = Substitute.For>(); + private readonly IPricingClient _pricingClient = Substitute.For(); + private readonly IStripeAdapter _stripeAdapter = Substitute.For(); + private readonly GetBitwardenSubscriptionQuery _query; + + public GetBitwardenSubscriptionQueryTests() + { + _query = new GetBitwardenSubscriptionQuery( + _logger, + _pricingClient, + _stripeAdapter); + } + + [Fact] + public async Task Run_IncompleteStatus_ReturnsBitwardenSubscriptionWithSuspension() + { + var user = CreateUser(); + var subscription = CreateSubscription(SubscriptionStatus.Incomplete); + var premiumPlans = CreatePremiumPlans(); + + _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(CreateInvoicePreview()); + + var result = await _query.Run(user); + + Assert.NotNull(result); + Assert.Equal(SubscriptionStatus.Incomplete, result.Status); + Assert.NotNull(result.Suspension); + Assert.Equal(subscription.Created.AddHours(23), result.Suspension); + Assert.Equal(1, result.GracePeriod); + Assert.Null(result.NextCharge); + Assert.Null(result.CancelAt); + } + + [Fact] + public async Task Run_IncompleteExpiredStatus_ReturnsBitwardenSubscriptionWithSuspension() + { + var user = CreateUser(); + var subscription = CreateSubscription(SubscriptionStatus.IncompleteExpired); + var premiumPlans = CreatePremiumPlans(); + + _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(CreateInvoicePreview()); + + var result = await _query.Run(user); + + Assert.NotNull(result); + Assert.Equal(SubscriptionStatus.IncompleteExpired, result.Status); + Assert.NotNull(result.Suspension); + Assert.Equal(subscription.Created.AddHours(23), result.Suspension); + Assert.Equal(1, result.GracePeriod); + } + + [Fact] + public async Task Run_TrialingStatus_ReturnsBitwardenSubscriptionWithNextCharge() + { + var user = CreateUser(); + var subscription = CreateSubscription(SubscriptionStatus.Trialing); + var premiumPlans = CreatePremiumPlans(); + + _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(CreateInvoicePreview()); + + var result = await _query.Run(user); + + Assert.NotNull(result); + Assert.Equal(SubscriptionStatus.Trialing, result.Status); + Assert.NotNull(result.NextCharge); + Assert.Equal(subscription.Items.First().CurrentPeriodEnd, result.NextCharge); + Assert.Null(result.Suspension); + Assert.Null(result.GracePeriod); + } + + [Fact] + public async Task Run_ActiveStatus_ReturnsBitwardenSubscriptionWithNextCharge() + { + var user = CreateUser(); + var subscription = CreateSubscription(SubscriptionStatus.Active); + var premiumPlans = CreatePremiumPlans(); + + _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(CreateInvoicePreview()); + + var result = await _query.Run(user); + + Assert.NotNull(result); + Assert.Equal(SubscriptionStatus.Active, result.Status); + Assert.NotNull(result.NextCharge); + Assert.Equal(subscription.Items.First().CurrentPeriodEnd, result.NextCharge); + Assert.Null(result.Suspension); + Assert.Null(result.GracePeriod); + } + + [Fact] + public async Task Run_ActiveStatusWithCancelAt_ReturnsCancelAt() + { + var user = CreateUser(); + var cancelAt = DateTime.UtcNow.AddMonths(1); + var subscription = CreateSubscription(SubscriptionStatus.Active, cancelAt: cancelAt); + var premiumPlans = CreatePremiumPlans(); + + _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(CreateInvoicePreview()); + + var result = await _query.Run(user); + + Assert.NotNull(result); + Assert.Equal(SubscriptionStatus.Active, result.Status); + Assert.Equal(cancelAt, result.CancelAt); + } + + [Fact] + public async Task Run_PastDueStatus_WithOpenInvoices_ReturnsSuspension() + { + var user = CreateUser(); + var subscription = CreateSubscription(SubscriptionStatus.PastDue, collectionMethod: "charge_automatically"); + var premiumPlans = CreatePremiumPlans(); + var openInvoice = CreateInvoice(); + + _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(CreateInvoicePreview()); + _stripeAdapter.SearchInvoiceAsync(Arg.Any()) + .Returns([openInvoice]); + + var result = await _query.Run(user); + + Assert.NotNull(result); + Assert.Equal(SubscriptionStatus.PastDue, result.Status); + Assert.NotNull(result.Suspension); + Assert.Equal(openInvoice.Created.AddDays(14), result.Suspension); + Assert.Equal(14, result.GracePeriod); + } + + [Fact] + public async Task Run_PastDueStatus_WithoutOpenInvoices_ReturnsNoSuspension() + { + var user = CreateUser(); + var subscription = CreateSubscription(SubscriptionStatus.PastDue); + var premiumPlans = CreatePremiumPlans(); + + _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(CreateInvoicePreview()); + _stripeAdapter.SearchInvoiceAsync(Arg.Any()) + .Returns([]); + + var result = await _query.Run(user); + + Assert.NotNull(result); + Assert.Equal(SubscriptionStatus.PastDue, result.Status); + Assert.Null(result.Suspension); + Assert.Null(result.GracePeriod); + } + + [Fact] + public async Task Run_UnpaidStatus_WithOpenInvoices_ReturnsSuspension() + { + var user = CreateUser(); + var subscription = CreateSubscription(SubscriptionStatus.Unpaid, collectionMethod: "charge_automatically"); + var premiumPlans = CreatePremiumPlans(); + var openInvoice = CreateInvoice(); + + _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(CreateInvoicePreview()); + _stripeAdapter.SearchInvoiceAsync(Arg.Any()) + .Returns([openInvoice]); + + var result = await _query.Run(user); + + Assert.NotNull(result); + Assert.Equal(SubscriptionStatus.Unpaid, result.Status); + Assert.NotNull(result.Suspension); + Assert.Equal(14, result.GracePeriod); + } + + [Fact] + public async Task Run_CanceledStatus_ReturnsCanceledDate() + { + var user = CreateUser(); + var canceledAt = DateTime.UtcNow.AddDays(-5); + var subscription = CreateSubscription(SubscriptionStatus.Canceled, canceledAt: canceledAt); + var premiumPlans = CreatePremiumPlans(); + + _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(CreateInvoicePreview()); + + var result = await _query.Run(user); + + Assert.NotNull(result); + Assert.Equal(SubscriptionStatus.Canceled, result.Status); + Assert.Equal(canceledAt, result.Canceled); + Assert.Null(result.Suspension); + Assert.Null(result.NextCharge); + } + + [Fact] + public async Task Run_UnmanagedStatus_ThrowsConflictException() + { + var user = CreateUser(); + var subscription = CreateSubscription("unmanaged_status"); + var premiumPlans = CreatePremiumPlans(); + + _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(CreateInvoicePreview()); + + await Assert.ThrowsAsync(() => _query.Run(user)); + } + + [Fact] + public async Task Run_WithAdditionalStorage_IncludesStorageInCart() + { + var user = CreateUser(); + var subscription = CreateSubscription(SubscriptionStatus.Active, includeStorage: true); + var premiumPlans = CreatePremiumPlans(); + + _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(CreateInvoicePreview()); + + var result = await _query.Run(user); + + Assert.NotNull(result); + Assert.NotNull(result.Cart.PasswordManager.AdditionalStorage); + Assert.Equal("additionalStorageGB", result.Cart.PasswordManager.AdditionalStorage.TranslationKey); + Assert.Equal(2, result.Cart.PasswordManager.AdditionalStorage.Quantity); + Assert.NotNull(result.Storage); + } + + [Fact] + public async Task Run_WithoutAdditionalStorage_ExcludesStorageFromCart() + { + var user = CreateUser(); + var subscription = CreateSubscription(SubscriptionStatus.Active, includeStorage: false); + var premiumPlans = CreatePremiumPlans(); + + _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(CreateInvoicePreview()); + + var result = await _query.Run(user); + + Assert.NotNull(result); + Assert.Null(result.Cart.PasswordManager.AdditionalStorage); + Assert.NotNull(result.Storage); + } + + [Fact] + public async Task Run_WithCartLevelDiscount_IncludesDiscountInCart() + { + var user = CreateUser(); + var subscription = CreateSubscription(SubscriptionStatus.Active); + subscription.Customer.Discount = CreateDiscount(discountType: "cart"); + var premiumPlans = CreatePremiumPlans(); + + _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(CreateInvoicePreview()); + + var result = await _query.Run(user); + + Assert.NotNull(result); + Assert.NotNull(result.Cart.Discount); + Assert.Equal(BitwardenDiscountType.PercentOff, result.Cart.Discount.Type); + Assert.Equal(20, result.Cart.Discount.Value); + } + + [Fact] + public async Task Run_WithProductLevelDiscount_IncludesDiscountInCartItem() + { + var user = CreateUser(); + var subscription = CreateSubscription(SubscriptionStatus.Active); + var productDiscount = CreateDiscount(discountType: "product", productId: "prod_premium_seat"); + subscription.Discounts = [productDiscount]; + var premiumPlans = CreatePremiumPlans(); + + _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(CreateInvoicePreview()); + + var result = await _query.Run(user); + + Assert.NotNull(result); + Assert.NotNull(result.Cart.PasswordManager.Seats.Discount); + Assert.Equal(BitwardenDiscountType.PercentOff, result.Cart.PasswordManager.Seats.Discount.Type); + } + + [Fact] + public async Task Run_WithoutMaxStorageGb_ReturnsNullStorage() + { + var user = CreateUser(); + user.MaxStorageGb = null; + var subscription = CreateSubscription(SubscriptionStatus.Active); + var premiumPlans = CreatePremiumPlans(); + + _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(CreateInvoicePreview()); + + var result = await _query.Run(user); + + Assert.NotNull(result); + Assert.Null(result.Storage); + } + + [Fact] + public async Task Run_CalculatesStorageCorrectly() + { + var user = CreateUser(); + user.Storage = 5368709120; // 5 GB in bytes + user.MaxStorageGb = 10; + var subscription = CreateSubscription(SubscriptionStatus.Active, includeStorage: true); + var premiumPlans = CreatePremiumPlans(); + + _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(CreateInvoicePreview()); + + var result = await _query.Run(user); + + Assert.NotNull(result); + Assert.NotNull(result.Storage); + Assert.Equal(10, result.Storage.Available); + Assert.Equal(5.0, result.Storage.Used); + Assert.NotEmpty(result.Storage.ReadableUsed); + } + + [Fact] + public async Task Run_TaxEstimation_WithInvoiceUpcomingNoneError_ReturnsZeroTax() + { + var user = CreateUser(); + var subscription = CreateSubscription(SubscriptionStatus.Active); + var premiumPlans = CreatePremiumPlans(); + + _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .ThrowsAsync(new StripeException { StripeError = new StripeError { Code = ErrorCodes.InvoiceUpcomingNone } }); + + var result = await _query.Run(user); + + Assert.NotNull(result); + Assert.Equal(0, result.Cart.EstimatedTax); + } + + [Fact] + public async Task Run_MissingPasswordManagerSeatsItem_ThrowsConflictException() + { + var user = CreateUser(); + var subscription = CreateSubscription(SubscriptionStatus.Active); + subscription.Items = new StripeList + { + Data = [] + }; + var premiumPlans = CreatePremiumPlans(); + + _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + + await Assert.ThrowsAsync(() => _query.Run(user)); + } + + [Fact] + public async Task Run_IncludesEstimatedTax() + { + var user = CreateUser(); + var subscription = CreateSubscription(SubscriptionStatus.Active); + var premiumPlans = CreatePremiumPlans(); + var invoice = CreateInvoicePreview(totalTax: 500); // $5.00 tax + + _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(invoice); + + var result = await _query.Run(user); + + Assert.NotNull(result); + Assert.Equal(5.0m, result.Cart.EstimatedTax); + } + + [Fact] + public async Task Run_SetsCadenceToAnnually() + { + var user = CreateUser(); + var subscription = CreateSubscription(SubscriptionStatus.Active); + var premiumPlans = CreatePremiumPlans(); + + _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(CreateInvoicePreview()); + + var result = await _query.Run(user); + + Assert.NotNull(result); + Assert.Equal(PlanCadenceType.Annually, result.Cart.Cadence); + } + + #region Helper Methods + + private static User CreateUser() + { + return new User + { + Id = Guid.NewGuid(), + GatewaySubscriptionId = "sub_test123", + MaxStorageGb = 1, + Storage = 1073741824 // 1 GB in bytes + }; + } + + private static Subscription CreateSubscription( + string status, + bool includeStorage = false, + DateTime? cancelAt = null, + DateTime? canceledAt = null, + string collectionMethod = "charge_automatically") + { + var currentPeriodEnd = DateTime.UtcNow.AddMonths(1); + var items = new List + { + new() + { + Id = "si_premium_seat", + Price = new Price + { + Id = "price_premium_seat", + UnitAmountDecimal = 1000, + Product = new Product { Id = "prod_premium_seat" } + }, + Quantity = 1, + CurrentPeriodStart = DateTime.UtcNow, + CurrentPeriodEnd = currentPeriodEnd + } + }; + + if (includeStorage) + { + items.Add(new SubscriptionItem + { + Id = "si_storage", + Price = new Price + { + Id = "price_storage", + UnitAmountDecimal = 400, + Product = new Product { Id = "prod_storage" } + }, + Quantity = 2, + CurrentPeriodStart = DateTime.UtcNow, + CurrentPeriodEnd = currentPeriodEnd + }); + } + + return new Subscription + { + Id = "sub_test123", + Status = status, + Created = DateTime.UtcNow.AddMonths(-1), + Customer = new Customer + { + Id = "cus_test123", + Discount = null + }, + Items = new StripeList + { + Data = items + }, + CancelAt = cancelAt, + CanceledAt = canceledAt, + CollectionMethod = collectionMethod, + Discounts = [] + }; + } + + private static List CreatePremiumPlans() + { + return + [ + new() + { + Name = "Premium", + Available = true, + Seat = new Bit.Core.Billing.Pricing.Premium.Purchasable + { + StripePriceId = "price_premium_seat", + Price = 10.0m, + Provided = 1 + }, + Storage = new Bit.Core.Billing.Pricing.Premium.Purchasable + { + StripePriceId = "price_storage", + Price = 4.0m, + Provided = 1 + } + } + ]; + } + + private static Invoice CreateInvoice() + { + return new Invoice + { + Id = "in_test123", + Created = DateTime.UtcNow.AddDays(-10), + PeriodEnd = DateTime.UtcNow.AddDays(-5), + Attempted = true, + Status = "open" + }; + } + + private static Invoice CreateInvoicePreview(long totalTax = 0) + { + var taxes = totalTax > 0 + ? new List { new() { Amount = totalTax } } + : new List(); + + return new Invoice + { + Id = "in_preview", + TotalTaxes = taxes + }; + } + + private static Discount CreateDiscount(string discountType = "cart", string? productId = null) + { + var coupon = new Coupon + { + Valid = true, + PercentOff = 20, + AppliesTo = discountType == "product" && productId != null + ? new CouponAppliesTo { Products = [productId] } + : new CouponAppliesTo { Products = [] } + }; + + return new Discount + { + Coupon = coupon + }; + } + + #endregion +} diff --git a/test/Core.Test/Utilities/EnumMemberJsonConverterTests.cs b/test/Core.Test/Utilities/EnumMemberJsonConverterTests.cs new file mode 100644 index 0000000000..d0d0d72687 --- /dev/null +++ b/test/Core.Test/Utilities/EnumMemberJsonConverterTests.cs @@ -0,0 +1,219 @@ +using System.Runtime.Serialization; +using System.Text.Json; +using System.Text.Json.Serialization; +using Bit.Core.Utilities; +using Xunit; + +namespace Bit.Core.Test.Utilities; + +public class EnumMemberJsonConverterTests +{ + [Fact] + public void Serialize_WithEnumMemberAttribute_UsesAttributeValue() + { + // Arrange + var obj = new EnumConverterTestObject + { + Status = EnumConverterTestStatus.InProgress + }; + const string expectedJsonString = "{\"Status\":\"in_progress\"}"; + + // Act + var jsonString = JsonSerializer.Serialize(obj); + + // Assert + Assert.Equal(expectedJsonString, jsonString); + } + + [Fact] + public void Serialize_WithoutEnumMemberAttribute_UsesEnumName() + { + // Arrange + var obj = new EnumConverterTestObject + { + Status = EnumConverterTestStatus.Pending + }; + const string expectedJsonString = "{\"Status\":\"Pending\"}"; + + // Act + var jsonString = JsonSerializer.Serialize(obj); + + // Assert + Assert.Equal(expectedJsonString, jsonString); + } + + [Fact] + public void Serialize_MultipleValues_SerializesCorrectly() + { + // Arrange + var obj = new EnumConverterTestObjectWithMultiple + { + Status1 = EnumConverterTestStatus.Active, + Status2 = EnumConverterTestStatus.InProgress, + Status3 = EnumConverterTestStatus.Pending + }; + const string expectedJsonString = "{\"Status1\":\"active\",\"Status2\":\"in_progress\",\"Status3\":\"Pending\"}"; + + // Act + var jsonString = JsonSerializer.Serialize(obj); + + // Assert + Assert.Equal(expectedJsonString, jsonString); + } + + [Fact] + public void Deserialize_WithEnumMemberAttribute_ReturnsCorrectEnumValue() + { + // Arrange + const string json = "{\"Status\":\"in_progress\"}"; + + // Act + var obj = JsonSerializer.Deserialize(json); + + // Assert + Assert.Equal(EnumConverterTestStatus.InProgress, obj.Status); + } + + [Fact] + public void Deserialize_WithoutEnumMemberAttribute_ReturnsCorrectEnumValue() + { + // Arrange + const string json = "{\"Status\":\"Pending\"}"; + + // Act + var obj = JsonSerializer.Deserialize(json); + + // Assert + Assert.Equal(EnumConverterTestStatus.Pending, obj.Status); + } + + [Fact] + public void Deserialize_MultipleValues_DeserializesCorrectly() + { + // Arrange + const string json = "{\"Status1\":\"active\",\"Status2\":\"in_progress\",\"Status3\":\"Pending\"}"; + + // Act + var obj = JsonSerializer.Deserialize(json); + + // Assert + Assert.Equal(EnumConverterTestStatus.Active, obj.Status1); + Assert.Equal(EnumConverterTestStatus.InProgress, obj.Status2); + Assert.Equal(EnumConverterTestStatus.Pending, obj.Status3); + } + + [Fact] + public void Deserialize_InvalidEnumString_ThrowsJsonException() + { + // Arrange + const string json = "{\"Status\":\"invalid_value\"}"; + + // Act & Assert + var exception = Assert.Throws(() => JsonSerializer.Deserialize(json)); + Assert.Contains("Unable to convert 'invalid_value' to EnumConverterTestStatus", exception.Message); + } + + [Fact] + public void Deserialize_EmptyString_ThrowsJsonException() + { + // Arrange + const string json = "{\"Status\":\"\"}"; + + // Act & Assert + var exception = Assert.Throws(() => JsonSerializer.Deserialize(json)); + Assert.Contains("Unable to convert '' to EnumConverterTestStatus", exception.Message); + } + + [Fact] + public void RoundTrip_WithEnumMemberAttribute_PreservesValue() + { + // Arrange + var originalObj = new EnumConverterTestObject + { + Status = EnumConverterTestStatus.Completed + }; + + // Act + var json = JsonSerializer.Serialize(originalObj); + var deserializedObj = JsonSerializer.Deserialize(json); + + // Assert + Assert.Equal(originalObj.Status, deserializedObj.Status); + } + + [Fact] + public void RoundTrip_WithoutEnumMemberAttribute_PreservesValue() + { + // Arrange + var originalObj = new EnumConverterTestObject + { + Status = EnumConverterTestStatus.Pending + }; + + // Act + var json = JsonSerializer.Serialize(originalObj); + var deserializedObj = JsonSerializer.Deserialize(json); + + // Assert + Assert.Equal(originalObj.Status, deserializedObj.Status); + } + + [Fact] + public void Serialize_AllEnumValues_ProducesExpectedStrings() + { + // Arrange & Act & Assert + Assert.Equal("\"Pending\"", JsonSerializer.Serialize(EnumConverterTestStatus.Pending, CreateOptions())); + Assert.Equal("\"active\"", JsonSerializer.Serialize(EnumConverterTestStatus.Active, CreateOptions())); + Assert.Equal("\"in_progress\"", JsonSerializer.Serialize(EnumConverterTestStatus.InProgress, CreateOptions())); + Assert.Equal("\"completed\"", JsonSerializer.Serialize(EnumConverterTestStatus.Completed, CreateOptions())); + } + + [Fact] + public void Deserialize_AllEnumValues_ReturnsCorrectEnums() + { + // Arrange & Act & Assert + Assert.Equal(EnumConverterTestStatus.Pending, JsonSerializer.Deserialize("\"Pending\"", CreateOptions())); + Assert.Equal(EnumConverterTestStatus.Active, JsonSerializer.Deserialize("\"active\"", CreateOptions())); + Assert.Equal(EnumConverterTestStatus.InProgress, JsonSerializer.Deserialize("\"in_progress\"", CreateOptions())); + Assert.Equal(EnumConverterTestStatus.Completed, JsonSerializer.Deserialize("\"completed\"", CreateOptions())); + } + + private static JsonSerializerOptions CreateOptions() + { + var options = new JsonSerializerOptions(); + options.Converters.Add(new EnumMemberJsonConverter()); + return options; + } +} + +public class EnumConverterTestObject +{ + [JsonConverter(typeof(EnumMemberJsonConverter))] + public EnumConverterTestStatus Status { get; set; } +} + +public class EnumConverterTestObjectWithMultiple +{ + [JsonConverter(typeof(EnumMemberJsonConverter))] + public EnumConverterTestStatus Status1 { get; set; } + + [JsonConverter(typeof(EnumMemberJsonConverter))] + public EnumConverterTestStatus Status2 { get; set; } + + [JsonConverter(typeof(EnumMemberJsonConverter))] + public EnumConverterTestStatus Status3 { get; set; } +} + +public enum EnumConverterTestStatus +{ + Pending, // No EnumMemberAttribute + + [EnumMember(Value = "active")] + Active, + + [EnumMember(Value = "in_progress")] + InProgress, + + [EnumMember(Value = "completed")] + Completed +} From b9d1a3530195ed85edfd86ffb63eb0808ae22dc7 Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Mon, 12 Jan 2026 15:08:52 -0500 Subject: [PATCH 02/49] Enable Telemetry for Billing Project (#6802) --- src/Billing/Billing.csproj | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Billing/Billing.csproj b/src/Billing/Billing.csproj index 69999dc795..c7620d6df8 100644 --- a/src/Billing/Billing.csproj +++ b/src/Billing/Billing.csproj @@ -8,7 +8,6 @@ false - false false From d559b1da112da7a0df8b637f3ec8d7241f64b2c6 Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Tue, 13 Jan 2026 04:02:56 -0500 Subject: [PATCH 03/49] Make CA1304 & CA1305 warnings (#6813) --- .editorconfig | 10 ++- Directory.Build.props | 18 ++--- .../Scim.IntegrationTest.csproj | 72 ++++++++++--------- src/Admin/Admin.csproj | 2 + src/Api/Api.csproj | 2 + src/Billing/Billing.csproj | 2 + src/Core/Core.csproj | 2 + src/Icons/Icons.csproj | 2 + src/Identity/Identity.csproj | 2 + .../Infrastructure.Dapper.csproj | 5 ++ .../Infrastructure.EntityFramework.csproj | 5 ++ src/SharedWeb/SharedWeb.csproj | 5 ++ .../Api.IntegrationTest.csproj | 2 + test/Api.Test/Api.Test.csproj | 2 + test/Common/Common.csproj | 2 + test/Core.Test/Core.Test.csproj | 4 +- .../Identity.IntegrationTest.csproj | 2 + test/Identity.Test/Identity.Test.csproj | 2 + .../Infrastructure.EFIntegration.Test.csproj | 2 + .../Infrastructure.IntegrationTest.csproj | 2 + .../IntegrationTestCommon.csproj | 2 + util/Migrator/Migrator.csproj | 5 ++ util/MySqlMigrations/MySqlMigrations.csproj | 2 + .../PostgresMigrations.csproj | 5 ++ util/Server/Server.csproj | 2 + util/Setup/Setup.csproj | 2 + .../SqlServerEFScaffold.csproj | 5 ++ util/SqliteMigrations/SqliteMigrations.csproj | 2 + 28 files changed, 123 insertions(+), 47 deletions(-) diff --git a/.editorconfig b/.editorconfig index fd68808456..71dd40de98 100644 --- a/.editorconfig +++ b/.editorconfig @@ -71,10 +71,10 @@ dotnet_naming_symbols.any_async_methods.applicable_kinds = method dotnet_naming_symbols.any_async_methods.applicable_accessibilities = * dotnet_naming_symbols.any_async_methods.required_modifiers = async -dotnet_naming_style.end_in_async.required_prefix = +dotnet_naming_style.end_in_async.required_prefix = dotnet_naming_style.end_in_async.required_suffix = Async dotnet_naming_style.end_in_async.capitalization = pascal_case -dotnet_naming_style.end_in_async.word_separator = +dotnet_naming_style.end_in_async.word_separator = # Obsolete warnings, this should be removed or changed to warning once we address some of the obsolete items. dotnet_diagnostic.CS0618.severity = suggestion @@ -85,6 +85,12 @@ dotnet_diagnostic.CS0612.severity = suggestion # Remove unnecessary using directives https://docs.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0005 dotnet_diagnostic.IDE0005.severity = warning +# Specify CultureInfo https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1304 +dotnet_diagnostic.CA1304.severity = warning + +# Specify IFormatProvider https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1305 +dotnet_diagnostic.CA1305.severity = warning + # CSharp code style settings: [*.cs] # Prefer "var" everywhere diff --git a/Directory.Build.props b/Directory.Build.props index db3ccf40f5..9438ef3351 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -13,21 +13,21 @@ true - + - + 18.0.1 - + 2.6.6 - + 2.5.6 - + 6.0.0 - + 5.1.0 - + 4.18.1 - + 4.18.1 - \ No newline at end of file + diff --git a/bitwarden_license/test/Scim.IntegrationTest/Scim.IntegrationTest.csproj b/bitwarden_license/test/Scim.IntegrationTest/Scim.IntegrationTest.csproj index 4fc79f2025..d0d329397c 100644 --- a/bitwarden_license/test/Scim.IntegrationTest/Scim.IntegrationTest.csproj +++ b/bitwarden_license/test/Scim.IntegrationTest/Scim.IntegrationTest.csproj @@ -1,35 +1,37 @@ - - - - false - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - - - - - - - - true - PreserveNewest - Never - - - + + + + false + + $(WarningsNotAsErrors);CA1305 + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + true + PreserveNewest + Never + + + diff --git a/src/Admin/Admin.csproj b/src/Admin/Admin.csproj index cd30e841b4..b815ddea82 100644 --- a/src/Admin/Admin.csproj +++ b/src/Admin/Admin.csproj @@ -2,6 +2,8 @@ bitwarden-Admin + + $(WarningsNotAsErrors);CA1304;CA1305 diff --git a/src/Api/Api.csproj b/src/Api/Api.csproj index dd27de2e63..d25b989d11 100644 --- a/src/Api/Api.csproj +++ b/src/Api/Api.csproj @@ -4,6 +4,8 @@ false bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml true + + $(WarningsNotAsErrors);CA1304;CA1305 diff --git a/src/Billing/Billing.csproj b/src/Billing/Billing.csproj index c7620d6df8..27ee9a7ce3 100644 --- a/src/Billing/Billing.csproj +++ b/src/Billing/Billing.csproj @@ -3,6 +3,8 @@ bitwarden-Billing + + $(WarningsNotAsErrors);CA1305 diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index 0783e84cc4..3df438b493 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -3,6 +3,8 @@ false bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml + + $(WarningsNotAsErrors);CA1304;CA1305 diff --git a/src/Icons/Icons.csproj b/src/Icons/Icons.csproj index 455c8b3155..97e9562183 100644 --- a/src/Icons/Icons.csproj +++ b/src/Icons/Icons.csproj @@ -3,6 +3,8 @@ bitwarden-Icons false + + $(WarningsNotAsErrors);CA1304;CA1305 diff --git a/src/Identity/Identity.csproj b/src/Identity/Identity.csproj index bf5ab82166..db49f8c856 100644 --- a/src/Identity/Identity.csproj +++ b/src/Identity/Identity.csproj @@ -3,6 +3,8 @@ bitwarden-Identity false + + $(WarningsNotAsErrors);CA1305 diff --git a/src/Infrastructure.Dapper/Infrastructure.Dapper.csproj b/src/Infrastructure.Dapper/Infrastructure.Dapper.csproj index 8feb455feb..d87bdc33a9 100644 --- a/src/Infrastructure.Dapper/Infrastructure.Dapper.csproj +++ b/src/Infrastructure.Dapper/Infrastructure.Dapper.csproj @@ -1,5 +1,10 @@ + + + $(WarningsNotAsErrors);CA1305 + + diff --git a/src/Infrastructure.EntityFramework/Infrastructure.EntityFramework.csproj b/src/Infrastructure.EntityFramework/Infrastructure.EntityFramework.csproj index 9814eef2aa..180bcd7705 100644 --- a/src/Infrastructure.EntityFramework/Infrastructure.EntityFramework.csproj +++ b/src/Infrastructure.EntityFramework/Infrastructure.EntityFramework.csproj @@ -1,4 +1,9 @@ + + + $(WarningsNotAsErrors);CA1304;CA1305 + + diff --git a/src/SharedWeb/SharedWeb.csproj b/src/SharedWeb/SharedWeb.csproj index d8dc61178d..b6036845b0 100644 --- a/src/SharedWeb/SharedWeb.csproj +++ b/src/SharedWeb/SharedWeb.csproj @@ -1,5 +1,10 @@ + + + $(WarningsNotAsErrors);CA1304 + + diff --git a/test/Api.IntegrationTest/Api.IntegrationTest.csproj b/test/Api.IntegrationTest/Api.IntegrationTest.csproj index a9d7fd502e..153803ef21 100644 --- a/test/Api.IntegrationTest/Api.IntegrationTest.csproj +++ b/test/Api.IntegrationTest/Api.IntegrationTest.csproj @@ -1,6 +1,8 @@ false + + $(WarningsNotAsErrors);CA1304;CA1305 diff --git a/test/Api.Test/Api.Test.csproj b/test/Api.Test/Api.Test.csproj index fb75246d4f..da9cdcff06 100644 --- a/test/Api.Test/Api.Test.csproj +++ b/test/Api.Test/Api.Test.csproj @@ -2,6 +2,8 @@ false + + $(WarningsNotAsErrors);CA1304 diff --git a/test/Common/Common.csproj b/test/Common/Common.csproj index 2f11798cef..3d1b6a6c3b 100644 --- a/test/Common/Common.csproj +++ b/test/Common/Common.csproj @@ -2,6 +2,8 @@ false Bit.Test.Common + + $(WarningsNotAsErrors);CA1305 diff --git a/test/Core.Test/Core.Test.csproj b/test/Core.Test/Core.Test.csproj index b9e218205c..243e9af806 100644 --- a/test/Core.Test/Core.Test.csproj +++ b/test/Core.Test/Core.Test.csproj @@ -2,6 +2,8 @@ false Bit.Core.Test + + $(WarningsNotAsErrors);CA1304;CA1305 @@ -30,7 +32,7 @@ - + diff --git a/test/Identity.IntegrationTest/Identity.IntegrationTest.csproj b/test/Identity.IntegrationTest/Identity.IntegrationTest.csproj index 5c94fad1d1..8a3c0d0fc2 100644 --- a/test/Identity.IntegrationTest/Identity.IntegrationTest.csproj +++ b/test/Identity.IntegrationTest/Identity.IntegrationTest.csproj @@ -2,6 +2,8 @@ false + + $(WarningsNotAsErrors);CA1304;CA1305 diff --git a/test/Identity.Test/Identity.Test.csproj b/test/Identity.Test/Identity.Test.csproj index 496d652b30..8acb0ced92 100644 --- a/test/Identity.Test/Identity.Test.csproj +++ b/test/Identity.Test/Identity.Test.csproj @@ -2,6 +2,8 @@ false + + $(WarningsNotAsErrors);CA1305 diff --git a/test/Infrastructure.EFIntegration.Test/Infrastructure.EFIntegration.Test.csproj b/test/Infrastructure.EFIntegration.Test/Infrastructure.EFIntegration.Test.csproj index e63d3d7419..c2e0412752 100644 --- a/test/Infrastructure.EFIntegration.Test/Infrastructure.EFIntegration.Test.csproj +++ b/test/Infrastructure.EFIntegration.Test/Infrastructure.EFIntegration.Test.csproj @@ -1,6 +1,8 @@ false + + $(WarningsNotAsErrors);CA1305 diff --git a/test/Infrastructure.IntegrationTest/Infrastructure.IntegrationTest.csproj b/test/Infrastructure.IntegrationTest/Infrastructure.IntegrationTest.csproj index a2215e3453..4822df4c77 100644 --- a/test/Infrastructure.IntegrationTest/Infrastructure.IntegrationTest.csproj +++ b/test/Infrastructure.IntegrationTest/Infrastructure.IntegrationTest.csproj @@ -3,6 +3,8 @@ false 6570f288-5c2c-47ad-8978-f3da255079c2 + + $(WarningsNotAsErrors);CA1305 diff --git a/test/IntegrationTestCommon/IntegrationTestCommon.csproj b/test/IntegrationTestCommon/IntegrationTestCommon.csproj index 7e04d29248..6fd6369f49 100644 --- a/test/IntegrationTestCommon/IntegrationTestCommon.csproj +++ b/test/IntegrationTestCommon/IntegrationTestCommon.csproj @@ -2,6 +2,8 @@ false + + $(WarningsNotAsErrors);CA1305 diff --git a/util/Migrator/Migrator.csproj b/util/Migrator/Migrator.csproj index bef6dadfb8..29caf74f39 100644 --- a/util/Migrator/Migrator.csproj +++ b/util/Migrator/Migrator.csproj @@ -1,5 +1,10 @@ + + + $(WarningsNotAsErrors);CA1305 + + diff --git a/util/MySqlMigrations/MySqlMigrations.csproj b/util/MySqlMigrations/MySqlMigrations.csproj index 641ad90924..b297ea1041 100644 --- a/util/MySqlMigrations/MySqlMigrations.csproj +++ b/util/MySqlMigrations/MySqlMigrations.csproj @@ -2,6 +2,8 @@ 9f1cd3e0-70f2-4921-8068-b2538fd7c3f7 + + $(WarningsNotAsErrors);CA1305 diff --git a/util/PostgresMigrations/PostgresMigrations.csproj b/util/PostgresMigrations/PostgresMigrations.csproj index 3496ff67c1..66f3abe769 100644 --- a/util/PostgresMigrations/PostgresMigrations.csproj +++ b/util/PostgresMigrations/PostgresMigrations.csproj @@ -1,5 +1,10 @@ + + + $(WarningsNotAsErrors);CA1305 + + diff --git a/util/Server/Server.csproj b/util/Server/Server.csproj index 5aaeee7f4a..c64019e154 100644 --- a/util/Server/Server.csproj +++ b/util/Server/Server.csproj @@ -2,6 +2,8 @@ false + + $(WarningsNotAsErrors);CA1305 diff --git a/util/Setup/Setup.csproj b/util/Setup/Setup.csproj index 6366d46d3d..b4ab0bd806 100644 --- a/util/Setup/Setup.csproj +++ b/util/Setup/Setup.csproj @@ -3,6 +3,8 @@ Exe 1701;1702;1705;NU1701 + + $(WarningsNotAsErrors);CA1305 diff --git a/util/SqlServerEFScaffold/SqlServerEFScaffold.csproj b/util/SqlServerEFScaffold/SqlServerEFScaffold.csproj index 47001803ad..a2fb8173bf 100644 --- a/util/SqlServerEFScaffold/SqlServerEFScaffold.csproj +++ b/util/SqlServerEFScaffold/SqlServerEFScaffold.csproj @@ -1,4 +1,9 @@ + + + $(WarningsNotAsErrors);CA1305 + + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/util/SqliteMigrations/SqliteMigrations.csproj b/util/SqliteMigrations/SqliteMigrations.csproj index dce863036f..26def0dad2 100644 --- a/util/SqliteMigrations/SqliteMigrations.csproj +++ b/util/SqliteMigrations/SqliteMigrations.csproj @@ -3,6 +3,8 @@ enable enable + + $(WarningsNotAsErrors);CA1305 From 4f6b0236679753c90f378c9e4f03fa6c61204dce Mon Sep 17 00:00:00 2001 From: Jimmy Vo Date: Tue, 13 Jan 2026 08:55:32 -0500 Subject: [PATCH 04/49] [PM-29847] Fix styling (#6828) --- ...onConfirmationEnterpriseTeamsView.html.hbs | 1509 +++++++------- ...izationConfirmationFamilyFreeView.html.hbs | 1834 ++++++++--------- ...ization-confirmation-enterprise-teams.mjml | 6 +- ...organization-confirmation-family-free.mjml | 6 +- 4 files changed, 1677 insertions(+), 1678 deletions(-) diff --git a/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationEnterpriseTeamsView.html.hbs b/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationEnterpriseTeamsView.html.hbs index 8477efff26..3c8f498403 100644 --- a/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationEnterpriseTeamsView.html.hbs +++ b/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationEnterpriseTeamsView.html.hbs @@ -1,6 +1,6 @@ - + @@ -8,808 +8,807 @@ - - - - - - - + + + + + + + - - - - + + + + - + - - - - - -
- - - - - -
- + + + + + +
+ + + + + +
+ - + - - - -
- - - - - - - - -
- - - - - -
- - - - - - +
- - -
- - - - - - - - - - - - - - - - - -
- - - - - - - -
- - - -
- +
+ + + + + + + + +
+ + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + + + + + + + +
+ + + + + + + +
+ + + +
+ +
+ +

+ You can now share passwords with members of {{OrganizationName}}! +

+ +
+ + + + + + + +
+ + Log in + +
+ +
+ +
+ + + +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ +
+ +
+ + +
+ +
+ + + + + +
+ +
- -

- You can now share passwords with members of {{OrganizationName}}! -

- -
- - - - - - - -
- - Log in - -
- -
- -
- - - -
- - - - - - - - - -
- - - - - - - -
- - - -
- -
- -
- - -
- -
- - - - - -
- - -
- -
- - - - + +
+ + + + - - - - -
- + + + + +
+ - + - - - -
- - - -
- - - - - - - -
- - -
- - - - - - - - - -
- -
As a member of {{OrganizationName}}:
- -
- -
- - -
- -
- - - - - -
- - - - - - - -
- - -
- - -
- - - - - - - - - -
- - - - - - - -
- - Organization Icon - -
- -
- -
- - - -
- - - - - - - - - -
- -
Your account is owned by {{OrganizationName}} and is subject to their security and management policies.
- -
- -
- - -
- - -
- -
- - - - - -
- - - - - - +
- - -
- - -
- - - - - - - - - -
- - - - - - - -
- - Group Users Icon - -
- -
- -
- - - -
- - - - - - - - - - - - - -
- -
You can easily access and share passwords with your team.
- -
- - + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + +
+ +
As a member of {{OrganizationName}}:
+ +
+ +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ + +
+ + +
+ + + + + + + + + +
+ + + + + + + +
+ + Organization Icon + +
+ +
+ +
+ + + +
+ + + + + + + + + +
+ +
Your account is owned by {{OrganizationName}} and is subject to their security and management policies.
+ +
+ +
+ + +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ + +
+ + +
+ + + + + + + + + +
+ + + + + + + +
+ + Group Users Icon + +
+ +
+ +
+ + + +
+ + + + + + + + + + + + + +
+ +
You can easily access and share passwords with your team.
+ +
+ + - + + +
+ +
+ + +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + +
- -
- - -
- - -
- -
- - - - - -
- - - - - - - -
- -
- -
- - - -
- -
- - - - + +
+ + + + - - - - -
- + + + + +
+ - + - - - -
- - - -
- - - - - - +
- - -
- - - - - - - - - -
- -

- Learn more about Bitwarden -

- Find user guides, product documentation, and videos on the - Bitwarden Help Center.
- +
+ + + +
+ + + + + + + +
+ + +
+ + + + + + + + + +
+ +

+ Learn more about Bitwarden +

+ Find user guides, product documentation, and videos on the + Bitwarden Help Center.
+ +
+ +
+ + + +
+ + + + + + + + + +
+ +
+ + +
+ +
+ + +
- -
- - - -
- - - - - - - - - -
- -
- - -
- -
- - - -
- -
- - - - + +
+ + + + - - - - -
- + + + + +
+ - + - - +
- - -
- - - - - - - - - - - - - -
- - - - - - - - - - - - -
- - - - - - -
- - - + + + +
+ + + + + + + + + + + + + +
+ + + + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + +
+ +

+ © 2025 Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa + Barbara, CA, USA +

+

+ Always confirm you are on a trusted Bitwarden domain before logging + in:
+ bitwarden.com | + Learn why we include this +

+ +
+ +
+ +
-
- - - - - - - - - - -
- - - - - - -
- - - -
-
- - - - - - - - - - -
- - - - - - -
- - - -
-
- - - - - - - - - - -
- - - - - - -
- - - -
-
- - - - - - - - - - -
- - - - - - -
- - - -
-
- - - - - - - - - - -
- - - - - - -
- - - -
-
- - - - - - - - - - -
- - - - - - -
- - - -
-
- - - -
- -

- © 2025 Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa - Barbara, CA, USA -

-

- Always confirm you are on a trusted Bitwarden domain before logging - in:
- bitwarden.com | - Learn why we include this -

- -
- -
- - -
- -
- - - - - -
- - + +
+ + + + + +
+ + - \ No newline at end of file diff --git a/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationFamilyFreeView.html.hbs b/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationFamilyFreeView.html.hbs index cbe09d3e93..c0f838e0c7 100644 --- a/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationFamilyFreeView.html.hbs +++ b/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationFamilyFreeView.html.hbs @@ -1,6 +1,6 @@ - + @@ -8,976 +8,976 @@ - - - - - - - + + + + + + + - - - - + + + + - + - - - - - -
- - - - - -
- + + + + + +
+ + + + + +
+ - + - - - -
- - - - - - - - -
- - - - - -
- - - - - - +
- - -
- - - - - - - - - - - - - - - - - -
- - - - - - - -
- - - -
- +
+ + + + + + + + +
+ + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + + + + + + + +
+ + + + + + + +
+ + + +
+ +
+ +

+ You can now share passwords with members of {{OrganizationName}}! +

+ +
+ + + + + + + +
+ + Log in + +
+ +
+ +
+ + + +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ +
+ +
+ + +
+ +
+ + + + + +
+ +
- -

- You can now share passwords with members of {{OrganizationName}}! -

- -
- - - - - - - -
- - Log in - -
- -
- -
- - - -
- - - - - - - - - -
- - - - - - - -
- - - -
- -
- -
- - -
- -
- - - - - -
- - -
- -
- - - - + +
+ + + + - - - - -
- + + + + +
+ - + - - - -
- - - -
- - - - - - - -
- - -
- - - - - - - - - -
- -
As a member of {{OrganizationName}}:
- -
- -
- - -
- -
- - - - - -
- - - - - - - -
- - -
- - -
- - - - - - - - - -
- - - - - - - -
- - Collections Icon - -
- -
- -
- - - -
- - - - - - - - - -
- -
You can access passwords {{OrganizationName}} has shared with you.
- -
- -
- - -
- - -
- -
- - - - - -
- - - - - - +
- - -
- - -
- - - - - - - - - -
- - - - - - - -
- - Group Users Icon - -
- -
- -
- - - -
- - - - - - - - - - - - - -
- -
You can easily share passwords with friends, family, or coworkers.
- -
- - + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + +
+ +
As a member of {{OrganizationName}}:
+ +
+ +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ + +
+ + +
+ + + + + + + + + +
+ + + + + + + +
+ + Collections Icon + +
+ +
+ +
+ + + +
+ + + + + + + + + +
+ +
You can access passwords {{OrganizationName}} has shared with you.
+ +
+ +
+ + +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ + +
+ + +
+ + + + + + + + + +
+ + + + + + + +
+ + Group Users Icon + +
+ +
+ +
+ + + +
+ + + + + + + + + + + + + +
+ +
You can easily share passwords with friends, family, or coworkers.
+ +
+ + - + + +
+ +
+ + +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + +
- -
- - -
- - -
- -
- - - - - -
- - - - - - - -
- -
- -
- - - -
- -
- - - - + +
+ + + + - - - - -
- + + + + +
+ - + - - - -
- - - -
- - - - - - +
- - -
- - - - - - - - - - - - - -
- -
Download Bitwarden on all devices
- +
+ + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + + + +
+ +
Download Bitwarden on all devices
+ +
+ +
Already using the browser extension? + Download the Bitwarden mobile app from the + App Store + or Google Play + to quickly save logins and autofill forms on the go.
+ +
+ +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ + +
+ + +
+ + + + + + + + + +
+ + + + + + + +
+ + + + Download on the App Store + + + +
+ +
+ +
+ + + +
+ + + + + + + + + +
+ + + + + + + +
+ + + + Get it on Google Play + + + +
+ +
+ +
+ + +
+ + +
+ +
+ + +
- -
Already using the browser extension? - Download the Bitwarden mobile app from the - App Store - or Google Play - to quickly save logins and autofill forms on the go.
- -
- -
- - -
- -
- - - - - -
- - - - - - - -
- - -
- - -
- - - - - - - - - -
- - - - - - - -
- - - - Download on the App Store - - - -
- -
- -
- - - -
- - - - - - - - - -
- - - - - - - -
- - - - Get it on Google Play - - - -
- -
- -
- - -
- - -
- -
- - - -
- -
- - - - + +
+ + + + - - - - -
- + + + + +
+ - + - - - -
- - - -
- - - - - - +
- - -
- - - - - - - - - -
- -

- Learn more about Bitwarden -

- Find user guides, product documentation, and videos on the - Bitwarden Help Center.
- +
+ + + +
+ + + + + + + +
+ + +
+ + + + + + + + + +
+ +

+ Learn more about Bitwarden +

+ Find user guides, product documentation, and videos on the + Bitwarden Help Center.
+ +
+ +
+ + + +
+ + + + + + + + + +
+ +
+ + +
+ +
+ + +
- -
- - - -
- - - - - - - - - -
- -
- - -
- -
- - - -
- -
- - - - + +
+ + + + - - - - -
- + + + + +
+ - + - - +
- - -
- - - - - - - - - - - - - -
- - - - - - - - - - - - -
- - - - - - -
- - - + + + +
+ + + + + + + + + + + + + +
+ + + + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + +
+ +

+ © 2025 Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa + Barbara, CA, USA +

+

+ Always confirm you are on a trusted Bitwarden domain before logging + in:
+ bitwarden.com | + Learn why we include this +

+ +
+ +
+ +
-
- - - - - - - - - - -
- - - - - - -
- - - -
-
- - - - - - - - - - -
- - - - - - -
- - - -
-
- - - - - - - - - - -
- - - - - - -
- - - -
-
- - - - - - - - - - -
- - - - - - -
- - - -
-
- - - - - - - - - - -
- - - - - - -
- - - -
-
- - - - - - - - - - -
- - - - - - -
- - - -
-
- - - -
- -

- © 2025 Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa - Barbara, CA, USA -

-

- Always confirm you are on a trusted Bitwarden domain before logging - in:
- bitwarden.com | - Learn why we include this -

- -
- -
- - -
- -
- - - - - -
- - + +
+ + + + + +
+ + - \ No newline at end of file + diff --git a/src/Core/MailTemplates/Mjml/emails/AdminConsole/OrganizationConfirmation/organization-confirmation-enterprise-teams.mjml b/src/Core/MailTemplates/Mjml/emails/AdminConsole/OrganizationConfirmation/organization-confirmation-enterprise-teams.mjml index 6d3c46ae67..b94bf0dc86 100644 --- a/src/Core/MailTemplates/Mjml/emails/AdminConsole/OrganizationConfirmation/organization-confirmation-enterprise-teams.mjml +++ b/src/Core/MailTemplates/Mjml/emails/AdminConsole/OrganizationConfirmation/organization-confirmation-enterprise-teams.mjml @@ -8,8 +8,8 @@ @@ -33,7 +33,7 @@ icon-alt="Group Users Icon" text="You can easily access and share passwords with your team." foot-url-text="Share passwords in Bitwarden" - foot-url="https://bitwarden.com/help/share-to-a-collection/" + foot-url="https://bitwarden.com/help/sharing" /> diff --git a/src/Core/MailTemplates/Mjml/emails/AdminConsole/OrganizationConfirmation/organization-confirmation-family-free.mjml b/src/Core/MailTemplates/Mjml/emails/AdminConsole/OrganizationConfirmation/organization-confirmation-family-free.mjml index 2b2d854134..c223e2f650 100644 --- a/src/Core/MailTemplates/Mjml/emails/AdminConsole/OrganizationConfirmation/organization-confirmation-family-free.mjml +++ b/src/Core/MailTemplates/Mjml/emails/AdminConsole/OrganizationConfirmation/organization-confirmation-family-free.mjml @@ -8,8 +8,8 @@ @@ -33,7 +33,7 @@ icon-alt="Group Users Icon" text="You can easily share passwords with friends, family, or coworkers." foot-url-text="Share passwords in Bitwarden" - foot-url="https://bitwarden.com/help/share-to-a-collection/" + foot-url="https://bitwarden.com/help/sharing" /> From 12d18ebb2c749a958f8f5305f26176527b322008 Mon Sep 17 00:00:00 2001 From: Conner Turnbull <133619638+cturnbull-bitwarden@users.noreply.github.com> Date: Tue, 13 Jan 2026 09:32:02 -0500 Subject: [PATCH 05/49] [PM-27731] Updated organization licenses to save the correct values from the token (#6546) * Updated organization licenses to save the correct values from the token * Added additional test cases around licenses * Added missing properties from Organization to UpdateOrganizationLicenseCommand.UpdateLicenseAsync() * Add tests to validate license property synchronization pipeline * `dotnet format` --- .../SelfHostedOrganizationDetails.cs | 3 + .../UpdateOrganizationLicenseCommand.cs | 59 +++- .../Services/Implementations/UserService.cs | 12 + .../Entities/OrganizationTests.cs | 106 +++++- .../Billing/Licenses/LicenseConstantsTests.cs | 68 ++++ .../OrganizationLicenseClaimsFactoryTests.cs | 92 +++++ .../UpdateOrganizationLicenseCommandTests.cs | 321 +++++++++++++++++- 7 files changed, 653 insertions(+), 8 deletions(-) create mode 100644 test/Core.Test/Billing/Licenses/LicenseConstantsTests.cs create mode 100644 test/Core.Test/Billing/Licenses/Services/Implementations/OrganizationLicenseClaimsFactoryTests.cs diff --git a/src/Core/AdminConsole/Models/Data/Organizations/SelfHostedOrganizationDetails.cs b/src/Core/AdminConsole/Models/Data/Organizations/SelfHostedOrganizationDetails.cs index d74fb4f138..5ec9dc255a 100644 --- a/src/Core/AdminConsole/Models/Data/Organizations/SelfHostedOrganizationDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Organizations/SelfHostedOrganizationDetails.cs @@ -128,6 +128,7 @@ public class SelfHostedOrganizationDetails : Organization UseApi = UseApi, UseResetPassword = UseResetPassword, UseSecretsManager = UseSecretsManager, + UsePasswordManager = UsePasswordManager, SelfHost = SelfHost, UsersGetPremium = UsersGetPremium, UseCustomPermissions = UseCustomPermissions, @@ -156,6 +157,8 @@ public class SelfHostedOrganizationDetails : Organization UseAdminSponsoredFamilies = UseAdminSponsoredFamilies, UseDisableSmAdsForUsers = UseDisableSmAdsForUsers, UsePhishingBlocker = UsePhishingBlocker, + UseOrganizationDomains = UseOrganizationDomains, + UseAutomaticUserConfirmation = UseAutomaticUserConfirmation, }; } } diff --git a/src/Core/Billing/Organizations/Commands/UpdateOrganizationLicenseCommand.cs b/src/Core/Billing/Organizations/Commands/UpdateOrganizationLicenseCommand.cs index 1dfd786210..000edda1c2 100644 --- a/src/Core/Billing/Organizations/Commands/UpdateOrganizationLicenseCommand.cs +++ b/src/Core/Billing/Organizations/Commands/UpdateOrganizationLicenseCommand.cs @@ -1,9 +1,11 @@ using System.Text.Json; using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Enums; using Bit.Core.Billing.Licenses; using Bit.Core.Billing.Licenses.Extensions; using Bit.Core.Billing.Organizations.Models; using Bit.Core.Billing.Services; +using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Data.Organizations; using Bit.Core.Services; @@ -46,6 +48,57 @@ public class UpdateOrganizationLicenseCommand : IUpdateOrganizationLicenseComman } var claimsPrincipal = _licensingService.GetClaimsPrincipalFromLicense(license); + + // If the license has a Token (claims-based), extract all properties from claims BEFORE validation + // This ensures that CanUseLicense validation has access to the correct values from claims + // Otherwise, fall back to using the properties already on the license object (backward compatibility) + if (claimsPrincipal != null) + { + license.Name = claimsPrincipal.GetValue(OrganizationLicenseConstants.Name); + license.BillingEmail = claimsPrincipal.GetValue(OrganizationLicenseConstants.BillingEmail); + license.BusinessName = claimsPrincipal.GetValue(OrganizationLicenseConstants.BusinessName); + license.PlanType = claimsPrincipal.GetValue(OrganizationLicenseConstants.PlanType); + license.Seats = claimsPrincipal.GetValue(OrganizationLicenseConstants.Seats); + license.MaxCollections = claimsPrincipal.GetValue(OrganizationLicenseConstants.MaxCollections); + license.UsePolicies = claimsPrincipal.GetValue(OrganizationLicenseConstants.UsePolicies); + license.UseSso = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseSso); + license.UseKeyConnector = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseKeyConnector); + license.UseScim = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseScim); + license.UseGroups = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseGroups); + license.UseDirectory = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseDirectory); + license.UseEvents = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseEvents); + license.UseTotp = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseTotp); + license.Use2fa = claimsPrincipal.GetValue(OrganizationLicenseConstants.Use2fa); + license.UseApi = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseApi); + license.UseResetPassword = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseResetPassword); + license.Plan = claimsPrincipal.GetValue(OrganizationLicenseConstants.Plan); + license.SelfHost = claimsPrincipal.GetValue(OrganizationLicenseConstants.SelfHost); + license.UsersGetPremium = claimsPrincipal.GetValue(OrganizationLicenseConstants.UsersGetPremium); + license.UseCustomPermissions = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseCustomPermissions); + license.Enabled = claimsPrincipal.GetValue(OrganizationLicenseConstants.Enabled); + license.Expires = claimsPrincipal.GetValue(OrganizationLicenseConstants.Expires); + license.LicenseKey = claimsPrincipal.GetValue(OrganizationLicenseConstants.LicenseKey); + license.UsePasswordManager = claimsPrincipal.GetValue(OrganizationLicenseConstants.UsePasswordManager); + license.UseSecretsManager = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseSecretsManager); + license.SmSeats = claimsPrincipal.GetValue(OrganizationLicenseConstants.SmSeats); + license.SmServiceAccounts = claimsPrincipal.GetValue(OrganizationLicenseConstants.SmServiceAccounts); + license.UseRiskInsights = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseRiskInsights); + license.UseOrganizationDomains = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseOrganizationDomains); + license.UseAdminSponsoredFamilies = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseAdminSponsoredFamilies); + license.UseAutomaticUserConfirmation = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseAutomaticUserConfirmation); + license.UseDisableSmAdsForUsers = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseDisableSmAdsForUsers); + license.UsePhishingBlocker = claimsPrincipal.GetValue(OrganizationLicenseConstants.UsePhishingBlocker); + license.MaxStorageGb = claimsPrincipal.GetValue(OrganizationLicenseConstants.MaxStorageGb); + license.InstallationId = claimsPrincipal.GetValue(OrganizationLicenseConstants.InstallationId); + license.LicenseType = claimsPrincipal.GetValue(OrganizationLicenseConstants.LicenseType); + license.Issued = claimsPrincipal.GetValue(OrganizationLicenseConstants.Issued); + license.Refresh = claimsPrincipal.GetValue(OrganizationLicenseConstants.Refresh); + license.ExpirationWithoutGracePeriod = claimsPrincipal.GetValue(OrganizationLicenseConstants.ExpirationWithoutGracePeriod); + license.Trial = claimsPrincipal.GetValue(OrganizationLicenseConstants.Trial); + license.LimitCollectionCreationDeletion = claimsPrincipal.GetValue(OrganizationLicenseConstants.LimitCollectionCreationDeletion); + license.AllowAdminAccessToAllCollectionItems = claimsPrincipal.GetValue(OrganizationLicenseConstants.AllowAdminAccessToAllCollectionItems); + } + var canUse = license.CanUse(_globalSettings, _licensingService, claimsPrincipal, out var exception) && selfHostedOrganization.CanUseLicense(license, out exception); @@ -54,12 +107,6 @@ public class UpdateOrganizationLicenseCommand : IUpdateOrganizationLicenseComman throw new BadRequestException(exception); } - var useAutomaticUserConfirmation = claimsPrincipal? - .GetValue(OrganizationLicenseConstants.UseAutomaticUserConfirmation) ?? false; - - selfHostedOrganization.UseAutomaticUserConfirmation = useAutomaticUserConfirmation; - license.UseAutomaticUserConfirmation = useAutomaticUserConfirmation; - await WriteLicenseFileAsync(selfHostedOrganization, license); await UpdateOrganizationAsync(selfHostedOrganization, license); } diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index 763f70dd0c..64caf1d462 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -14,6 +14,8 @@ using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; +using Bit.Core.Billing.Licenses; +using Bit.Core.Billing.Licenses.Extensions; using Bit.Core.Billing.Models; using Bit.Core.Billing.Models.Business; using Bit.Core.Billing.Models.Sales; @@ -982,6 +984,16 @@ public class UserService : UserManager, IUserService throw new BadRequestException(exceptionMessage); } + // If the license has a Token (claims-based), extract all properties from claims + // Otherwise, fall back to using the properties already on the license object (backward compatibility) + if (claimsPrincipal != null) + { + license.LicenseKey = claimsPrincipal.GetValue(UserLicenseConstants.LicenseKey); + license.Premium = claimsPrincipal.GetValue(UserLicenseConstants.Premium); + license.MaxStorageGb = claimsPrincipal.GetValue(UserLicenseConstants.MaxStorageGb); + license.Expires = claimsPrincipal.GetValue(UserLicenseConstants.Expires); + } + var dir = $"{_globalSettings.LicenseDirectory}/user"; Directory.CreateDirectory(dir); using var fs = File.OpenWrite(Path.Combine(dir, $"{user.Id}.json")); diff --git a/test/Core.Test/AdminConsole/Entities/OrganizationTests.cs b/test/Core.Test/AdminConsole/Entities/OrganizationTests.cs index 7fcda324d9..aa0c2c80c3 100644 --- a/test/Core.Test/AdminConsole/Entities/OrganizationTests.cs +++ b/test/Core.Test/AdminConsole/Entities/OrganizationTests.cs @@ -1,7 +1,10 @@ -using System.Text.Json; +using System.Reflection; +using System.Text.Json; +using System.Text.RegularExpressions; using Bit.Core.AdminConsole.Entities; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; +using Bit.Core.Billing.Organizations.Models; using Bit.Test.Common.Helpers; using Xunit; @@ -115,4 +118,105 @@ public class OrganizationTests Assert.True(organization.UseDisableSmAdsForUsers); } + + [Fact] + public void UpdateFromLicense_AppliesAllLicenseProperties() + { + // This test ensures that when a new property is added to OrganizationLicense, + // it is also applied to the Organization in UpdateFromLicense(). + // This is the fourth step in the license synchronization pipeline: + // Property → Constant → Claim → Extraction → Application + + // 1. Get all public properties from OrganizationLicense + var licenseProperties = typeof(OrganizationLicense) + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Select(p => p.Name) + .ToHashSet(); + + // 2. Define properties that don't need to be applied to Organization + var excludedProperties = new HashSet + { + // Internal/computed properties + "SignatureBytes", // Computed from Signature property + "ValidLicenseVersion", // Internal property, not serialized + "CurrentLicenseFileVersion", // Constant field, not an instance property + "Hash", // Signature-related, not applied to org + "Signature", // Signature-related, not applied to org + "Token", // The JWT itself, not applied to org + "Version", // License version, not stored on org + + // Properties intentionally excluded from UpdateFromLicense + "Id", // Self-hosted org has its own unique Guid + "MaxStorageGb", // Not enforced for self-hosted (per comment in UpdateFromLicense) + + // Properties not stored on Organization model + "LicenseType", // Not a property on Organization + "InstallationId", // Not a property on Organization + "Issued", // Not a property on Organization + "Refresh", // Not a property on Organization + "ExpirationWithoutGracePeriod", // Not a property on Organization + "Trial", // Not a property on Organization + "Expires", // Mapped to ExpirationDate on Organization (different name) + + // Deprecated properties not applied + "LimitCollectionCreationDeletion", // Deprecated, not applied + "AllowAdminAccessToAllCollectionItems", // Deprecated, not applied + }; + + // 3. Get properties that should be applied + var propertiesThatShouldBeApplied = licenseProperties + .Except(excludedProperties) + .ToHashSet(); + + // 4. Read Organization.UpdateFromLicense source code + var organizationSourcePath = Path.Combine( + Directory.GetCurrentDirectory(), + "..", "..", "..", "..", "..", "src", "Core", "AdminConsole", "Entities", "Organization.cs"); + var sourceCode = File.ReadAllText(organizationSourcePath); + + // 5. Find all property assignments in UpdateFromLicense method + // Pattern matches: PropertyName = license.PropertyName + // This regex looks for assignments like "Name = license.Name" or "ExpirationDate = license.Expires" + var assignmentPattern = @"(\w+)\s*=\s*license\.(\w+)"; + var matches = Regex.Matches(sourceCode, assignmentPattern); + + var appliedProperties = new HashSet(); + foreach (Match match in matches) + { + // Get the license property name (right side of assignment) + var licensePropertyName = match.Groups[2].Value; + appliedProperties.Add(licensePropertyName); + } + + // Special case: Expires is mapped to ExpirationDate + if (appliedProperties.Contains("Expires")) + { + appliedProperties.Add("Expires"); // Already added, but being explicit + } + + // 6. Find missing applications + var missingApplications = propertiesThatShouldBeApplied + .Except(appliedProperties) + .OrderBy(p => p) + .ToList(); + + // 7. Build error message with guidance + var errorMessage = ""; + if (missingApplications.Any()) + { + errorMessage = $"The following OrganizationLicense properties are NOT applied to Organization in UpdateFromLicense():\n"; + errorMessage += string.Join("\n", missingApplications.Select(p => $" - {p}")); + errorMessage += "\n\nPlease add the following lines to Organization.UpdateFromLicense():\n"; + foreach (var prop in missingApplications) + { + errorMessage += $" {prop} = license.{prop};\n"; + } + errorMessage += "\nNote: If the property maps to a different name on Organization (like Expires → ExpirationDate), adjust accordingly."; + } + + // 8. Assert - if this fails, the error message guides the developer to add the application + Assert.True( + !missingApplications.Any(), + $"\n{errorMessage}"); + } } diff --git a/test/Core.Test/Billing/Licenses/LicenseConstantsTests.cs b/test/Core.Test/Billing/Licenses/LicenseConstantsTests.cs new file mode 100644 index 0000000000..df0e7133ad --- /dev/null +++ b/test/Core.Test/Billing/Licenses/LicenseConstantsTests.cs @@ -0,0 +1,68 @@ +using System.Reflection; +using Bit.Core.Billing.Licenses; +using Bit.Core.Billing.Organizations.Models; +using Xunit; + +namespace Bit.Core.Test.Billing.Licenses; + +public class LicenseConstantsTests +{ + [Fact] + public void OrganizationLicenseConstants_HasConstantForEveryLicenseProperty() + { + // This test ensures that when a new property is added to OrganizationLicense, + // a corresponding constant is added to OrganizationLicenseConstants. + // This is the first step in the license synchronization pipeline: + // Property → Constant → Claim → Extraction → Application + + // 1. Get all public properties from OrganizationLicense + var licenseProperties = typeof(OrganizationLicense) + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Select(p => p.Name) + .ToHashSet(); + + // 2. Get all constants from OrganizationLicenseConstants + var constants = typeof(OrganizationLicenseConstants) + .GetFields(BindingFlags.Public | BindingFlags.Static) + .Where(f => f.IsLiteral && !f.IsInitOnly) + .Select(f => f.GetValue(null) as string) + .ToHashSet(); + + // 3. Define properties that don't need constants (internal/computed/non-claims properties) + var excludedProperties = new HashSet + { + "SignatureBytes", // Computed from Signature property + "ValidLicenseVersion", // Internal property, not serialized + "CurrentLicenseFileVersion", // Constant field, not an instance property + "Hash", // Signature-related, not in claims system + "Signature", // Signature-related, not in claims system + "Token", // The JWT itself, not a claim within the token + "Version" // Not in claims system (only in deprecated property-based licenses) + }; + + // 4. Find license properties without corresponding constants + var propertiesWithoutConstants = licenseProperties + .Except(constants) + .Except(excludedProperties) + .OrderBy(p => p) + .ToList(); + + // 5. Build error message with guidance + var errorMessage = ""; + if (propertiesWithoutConstants.Any()) + { + errorMessage = $"The following OrganizationLicense properties don't have constants in OrganizationLicenseConstants:\n"; + errorMessage += string.Join("\n", propertiesWithoutConstants.Select(p => $" - {p}")); + errorMessage += "\n\nPlease add the following constants to OrganizationLicenseConstants:\n"; + foreach (var prop in propertiesWithoutConstants) + { + errorMessage += $" public const string {prop} = nameof({prop});\n"; + } + } + + // 6. Assert - if this fails, the error message guides the developer to add the constant + Assert.True( + !propertiesWithoutConstants.Any(), + $"\n{errorMessage}"); + } +} diff --git a/test/Core.Test/Billing/Licenses/Services/Implementations/OrganizationLicenseClaimsFactoryTests.cs b/test/Core.Test/Billing/Licenses/Services/Implementations/OrganizationLicenseClaimsFactoryTests.cs new file mode 100644 index 0000000000..43dc416de1 --- /dev/null +++ b/test/Core.Test/Billing/Licenses/Services/Implementations/OrganizationLicenseClaimsFactoryTests.cs @@ -0,0 +1,92 @@ +using System.Reflection; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Licenses; +using Bit.Core.Billing.Licenses.Models; +using Bit.Core.Billing.Licenses.Services.Implementations; +using Bit.Core.Models.Business; +using Bit.Test.Common.AutoFixture.Attributes; +using Xunit; + +namespace Bit.Core.Test.Billing.Licenses.Services.Implementations; + +public class OrganizationLicenseClaimsFactoryTests +{ + [Theory, BitAutoData] + public async Task GenerateClaims_CreatesClaimsForAllConstants(Organization organization) + { + // This test ensures that when a constant is added to OrganizationLicenseConstants, + // it is also added to the OrganizationLicenseClaimsFactory to generate claims. + // This is the second step in the license synchronization pipeline: + // Property → Constant → Claim → Extraction → Application + + // 1. Populate all nullable properties to ensure claims can be generated + // The factory only adds claims for properties that have values + organization.Name = "Test Organization"; + organization.BillingEmail = "billing@test.com"; + organization.BusinessName = "Test Business"; + organization.Plan = "Enterprise"; + organization.LicenseKey = "test-license-key"; + organization.Seats = 100; + organization.MaxCollections = 50; + organization.MaxStorageGb = 10; + organization.SmSeats = 25; + organization.SmServiceAccounts = 10; + organization.ExpirationDate = DateTime.UtcNow.AddYears(1); // Ensure org is not expired + + // Create a LicenseContext with a minimal SubscriptionInfo to trigger conditional claims + // ExpirationWithoutGracePeriod is only generated for active, non-trial, annual subscriptions + var licenseContext = new LicenseContext + { + InstallationId = Guid.NewGuid(), + SubscriptionInfo = new SubscriptionInfo + { + Subscription = new SubscriptionInfo.BillingSubscription(null!) + { + TrialEndDate = DateTime.UtcNow.AddDays(-30), // Trial ended in the past + PeriodStartDate = DateTime.UtcNow, + PeriodEndDate = DateTime.UtcNow.AddDays(365), // Annual subscription (>180 days) + Status = "active" + } + } + }; + + // 2. Generate claims + var factory = new OrganizationLicenseClaimsFactory(); + var claims = await factory.GenerateClaims(organization, licenseContext); + + // 3. Get all constants from OrganizationLicenseConstants + var allConstants = typeof(OrganizationLicenseConstants) + .GetFields(BindingFlags.Public | BindingFlags.Static) + .Where(f => f.IsLiteral && !f.IsInitOnly) + .Select(f => f.GetValue(null) as string) + .ToHashSet(); + + // 4. Get claim types from generated claims + var generatedClaimTypes = claims.Select(c => c.Type).ToHashSet(); + + // 5. Find constants that don't have corresponding claims + var constantsWithoutClaims = allConstants + .Except(generatedClaimTypes) + .OrderBy(c => c) + .ToList(); + + // 6. Build error message with guidance + var errorMessage = ""; + if (constantsWithoutClaims.Any()) + { + errorMessage = $"The following constants in OrganizationLicenseConstants are NOT generated as claims in OrganizationLicenseClaimsFactory:\n"; + errorMessage += string.Join("\n", constantsWithoutClaims.Select(c => $" - {c}")); + errorMessage += "\n\nPlease add the following claims to OrganizationLicenseClaimsFactory.GenerateClaims():\n"; + foreach (var constant in constantsWithoutClaims) + { + errorMessage += $" new(nameof(OrganizationLicenseConstants.{constant}), entity.{constant}.ToString()),\n"; + } + errorMessage += "\nNote: If the property is nullable, you may need to add it conditionally."; + } + + // 7. Assert - if this fails, the error message guides the developer to add claim generation + Assert.True( + !constantsWithoutClaims.Any(), + $"\n{errorMessage}"); + } +} diff --git a/test/Core.Test/Billing/Organizations/Commands/UpdateOrganizationLicenseCommandTests.cs b/test/Core.Test/Billing/Organizations/Commands/UpdateOrganizationLicenseCommandTests.cs index 8befbb000d..46c418e913 100644 --- a/test/Core.Test/Billing/Organizations/Commands/UpdateOrganizationLicenseCommandTests.cs +++ b/test/Core.Test/Billing/Organizations/Commands/UpdateOrganizationLicenseCommandTests.cs @@ -1,9 +1,14 @@ -using System.Security.Claims; +using System.Reflection; +using System.Security.Claims; +using System.Text.RegularExpressions; using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Licenses; using Bit.Core.Billing.Organizations.Commands; using Bit.Core.Billing.Organizations.Models; using Bit.Core.Billing.Services; using Bit.Core.Enums; +using Bit.Core.Exceptions; using Bit.Core.Models.Data.Organizations; using Bit.Core.Services; using Bit.Core.Settings; @@ -99,6 +104,320 @@ public class UpdateOrganizationLicenseCommandTests } } + [Theory, BitAutoData] + public async Task UpdateLicenseAsync_WithClaimsPrincipal_ExtractsAllPropertiesFromClaims( + SelfHostedOrganizationDetails selfHostedOrg, + OrganizationLicense license, + SutProvider sutProvider) + { + var globalSettings = sutProvider.GetDependency(); + globalSettings.LicenseDirectory = LicenseDirectory; + globalSettings.SelfHosted = true; + + // Setup license for CanUse validation + license.Enabled = true; + license.Issued = DateTime.Now.AddDays(-1); + license.Expires = DateTime.Now.AddDays(1); + license.Version = OrganizationLicense.CurrentLicenseFileVersion; + license.InstallationId = globalSettings.Installation.Id; + license.LicenseType = LicenseType.Organization; + license.Token = "test-token"; // Indicates this is a claims-based license + sutProvider.GetDependency().VerifyLicense(license).Returns(true); + + // Create a ClaimsPrincipal with all organization license claims + var claims = new List + { + new(OrganizationLicenseConstants.LicenseType, ((int)LicenseType.Organization).ToString()), + new(OrganizationLicenseConstants.InstallationId, globalSettings.Installation.Id.ToString()), + new(OrganizationLicenseConstants.Name, "Test Organization"), + new(OrganizationLicenseConstants.BillingEmail, "billing@test.com"), + new(OrganizationLicenseConstants.BusinessName, "Test Business"), + new(OrganizationLicenseConstants.PlanType, ((int)PlanType.EnterpriseAnnually).ToString()), + new(OrganizationLicenseConstants.Seats, "100"), + new(OrganizationLicenseConstants.MaxCollections, "50"), + new(OrganizationLicenseConstants.UsePolicies, "true"), + new(OrganizationLicenseConstants.UseSso, "true"), + new(OrganizationLicenseConstants.UseKeyConnector, "true"), + new(OrganizationLicenseConstants.UseScim, "true"), + new(OrganizationLicenseConstants.UseGroups, "true"), + new(OrganizationLicenseConstants.UseDirectory, "true"), + new(OrganizationLicenseConstants.UseEvents, "true"), + new(OrganizationLicenseConstants.UseTotp, "true"), + new(OrganizationLicenseConstants.Use2fa, "true"), + new(OrganizationLicenseConstants.UseApi, "true"), + new(OrganizationLicenseConstants.UseResetPassword, "true"), + new(OrganizationLicenseConstants.Plan, "Enterprise"), + new(OrganizationLicenseConstants.SelfHost, "true"), + new(OrganizationLicenseConstants.UsersGetPremium, "true"), + new(OrganizationLicenseConstants.UseCustomPermissions, "true"), + new(OrganizationLicenseConstants.Enabled, "true"), + new(OrganizationLicenseConstants.Expires, DateTime.Now.AddDays(1).ToString("O")), + new(OrganizationLicenseConstants.LicenseKey, "test-license-key"), + new(OrganizationLicenseConstants.UsePasswordManager, "true"), + new(OrganizationLicenseConstants.UseSecretsManager, "true"), + new(OrganizationLicenseConstants.SmSeats, "25"), + new(OrganizationLicenseConstants.SmServiceAccounts, "10"), + new(OrganizationLicenseConstants.UseRiskInsights, "true"), + new(OrganizationLicenseConstants.UseOrganizationDomains, "true"), + new(OrganizationLicenseConstants.UseAdminSponsoredFamilies, "true"), + new(OrganizationLicenseConstants.UseAutomaticUserConfirmation, "true"), + new(OrganizationLicenseConstants.UseDisableSmAdsForUsers, "true"), + new(OrganizationLicenseConstants.UsePhishingBlocker, "true"), + new(OrganizationLicenseConstants.MaxStorageGb, "5"), + new(OrganizationLicenseConstants.Issued, DateTime.Now.AddDays(-1).ToString("O")), + new(OrganizationLicenseConstants.Refresh, DateTime.Now.AddMonths(1).ToString("O")), + new(OrganizationLicenseConstants.ExpirationWithoutGracePeriod, DateTime.Now.AddMonths(12).ToString("O")), + new(OrganizationLicenseConstants.Trial, "false"), + new(OrganizationLicenseConstants.LimitCollectionCreationDeletion, "true"), + new(OrganizationLicenseConstants.AllowAdminAccessToAllCollectionItems, "true") + }; + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity(claims)); + + sutProvider.GetDependency() + .GetClaimsPrincipalFromLicense(license) + .Returns(claimsPrincipal); + + // Setup selfHostedOrg for CanUseLicense validation + selfHostedOrg.OccupiedSeatCount = 50; // Less than the 100 seats in the license + selfHostedOrg.CollectionCount = 10; // Less than the 50 max collections in the license + selfHostedOrg.GroupCount = 1; + selfHostedOrg.UseGroups = true; + selfHostedOrg.UsePolicies = true; + selfHostedOrg.UseSso = true; + selfHostedOrg.UseKeyConnector = true; + selfHostedOrg.UseScim = true; + selfHostedOrg.UseCustomPermissions = true; + selfHostedOrg.UseResetPassword = true; + + try + { + await sutProvider.Sut.UpdateLicenseAsync(selfHostedOrg, license, null); + + // Assertion: license file should be written to disk + var filePath = Path.Combine(LicenseDirectory, "organization", $"{selfHostedOrg.Id}.json"); + await using var fs = File.OpenRead(filePath); + var licenseFromFile = await JsonSerializer.DeserializeAsync(fs); + + AssertHelper.AssertPropertyEqual(license, licenseFromFile, "SignatureBytes"); + + // Assertion: organization should be updated with ALL properties extracted from claims + await sutProvider.GetDependency() + .Received(1) + .ReplaceAndUpdateCacheAsync(Arg.Is(org => + org.Name == "Test Organization" && + org.BillingEmail == "billing@test.com" && + org.BusinessName == "Test Business" && + org.PlanType == PlanType.EnterpriseAnnually && + org.Seats == 100 && + org.MaxCollections == 50 && + org.UsePolicies == true && + org.UseSso == true && + org.UseKeyConnector == true && + org.UseScim == true && + org.UseGroups == true && + org.UseDirectory == true && + org.UseEvents == true && + org.UseTotp == true && + org.Use2fa == true && + org.UseApi == true && + org.UseResetPassword == true && + org.Plan == "Enterprise" && + org.SelfHost == true && + org.UsersGetPremium == true && + org.UseCustomPermissions == true && + org.Enabled == true && + org.LicenseKey == "test-license-key" && + org.UsePasswordManager == true && + org.UseSecretsManager == true && + org.SmSeats == 25 && + org.SmServiceAccounts == 10 && + org.UseRiskInsights == true && + org.UseOrganizationDomains == true && + org.UseAdminSponsoredFamilies == true && + org.UseAutomaticUserConfirmation == true && + org.UseDisableSmAdsForUsers == true && + org.UsePhishingBlocker == true)); + } + finally + { + // Clean up temporary directory + if (Directory.Exists(OrganizationLicenseDirectory.Value)) + { + Directory.Delete(OrganizationLicenseDirectory.Value, true); + } + } + } + + [Theory, BitAutoData] + public async Task UpdateLicenseAsync_WrongInstallationIdInClaims_ThrowsBadRequestException( + SelfHostedOrganizationDetails selfHostedOrg, + OrganizationLicense license, + SutProvider sutProvider) + { + var globalSettings = sutProvider.GetDependency(); + globalSettings.LicenseDirectory = LicenseDirectory; + globalSettings.SelfHosted = true; + + // Setup license for CanUse validation + license.Enabled = true; + license.Issued = DateTime.Now.AddDays(-1); + license.Expires = DateTime.Now.AddDays(1); + license.Version = OrganizationLicense.CurrentLicenseFileVersion; + license.LicenseType = LicenseType.Organization; + license.Token = "test-token"; // Indicates this is a claims-based license + sutProvider.GetDependency().VerifyLicense(license).Returns(true); + + // Create a ClaimsPrincipal with WRONG installation ID + var wrongInstallationId = Guid.NewGuid(); // Different from globalSettings.Installation.Id + var claims = new List + { + new(OrganizationLicenseConstants.LicenseType, ((int)LicenseType.Organization).ToString()), + new(OrganizationLicenseConstants.InstallationId, wrongInstallationId.ToString()), + new(OrganizationLicenseConstants.Enabled, "true"), + new(OrganizationLicenseConstants.SelfHost, "true") + }; + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity(claims)); + + sutProvider.GetDependency() + .GetClaimsPrincipalFromLicense(license) + .Returns(claimsPrincipal); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.UpdateLicenseAsync(selfHostedOrg, license, null)); + + Assert.Contains("The installation ID does not match the current installation.", exception.Message); + + // Verify organization was NOT saved + await sutProvider.GetDependency() + .DidNotReceive() + .ReplaceAndUpdateCacheAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdateLicenseAsync_ExpiredLicenseWithoutClaims_ThrowsBadRequestException( + SelfHostedOrganizationDetails selfHostedOrg, + OrganizationLicense license, + SutProvider sutProvider) + { + var globalSettings = sutProvider.GetDependency(); + globalSettings.LicenseDirectory = LicenseDirectory; + globalSettings.SelfHosted = true; + + // Setup legacy license (no Token, no claims) + license.Token = null; // Legacy license + license.Enabled = true; + license.Issued = DateTime.Now.AddDays(-2); + license.Expires = DateTime.Now.AddDays(-1); // Expired yesterday + license.Version = OrganizationLicense.CurrentLicenseFileVersion; + license.InstallationId = globalSettings.Installation.Id; + license.LicenseType = LicenseType.Organization; + license.SelfHost = true; + + sutProvider.GetDependency().VerifyLicense(license).Returns(true); + sutProvider.GetDependency() + .GetClaimsPrincipalFromLicense(license) + .Returns((ClaimsPrincipal)null); // No claims for legacy license + + // Passing values for SelfHostedOrganizationDetails.CanUseLicense + license.Seats = null; + license.MaxCollections = null; + license.UseGroups = true; + license.UsePolicies = true; + license.UseSso = true; + license.UseKeyConnector = true; + license.UseScim = true; + license.UseCustomPermissions = true; + license.UseResetPassword = true; + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.UpdateLicenseAsync(selfHostedOrg, license, null)); + + Assert.Contains("The license has expired.", exception.Message); + + // Verify organization was NOT saved + await sutProvider.GetDependency() + .DidNotReceive() + .ReplaceAndUpdateCacheAsync(Arg.Any()); + } + + [Fact] + public async Task UpdateLicenseAsync_ExtractsAllClaimsBasedProperties_WhenClaimsPrincipalProvided() + { + // This test ensures that when new properties are added to OrganizationLicense, + // they are automatically extracted from JWT claims in UpdateOrganizationLicenseCommand. + // If a new constant is added to OrganizationLicenseConstants but not extracted, + // this test will fail with a clear message showing which properties are missing. + + // 1. Get all OrganizationLicenseConstants + var constantFields = typeof(OrganizationLicenseConstants) + .GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.GetField) + .Where(f => f.IsLiteral && !f.IsInitOnly) + .Select(f => f.GetValue(null) as string) + .ToList(); + + // 2. Define properties that should be excluded (not claims-based or intentionally not extracted) + var excludedProperties = new HashSet + { + "Version", // Not in claims system (only in deprecated property-based licenses) + "Hash", // Signature-related, not extracted from claims + "Signature", // Signature-related, not extracted from claims + "SignatureBytes", // Computed from Signature, not a claim + "Token", // The JWT itself, not extracted from claims + "Id" // Cloud org ID from license, not used - self-hosted org has its own separate ID + }; + + // 3. Get properties that should be extracted from claims + var propertiesThatShouldBeExtracted = constantFields + .Where(c => !excludedProperties.Contains(c)) + .ToHashSet(); + + // 4. Read UpdateOrganizationLicenseCommand source code + var commandSourcePath = Path.Combine( + Directory.GetCurrentDirectory(), + "..", "..", "..", "..", "..", + "src", "Core", "Billing", "Organizations", "Commands", "UpdateOrganizationLicenseCommand.cs"); + var sourceCode = await File.ReadAllTextAsync(commandSourcePath); + + // 5. Find all GetValue calls that extract properties from claims + // Pattern matches: license.PropertyName = claimsPrincipal.GetValue(OrganizationLicenseConstants.PropertyName) + var extractedProperties = new HashSet(); + var getValuePattern = @"claimsPrincipal\.GetValue<[^>]+>\(OrganizationLicenseConstants\.(\w+)\)"; + var matches = Regex.Matches(sourceCode, getValuePattern); + + foreach (Match match in matches) + { + extractedProperties.Add(match.Groups[1].Value); + } + + // 6. Find missing extractions + var missingExtractions = propertiesThatShouldBeExtracted + .Except(extractedProperties) + .OrderBy(p => p) + .ToList(); + + // 7. Build error message with guidance if there are missing extractions + var errorMessage = ""; + if (missingExtractions.Any()) + { + errorMessage = $"The following constants in OrganizationLicenseConstants are NOT extracted from claims in UpdateOrganizationLicenseCommand:\n"; + errorMessage += string.Join("\n", missingExtractions.Select(p => $" - {p}")); + errorMessage += "\n\nPlease add the following lines to UpdateOrganizationLicenseCommand.cs in the 'if (claimsPrincipal != null)' block:\n"; + foreach (var prop in missingExtractions) + { + errorMessage += $" license.{prop} = claimsPrincipal.GetValue(OrganizationLicenseConstants.{prop});\n"; + } + } + + // 8. Assert - if this fails, the error message guides the developer to add the extraction + // Note: We don't check for "extra extractions" because that would be a compile error + // (can't reference OrganizationLicenseConstants.Foo if Foo doesn't exist) + Assert.True( + !missingExtractions.Any(), + $"\n{errorMessage}"); + } + // Wrapper to compare 2 objects that are different types private bool AssertPropertyEqual(OrganizationLicense expected, Organization actual, params string[] excludedPropertyStrings) { From 07b07216160e6af9a667f7884bc584a85d3c0dfc Mon Sep 17 00:00:00 2001 From: Jared McCannon Date: Tue, 13 Jan 2026 08:39:27 -0600 Subject: [PATCH 06/49] Removed old implementation and feature flag checks for bulk revoke. (#6827) --- .../OrganizationUsersController.cs | 5 -- .../v1/IRevokeOrganizationUserCommand.cs | 2 - .../v1/RevokeOrganizationUserCommand.cs | 64 ------------------- ...ganizationUserControllerBulkRevokeTests.cs | 9 --- 4 files changed, 80 deletions(-) diff --git a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs index 5cdd857f3f..90d02a46a1 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs @@ -665,11 +665,6 @@ public class OrganizationUsersController : BaseAdminConsoleController [Authorize] public async Task> BulkRevokeAsync(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model) { - if (!_featureService.IsEnabled(FeatureFlagKeys.BulkRevokeUsersV2)) - { - return await RestoreOrRevokeUsersAsync(orgId, model, _revokeOrganizationUserCommand.RevokeUsersAsync); - } - var currentUserId = _userService.GetProperUserId(User); if (currentUserId == null) { diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v1/IRevokeOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v1/IRevokeOrganizationUserCommand.cs index 7b5541c3ce..313c01af7c 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v1/IRevokeOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v1/IRevokeOrganizationUserCommand.cs @@ -7,6 +7,4 @@ public interface IRevokeOrganizationUserCommand { Task RevokeUserAsync(OrganizationUser organizationUser, Guid? revokingUserId); Task RevokeUserAsync(OrganizationUser organizationUser, EventSystemUser systemUser); - Task>> RevokeUsersAsync(Guid organizationId, - IEnumerable organizationUserIds, Guid? revokingUserId); } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v1/RevokeOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v1/RevokeOrganizationUserCommand.cs index 7aa67f0813..750ebf2518 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v1/RevokeOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v1/RevokeOrganizationUserCommand.cs @@ -68,68 +68,4 @@ public class RevokeOrganizationUserCommand( await organizationUserRepository.RevokeAsync(organizationUser.Id); organizationUser.Status = OrganizationUserStatusType.Revoked; } - - public async Task>> RevokeUsersAsync(Guid organizationId, - IEnumerable organizationUserIds, Guid? revokingUserId) - { - var orgUsers = await organizationUserRepository.GetManyAsync(organizationUserIds); - var filteredUsers = orgUsers.Where(u => u.OrganizationId == organizationId) - .ToList(); - - if (!filteredUsers.Any()) - { - throw new BadRequestException("Users invalid."); - } - - if (!await hasConfirmedOwnersExceptQuery.HasConfirmedOwnersExceptAsync(organizationId, organizationUserIds)) - { - throw new BadRequestException("Organization must have at least one confirmed owner."); - } - - var deletingUserIsOwner = false; - if (revokingUserId.HasValue) - { - deletingUserIsOwner = await currentContext.OrganizationOwner(organizationId); - } - - var result = new List>(); - - foreach (var organizationUser in filteredUsers) - { - try - { - if (organizationUser.Status == OrganizationUserStatusType.Revoked) - { - throw new BadRequestException("Already revoked."); - } - - if (revokingUserId.HasValue && organizationUser.UserId == revokingUserId) - { - throw new BadRequestException("You cannot revoke yourself."); - } - - if (organizationUser.Type == OrganizationUserType.Owner && revokingUserId.HasValue && - !deletingUserIsOwner) - { - throw new BadRequestException("Only owners can revoke other owners."); - } - - await organizationUserRepository.RevokeAsync(organizationUser.Id); - organizationUser.Status = OrganizationUserStatusType.Revoked; - await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Revoked); - if (organizationUser.UserId.HasValue) - { - await pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value); - } - - result.Add(Tuple.Create(organizationUser, "")); - } - catch (BadRequestException e) - { - result.Add(Tuple.Create(organizationUser, e.Message)); - } - } - - return result; - } } diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerBulkRevokeTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerBulkRevokeTests.cs index 6645f29eae..3ea7d9ff5a 100644 --- a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerBulkRevokeTests.cs +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerBulkRevokeTests.cs @@ -4,7 +4,6 @@ using Bit.Api.AdminConsole.Models.Response.Organizations; using Bit.Api.IntegrationTest.Factories; using Bit.Api.IntegrationTest.Helpers; using Bit.Api.Models.Response; -using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; @@ -14,8 +13,6 @@ using Bit.Core.Billing.Enums; using Bit.Core.Enums; using Bit.Core.Models.Data; using Bit.Core.Repositories; -using Bit.Core.Services; -using NSubstitute; using Xunit; namespace Bit.Api.IntegrationTest.AdminConsole.Controllers; @@ -32,12 +29,6 @@ public class OrganizationUserControllerBulkRevokeTests : IClassFixture(featureService => - { - featureService - .IsEnabled(FeatureFlagKeys.BulkRevokeUsersV2) - .Returns(true); - }); _client = _factory.CreateClient(); _loginHelper = new LoginHelper(_factory, _client); } From a9f78487efcf245d304b477391bd57deae7bca80 Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Tue, 13 Jan 2026 15:47:22 +0100 Subject: [PATCH 07/49] Add feature flag (#6820) --- src/Core/Constants.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 24e30fbcf0..47e7eb40bd 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -202,6 +202,7 @@ public static class FeatureFlagKeys public const string V2RegistrationTDEJIT = "pm-27279-v2-registration-tde-jit"; public const string DataRecoveryTool = "pm-28813-data-recovery-tool"; public const string EnableAccountEncryptionV2KeyConnectorRegistration = "enable-account-encryption-v2-key-connector-registration"; + public const string SdkKeyRotation = "pm-30144-sdk-key-rotation"; public const string EnableAccountEncryptionV2JitPasswordRegistration = "enable-account-encryption-v2-jit-password-registration"; /* Mobile Team */ From f144828a8775af4f32ae62ecd0179b8fac893a87 Mon Sep 17 00:00:00 2001 From: Oscar Hinton Date: Tue, 13 Jan 2026 18:10:01 +0100 Subject: [PATCH 08/49] [PM-22263] [PM-29849] Initial PoC of seeder API (#6424) We want to reduce the amount of business critical test data in the company. One way of doing that is to generate test data on demand prior to client side testing. Clients will request a scene to be set up with a JSON body set of options, specific to a given scene. Successful seed requests will be responded to with a mangleMap which maps magic strings present in the request to the mangled, non-colliding versions inserted into the database. This way, the server is solely responsible for understanding uniqueness requirements in the database. scenes also are able to return custom data, depending on the scene. For example, user creation would benefit from a return value of the userId for further test setup on the client side. Clients will indicate they are running tests by including a unique header, x-play-id which specifies a unique testing context. The server uses this PlayId as the seed for any mangling that occurs. This allows the client to decide it will reuse a given PlayId if the test context builds on top of previously executed tests. When a given context is no longer needed, the API user will delete all test data associated with the PlayId by calling a delete endpoint. --------- Co-authored-by: Matt Gibson --- .vscode/launch.json | 84 + .vscode/tasks.json | 69 + bitwarden-server.sln | 13 + bitwarden_license/src/Scim/Startup.cs | 1 + bitwarden_license/src/Sso/Startup.cs | 1 + dev/setup_secrets.ps1 | 23 +- src/Admin/Startup.cs | 1 + .../Request/EmergencyAccessRequestModels.cs | 2 +- src/Api/Startup.cs | 1 + src/Billing/Startup.cs | 1 + src/Core/Auth/Entities/EmergencyAccess.cs | 2 +- .../EmergencyAccess/EmergencyAccessService.cs | 2 +- src/Core/Entities/PlayItem.cs | 60 + src/Core/Repositories/IPlayItemRepository.cs | 11 + src/Core/Services/Play/IPlayIdService.cs | 23 + src/Core/Services/Play/IPlayItemService.cs | 27 + .../Implementations/NeverPlayIdServices.cs | 16 + .../Play/Implementations/PlayIdService.cs | 13 + .../Implementations/PlayIdSingletonService.cs | 48 + .../Play/Implementations/PlayItemService.cs | 26 + src/Core/Services/Play/README.md | 27 + src/Core/Settings/GlobalSettings.cs | 1 + src/Events/Startup.cs | 1 + src/EventsProcessor/Startup.cs | 1 + src/Identity/Startup.cs | 1 + .../Repositories/OrganizationRepository.cs | 2 +- .../DapperServiceCollectionExtensions.cs | 1 + .../Repositories/PlayItemRepository.cs | 45 + .../Repositories/OrganizationRepository.cs | 2 +- .../PlayItemEntityTypeConfiguration.cs | 46 + ...ityFrameworkServiceCollectionExtensions.cs | 1 + .../Models/PlayItem.cs | 19 + .../Repositories/DatabaseContext.cs | 1 + .../Repositories/PlayItemRepository.cs | 42 + .../Play/PlayServiceCollectionExtensions.cs | 30 + ...anizationTrackingOrganizationRepository.cs | 32 + .../DapperTestUserTrackingUserRepository.cs | 33 + ...anizationTrackingOrganizationRepository.cs | 33 + .../EFTestUserTrackingUserRepository.cs | 31 + src/SharedWeb/Utilities/PlayIdMiddleware.cs | 41 + .../Utilities/ServiceCollectionExtensions.cs | 39 + .../dbo/Stored Procedures/PlayItem_Create.sql | 27 + .../PlayItem_DeleteByPlayId.sql | 12 + .../PlayItem_ReadByPlayId.sql | 17 + src/Sql/dbo/Tables/PlayItem.sql | 23 + test/Common/AutoFixture/SutProvider.cs | 14 + test/Core.Test/Services/PlayIdServiceTests.cs | 211 + .../Services/PlayItemServiceTests.cs | 143 + .../DatabaseDataAttribute.cs | 4 +- .../Factories/WebApplicationFactoryBase.cs | 2 +- .../HttpClientExtensions.cs | 40 + .../QueryControllerTest.cs | 75 + .../SeedControllerTest.cs | 222 ++ .../SeederApi.IntegrationTest.csproj | 29 + .../SeederApiApplicationFactory.cs | 18 + test/SharedWeb.Test/PlayIdMiddlewareTests.cs | 102 + .../2026-01-08_00_CreatePlayItem.sql | 90 + .../20260108193951_CreatePlayItem.Designer.cs | 3502 ++++++++++++++++ .../20260108193951_CreatePlayItem.cs | 65 + .../DatabaseContextModelSnapshot.cs | 231 +- .../20260108193909_CreatePlayItem.Designer.cs | 3508 +++++++++++++++++ .../20260108193909_CreatePlayItem.cs | 63 + .../DatabaseContextModelSnapshot.cs | 231 +- util/RustSdk/RustSdk.csproj | 33 +- util/RustSdk/rust-toolchain.toml | 2 + util/Seeder/Factories/OrganizationSeeder.cs | 2 +- util/Seeder/Factories/UserSeeder.cs | 96 +- util/Seeder/IQuery.cs | 60 + util/Seeder/IScene.cs | 96 + util/Seeder/MangleId.cs | 19 + .../Queries/EmergencyAccessInviteQuery.cs | 35 + .../Recipes/OrganizationWithUsersRecipe.cs | 8 +- util/Seeder/SceneResult.cs | 28 + util/Seeder/Scenes/SingleUserScene.cs | 38 + util/Seeder/Seeder.csproj | 4 - .../Commands/DestroyBatchScenesCommand.cs | 36 + .../SeederApi/Commands/DestroySceneCommand.cs | 57 + .../Interfaces/IDestroyBatchScenesCommand.cs | 14 + .../Interfaces/IDestroySceneCommand.cs | 15 + util/SeederApi/Controllers/InfoController.cs | 20 + util/SeederApi/Controllers/QueryController.cs | 32 + util/SeederApi/Controllers/SeedController.cs | 100 + util/SeederApi/Execution/IQueryExecutor.cs | 22 + util/SeederApi/Execution/ISceneExecutor.cs | 22 + util/SeederApi/Execution/JsonConfiguration.cs | 19 + util/SeederApi/Execution/QueryExecutor.cs | 77 + util/SeederApi/Execution/SceneExecutor.cs | 78 + .../Extensions/ServiceCollectionExtensions.cs | 76 + .../Models/Request/QueryRequestModel.cs | 11 + .../Models/Request/SeedRequestModel.cs | 11 + .../Models/Response/SeedResponseModel.cs | 18 + util/SeederApi/Program.cs | 20 + util/SeederApi/Properties/launchSettings.json | 37 + util/SeederApi/Queries/GetAllPlayIdsQuery.cs | 15 + .../Queries/Interfaces/IGetAllPlayIdsQuery.cs | 13 + util/SeederApi/README.md | 185 + util/SeederApi/SeederApi.csproj | 16 + util/SeederApi/Services/QueryExceptions.cs | 10 + util/SeederApi/Services/SceneExceptions.cs | 10 + util/SeederApi/Startup.cs | 80 + util/SeederApi/appsettings.Development.json | 8 + util/SeederApi/appsettings.json | 11 + .../20260108193841_CreatePlayItem.Designer.cs | 3491 ++++++++++++++++ .../20260108193841_CreatePlayItem.cs | 63 + .../DatabaseContextModelSnapshot.cs | 229 +- 105 files changed, 14377 insertions(+), 322 deletions(-) mode change 100644 => 100755 dev/setup_secrets.ps1 create mode 100644 src/Core/Entities/PlayItem.cs create mode 100644 src/Core/Repositories/IPlayItemRepository.cs create mode 100644 src/Core/Services/Play/IPlayIdService.cs create mode 100644 src/Core/Services/Play/IPlayItemService.cs create mode 100644 src/Core/Services/Play/Implementations/NeverPlayIdServices.cs create mode 100644 src/Core/Services/Play/Implementations/PlayIdService.cs create mode 100644 src/Core/Services/Play/Implementations/PlayIdSingletonService.cs create mode 100644 src/Core/Services/Play/Implementations/PlayItemService.cs create mode 100644 src/Core/Services/Play/README.md create mode 100644 src/Infrastructure.Dapper/Repositories/PlayItemRepository.cs create mode 100644 src/Infrastructure.EntityFramework/Configurations/PlayItemEntityTypeConfiguration.cs create mode 100644 src/Infrastructure.EntityFramework/Models/PlayItem.cs create mode 100644 src/Infrastructure.EntityFramework/Repositories/PlayItemRepository.cs create mode 100644 src/SharedWeb/Play/PlayServiceCollectionExtensions.cs create mode 100644 src/SharedWeb/Play/Repositories/DapperTestOrganizationTrackingOrganizationRepository.cs create mode 100644 src/SharedWeb/Play/Repositories/DapperTestUserTrackingUserRepository.cs create mode 100644 src/SharedWeb/Play/Repositories/EFTestOrganizationTrackingOrganizationRepository.cs create mode 100644 src/SharedWeb/Play/Repositories/EFTestUserTrackingUserRepository.cs create mode 100644 src/SharedWeb/Utilities/PlayIdMiddleware.cs create mode 100644 src/Sql/dbo/Stored Procedures/PlayItem_Create.sql create mode 100644 src/Sql/dbo/Stored Procedures/PlayItem_DeleteByPlayId.sql create mode 100644 src/Sql/dbo/Stored Procedures/PlayItem_ReadByPlayId.sql create mode 100644 src/Sql/dbo/Tables/PlayItem.sql create mode 100644 test/Core.Test/Services/PlayIdServiceTests.cs create mode 100644 test/Core.Test/Services/PlayItemServiceTests.cs create mode 100644 test/SeederApi.IntegrationTest/HttpClientExtensions.cs create mode 100644 test/SeederApi.IntegrationTest/QueryControllerTest.cs create mode 100644 test/SeederApi.IntegrationTest/SeedControllerTest.cs create mode 100644 test/SeederApi.IntegrationTest/SeederApi.IntegrationTest.csproj create mode 100644 test/SeederApi.IntegrationTest/SeederApiApplicationFactory.cs create mode 100644 test/SharedWeb.Test/PlayIdMiddlewareTests.cs create mode 100644 util/Migrator/DbScripts/2026-01-08_00_CreatePlayItem.sql create mode 100644 util/MySqlMigrations/Migrations/20260108193951_CreatePlayItem.Designer.cs create mode 100644 util/MySqlMigrations/Migrations/20260108193951_CreatePlayItem.cs create mode 100644 util/PostgresMigrations/Migrations/20260108193909_CreatePlayItem.Designer.cs create mode 100644 util/PostgresMigrations/Migrations/20260108193909_CreatePlayItem.cs create mode 100644 util/RustSdk/rust-toolchain.toml create mode 100644 util/Seeder/IQuery.cs create mode 100644 util/Seeder/IScene.cs create mode 100644 util/Seeder/MangleId.cs create mode 100644 util/Seeder/Queries/EmergencyAccessInviteQuery.cs create mode 100644 util/Seeder/SceneResult.cs create mode 100644 util/Seeder/Scenes/SingleUserScene.cs create mode 100644 util/SeederApi/Commands/DestroyBatchScenesCommand.cs create mode 100644 util/SeederApi/Commands/DestroySceneCommand.cs create mode 100644 util/SeederApi/Commands/Interfaces/IDestroyBatchScenesCommand.cs create mode 100644 util/SeederApi/Commands/Interfaces/IDestroySceneCommand.cs create mode 100644 util/SeederApi/Controllers/InfoController.cs create mode 100644 util/SeederApi/Controllers/QueryController.cs create mode 100644 util/SeederApi/Controllers/SeedController.cs create mode 100644 util/SeederApi/Execution/IQueryExecutor.cs create mode 100644 util/SeederApi/Execution/ISceneExecutor.cs create mode 100644 util/SeederApi/Execution/JsonConfiguration.cs create mode 100644 util/SeederApi/Execution/QueryExecutor.cs create mode 100644 util/SeederApi/Execution/SceneExecutor.cs create mode 100644 util/SeederApi/Extensions/ServiceCollectionExtensions.cs create mode 100644 util/SeederApi/Models/Request/QueryRequestModel.cs create mode 100644 util/SeederApi/Models/Request/SeedRequestModel.cs create mode 100644 util/SeederApi/Models/Response/SeedResponseModel.cs create mode 100644 util/SeederApi/Program.cs create mode 100644 util/SeederApi/Properties/launchSettings.json create mode 100644 util/SeederApi/Queries/GetAllPlayIdsQuery.cs create mode 100644 util/SeederApi/Queries/Interfaces/IGetAllPlayIdsQuery.cs create mode 100644 util/SeederApi/README.md create mode 100644 util/SeederApi/SeederApi.csproj create mode 100644 util/SeederApi/Services/QueryExceptions.cs create mode 100644 util/SeederApi/Services/SceneExceptions.cs create mode 100644 util/SeederApi/Startup.cs create mode 100644 util/SeederApi/appsettings.Development.json create mode 100644 util/SeederApi/appsettings.json create mode 100644 util/SqliteMigrations/Migrations/20260108193841_CreatePlayItem.Designer.cs create mode 100644 util/SqliteMigrations/Migrations/20260108193841_CreatePlayItem.cs diff --git a/.vscode/launch.json b/.vscode/launch.json index c407ba5604..74115dcc86 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -69,6 +69,28 @@ "preLaunchTask": "buildFullServer", "stopAll": true }, + { + "name": "Full Server with Seeder API", + "configurations": [ + "run-Admin", + "run-API", + "run-Events", + "run-EventsProcessor", + "run-Identity", + "run-Sso", + "run-Icons", + "run-Billing", + "run-Notifications", + "run-SeederAPI" + ], + "presentation": { + "hidden": false, + "group": "AA_compounds", + "order": 6 + }, + "preLaunchTask": "buildFullServerWithSeederApi", + "stopAll": true + }, { "name": "Self Host: Bit", "configurations": [ @@ -204,6 +226,17 @@ }, "preLaunchTask": "buildSso", }, + { + "name": "Seeder API", + "configurations": [ + "run-SeederAPI" + ], + "presentation": { + "hidden": false, + "group": "cloud", + }, + "preLaunchTask": "buildSeederAPI", + }, { "name": "Admin Self Host", "configurations": [ @@ -270,6 +303,17 @@ }, "preLaunchTask": "buildSso", }, + { + "name": "Seeder API Self Host", + "configurations": [ + "run-SeederAPI-SelfHost" + ], + "presentation": { + "hidden": false, + "group": "self-host", + }, + "preLaunchTask": "buildSeederAPI", + } ], "configurations": [ // Configurations represent run-only scenarios so that they can be used in multiple compounds @@ -311,6 +355,25 @@ "/Views": "${workspaceFolder}/Views" } }, + { + "name": "run-SeederAPI", + "presentation": { + "hidden": true, + }, + "requireExactSource": true, + "type": "coreclr", + "request": "launch", + "program": "${workspaceFolder}/util/SeederApi/bin/Debug/net8.0/SeederApi.dll", + "args": [], + "cwd": "${workspaceFolder}/util/SeederApi", + "stopAtEntry": false, + "env": { + "ASPNETCORE_ENVIRONMENT": "Development", + }, + "sourceFileMap": { + "/Views": "${workspaceFolder}/Views" + } + }, { "name": "run-Billing", "presentation": { @@ -488,6 +551,27 @@ "/Views": "${workspaceFolder}/Views" } }, + { + "name": "run-SeederAPI-SelfHost", + "presentation": { + "hidden": true, + }, + "requireExactSource": true, + "type": "coreclr", + "request": "launch", + "program": "${workspaceFolder}/util/SeederApi/bin/Debug/net8.0/SeederApi.dll", + "args": [], + "cwd": "${workspaceFolder}/util/SeederApi", + "stopAtEntry": false, + "env": { + "ASPNETCORE_ENVIRONMENT": "Development", + "ASPNETCORE_URLS": "http://localhost:5048", + "developSelfHosted": "true", + }, + "sourceFileMap": { + "/Views": "${workspaceFolder}/Views" + } + }, { "name": "run-Admin-SelfHost", "presentation": { diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 567f9b6e58..07a55fdeb3 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -43,6 +43,21 @@ "label": "buildFullServer", "hide": true, "dependsOrder": "sequence", + "dependsOn": [ + "buildAdmin", + "buildAPI", + "buildEventsProcessor", + "buildIdentity", + "buildSso", + "buildIcons", + "buildBilling", + "buildNotifications" + ], + }, + { + "label": "buildFullServerWithSeederApi", + "hide": true, + "dependsOrder": "sequence", "dependsOn": [ "buildAdmin", "buildAPI", @@ -52,6 +67,7 @@ "buildIcons", "buildBilling", "buildNotifications", + "buildSeederAPI" ], }, { @@ -89,6 +105,9 @@ "/property:GenerateFullPaths=true", "/consoleloggerparameters:NoSummary" ], + "options": { + "cwd": "${workspaceFolder}" + }, "problemMatcher": "$msCompile" }, { @@ -102,6 +121,9 @@ "/property:GenerateFullPaths=true", "/consoleloggerparameters:NoSummary" ], + "options": { + "cwd": "${workspaceFolder}" + }, "problemMatcher": "$msCompile" }, { @@ -115,6 +137,9 @@ "/property:GenerateFullPaths=true", "/consoleloggerparameters:NoSummary" ], + "options": { + "cwd": "${workspaceFolder}" + }, "problemMatcher": "$msCompile" }, { @@ -128,6 +153,9 @@ "/property:GenerateFullPaths=true", "/consoleloggerparameters:NoSummary" ], + "options": { + "cwd": "${workspaceFolder}" + }, "problemMatcher": "$msCompile" }, { @@ -141,6 +169,9 @@ "/property:GenerateFullPaths=true", "/consoleloggerparameters:NoSummary" ], + "options": { + "cwd": "${workspaceFolder}" + }, "problemMatcher": "$msCompile" }, { @@ -154,6 +185,9 @@ "/property:GenerateFullPaths=true", "/consoleloggerparameters:NoSummary" ], + "options": { + "cwd": "${workspaceFolder}" + }, "problemMatcher": "$msCompile" }, { @@ -167,6 +201,9 @@ "/property:GenerateFullPaths=true", "/consoleloggerparameters:NoSummary" ], + "options": { + "cwd": "${workspaceFolder}" + }, "problemMatcher": "$msCompile" }, { @@ -180,6 +217,29 @@ "/property:GenerateFullPaths=true", "/consoleloggerparameters:NoSummary" ], + "options": { + "cwd": "${workspaceFolder}" + }, + "problemMatcher": "$msCompile", + "group": { + "kind": "build", + "isDefault": true + } + }, + { + "label": "buildSeederAPI", + "hide": true, + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/util/SeederApi/SeederApi.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary" + ], + "options": { + "cwd": "${workspaceFolder}" + }, "problemMatcher": "$msCompile", "group": { "kind": "build", @@ -197,6 +257,9 @@ "/property:GenerateFullPaths=true", "/consoleloggerparameters:NoSummary" ], + "options": { + "cwd": "${workspaceFolder}" + }, "problemMatcher": "$msCompile", "group": { "kind": "build", @@ -214,6 +277,9 @@ "/property:GenerateFullPaths=true", "/consoleloggerparameters:NoSummary" ], + "options": { + "cwd": "${workspaceFolder}" + }, "problemMatcher": "$msCompile", "group": { "kind": "build", @@ -224,6 +290,9 @@ "label": "test", "type": "shell", "command": "dotnet test", + "options": { + "cwd": "${workspaceFolder}" + }, "group": { "kind": "test", "isDefault": true diff --git a/bitwarden-server.sln b/bitwarden-server.sln index 055811478d..ae9571a4a5 100644 --- a/bitwarden-server.sln +++ b/bitwarden-server.sln @@ -137,6 +137,9 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RustSdk", "util\RustSdk\Rus EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharedWeb.Test", "test\SharedWeb.Test\SharedWeb.Test.csproj", "{AD59537D-5259-4B7A-948F-0CF58E80B359}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SeederApi", "util\SeederApi\SeederApi.csproj", "{9F08DFBB-482B-4C9D-A5F4-6BDA6EC2E68F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SeederApi.IntegrationTest", "test\SeederApi.IntegrationTest\SeederApi.IntegrationTest.csproj", "{A2E067EF-609C-4D13-895A-E054C61D48BB}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SSO.Test", "bitwarden_license\test\SSO.Test\SSO.Test.csproj", "{7D98784C-C253-43FB-9873-25B65C6250D6}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sso.IntegrationTest", "bitwarden_license\test\Sso.IntegrationTest\Sso.IntegrationTest.csproj", "{FFB09376-595B-6F93-36F0-70CAE90AFECB}" @@ -353,6 +356,14 @@ Global {AD59537D-5259-4B7A-948F-0CF58E80B359}.Debug|Any CPU.Build.0 = Debug|Any CPU {AD59537D-5259-4B7A-948F-0CF58E80B359}.Release|Any CPU.ActiveCfg = Release|Any CPU {AD59537D-5259-4B7A-948F-0CF58E80B359}.Release|Any CPU.Build.0 = Release|Any CPU + {9F08DFBB-482B-4C9D-A5F4-6BDA6EC2E68F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9F08DFBB-482B-4C9D-A5F4-6BDA6EC2E68F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9F08DFBB-482B-4C9D-A5F4-6BDA6EC2E68F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9F08DFBB-482B-4C9D-A5F4-6BDA6EC2E68F}.Release|Any CPU.Build.0 = Release|Any CPU + {A2E067EF-609C-4D13-895A-E054C61D48BB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A2E067EF-609C-4D13-895A-E054C61D48BB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A2E067EF-609C-4D13-895A-E054C61D48BB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A2E067EF-609C-4D13-895A-E054C61D48BB}.Release|Any CPU.Build.0 = Release|Any CPU {7D98784C-C253-43FB-9873-25B65C6250D6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {7D98784C-C253-43FB-9873-25B65C6250D6}.Debug|Any CPU.Build.0 = Debug|Any CPU {7D98784C-C253-43FB-9873-25B65C6250D6}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -417,6 +428,8 @@ Global {17A89266-260A-4A03-81AE-C0468C6EE06E} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E} {D1513D90-E4F5-44A9-9121-5E46E3E4A3F7} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E} {AD59537D-5259-4B7A-948F-0CF58E80B359} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F} + {9F08DFBB-482B-4C9D-A5F4-6BDA6EC2E68F} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E} + {A2E067EF-609C-4D13-895A-E054C61D48BB} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F} {7D98784C-C253-43FB-9873-25B65C6250D6} = {287CFF34-BBDB-4BC4-AF88-1E19A5A4679B} {FFB09376-595B-6F93-36F0-70CAE90AFECB} = {287CFF34-BBDB-4BC4-AF88-1E19A5A4679B} EndGlobalSection diff --git a/bitwarden_license/src/Scim/Startup.cs b/bitwarden_license/src/Scim/Startup.cs index 2a84faa8dd..a912562f72 100644 --- a/bitwarden_license/src/Scim/Startup.cs +++ b/bitwarden_license/src/Scim/Startup.cs @@ -44,6 +44,7 @@ public class Startup // Repositories services.AddDatabaseRepositories(globalSettings); + services.AddTestPlayIdTracking(globalSettings); // Context services.AddScoped(); diff --git a/bitwarden_license/src/Sso/Startup.cs b/bitwarden_license/src/Sso/Startup.cs index 2f83f3dad0..a2f363d533 100644 --- a/bitwarden_license/src/Sso/Startup.cs +++ b/bitwarden_license/src/Sso/Startup.cs @@ -41,6 +41,7 @@ public class Startup // Repositories services.AddDatabaseRepositories(globalSettings); + services.AddTestPlayIdTracking(globalSettings); // Context services.AddScoped(); diff --git a/dev/setup_secrets.ps1 b/dev/setup_secrets.ps1 old mode 100644 new mode 100755 index 96dff04632..5013ca8bac --- a/dev/setup_secrets.ps1 +++ b/dev/setup_secrets.ps1 @@ -2,7 +2,7 @@ # Helper script for applying the same user secrets to each project param ( [switch]$clear, - [Parameter(ValueFromRemainingArguments = $true, Position=1)] + [Parameter(ValueFromRemainingArguments = $true, Position = 1)] $cmdArgs ) @@ -16,17 +16,18 @@ if ($clear -eq $true) { } $projects = @{ - Admin = "../src/Admin" - Api = "../src/Api" - Billing = "../src/Billing" - Events = "../src/Events" - EventsProcessor = "../src/EventsProcessor" - Icons = "../src/Icons" - Identity = "../src/Identity" - Notifications = "../src/Notifications" - Sso = "../bitwarden_license/src/Sso" - Scim = "../bitwarden_license/src/Scim" + Admin = "../src/Admin" + Api = "../src/Api" + Billing = "../src/Billing" + Events = "../src/Events" + EventsProcessor = "../src/EventsProcessor" + Icons = "../src/Icons" + Identity = "../src/Identity" + Notifications = "../src/Notifications" + Sso = "../bitwarden_license/src/Sso" + Scim = "../bitwarden_license/src/Scim" IntegrationTests = "../test/Infrastructure.IntegrationTest" + SeederApi = "../util/SeederApi" } foreach ($key in $projects.keys) { diff --git a/src/Admin/Startup.cs b/src/Admin/Startup.cs index 87d68a7ac6..6c0a644ee6 100644 --- a/src/Admin/Startup.cs +++ b/src/Admin/Startup.cs @@ -65,6 +65,7 @@ public class Startup default: break; } + services.AddTestPlayIdTracking(globalSettings); // Context services.AddScoped(); diff --git a/src/Api/Auth/Models/Request/EmergencyAccessRequestModels.cs b/src/Api/Auth/Models/Request/EmergencyAccessRequestModels.cs index 33a7e52791..75e96ebc66 100644 --- a/src/Api/Auth/Models/Request/EmergencyAccessRequestModels.cs +++ b/src/Api/Auth/Models/Request/EmergencyAccessRequestModels.cs @@ -36,7 +36,7 @@ public class EmergencyAccessUpdateRequestModel existingEmergencyAccess.KeyEncrypted = KeyEncrypted; } existingEmergencyAccess.Type = Type; - existingEmergencyAccess.WaitTimeDays = WaitTimeDays; + existingEmergencyAccess.WaitTimeDays = (short)WaitTimeDays; return existingEmergencyAccess; } } diff --git a/src/Api/Startup.cs b/src/Api/Startup.cs index 2f16470cd4..b201cef0f3 100644 --- a/src/Api/Startup.cs +++ b/src/Api/Startup.cs @@ -85,6 +85,7 @@ public class Startup // Repositories services.AddDatabaseRepositories(globalSettings); + services.AddTestPlayIdTracking(globalSettings); // Context services.AddScoped(); diff --git a/src/Billing/Startup.cs b/src/Billing/Startup.cs index 30f4f5f562..f5f98bfd53 100644 --- a/src/Billing/Startup.cs +++ b/src/Billing/Startup.cs @@ -48,6 +48,7 @@ public class Startup // Repositories services.AddDatabaseRepositories(globalSettings); + services.AddTestPlayIdTracking(globalSettings); // PayPal IPN Client services.AddHttpClient(); diff --git a/src/Core/Auth/Entities/EmergencyAccess.cs b/src/Core/Auth/Entities/EmergencyAccess.cs index d855126468..36aaf46a8c 100644 --- a/src/Core/Auth/Entities/EmergencyAccess.cs +++ b/src/Core/Auth/Entities/EmergencyAccess.cs @@ -18,7 +18,7 @@ public class EmergencyAccess : ITableObject public string KeyEncrypted { get; set; } public EmergencyAccessType Type { get; set; } public EmergencyAccessStatusType Status { get; set; } - public int WaitTimeDays { get; set; } + public short WaitTimeDays { get; set; } public DateTime? RecoveryInitiatedDate { get; set; } public DateTime? LastNotificationDate { get; set; } public DateTime CreationDate { get; set; } = DateTime.UtcNow; diff --git a/src/Core/Auth/Services/EmergencyAccess/EmergencyAccessService.cs b/src/Core/Auth/Services/EmergencyAccess/EmergencyAccessService.cs index 4331179554..0072f85e61 100644 --- a/src/Core/Auth/Services/EmergencyAccess/EmergencyAccessService.cs +++ b/src/Core/Auth/Services/EmergencyAccess/EmergencyAccessService.cs @@ -79,7 +79,7 @@ public class EmergencyAccessService : IEmergencyAccessService Email = emergencyContactEmail.ToLowerInvariant(), Status = EmergencyAccessStatusType.Invited, Type = accessType, - WaitTimeDays = waitTime, + WaitTimeDays = (short)waitTime, CreationDate = DateTime.UtcNow, RevisionDate = DateTime.UtcNow, }; diff --git a/src/Core/Entities/PlayItem.cs b/src/Core/Entities/PlayItem.cs new file mode 100644 index 0000000000..cf2f5c946b --- /dev/null +++ b/src/Core/Entities/PlayItem.cs @@ -0,0 +1,60 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Utilities; + +namespace Bit.Core.Entities; + +/// +/// PlayItem is a join table tracking entities created during automated testing. +/// A `PlayId` is supplied by the clients in the `x-play-id` header to inform the server +/// that any data created should be associated with the play, and therefore cleaned up with it. +/// +public class PlayItem : ITableObject +{ + public Guid Id { get; set; } + [MaxLength(256)] + public required string PlayId { get; init; } + public Guid? UserId { get; init; } + public Guid? OrganizationId { get; init; } + public DateTime CreationDate { get; init; } + + /// + /// Generates and sets a new COMB GUID for the Id property. + /// + public void SetNewId() + { + Id = CoreHelpers.GenerateComb(); + } + + /// + /// Creates a new PlayItem record associated with a User. + /// + /// The user entity created during the play. + /// The play identifier from the x-play-id header. + /// A new PlayItem instance tracking the user. + public static PlayItem Create(User user, string playId) + { + return new PlayItem + { + PlayId = playId, + UserId = user.Id, + CreationDate = DateTime.UtcNow + }; + } + + /// + /// Creates a new PlayItem record associated with an Organization. + /// + /// The organization entity created during the play. + /// The play identifier from the x-play-id header. + /// A new PlayItem instance tracking the organization. + public static PlayItem Create(Organization organization, string playId) + { + return new PlayItem + { + PlayId = playId, + OrganizationId = organization.Id, + CreationDate = DateTime.UtcNow + }; + } +} diff --git a/src/Core/Repositories/IPlayItemRepository.cs b/src/Core/Repositories/IPlayItemRepository.cs new file mode 100644 index 0000000000..fb00be2bc1 --- /dev/null +++ b/src/Core/Repositories/IPlayItemRepository.cs @@ -0,0 +1,11 @@ +using Bit.Core.Entities; + +#nullable enable + +namespace Bit.Core.Repositories; + +public interface IPlayItemRepository : IRepository +{ + Task> GetByPlayIdAsync(string playId); + Task DeleteByPlayIdAsync(string playId); +} diff --git a/src/Core/Services/Play/IPlayIdService.cs b/src/Core/Services/Play/IPlayIdService.cs new file mode 100644 index 0000000000..542de9725f --- /dev/null +++ b/src/Core/Services/Play/IPlayIdService.cs @@ -0,0 +1,23 @@ +namespace Bit.Core.Services; + +/// +/// Service for managing Play identifiers in automated testing infrastructure. +/// A "Play" is a test session that groups entities created during testing to enable cleanup. +/// The PlayId flows from client request (x-play-id header) through PlayIdMiddleware to this service, +/// which repositories query to create PlayItem tracking records via IPlayItemService. The SeederAPI uses these records +/// to bulk delete all entities associated with a PlayId. Only active in Development environments. +/// +public interface IPlayIdService +{ + /// + /// Gets or sets the current Play identifier from the x-play-id request header. + /// + string? PlayId { get; set; } + + /// + /// Checks whether the current request is part of an active Play session. + /// + /// The Play identifier if active, otherwise empty string. + /// True if in a Play session (has PlayId and in Development environment), otherwise false. + bool InPlay(out string playId); +} diff --git a/src/Core/Services/Play/IPlayItemService.cs b/src/Core/Services/Play/IPlayItemService.cs new file mode 100644 index 0000000000..5033aaf626 --- /dev/null +++ b/src/Core/Services/Play/IPlayItemService.cs @@ -0,0 +1,27 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Entities; + +namespace Bit.Core.Services; + +/// +/// Service used to track added users and organizations during a Play session. +/// +public interface IPlayItemService +{ + /// + /// Records a PlayItem entry for the given User created during a Play session. + /// + /// Does nothing if no Play Id is set for this http scope. + /// + /// + /// + Task Record(User user); + /// + /// Records a PlayItem entry for the given Organization created during a Play session. + /// + /// Does nothing if no Play Id is set for this http scope. + /// + /// + /// + Task Record(Organization organization); +} diff --git a/src/Core/Services/Play/Implementations/NeverPlayIdServices.cs b/src/Core/Services/Play/Implementations/NeverPlayIdServices.cs new file mode 100644 index 0000000000..baab44eedb --- /dev/null +++ b/src/Core/Services/Play/Implementations/NeverPlayIdServices.cs @@ -0,0 +1,16 @@ +namespace Bit.Core.Services; + +public class NeverPlayIdServices : IPlayIdService +{ + public string? PlayId + { + get => null; + set { } + } + + public bool InPlay(out string playId) + { + playId = string.Empty; + return false; + } +} diff --git a/src/Core/Services/Play/Implementations/PlayIdService.cs b/src/Core/Services/Play/Implementations/PlayIdService.cs new file mode 100644 index 0000000000..0c64f1dd14 --- /dev/null +++ b/src/Core/Services/Play/Implementations/PlayIdService.cs @@ -0,0 +1,13 @@ +using Microsoft.Extensions.Hosting; + +namespace Bit.Core.Services; + +public class PlayIdService(IHostEnvironment hostEnvironment) : IPlayIdService +{ + public string? PlayId { get; set; } + public bool InPlay(out string playId) + { + playId = PlayId ?? string.Empty; + return !string.IsNullOrEmpty(PlayId) && hostEnvironment.IsDevelopment(); + } +} diff --git a/src/Core/Services/Play/Implementations/PlayIdSingletonService.cs b/src/Core/Services/Play/Implementations/PlayIdSingletonService.cs new file mode 100644 index 0000000000..30bb30aad2 --- /dev/null +++ b/src/Core/Services/Play/Implementations/PlayIdSingletonService.cs @@ -0,0 +1,48 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Bit.Core.Services; + +/// +/// Singleton wrapper service that bridges singleton-scoped service boundaries for PlayId tracking. +/// This allows singleton services to access the scoped PlayIdService via HttpContext.RequestServices. +/// +/// Uses IHttpContextAccessor to retrieve the current request's scoped PlayIdService instance, enabling +/// singleton services to participate in Play session tracking without violating DI lifetime rules. +/// Falls back to NeverPlayIdServices when no HttpContext is available (e.g., background jobs). +/// +public class PlayIdSingletonService(IHttpContextAccessor httpContextAccessor, IHostEnvironment hostEnvironment) : IPlayIdService +{ + private IPlayIdService Current + { + get + { + var httpContext = httpContextAccessor.HttpContext; + if (httpContext == null) + { + return new NeverPlayIdServices(); + } + return httpContext.RequestServices.GetRequiredService(); + } + } + + public string? PlayId + { + get => Current.PlayId; + set => Current.PlayId = value; + } + + public bool InPlay(out string playId) + { + if (hostEnvironment.IsDevelopment()) + { + return Current.InPlay(out playId); + } + else + { + playId = string.Empty; + return false; + } + } +} diff --git a/src/Core/Services/Play/Implementations/PlayItemService.cs b/src/Core/Services/Play/Implementations/PlayItemService.cs new file mode 100644 index 0000000000..981445f2ea --- /dev/null +++ b/src/Core/Services/Play/Implementations/PlayItemService.cs @@ -0,0 +1,26 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Entities; +using Bit.Core.Repositories; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.Services; + +public class PlayItemService(IPlayIdService playIdService, IPlayItemRepository playItemRepository, ILogger logger) : IPlayItemService +{ + public async Task Record(User user) + { + if (playIdService.InPlay(out var playId)) + { + logger.LogInformation("Associating user {UserId} with Play ID {PlayId}", user.Id, playId); + await playItemRepository.CreateAsync(PlayItem.Create(user, playId)); + } + } + public async Task Record(Organization organization) + { + if (playIdService.InPlay(out var playId)) + { + logger.LogInformation("Associating organization {OrganizationId} with Play ID {PlayId}", organization.Id, playId); + await playItemRepository.CreateAsync(PlayItem.Create(organization, playId)); + } + } +} diff --git a/src/Core/Services/Play/README.md b/src/Core/Services/Play/README.md new file mode 100644 index 0000000000..b04b6a13c7 --- /dev/null +++ b/src/Core/Services/Play/README.md @@ -0,0 +1,27 @@ +# Play Services + +## Overview + +The Play services provide automated testing infrastructure for tracking and cleaning up test data in development +environments. A "Play" is a test session that groups entities (users, organizations, etc.) created during testing to +enable bulk cleanup via the SeederAPI. + +## How It Works + +1. Test client sends `x-play-id` header with a unique Play identifier +2. `PlayIdMiddleware` extracts the header and sets it on `IPlayIdService` +3. Repositories check `IPlayIdService.InPlay()` when creating entities +4. `IPlayItemService` records PlayItem entries for tracked entities +5. SeederAPI uses PlayItem records to bulk delete all entities associated with a PlayId + +Play services are **only active in Development environments**. + +## Classes + +- **`IPlayIdService`** - Interface for managing Play identifiers in the current request scope +- **`IPlayItemService`** - Interface for tracking entities created during a Play session +- **`PlayIdService`** - Default scoped implementation for tracking Play sessions per HTTP request +- **`NeverPlayIdServices`** - No-op implementation used as fallback when no HttpContext is available +- **`PlayIdSingletonService`** - Singleton wrapper that allows singleton services to access scoped PlayIdService via + HttpContext +- **`PlayItemService`** - Implementation that records PlayItem entries for entities created during Play sessions diff --git a/src/Core/Settings/GlobalSettings.cs b/src/Core/Settings/GlobalSettings.cs index 60a1fda19f..1f4fa6104b 100644 --- a/src/Core/Settings/GlobalSettings.cs +++ b/src/Core/Settings/GlobalSettings.cs @@ -44,6 +44,7 @@ public class GlobalSettings : IGlobalSettings public virtual bool EnableCloudCommunication { get; set; } = false; public virtual int OrganizationInviteExpirationHours { get; set; } = 120; // 5 days public virtual string EventGridKey { get; set; } + public virtual bool TestPlayIdTrackingEnabled { get; set; } = false; public virtual IInstallationSettings Installation { get; set; } = new InstallationSettings(); public virtual IBaseServiceUriSettings BaseServiceUri { get; set; } public virtual string DatabaseProvider { get; set; } diff --git a/src/Events/Startup.cs b/src/Events/Startup.cs index 75301cf08c..d97d65c2ed 100644 --- a/src/Events/Startup.cs +++ b/src/Events/Startup.cs @@ -36,6 +36,7 @@ public class Startup // Repositories services.AddDatabaseRepositories(globalSettings); + services.AddTestPlayIdTracking(globalSettings); // Context services.AddScoped(); diff --git a/src/EventsProcessor/Startup.cs b/src/EventsProcessor/Startup.cs index 888dda43a1..239393a693 100644 --- a/src/EventsProcessor/Startup.cs +++ b/src/EventsProcessor/Startup.cs @@ -30,6 +30,7 @@ public class Startup // Repositories services.AddDatabaseRepositories(globalSettings); + services.AddTestPlayIdTracking(globalSettings); // Add event integration services services.AddDistributedCache(globalSettings); diff --git a/src/Identity/Startup.cs b/src/Identity/Startup.cs index 5dc443a73c..9d5536fd10 100644 --- a/src/Identity/Startup.cs +++ b/src/Identity/Startup.cs @@ -49,6 +49,7 @@ public class Startup // Repositories services.AddDatabaseRepositories(globalSettings); + services.AddTestPlayIdTracking(globalSettings); // Context services.AddScoped(); diff --git a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationRepository.cs b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationRepository.cs index 96ddc8c7da..434edb7676 100644 --- a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationRepository.cs +++ b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationRepository.cs @@ -17,7 +17,7 @@ namespace Bit.Infrastructure.Dapper.Repositories; public class OrganizationRepository : Repository, IOrganizationRepository { - private readonly ILogger _logger; + protected readonly ILogger _logger; public OrganizationRepository( GlobalSettings globalSettings, diff --git a/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs b/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs index e3ee82270f..dcb0dc1306 100644 --- a/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs +++ b/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs @@ -51,6 +51,7 @@ public static class DapperServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Infrastructure.Dapper/Repositories/PlayItemRepository.cs b/src/Infrastructure.Dapper/Repositories/PlayItemRepository.cs new file mode 100644 index 0000000000..1fa8e88ce8 --- /dev/null +++ b/src/Infrastructure.Dapper/Repositories/PlayItemRepository.cs @@ -0,0 +1,45 @@ +using System.Data; +using Bit.Core.Entities; +using Bit.Core.Repositories; +using Bit.Core.Settings; +using Dapper; +using Microsoft.Data.SqlClient; + +#nullable enable + +namespace Bit.Infrastructure.Dapper.Repositories; + +public class PlayItemRepository : Repository, IPlayItemRepository +{ + public PlayItemRepository(GlobalSettings globalSettings) + : this(globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString) + { } + + public PlayItemRepository(string connectionString, string readOnlyConnectionString) + : base(connectionString, readOnlyConnectionString) + { } + + public async Task> GetByPlayIdAsync(string playId) + { + using (var connection = new SqlConnection(ConnectionString)) + { + var results = await connection.QueryAsync( + "[dbo].[PlayItem_ReadByPlayId]", + new { PlayId = playId }, + commandType: CommandType.StoredProcedure); + + return results.ToList(); + } + } + + public async Task DeleteByPlayIdAsync(string playId) + { + using (var connection = new SqlConnection(ConnectionString)) + { + await connection.ExecuteAsync( + "[dbo].[PlayItem_DeleteByPlayId]", + new { PlayId = playId }, + commandType: CommandType.StoredProcedure); + } + } +} diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs index 89f0bb5806..88410facf5 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs @@ -20,7 +20,7 @@ namespace Bit.Infrastructure.EntityFramework.Repositories; public class OrganizationRepository : Repository, IOrganizationRepository { - private readonly ILogger _logger; + protected readonly ILogger _logger; public OrganizationRepository( IServiceScopeFactory serviceScopeFactory, diff --git a/src/Infrastructure.EntityFramework/Configurations/PlayItemEntityTypeConfiguration.cs b/src/Infrastructure.EntityFramework/Configurations/PlayItemEntityTypeConfiguration.cs new file mode 100644 index 0000000000..91b26e5be4 --- /dev/null +++ b/src/Infrastructure.EntityFramework/Configurations/PlayItemEntityTypeConfiguration.cs @@ -0,0 +1,46 @@ +using Bit.Infrastructure.EntityFramework.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Bit.Infrastructure.EntityFramework.Configurations; + +public class PlayItemEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder + .Property(pd => pd.Id) + .ValueGeneratedNever(); + + builder + .HasIndex(pd => pd.PlayId) + .IsClustered(false); + + builder + .HasIndex(pd => pd.UserId) + .IsClustered(false); + + builder + .HasIndex(pd => pd.OrganizationId) + .IsClustered(false); + + builder + .HasOne(pd => pd.User) + .WithMany() + .HasForeignKey(pd => pd.UserId) + .OnDelete(DeleteBehavior.Cascade); + + builder + .HasOne(pd => pd.Organization) + .WithMany() + .HasForeignKey(pd => pd.OrganizationId) + .OnDelete(DeleteBehavior.Cascade); + + builder + .ToTable(nameof(PlayItem)) + .HasCheckConstraint( + "CK_PlayItem_UserOrOrganization", + "(\"UserId\" IS NOT NULL AND \"OrganizationId\" IS NULL) OR (\"UserId\" IS NULL AND \"OrganizationId\" IS NOT NULL)" + ); + } +} diff --git a/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs b/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs index 3c35df2a82..320cb9436d 100644 --- a/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs +++ b/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs @@ -88,6 +88,7 @@ public static class EntityFrameworkServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Infrastructure.EntityFramework/Models/PlayItem.cs b/src/Infrastructure.EntityFramework/Models/PlayItem.cs new file mode 100644 index 0000000000..3aafebd555 --- /dev/null +++ b/src/Infrastructure.EntityFramework/Models/PlayItem.cs @@ -0,0 +1,19 @@ +#nullable enable + +using AutoMapper; + +namespace Bit.Infrastructure.EntityFramework.Models; + +public class PlayItem : Core.Entities.PlayItem +{ + public virtual User? User { get; set; } + public virtual AdminConsole.Models.Organization? Organization { get; set; } +} + +public class PlayItemMapperProfile : Profile +{ + public PlayItemMapperProfile() + { + CreateMap().ReverseMap(); + } +} diff --git a/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs b/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs index b748a26db2..7b67a63912 100644 --- a/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs +++ b/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs @@ -57,6 +57,7 @@ public class DatabaseContext : DbContext public DbSet OrganizationApiKeys { get; set; } public DbSet OrganizationSponsorships { get; set; } public DbSet OrganizationConnections { get; set; } + public DbSet PlayItem { get; set; } public DbSet OrganizationIntegrations { get; set; } public DbSet OrganizationIntegrationConfigurations { get; set; } public DbSet OrganizationUsers { get; set; } diff --git a/src/Infrastructure.EntityFramework/Repositories/PlayItemRepository.cs b/src/Infrastructure.EntityFramework/Repositories/PlayItemRepository.cs new file mode 100644 index 0000000000..c8cf207b43 --- /dev/null +++ b/src/Infrastructure.EntityFramework/Repositories/PlayItemRepository.cs @@ -0,0 +1,42 @@ +using AutoMapper; +using Bit.Core.Repositories; +using Bit.Infrastructure.EntityFramework.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +#nullable enable + +namespace Bit.Infrastructure.EntityFramework.Repositories; + +public class PlayItemRepository : Repository, IPlayItemRepository +{ + public PlayItemRepository(IServiceScopeFactory serviceScopeFactory, IMapper mapper) + : base(serviceScopeFactory, mapper, (DatabaseContext context) => context.PlayItem) + { } + + public async Task> GetByPlayIdAsync(string playId) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + var playItemEntities = await GetDbSet(dbContext) + .Where(pd => pd.PlayId == playId) + .ToListAsync(); + return Mapper.Map>(playItemEntities); + } + } + + public async Task DeleteByPlayIdAsync(string playId) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + var entities = await GetDbSet(dbContext) + .Where(pd => pd.PlayId == playId) + .ToListAsync(); + + dbContext.PlayItem.RemoveRange(entities); + await dbContext.SaveChangesAsync(); + } + } +} diff --git a/src/SharedWeb/Play/PlayServiceCollectionExtensions.cs b/src/SharedWeb/Play/PlayServiceCollectionExtensions.cs new file mode 100644 index 0000000000..2611d58bfb --- /dev/null +++ b/src/SharedWeb/Play/PlayServiceCollectionExtensions.cs @@ -0,0 +1,30 @@ +using Bit.Core.Repositories; +using Bit.SharedWeb.Play.Repositories; +using Microsoft.Extensions.DependencyInjection; + +namespace Bit.SharedWeb.Play; + +public static class PlayServiceCollectionExtensions +{ + /// + /// Adds PlayId tracking decorators for User and Organization repositories using Dapper implementations. + /// This replaces the standard repository implementations with tracking versions + /// that record created entities for test data cleanup. Only call when TestPlayIdTrackingEnabled is true. + /// + public static void AddPlayIdTrackingDapperRepositories(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + } + + /// + /// Adds PlayId tracking decorators for User and Organization repositories using EntityFramework implementations. + /// This replaces the standard repository implementations with tracking versions + /// that record created entities for test data cleanup. Only call when TestPlayIdTrackingEnabled is true. + /// + public static void AddPlayIdTrackingEFRepositories(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + } +} diff --git a/src/SharedWeb/Play/Repositories/DapperTestOrganizationTrackingOrganizationRepository.cs b/src/SharedWeb/Play/Repositories/DapperTestOrganizationTrackingOrganizationRepository.cs new file mode 100644 index 0000000000..0d11d9c5eb --- /dev/null +++ b/src/SharedWeb/Play/Repositories/DapperTestOrganizationTrackingOrganizationRepository.cs @@ -0,0 +1,32 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Services; +using Bit.Core.Settings; +using Bit.Infrastructure.Dapper.Repositories; +using Microsoft.Extensions.Logging; + +namespace Bit.SharedWeb.Play.Repositories; + +/// +/// Dapper decorator around the that tracks +/// created Organizations for seeding. +/// +public class DapperTestOrganizationTrackingOrganizationRepository : OrganizationRepository +{ + private readonly IPlayItemService _playItemService; + + public DapperTestOrganizationTrackingOrganizationRepository( + IPlayItemService playItemService, + GlobalSettings globalSettings, + ILogger logger) + : base(globalSettings, logger) + { + _playItemService = playItemService; + } + + public override async Task CreateAsync(Organization obj) + { + var createdOrganization = await base.CreateAsync(obj); + await _playItemService.Record(createdOrganization); + return createdOrganization; + } +} diff --git a/src/SharedWeb/Play/Repositories/DapperTestUserTrackingUserRepository.cs b/src/SharedWeb/Play/Repositories/DapperTestUserTrackingUserRepository.cs new file mode 100644 index 0000000000..97954c0348 --- /dev/null +++ b/src/SharedWeb/Play/Repositories/DapperTestUserTrackingUserRepository.cs @@ -0,0 +1,33 @@ +using Bit.Core.Entities; +using Bit.Core.Services; +using Bit.Core.Settings; +using Bit.Infrastructure.Dapper.Repositories; +using Microsoft.AspNetCore.DataProtection; + +namespace Bit.SharedWeb.Play.Repositories; + +/// +/// Dapper decorator around the that tracks +/// created Users for seeding. +/// +public class DapperTestUserTrackingUserRepository : UserRepository +{ + private readonly IPlayItemService _playItemService; + + public DapperTestUserTrackingUserRepository( + IPlayItemService playItemService, + GlobalSettings globalSettings, + IDataProtectionProvider dataProtectionProvider) + : base(globalSettings, dataProtectionProvider) + { + _playItemService = playItemService; + } + + public override async Task CreateAsync(User user) + { + var createdUser = await base.CreateAsync(user); + + await _playItemService.Record(createdUser); + return createdUser; + } +} diff --git a/src/SharedWeb/Play/Repositories/EFTestOrganizationTrackingOrganizationRepository.cs b/src/SharedWeb/Play/Repositories/EFTestOrganizationTrackingOrganizationRepository.cs new file mode 100644 index 0000000000..fdf0e4d685 --- /dev/null +++ b/src/SharedWeb/Play/Repositories/EFTestOrganizationTrackingOrganizationRepository.cs @@ -0,0 +1,33 @@ +using AutoMapper; +using Bit.Core.Services; +using Bit.Infrastructure.EntityFramework.Repositories; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Bit.SharedWeb.Play.Repositories; + +/// +/// EntityFramework decorator around the that tracks +/// created Organizations for seeding. +/// +public class EFTestOrganizationTrackingOrganizationRepository : OrganizationRepository +{ + private readonly IPlayItemService _playItemService; + + public EFTestOrganizationTrackingOrganizationRepository( + IPlayItemService playItemService, + IServiceScopeFactory serviceScopeFactory, + IMapper mapper, + ILogger logger) + : base(serviceScopeFactory, mapper, logger) + { + _playItemService = playItemService; + } + + public override async Task CreateAsync(Core.AdminConsole.Entities.Organization organization) + { + var createdOrganization = await base.CreateAsync(organization); + await _playItemService.Record(createdOrganization); + return createdOrganization; + } +} diff --git a/src/SharedWeb/Play/Repositories/EFTestUserTrackingUserRepository.cs b/src/SharedWeb/Play/Repositories/EFTestUserTrackingUserRepository.cs new file mode 100644 index 0000000000..bafcd17ba0 --- /dev/null +++ b/src/SharedWeb/Play/Repositories/EFTestUserTrackingUserRepository.cs @@ -0,0 +1,31 @@ +using AutoMapper; +using Bit.Core.Services; +using Bit.Infrastructure.EntityFramework.Repositories; +using Microsoft.Extensions.DependencyInjection; + +namespace Bit.SharedWeb.Play.Repositories; + +/// +/// EntityFramework decorator around the that tracks +/// created Users for seeding. +/// +public class EFTestUserTrackingUserRepository : UserRepository +{ + private readonly IPlayItemService _playItemService; + + public EFTestUserTrackingUserRepository( + IPlayItemService playItemService, + IServiceScopeFactory serviceScopeFactory, + IMapper mapper) + : base(serviceScopeFactory, mapper) + { + _playItemService = playItemService; + } + + public override async Task CreateAsync(Core.Entities.User user) + { + var createdUser = await base.CreateAsync(user); + await _playItemService.Record(createdUser); + return createdUser; + } +} diff --git a/src/SharedWeb/Utilities/PlayIdMiddleware.cs b/src/SharedWeb/Utilities/PlayIdMiddleware.cs new file mode 100644 index 0000000000..c00ab2b657 --- /dev/null +++ b/src/SharedWeb/Utilities/PlayIdMiddleware.cs @@ -0,0 +1,41 @@ +using Bit.Core.Services; +using Microsoft.AspNetCore.Http; + +namespace Bit.SharedWeb.Utilities; + +/// +/// Middleware to extract the x-play-id header and set it in the PlayIdService. +/// +/// PlayId is used in testing infrastructure to track data created during automated testing and fa cilitate cleanup. +/// +/// +public sealed class PlayIdMiddleware(RequestDelegate next) +{ + private const int MaxPlayIdLength = 256; + + public async Task Invoke(HttpContext context, PlayIdService playIdService) + { + if (context.Request.Headers.TryGetValue("x-play-id", out var playId)) + { + var playIdValue = playId.ToString(); + + if (string.IsNullOrWhiteSpace(playIdValue)) + { + context.Response.StatusCode = StatusCodes.Status400BadRequest; + await context.Response.WriteAsJsonAsync(new { Error = "x-play-id header cannot be empty or whitespace" }); + return; + } + + if (playIdValue.Length > MaxPlayIdLength) + { + context.Response.StatusCode = StatusCodes.Status400BadRequest; + await context.Response.WriteAsJsonAsync(new { Error = $"x-play-id header cannot exceed {MaxPlayIdLength} characters" }); + return; + } + + playIdService.PlayId = playIdValue; + } + + await next(context); + } +} diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index 8f5dfdf3f4..1bb9cb6c7a 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -56,6 +56,7 @@ using Bit.Core.Vault; using Bit.Core.Vault.Services; using Bit.Infrastructure.Dapper; using Bit.Infrastructure.EntityFramework; +using Bit.SharedWeb.Play; using DnsClient; using Duende.IdentityModel; using LaunchDarkly.Sdk.Server; @@ -117,6 +118,40 @@ public static class ServiceCollectionExtensions return provider; } + /// + /// Registers test PlayId tracking services for test data management and cleanup. + /// This infrastructure is isolated to test environments and enables tracking of test-generated entities. + /// + public static void AddTestPlayIdTracking(this IServiceCollection services, GlobalSettings globalSettings) + { + if (globalSettings.TestPlayIdTrackingEnabled) + { + var (provider, _) = GetDatabaseProvider(globalSettings); + + // Include PlayIdService for tracking Play Ids in repositories + // We need the http context accessor to use the Singleton version, which pulls from the scoped version + services.AddHttpContextAccessor(); + + services.AddSingleton(); + services.AddSingleton(); + services.AddScoped(); + + // Replace standard repositories with PlayId tracking decorators + if (provider == SupportedDatabaseProviders.SqlServer) + { + services.AddPlayIdTrackingDapperRepositories(); + } + else + { + services.AddPlayIdTrackingEFRepositories(); + } + } + else + { + services.AddSingleton(); + } + } + public static void AddBaseServices(this IServiceCollection services, IGlobalSettings globalSettings) { services.AddScoped(); @@ -522,6 +557,10 @@ public static class ServiceCollectionExtensions IWebHostEnvironment env, GlobalSettings globalSettings) { app.UseMiddleware(); + if (globalSettings.TestPlayIdTrackingEnabled) + { + app.UseMiddleware(); + } } public static void UseForwardedHeaders(this IApplicationBuilder app, IGlobalSettings globalSettings) diff --git a/src/Sql/dbo/Stored Procedures/PlayItem_Create.sql b/src/Sql/dbo/Stored Procedures/PlayItem_Create.sql new file mode 100644 index 0000000000..cf75d03d10 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/PlayItem_Create.sql @@ -0,0 +1,27 @@ +CREATE PROCEDURE [dbo].[PlayItem_Create] + @Id UNIQUEIDENTIFIER OUTPUT, + @PlayId NVARCHAR(256), + @UserId UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @CreationDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + INSERT INTO [dbo].[PlayItem] + ( + [Id], + [PlayId], + [UserId], + [OrganizationId], + [CreationDate] + ) + VALUES + ( + @Id, + @PlayId, + @UserId, + @OrganizationId, + @CreationDate + ) +END diff --git a/src/Sql/dbo/Stored Procedures/PlayItem_DeleteByPlayId.sql b/src/Sql/dbo/Stored Procedures/PlayItem_DeleteByPlayId.sql new file mode 100644 index 0000000000..5e2a102e40 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/PlayItem_DeleteByPlayId.sql @@ -0,0 +1,12 @@ +CREATE PROCEDURE [dbo].[PlayItem_DeleteByPlayId] + @PlayId NVARCHAR(256) +AS +BEGIN + SET NOCOUNT ON + + DELETE + FROM + [dbo].[PlayItem] + WHERE + [PlayId] = @PlayId +END diff --git a/src/Sql/dbo/Stored Procedures/PlayItem_ReadByPlayId.sql b/src/Sql/dbo/Stored Procedures/PlayItem_ReadByPlayId.sql new file mode 100644 index 0000000000..29592c527b --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/PlayItem_ReadByPlayId.sql @@ -0,0 +1,17 @@ +CREATE PROCEDURE [dbo].[PlayItem_ReadByPlayId] + @PlayId NVARCHAR(256) +AS +BEGIN + SET NOCOUNT ON + + SELECT + [Id], + [PlayId], + [UserId], + [OrganizationId], + [CreationDate] + FROM + [dbo].[PlayItem] + WHERE + [PlayId] = @PlayId +END diff --git a/src/Sql/dbo/Tables/PlayItem.sql b/src/Sql/dbo/Tables/PlayItem.sql new file mode 100644 index 0000000000..fbf3d2e0dd --- /dev/null +++ b/src/Sql/dbo/Tables/PlayItem.sql @@ -0,0 +1,23 @@ +CREATE TABLE [dbo].[PlayItem] ( + [Id] UNIQUEIDENTIFIER NOT NULL, + [PlayId] NVARCHAR (256) NOT NULL, + [UserId] UNIQUEIDENTIFIER NULL, + [OrganizationId] UNIQUEIDENTIFIER NULL, + [CreationDate] DATETIME2 (7) NOT NULL, + CONSTRAINT [PK_PlayItem] PRIMARY KEY CLUSTERED ([Id] ASC), + CONSTRAINT [FK_PlayItem_User] FOREIGN KEY ([UserId]) REFERENCES [dbo].[User] ([Id]) ON DELETE CASCADE, + CONSTRAINT [FK_PlayItem_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]) ON DELETE CASCADE, + CONSTRAINT [CK_PlayItem_UserOrOrganization] CHECK (([UserId] IS NOT NULL AND [OrganizationId] IS NULL) OR ([UserId] IS NULL AND [OrganizationId] IS NOT NULL)) +); + +GO +CREATE NONCLUSTERED INDEX [IX_PlayItem_PlayId] + ON [dbo].[PlayItem]([PlayId] ASC); + +GO +CREATE NONCLUSTERED INDEX [IX_PlayItem_UserId] + ON [dbo].[PlayItem]([UserId] ASC); + +GO +CREATE NONCLUSTERED INDEX [IX_PlayItem_OrganizationId] + ON [dbo].[PlayItem]([OrganizationId] ASC); diff --git a/test/Common/AutoFixture/SutProvider.cs b/test/Common/AutoFixture/SutProvider.cs index e1b37a9827..295f6bc950 100644 --- a/test/Common/AutoFixture/SutProvider.cs +++ b/test/Common/AutoFixture/SutProvider.cs @@ -26,6 +26,7 @@ public class SutProvider : ISutProvider public TSut Sut { get; private set; } public Type SutType => typeof(TSut); + public IFixture Fixture => _fixture; public SutProvider() : this(new Fixture()) { } @@ -65,6 +66,19 @@ public class SutProvider : ISutProvider return this; } + /// + /// Creates and registers a dependency to be injected when the sut is created. + /// + /// The Dependency type to create + /// The (optional) parameter name to register the dependency under + /// The created dependency value + public TDep CreateDependency(string parameterName = "") + { + var dependency = _fixture.Create(); + SetDependency(dependency, parameterName); + return dependency; + } + /// /// Gets a dependency of the sut. Can only be called after the dependency has been set, either explicitly with /// or automatically with . diff --git a/test/Core.Test/Services/PlayIdServiceTests.cs b/test/Core.Test/Services/PlayIdServiceTests.cs new file mode 100644 index 0000000000..7e4977c7bb --- /dev/null +++ b/test/Core.Test/Services/PlayIdServiceTests.cs @@ -0,0 +1,211 @@ +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Services; + +[SutProviderCustomize] +public class PlayIdServiceTests +{ + [Theory] + [BitAutoData] + public void InPlay_WhenPlayIdSetAndDevelopment_ReturnsTrue( + string playId, + SutProvider sutProvider) + { + sutProvider.GetDependency().EnvironmentName.Returns(Environments.Development); + sutProvider.Sut.PlayId = playId; + + var result = sutProvider.Sut.InPlay(out var resultPlayId); + + Assert.True(result); + Assert.Equal(playId, resultPlayId); + } + + [Theory] + [BitAutoData] + public void InPlay_WhenPlayIdSetButNotDevelopment_ReturnsFalse( + string playId, + SutProvider sutProvider) + { + sutProvider.GetDependency().EnvironmentName.Returns(Environments.Production); + sutProvider.Sut.PlayId = playId; + + var result = sutProvider.Sut.InPlay(out var resultPlayId); + + Assert.False(result); + Assert.Equal(playId, resultPlayId); + } + + [Theory] + [BitAutoData((string?)null)] + [BitAutoData("")] + public void InPlay_WhenPlayIdNullOrEmptyAndDevelopment_ReturnsFalse( + string? playId, + SutProvider sutProvider) + { + sutProvider.GetDependency().EnvironmentName.Returns(Environments.Development); + sutProvider.Sut.PlayId = playId; + + var result = sutProvider.Sut.InPlay(out var resultPlayId); + + Assert.False(result); + Assert.Empty(resultPlayId); + } + + [Theory] + [BitAutoData] + public void PlayId_CanGetAndSet(string playId) + { + var hostEnvironment = Substitute.For(); + var sut = new PlayIdService(hostEnvironment); + + sut.PlayId = playId; + + Assert.Equal(playId, sut.PlayId); + } +} + +[SutProviderCustomize] +public class NeverPlayIdServicesTests +{ + [Fact] + public void InPlay_ReturnsFalse() + { + var sut = new NeverPlayIdServices(); + + var result = sut.InPlay(out var playId); + + Assert.False(result); + Assert.Empty(playId); + } + + [Theory] + [InlineData("test-play-id")] + [InlineData(null)] + public void PlayId_SetterDoesNothing_GetterReturnsNull(string? value) + { + var sut = new NeverPlayIdServices(); + + sut.PlayId = value; + + Assert.Null(sut.PlayId); + } +} + +[SutProviderCustomize] +public class PlayIdSingletonServiceTests +{ + public static IEnumerable SutProvider() + { + var sutProvider = new SutProvider(); + var httpContext = sutProvider.CreateDependency(); + var serviceProvider = sutProvider.CreateDependency(); + var hostEnvironment = sutProvider.CreateDependency(); + var playIdService = new PlayIdService(hostEnvironment); + sutProvider.SetDependency(playIdService); + httpContext.RequestServices.Returns(serviceProvider); + serviceProvider.GetService().Returns(playIdService); + serviceProvider.GetRequiredService().Returns(playIdService); + sutProvider.CreateDependency().HttpContext.Returns(httpContext); + sutProvider.Create(); + return [[sutProvider]]; + } + + private void PrepHttpContext( + SutProvider sutProvider) + { + var httpContext = sutProvider.CreateDependency(); + var serviceProvider = sutProvider.CreateDependency(); + var PlayIdService = sutProvider.CreateDependency(); + httpContext.RequestServices.Returns(serviceProvider); + serviceProvider.GetRequiredService().Returns(PlayIdService); + sutProvider.GetDependency().HttpContext.Returns(httpContext); + } + + [Theory] + [BitMemberAutoData(nameof(SutProvider))] + public void InPlay_WhenNoHttpContext_ReturnsFalse( + SutProvider sutProvider) + { + sutProvider.GetDependency().HttpContext.Returns((HttpContext?)null); + sutProvider.GetDependency().EnvironmentName.Returns(Environments.Development); + + var result = sutProvider.Sut.InPlay(out var playId); + + Assert.False(result); + Assert.Empty(playId); + } + + [Theory] + [BitMemberAutoData(nameof(SutProvider))] + public void InPlay_WhenNotDevelopment_ReturnsFalse( + SutProvider sutProvider, + string playIdValue) + { + var scopedPlayIdService = sutProvider.GetDependency(); + scopedPlayIdService.PlayId = playIdValue; + sutProvider.GetDependency().EnvironmentName.Returns(Environments.Production); + + var result = sutProvider.Sut.InPlay(out var playId); + + Assert.False(result); + Assert.Empty(playId); + } + + [Theory] + [BitMemberAutoData(nameof(SutProvider))] + public void InPlay_WhenDevelopmentAndHttpContextWithPlayId_ReturnsTrue( + SutProvider sutProvider, + string playIdValue) + { + sutProvider.GetDependency().PlayId = playIdValue; + sutProvider.GetDependency().EnvironmentName.Returns(Environments.Development); + + var result = sutProvider.Sut.InPlay(out var playId); + + Assert.True(result); + Assert.Equal(playIdValue, playId); + } + + [Theory] + [BitMemberAutoData(nameof(SutProvider))] + public void PlayId_SetterSetsOnScopedService( + SutProvider sutProvider, + string playIdValue) + { + var scopedPlayIdService = sutProvider.GetDependency(); + + sutProvider.Sut.PlayId = playIdValue; + + Assert.Equal(playIdValue, scopedPlayIdService.PlayId); + } + + [Theory] + [BitMemberAutoData(nameof(SutProvider))] + public void PlayId_WhenNoHttpContext_GetterReturnsNull( + SutProvider sutProvider) + { + sutProvider.GetDependency().HttpContext.Returns((HttpContext?)null); + + var result = sutProvider.Sut.PlayId; + + Assert.Null(result); + } + + [Theory] + [BitMemberAutoData(nameof(SutProvider))] + public void PlayId_WhenNoHttpContext_SetterDoesNotThrow( + SutProvider sutProvider, + string playIdValue) + { + sutProvider.GetDependency().HttpContext.Returns((HttpContext?)null); + + sutProvider.Sut.PlayId = playIdValue; + } +} diff --git a/test/Core.Test/Services/PlayItemServiceTests.cs b/test/Core.Test/Services/PlayItemServiceTests.cs new file mode 100644 index 0000000000..02e815be59 --- /dev/null +++ b/test/Core.Test/Services/PlayItemServiceTests.cs @@ -0,0 +1,143 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Entities; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Services; + +[SutProviderCustomize] +public class PlayItemServiceTests +{ + [Theory] + [BitAutoData] + public async Task Record_User_WhenInPlay_RecordsPlayItem( + string playId, + User user, + SutProvider sutProvider) + { + sutProvider.GetDependency() + .InPlay(out Arg.Any()) + .Returns(x => + { + x[0] = playId; + return true; + }); + + await sutProvider.Sut.Record(user); + + await sutProvider.GetDependency() + .Received(1) + .CreateAsync(Arg.Is(pd => + pd.PlayId == playId && + pd.UserId == user.Id && + pd.OrganizationId == null)); + + sutProvider.GetDependency>() + .Received(1) + .Log( + LogLevel.Information, + Arg.Any(), + Arg.Is(o => o.ToString().Contains(user.Id.ToString()) && o.ToString().Contains(playId)), + null, + Arg.Any>()); + } + + [Theory] + [BitAutoData] + public async Task Record_User_WhenNotInPlay_DoesNotRecordPlayItem( + User user, + SutProvider sutProvider) + { + sutProvider.GetDependency() + .InPlay(out Arg.Any()) + .Returns(x => + { + x[0] = null; + return false; + }); + + await sutProvider.Sut.Record(user); + + await sutProvider.GetDependency() + .DidNotReceive() + .CreateAsync(Arg.Any()); + + sutProvider.GetDependency>() + .DidNotReceive() + .Log( + LogLevel.Information, + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any>()); + } + + [Theory] + [BitAutoData] + public async Task Record_Organization_WhenInPlay_RecordsPlayItem( + string playId, + Organization organization, + SutProvider sutProvider) + { + sutProvider.GetDependency() + .InPlay(out Arg.Any()) + .Returns(x => + { + x[0] = playId; + return true; + }); + + await sutProvider.Sut.Record(organization); + + await sutProvider.GetDependency() + .Received(1) + .CreateAsync(Arg.Is(pd => + pd.PlayId == playId && + pd.OrganizationId == organization.Id && + pd.UserId == null)); + + sutProvider.GetDependency>() + .Received(1) + .Log( + LogLevel.Information, + Arg.Any(), + Arg.Is(o => o.ToString().Contains(organization.Id.ToString()) && o.ToString().Contains(playId)), + null, + Arg.Any>()); + } + + [Theory] + [BitAutoData] + public async Task Record_Organization_WhenNotInPlay_DoesNotRecordPlayItem( + Organization organization, + SutProvider sutProvider) + { + sutProvider.GetDependency() + .InPlay(out Arg.Any()) + .Returns(x => + { + x[0] = null; + return false; + }); + + await sutProvider.Sut.Record(organization); + + await sutProvider.GetDependency() + .DidNotReceive() + .CreateAsync(Arg.Any()); + + sutProvider.GetDependency>() + .DidNotReceive() + .Log( + LogLevel.Information, + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any>()); + } +} diff --git a/test/Infrastructure.IntegrationTest/DatabaseDataAttribute.cs b/test/Infrastructure.IntegrationTest/DatabaseDataAttribute.cs index c458969748..00e3149bbf 100644 --- a/test/Infrastructure.IntegrationTest/DatabaseDataAttribute.cs +++ b/test/Infrastructure.IntegrationTest/DatabaseDataAttribute.cs @@ -128,7 +128,6 @@ public class DatabaseDataAttribute : DataAttribute private void AddDapperServices(IServiceCollection services, Database database) { - services.AddDapperRepositories(SelfHosted); var globalSettings = new GlobalSettings { DatabaseProvider = "sqlServer", @@ -141,6 +140,7 @@ public class DatabaseDataAttribute : DataAttribute UserRequestExpiration = TimeSpan.FromMinutes(15), } }; + services.AddDapperRepositories(SelfHosted); services.AddSingleton(globalSettings); services.AddSingleton(globalSettings); services.AddSingleton(database); @@ -160,7 +160,6 @@ public class DatabaseDataAttribute : DataAttribute private void AddEfServices(IServiceCollection services, Database database) { services.SetupEntityFramework(database.ConnectionString, database.Type); - services.AddPasswordManagerEFRepositories(SelfHosted); var globalSettings = new GlobalSettings { @@ -169,6 +168,7 @@ public class DatabaseDataAttribute : DataAttribute UserRequestExpiration = TimeSpan.FromMinutes(15), }, }; + services.AddPasswordManagerEFRepositories(SelfHosted); services.AddSingleton(globalSettings); services.AddSingleton(globalSettings); diff --git a/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs b/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs index 4b42f575a1..dbea807259 100644 --- a/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs +++ b/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs @@ -47,7 +47,7 @@ public abstract class WebApplicationFactoryBase : WebApplicationFactory /// public bool ManagesDatabase { get; set; } = true; - private readonly List> _configureTestServices = new(); + protected readonly List> _configureTestServices = new(); private readonly List> _configureAppConfiguration = new(); public void SubstituteService(Action mockService) diff --git a/test/SeederApi.IntegrationTest/HttpClientExtensions.cs b/test/SeederApi.IntegrationTest/HttpClientExtensions.cs new file mode 100644 index 0000000000..0fd7934208 --- /dev/null +++ b/test/SeederApi.IntegrationTest/HttpClientExtensions.cs @@ -0,0 +1,40 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; + +namespace Bit.SeederApi.IntegrationTest; + +public static class HttpClientExtensions +{ + /// + /// Sends a POST request with JSON content and attaches the x-play-id header. + /// + /// The type of the value to serialize. + /// The HTTP client. + /// The URI the request is sent to. + /// The value to serialize. + /// The play ID to attach as x-play-id header. + /// Options to control the behavior during serialization. + /// A cancellation token that can be used to cancel the operation. + /// The task object representing the asynchronous operation. + public static Task PostAsJsonAsync( + this HttpClient client, + [StringSyntax(StringSyntaxAttribute.Uri)] string? requestUri, + TValue value, + string playId, + JsonSerializerOptions? options = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(client); + + if (string.IsNullOrWhiteSpace(playId)) + { + throw new ArgumentException("Play ID cannot be null or whitespace.", nameof(playId)); + } + + var content = JsonContent.Create(value, mediaType: null, options); + content.Headers.Remove("x-play-id"); + content.Headers.Add("x-play-id", playId); + + return client.PostAsync(requestUri, content, cancellationToken); + } +} diff --git a/test/SeederApi.IntegrationTest/QueryControllerTest.cs b/test/SeederApi.IntegrationTest/QueryControllerTest.cs new file mode 100644 index 0000000000..571181e49f --- /dev/null +++ b/test/SeederApi.IntegrationTest/QueryControllerTest.cs @@ -0,0 +1,75 @@ +using System.Net; +using Bit.SeederApi.Models.Request; +using Xunit; + +namespace Bit.SeederApi.IntegrationTest; + +public class QueryControllerTests : IClassFixture, IAsyncLifetime +{ + private readonly HttpClient _client; + private readonly SeederApiApplicationFactory _factory; + + public QueryControllerTests(SeederApiApplicationFactory factory) + { + _factory = factory; + _client = _factory.CreateClient(); + } + + public Task InitializeAsync() + { + return Task.CompletedTask; + } + + public Task DisposeAsync() + { + _client.Dispose(); + return Task.CompletedTask; + } + + [Fact] + public async Task QueryEndpoint_WithValidQueryAndArguments_ReturnsOk() + { + var testEmail = $"emergency-test-{Guid.NewGuid()}@bitwarden.com"; + + var response = await _client.PostAsJsonAsync("/query", new QueryRequestModel + { + Template = "EmergencyAccessInviteQuery", + Arguments = System.Text.Json.JsonSerializer.SerializeToElement(new { email = testEmail }) + }); + + response.EnsureSuccessStatusCode(); + var result = await response.Content.ReadAsStringAsync(); + + Assert.NotNull(result); + + var urls = System.Text.Json.JsonSerializer.Deserialize>(result); + Assert.NotNull(urls); + // For a non-existent email, we expect an empty list + Assert.Empty(urls); + } + + [Fact] + public async Task QueryEndpoint_WithInvalidQueryName_ReturnsNotFound() + { + var response = await _client.PostAsJsonAsync("/query", new QueryRequestModel + { + Template = "NonExistentQuery", + Arguments = System.Text.Json.JsonSerializer.SerializeToElement(new { email = "test@example.com" }) + }); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task QueryEndpoint_WithMissingRequiredField_ReturnsBadRequest() + { + // EmergencyAccessInviteQuery requires 'email' field + var response = await _client.PostAsJsonAsync("/query", new QueryRequestModel + { + Template = "EmergencyAccessInviteQuery", + Arguments = System.Text.Json.JsonSerializer.SerializeToElement(new { wrongField = "value" }) + }); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } +} diff --git a/test/SeederApi.IntegrationTest/SeedControllerTest.cs b/test/SeederApi.IntegrationTest/SeedControllerTest.cs new file mode 100644 index 0000000000..1d081d019e --- /dev/null +++ b/test/SeederApi.IntegrationTest/SeedControllerTest.cs @@ -0,0 +1,222 @@ +using System.Net; +using Bit.SeederApi.Models.Request; +using Bit.SeederApi.Models.Response; +using Xunit; + +namespace Bit.SeederApi.IntegrationTest; + +public class SeedControllerTests : IClassFixture, IAsyncLifetime +{ + private readonly HttpClient _client; + private readonly SeederApiApplicationFactory _factory; + + public SeedControllerTests(SeederApiApplicationFactory factory) + { + _factory = factory; + _client = _factory.CreateClient(); + } + + public Task InitializeAsync() + { + return Task.CompletedTask; + } + + public async Task DisposeAsync() + { + // Clean up any seeded data after each test + await _client.DeleteAsync("/seed"); + _client.Dispose(); + } + + [Fact] + public async Task SeedEndpoint_WithValidScene_ReturnsOk() + { + var testEmail = $"seed-test-{Guid.NewGuid()}@bitwarden.com"; + var playId = Guid.NewGuid().ToString(); + + var response = await _client.PostAsJsonAsync("/seed", new SeedRequestModel + { + Template = "SingleUserScene", + Arguments = System.Text.Json.JsonSerializer.SerializeToElement(new { email = testEmail }) + }, playId); + + response.EnsureSuccessStatusCode(); + var result = await response.Content.ReadFromJsonAsync(); + + Assert.NotNull(result); + Assert.NotNull(result.MangleMap); + Assert.Null(result.Result); + } + + [Fact] + public async Task SeedEndpoint_WithInvalidSceneName_ReturnsNotFound() + { + var response = await _client.PostAsJsonAsync("/seed", new SeedRequestModel + { + Template = "NonExistentScene", + Arguments = System.Text.Json.JsonSerializer.SerializeToElement(new { email = "test@example.com" }) + }); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task SeedEndpoint_WithMissingRequiredField_ReturnsBadRequest() + { + // SingleUserScene requires 'email' field + var response = await _client.PostAsJsonAsync("/seed", new SeedRequestModel + { + Template = "SingleUserScene", + Arguments = System.Text.Json.JsonSerializer.SerializeToElement(new { wrongField = "value" }) + }); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task DeleteEndpoint_WithValidPlayId_ReturnsOk() + { + var testEmail = $"delete-test-{Guid.NewGuid()}@bitwarden.com"; + var playId = Guid.NewGuid().ToString(); + + var seedResponse = await _client.PostAsJsonAsync("/seed", new SeedRequestModel + { + Template = "SingleUserScene", + Arguments = System.Text.Json.JsonSerializer.SerializeToElement(new { email = testEmail }) + }, playId); + + seedResponse.EnsureSuccessStatusCode(); + var seedResult = await seedResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(seedResult); + + var deleteResponse = await _client.DeleteAsync($"/seed/{playId}"); + deleteResponse.EnsureSuccessStatusCode(); + } + + [Fact] + public async Task DeleteEndpoint_WithInvalidPlayId_ReturnsOk() + { + // DestroyRecipe is idempotent - returns null for non-existent play IDs + var nonExistentPlayId = Guid.NewGuid().ToString(); + var response = await _client.DeleteAsync($"/seed/{nonExistentPlayId}"); + + response.EnsureSuccessStatusCode(); + var content = await response.Content.ReadAsStringAsync(); + Assert.Equal($$"""{"playId":"{{nonExistentPlayId}}"}""", content); + } + + [Fact] + public async Task DeleteBatchEndpoint_WithValidPlayIds_ReturnsOk() + { + // Create multiple seeds with different play IDs + var playIds = new List(); + for (var i = 0; i < 3; i++) + { + var playId = Guid.NewGuid().ToString(); + playIds.Add(playId); + + var testEmail = $"batch-test-{Guid.NewGuid()}@bitwarden.com"; + var seedResponse = await _client.PostAsJsonAsync("/seed", new SeedRequestModel + { + Template = "SingleUserScene", + Arguments = System.Text.Json.JsonSerializer.SerializeToElement(new { email = testEmail }) + }, playId); + + seedResponse.EnsureSuccessStatusCode(); + var seedResult = await seedResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(seedResult); + } + + // Delete them in batch + var request = new HttpRequestMessage(HttpMethod.Delete, "/seed/batch") + { + Content = JsonContent.Create(playIds) + }; + var deleteResponse = await _client.SendAsync(request); + deleteResponse.EnsureSuccessStatusCode(); + + var result = await deleteResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(result); + Assert.Equal("Batch delete completed successfully", result.Message); + } + + [Fact] + public async Task DeleteBatchEndpoint_WithSomeInvalidIds_ReturnsOk() + { + // DestroyRecipe is idempotent - batch delete succeeds even with non-existent IDs + // Create one valid seed with a play ID + var validPlayId = Guid.NewGuid().ToString(); + var testEmail = $"batch-partial-test-{Guid.NewGuid()}@bitwarden.com"; + + var seedResponse = await _client.PostAsJsonAsync("/seed", new SeedRequestModel + { + Template = "SingleUserScene", + Arguments = System.Text.Json.JsonSerializer.SerializeToElement(new { email = testEmail }) + }, validPlayId); + + seedResponse.EnsureSuccessStatusCode(); + var seedResult = await seedResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(seedResult); + + // Try to delete with mix of valid and invalid IDs + var playIds = new List { validPlayId, Guid.NewGuid().ToString(), Guid.NewGuid().ToString() }; + var request = new HttpRequestMessage(HttpMethod.Delete, "/seed/batch") + { + Content = JsonContent.Create(playIds) + }; + var deleteResponse = await _client.SendAsync(request); + + deleteResponse.EnsureSuccessStatusCode(); + var result = await deleteResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(result); + Assert.Equal("Batch delete completed successfully", result.Message); + } + + [Fact] + public async Task DeleteAllEndpoint_DeletesAllSeededData() + { + // Create multiple seeds + for (var i = 0; i < 2; i++) + { + var playId = Guid.NewGuid().ToString(); + var testEmail = $"deleteall-test-{Guid.NewGuid()}@bitwarden.com"; + + var seedResponse = await _client.PostAsJsonAsync("/seed", new SeedRequestModel + { + Template = "SingleUserScene", + Arguments = System.Text.Json.JsonSerializer.SerializeToElement(new { email = testEmail }) + }, playId); + + seedResponse.EnsureSuccessStatusCode(); + } + + // Delete all + var deleteResponse = await _client.DeleteAsync("/seed"); + Assert.Equal(HttpStatusCode.NoContent, deleteResponse.StatusCode); + } + + [Fact] + public async Task SeedEndpoint_VerifyResponseContainsMangleMapAndResult() + { + var testEmail = $"verify-response-{Guid.NewGuid()}@bitwarden.com"; + var playId = Guid.NewGuid().ToString(); + + var response = await _client.PostAsJsonAsync("/seed", new SeedRequestModel + { + Template = "SingleUserScene", + Arguments = System.Text.Json.JsonSerializer.SerializeToElement(new { email = testEmail }) + }, playId); + + response.EnsureSuccessStatusCode(); + var jsonString = await response.Content.ReadAsStringAsync(); + + // Verify the response contains MangleMap and Result fields + Assert.Contains("mangleMap", jsonString, StringComparison.OrdinalIgnoreCase); + Assert.Contains("result", jsonString, StringComparison.OrdinalIgnoreCase); + } + + private class BatchDeleteResponse + { + public string? Message { get; set; } + } +} diff --git a/test/SeederApi.IntegrationTest/SeederApi.IntegrationTest.csproj b/test/SeederApi.IntegrationTest/SeederApi.IntegrationTest.csproj new file mode 100644 index 0000000000..a4709ea58a --- /dev/null +++ b/test/SeederApi.IntegrationTest/SeederApi.IntegrationTest.csproj @@ -0,0 +1,29 @@ + + + false + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + %(RecursiveDir)%(Filename)%(Extension) + PreserveNewest + + + diff --git a/test/SeederApi.IntegrationTest/SeederApiApplicationFactory.cs b/test/SeederApi.IntegrationTest/SeederApiApplicationFactory.cs new file mode 100644 index 0000000000..6d815b03ea --- /dev/null +++ b/test/SeederApi.IntegrationTest/SeederApiApplicationFactory.cs @@ -0,0 +1,18 @@ +using Bit.Core.Services; +using Bit.IntegrationTestCommon; +using Bit.IntegrationTestCommon.Factories; + +namespace Bit.SeederApi.IntegrationTest; + +public class SeederApiApplicationFactory : WebApplicationFactoryBase +{ + public SeederApiApplicationFactory() + { + TestDatabase = new SqliteTestDatabase(); + _configureTestServices.Add(serviceCollection => + { + serviceCollection.AddSingleton(); + serviceCollection.AddHttpContextAccessor(); + }); + } +} diff --git a/test/SharedWeb.Test/PlayIdMiddlewareTests.cs b/test/SharedWeb.Test/PlayIdMiddlewareTests.cs new file mode 100644 index 0000000000..f9ed23c36f --- /dev/null +++ b/test/SharedWeb.Test/PlayIdMiddlewareTests.cs @@ -0,0 +1,102 @@ +using Bit.Core.Services; +using Bit.SharedWeb.Utilities; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Hosting; +using NSubstitute; + +namespace SharedWeb.Test; + +public class PlayIdMiddlewareTests +{ + private readonly PlayIdService _playIdService; + private readonly RequestDelegate _next; + private readonly PlayIdMiddleware _middleware; + + public PlayIdMiddlewareTests() + { + var hostEnvironment = Substitute.For(); + hostEnvironment.EnvironmentName.Returns(Environments.Development); + + _playIdService = new PlayIdService(hostEnvironment); + _next = Substitute.For(); + _middleware = new PlayIdMiddleware(_next); + } + + [Fact] + public async Task Invoke_WithValidPlayId_SetsPlayIdAndCallsNext() + { + var context = new DefaultHttpContext(); + context.Request.Headers["x-play-id"] = "test-play-id"; + + await _middleware.Invoke(context, _playIdService); + + Assert.Equal("test-play-id", _playIdService.PlayId); + await _next.Received(1).Invoke(context); + } + + [Fact] + public async Task Invoke_WithoutPlayIdHeader_CallsNext() + { + var context = new DefaultHttpContext(); + + await _middleware.Invoke(context, _playIdService); + + Assert.Null(_playIdService.PlayId); + await _next.Received(1).Invoke(context); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("\t")] + public async Task Invoke_WithEmptyOrWhitespacePlayId_Returns400(string playId) + { + var context = new DefaultHttpContext(); + context.Response.Body = new MemoryStream(); + context.Request.Headers["x-play-id"] = playId; + + await _middleware.Invoke(context, _playIdService); + + Assert.Equal(StatusCodes.Status400BadRequest, context.Response.StatusCode); + await _next.DidNotReceive().Invoke(context); + } + + [Fact] + public async Task Invoke_WithPlayIdExceedingMaxLength_Returns400() + { + var context = new DefaultHttpContext(); + context.Response.Body = new MemoryStream(); + var longPlayId = new string('a', 257); // Exceeds 256 character limit + context.Request.Headers["x-play-id"] = longPlayId; + + await _middleware.Invoke(context, _playIdService); + + Assert.Equal(StatusCodes.Status400BadRequest, context.Response.StatusCode); + await _next.DidNotReceive().Invoke(context); + } + + [Fact] + public async Task Invoke_WithPlayIdAtMaxLength_SetsPlayIdAndCallsNext() + { + var context = new DefaultHttpContext(); + var maxLengthPlayId = new string('a', 256); // Exactly 256 characters + context.Request.Headers["x-play-id"] = maxLengthPlayId; + + await _middleware.Invoke(context, _playIdService); + + Assert.Equal(maxLengthPlayId, _playIdService.PlayId); + await _next.Received(1).Invoke(context); + } + + [Fact] + public async Task Invoke_WithSpecialCharactersInPlayId_SetsPlayIdAndCallsNext() + { + var context = new DefaultHttpContext(); + context.Request.Headers["x-play-id"] = "test-play_id.123"; + + await _middleware.Invoke(context, _playIdService); + + Assert.Equal("test-play_id.123", _playIdService.PlayId); + await _next.Received(1).Invoke(context); + } +} diff --git a/util/Migrator/DbScripts/2026-01-08_00_CreatePlayItem.sql b/util/Migrator/DbScripts/2026-01-08_00_CreatePlayItem.sql new file mode 100644 index 0000000000..789538cb1c --- /dev/null +++ b/util/Migrator/DbScripts/2026-01-08_00_CreatePlayItem.sql @@ -0,0 +1,90 @@ +-- Create PlayItem table +IF OBJECT_ID('dbo.PlayItem') IS NULL +BEGIN + CREATE TABLE [dbo].[PlayItem] ( + [Id] UNIQUEIDENTIFIER NOT NULL, + [PlayId] NVARCHAR (256) NOT NULL, + [UserId] UNIQUEIDENTIFIER NULL, + [OrganizationId] UNIQUEIDENTIFIER NULL, + [CreationDate] DATETIME2 (7) NOT NULL, + CONSTRAINT [PK_PlayItem] PRIMARY KEY CLUSTERED ([Id] ASC), + CONSTRAINT [FK_PlayItem_User] FOREIGN KEY ([UserId]) REFERENCES [dbo].[User] ([Id]) ON DELETE CASCADE, + CONSTRAINT [FK_PlayItem_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]) ON DELETE CASCADE, + CONSTRAINT [CK_PlayItem_UserOrOrganization] CHECK (([UserId] IS NOT NULL AND [OrganizationId] IS NULL) OR ([UserId] IS NULL AND [OrganizationId] IS NOT NULL)) + ); + + CREATE NONCLUSTERED INDEX [IX_PlayItem_PlayId] + ON [dbo].[PlayItem]([PlayId] ASC); + + CREATE NONCLUSTERED INDEX [IX_PlayItem_UserId] + ON [dbo].[PlayItem]([UserId] ASC); + + CREATE NONCLUSTERED INDEX [IX_PlayItem_OrganizationId] + ON [dbo].[PlayItem]([OrganizationId] ASC); +END +GO + +-- Create PlayItem_Create stored procedure +CREATE OR ALTER PROCEDURE [dbo].[PlayItem_Create] + @Id UNIQUEIDENTIFIER OUTPUT, + @PlayId NVARCHAR(256), + @UserId UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @CreationDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + INSERT INTO [dbo].[PlayItem] + ( + [Id], + [PlayId], + [UserId], + [OrganizationId], + [CreationDate] + ) + VALUES + ( + @Id, + @PlayId, + @UserId, + @OrganizationId, + @CreationDate + ) +END +GO + +-- Create PlayItem_ReadByPlayId stored procedure +CREATE OR ALTER PROCEDURE [dbo].[PlayItem_ReadByPlayId] + @PlayId NVARCHAR(256) +AS +BEGIN + SET NOCOUNT ON + + SELECT + [Id], + [PlayId], + [UserId], + [OrganizationId], + [CreationDate] + FROM + [dbo].[PlayItem] + WHERE + [PlayId] = @PlayId +END +GO + +-- Create PlayItem_DeleteByPlayId stored procedure +CREATE OR ALTER PROCEDURE [dbo].[PlayItem_DeleteByPlayId] + @PlayId NVARCHAR(256) +AS +BEGIN + SET NOCOUNT ON + + DELETE + FROM + [dbo].[PlayItem] + WHERE + [PlayId] = @PlayId +END +GO diff --git a/util/MySqlMigrations/Migrations/20260108193951_CreatePlayItem.Designer.cs b/util/MySqlMigrations/Migrations/20260108193951_CreatePlayItem.Designer.cs new file mode 100644 index 0000000000..779f8a06f2 --- /dev/null +++ b/util/MySqlMigrations/Migrations/20260108193951_CreatePlayItem.Designer.cs @@ -0,0 +1,3502 @@ +// +using System; +using Bit.Infrastructure.EntityFramework.Repositories; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Bit.MySqlMigrations.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20260108193951_CreatePlayItem")] + partial class CreatePlayItem + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("Bit.Core.Dirt.Reports.Models.Data.OrganizationMemberBaseDetail", b => + { + b.Property("CipherId") + .HasColumnType("char(36)"); + + b.Property("CollectionId") + .HasColumnType("char(36)"); + + b.Property("CollectionName") + .HasColumnType("longtext"); + + b.Property("Email") + .HasColumnType("longtext"); + + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("GroupName") + .HasColumnType("longtext"); + + b.Property("HidePasswords") + .HasColumnType("tinyint(1)"); + + b.Property("Manage") + .HasColumnType("tinyint(1)"); + + b.Property("ReadOnly") + .HasColumnType("tinyint(1)"); + + b.Property("ResetPasswordKey") + .HasColumnType("longtext"); + + b.Property("TwoFactorProviders") + .HasColumnType("longtext"); + + b.Property("UserGuid") + .HasColumnType("char(36)"); + + b.Property("UserName") + .HasColumnType("longtext"); + + b.Property("UsesKeyConnector") + .HasColumnType("tinyint(1)"); + + b.ToTable("OrganizationMemberBaseDetails"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AllowAdminAccessToAllCollectionItems") + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("BillingEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("BusinessAddress1") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("BusinessAddress2") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("BusinessAddress3") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("BusinessCountry") + .HasMaxLength(2) + .HasColumnType("varchar(2)"); + + b.Property("BusinessName") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("BusinessTaxNumber") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("ExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("Gateway") + .HasColumnType("tinyint unsigned"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Identifier") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("LimitCollectionCreation") + .HasColumnType("tinyint(1)"); + + b.Property("LimitCollectionDeletion") + .HasColumnType("tinyint(1)"); + + b.Property("LimitItemDeletion") + .HasColumnType("tinyint(1)"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("int"); + + b.Property("MaxAutoscaleSmSeats") + .HasColumnType("int"); + + b.Property("MaxAutoscaleSmServiceAccounts") + .HasColumnType("int"); + + b.Property("MaxCollections") + .HasColumnType("smallint"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("OwnersNotifiedOfAutoscaling") + .HasColumnType("datetime(6)"); + + b.Property("Plan") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PlanType") + .HasColumnType("tinyint unsigned"); + + b.Property("PrivateKey") + .HasColumnType("longtext"); + + b.Property("PublicKey") + .HasColumnType("longtext"); + + b.Property("ReferenceData") + .HasColumnType("longtext"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Seats") + .HasColumnType("int"); + + b.Property("SelfHost") + .HasColumnType("tinyint(1)"); + + b.Property("SmSeats") + .HasColumnType("int"); + + b.Property("SmServiceAccounts") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.Property("Storage") + .HasColumnType("bigint"); + + b.Property("SyncSeats") + .HasColumnType("tinyint(1)"); + + b.Property("TwoFactorProviders") + .HasColumnType("longtext"); + + b.Property("Use2fa") + .HasColumnType("tinyint(1)"); + + b.Property("UseAdminSponsoredFamilies") + .HasColumnType("tinyint(1)"); + + b.Property("UseApi") + .HasColumnType("tinyint(1)"); + + b.Property("UseAutomaticUserConfirmation") + .HasColumnType("tinyint(1)"); + + b.Property("UseCustomPermissions") + .HasColumnType("tinyint(1)"); + + b.Property("UseDirectory") + .HasColumnType("tinyint(1)"); + + b.Property("UseDisableSmAdsForUsers") + .HasColumnType("tinyint(1)"); + + b.Property("UseEvents") + .HasColumnType("tinyint(1)"); + + b.Property("UseGroups") + .HasColumnType("tinyint(1)"); + + b.Property("UseKeyConnector") + .HasColumnType("tinyint(1)"); + + b.Property("UseOrganizationDomains") + .HasColumnType("tinyint(1)"); + + b.Property("UsePasswordManager") + .HasColumnType("tinyint(1)"); + + b.Property("UsePhishingBlocker") + .HasColumnType("tinyint(1)"); + + b.Property("UsePolicies") + .HasColumnType("tinyint(1)"); + + b.Property("UseResetPassword") + .HasColumnType("tinyint(1)"); + + b.Property("UseRiskInsights") + .HasColumnType("tinyint(1)"); + + b.Property("UseScim") + .HasColumnType("tinyint(1)"); + + b.Property("UseSecretsManager") + .HasColumnType("tinyint(1)"); + + b.Property("UseSso") + .HasColumnType("tinyint(1)"); + + b.Property("UseTotp") + .HasColumnType("tinyint(1)"); + + b.Property("UsersGetPremium") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Enabled") + .HasAnnotation("Npgsql:IndexInclude", new[] { "UseTotp", "UsersGetPremium" }); + + b.ToTable("Organization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Data") + .HasColumnType("longtext"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Policy", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("BillingEmail") + .HasColumnType("longtext"); + + b.Property("BillingPhone") + .HasColumnType("longtext"); + + b.Property("BusinessAddress1") + .HasColumnType("longtext"); + + b.Property("BusinessAddress2") + .HasColumnType("longtext"); + + b.Property("BusinessAddress3") + .HasColumnType("longtext"); + + b.Property("BusinessCountry") + .HasColumnType("longtext"); + + b.Property("BusinessName") + .HasColumnType("longtext"); + + b.Property("BusinessTaxNumber") + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("DiscountId") + .HasColumnType("longtext"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("Gateway") + .HasColumnType("tinyint unsigned"); + + b.Property("GatewayCustomerId") + .HasColumnType("longtext"); + + b.Property("GatewaySubscriptionId") + .HasColumnType("longtext"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UseEvents") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.ToTable("Provider", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Settings") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderOrganization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .HasColumnType("longtext"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("Permissions") + .HasColumnType("longtext"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId"); + + b.ToTable("ProviderUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AccessCode") + .HasMaxLength(25) + .HasColumnType("varchar(25)"); + + b.Property("Approved") + .HasColumnType("tinyint(1)"); + + b.Property("AuthenticationDate") + .HasColumnType("datetime(6)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("MasterPasswordHash") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("PublicKey") + .HasColumnType("longtext"); + + b.Property("RequestCountryName") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("RequestDeviceIdentifier") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("RequestDeviceType") + .HasColumnType("tinyint unsigned"); + + b.Property("RequestIpAddress") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("ResponseDate") + .HasColumnType("datetime(6)"); + + b.Property("ResponseDeviceId") + .HasColumnType("char(36)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ResponseDeviceId"); + + b.HasIndex("UserId"); + + b.ToTable("AuthRequest", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("GranteeId") + .HasColumnType("char(36)"); + + b.Property("GrantorId") + .HasColumnType("char(36)"); + + b.Property("KeyEncrypted") + .HasColumnType("longtext"); + + b.Property("LastNotificationDate") + .HasColumnType("datetime(6)"); + + b.Property("RecoveryInitiatedDate") + .HasColumnType("datetime(6)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("WaitTimeDays") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("GranteeId"); + + b.HasIndex("GrantorId"); + + b.ToTable("EmergencyAccess", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.Grant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ConsumedDate") + .HasColumnType("datetime(6)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Data") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Description") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("SessionId") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SubjectId") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.HasKey("Id") + .HasName("PK_Grant") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpirationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("Grant", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Data") + .HasColumnType("longtext"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("SsoConfig", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId"); + + b.HasIndex("OrganizationId", "ExternalId") + .IsUnique() + .HasAnnotation("Npgsql:IndexInclude", new[] { "UserId" }) + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "UserId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SsoUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AaGuid") + .HasColumnType("char(36)"); + + b.Property("Counter") + .HasColumnType("int"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("CredentialId") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("EncryptedPrivateKey") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("EncryptedPublicKey") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("EncryptedUserKey") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PublicKey") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("SupportsPrf") + .HasColumnType("tinyint(1)"); + + b.Property("Type") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("WebAuthnCredential", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ClientOrganizationMigrationRecord", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("GatewayCustomerId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("GatewaySubscriptionId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("int"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("PlanType") + .HasColumnType("tinyint unsigned"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("Seats") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId", "OrganizationId") + .IsUnique(); + + b.ToTable("ClientOrganizationMigrationRecord", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("InstallationId") + .HasColumnType("char(36)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("InstallationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationInstallation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AssignedSeats") + .HasColumnType("int"); + + b.Property("ClientId") + .HasColumnType("char(36)"); + + b.Property("ClientName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Created") + .HasColumnType("datetime(6)"); + + b.Property("InvoiceId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("InvoiceNumber") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PlanName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("Total") + .HasColumnType("decimal(65,30)"); + + b.Property("UsedSeats") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderInvoiceItem", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AllocatedSeats") + .HasColumnType("int"); + + b.Property("PlanType") + .HasColumnType("tinyint unsigned"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("PurchasedSeats") + .HasColumnType("int"); + + b.Property("SeatMinimum") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("Id", "PlanType") + .IsUnique(); + + b.ToTable("ProviderPlan", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationApplication", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Applications") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Configuration") + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationIntegration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegrationConfiguration", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Configuration") + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("EventType") + .HasColumnType("int"); + + b.Property("Filters") + .HasColumnType("longtext"); + + b.Property("OrganizationIntegrationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Template") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationIntegrationId"); + + b.ToTable("OrganizationIntegrationConfiguration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ApplicationAtRiskCount") + .HasColumnType("int"); + + b.Property("ApplicationCount") + .HasColumnType("int"); + + b.Property("ApplicationData") + .HasColumnType("longtext"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("CriticalApplicationAtRiskCount") + .HasColumnType("int"); + + b.Property("CriticalApplicationCount") + .HasColumnType("int"); + + b.Property("CriticalMemberAtRiskCount") + .HasColumnType("int"); + + b.Property("CriticalMemberCount") + .HasColumnType("int"); + + b.Property("CriticalPasswordAtRiskCount") + .HasColumnType("int"); + + b.Property("CriticalPasswordCount") + .HasColumnType("int"); + + b.Property("MemberAtRiskCount") + .HasColumnType("int"); + + b.Property("MemberCount") + .HasColumnType("int"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("PasswordAtRiskCount") + .HasColumnType("int"); + + b.Property("PasswordCount") + .HasColumnType("int"); + + b.Property("ReportData") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("SummaryData") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationReport", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.PasswordHealthReportApplication", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Uri") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("PasswordHealthReportApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Cache", b => + { + b.Property("Id") + .HasMaxLength(449) + .HasColumnType("varchar(449)"); + + b.Property("AbsoluteExpiration") + .HasColumnType("datetime(6)"); + + b.Property("ExpiresAtTime") + .HasColumnType("datetime(6)"); + + b.Property("SlidingExpirationInSeconds") + .HasColumnType("bigint"); + + b.Property("Value") + .IsRequired() + .HasColumnType("longblob"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpiresAtTime") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Cache", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("DefaultUserCollectionEmail") + .HasColumnType("longtext"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Collection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.Property("CollectionId") + .HasColumnType("char(36)"); + + b.Property("CipherId") + .HasColumnType("char(36)"); + + b.HasKey("CollectionId", "CipherId"); + + b.HasIndex("CipherId"); + + b.ToTable("CollectionCipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.Property("CollectionId") + .HasColumnType("char(36)"); + + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("HidePasswords") + .HasColumnType("tinyint(1)"); + + b.Property("Manage") + .HasColumnType("tinyint(1)"); + + b.Property("ReadOnly") + .HasColumnType("tinyint(1)"); + + b.HasKey("CollectionId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("CollectionGroups"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.Property("CollectionId") + .HasColumnType("char(36)"); + + b.Property("OrganizationUserId") + .HasColumnType("char(36)"); + + b.Property("HidePasswords") + .HasColumnType("tinyint(1)"); + + b.Property("Manage") + .HasColumnType("tinyint(1)"); + + b.Property("ReadOnly") + .HasColumnType("tinyint(1)"); + + b.HasKey("CollectionId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Active") + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("EncryptedPrivateKey") + .HasColumnType("longtext"); + + b.Property("EncryptedPublicKey") + .HasColumnType("longtext"); + + b.Property("EncryptedUserKey") + .HasColumnType("longtext"); + + b.Property("Identifier") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PushToken") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("Identifier") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "Identifier") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Device", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Event", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ActingUserId") + .HasColumnType("char(36)"); + + b.Property("CipherId") + .HasColumnType("char(36)"); + + b.Property("CollectionId") + .HasColumnType("char(36)"); + + b.Property("Date") + .HasColumnType("datetime(6)"); + + b.Property("DeviceType") + .HasColumnType("tinyint unsigned"); + + b.Property("DomainName") + .HasColumnType("longtext"); + + b.Property("GrantedServiceAccountId") + .HasColumnType("char(36)"); + + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("InstallationId") + .HasColumnType("char(36)"); + + b.Property("IpAddress") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("OrganizationUserId") + .HasColumnType("char(36)"); + + b.Property("PolicyId") + .HasColumnType("char(36)"); + + b.Property("ProjectId") + .HasColumnType("char(36)"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("ProviderOrganizationId") + .HasColumnType("char(36)"); + + b.Property("ProviderUserId") + .HasColumnType("char(36)"); + + b.Property("SecretId") + .HasColumnType("char(36)"); + + b.Property("ServiceAccountId") + .HasColumnType("char(36)"); + + b.Property("SystemUser") + .HasColumnType("tinyint unsigned"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("Date", "OrganizationId", "ActingUserId", "CipherId") + .HasDatabaseName("IX_Event_DateOrganizationIdUserId") + .HasAnnotation("SqlServer:Clustered", false) + .HasAnnotation("SqlServer:Include", new[] { "ServiceAccountId", "GrantedServiceAccountId" }); + + b.ToTable("Event", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Group", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("OrganizationUserId") + .HasColumnType("char(36)"); + + b.HasKey("GroupId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("GroupUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Config") + .HasColumnType("longtext"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationConnection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("DomainName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("JobRunCount") + .HasColumnType("int"); + + b.Property("LastCheckedDate") + .HasColumnType("datetime(6)"); + + b.Property("NextRunDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Txt") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("VerifiedDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationDomain", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("FriendlyName") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("IsAdminInitiated") + .HasColumnType("tinyint(1)"); + + b.Property("LastSyncDate") + .HasColumnType("datetime(6)"); + + b.Property("Notes") + .HasColumnType("longtext"); + + b.Property("OfferedToEmail") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("PlanSponsorshipType") + .HasColumnType("tinyint unsigned"); + + b.Property("SponsoredOrganizationId") + .HasColumnType("char(36)"); + + b.Property("SponsoringOrganizationId") + .HasColumnType("char(36)"); + + b.Property("SponsoringOrganizationUserId") + .HasColumnType("char(36)"); + + b.Property("ToDelete") + .HasColumnType("tinyint(1)"); + + b.Property("ValidUntil") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("SponsoredOrganizationId"); + + b.HasIndex("SponsoringOrganizationId"); + + b.HasIndex("SponsoringOrganizationUserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationSponsorship", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AccessSecretsManager") + .HasColumnType("tinyint(1)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Permissions") + .HasColumnType("longtext"); + + b.Property("ResetPasswordKey") + .HasColumnType("longtext"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.PlayItem", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("PlayId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("PlayId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("PlayItem", null, t => + { + t.HasCheckConstraint("CK_PlayItem_UserOrOrganization", "(\"UserId\" IS NOT NULL AND \"OrganizationId\" IS NULL) OR (\"UserId\" IS NULL AND \"OrganizationId\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AccessCount") + .HasColumnType("int"); + + b.Property("AuthType") + .HasColumnType("tinyint unsigned"); + + b.Property("CipherId") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Data") + .HasColumnType("longtext"); + + b.Property("DeletionDate") + .HasColumnType("datetime(6)"); + + b.Property("Disabled") + .HasColumnType("tinyint(1)"); + + b.Property("Emails") + .HasMaxLength(4000) + .HasColumnType("varchar(4000)"); + + b.Property("ExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("HideEmail") + .HasColumnType("tinyint(1)"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("MaxAccessCount") + .HasColumnType("int"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Password") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("DeletionDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Send", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.TaxRate", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("Active") + .HasColumnType("tinyint(1)"); + + b.Property("Country") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PostalCode") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Rate") + .HasColumnType("decimal(65,30)"); + + b.Property("State") + .HasMaxLength(2) + .HasColumnType("varchar(2)"); + + b.HasKey("Id"); + + b.ToTable("TaxRate", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Amount") + .HasColumnType("decimal(65,30)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Details") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Gateway") + .HasColumnType("tinyint unsigned"); + + b.Property("GatewayId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("PaymentMethodType") + .HasColumnType("tinyint unsigned"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("Refunded") + .HasColumnType("tinyint(1)"); + + b.Property("RefundedAmount") + .HasColumnType("decimal(65,30)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId", "CreationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Transaction", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AccountRevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("AvatarColor") + .HasMaxLength(7) + .HasColumnType("varchar(7)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Culture") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("EmailVerified") + .HasColumnType("tinyint(1)"); + + b.Property("EquivalentDomains") + .HasColumnType("longtext"); + + b.Property("ExcludedGlobalEquivalentDomains") + .HasColumnType("longtext"); + + b.Property("FailedLoginCount") + .HasColumnType("int"); + + b.Property("ForcePasswordReset") + .HasColumnType("tinyint(1)"); + + b.Property("Gateway") + .HasColumnType("tinyint unsigned"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Kdf") + .HasColumnType("tinyint unsigned"); + + b.Property("KdfIterations") + .HasColumnType("int"); + + b.Property("KdfMemory") + .HasColumnType("int"); + + b.Property("KdfParallelism") + .HasColumnType("int"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("LastEmailChangeDate") + .HasColumnType("datetime(6)"); + + b.Property("LastFailedLoginDate") + .HasColumnType("datetime(6)"); + + b.Property("LastKdfChangeDate") + .HasColumnType("datetime(6)"); + + b.Property("LastKeyRotationDate") + .HasColumnType("datetime(6)"); + + b.Property("LastPasswordChangeDate") + .HasColumnType("datetime(6)"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("MasterPassword") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("MasterPasswordHint") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Premium") + .HasColumnType("tinyint(1)"); + + b.Property("PremiumExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("PrivateKey") + .HasColumnType("longtext"); + + b.Property("PublicKey") + .HasColumnType("longtext"); + + b.Property("ReferenceData") + .HasColumnType("longtext"); + + b.Property("RenewalReminderDate") + .HasColumnType("datetime(6)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("SecurityStamp") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("SecurityState") + .HasColumnType("longtext"); + + b.Property("SecurityVersion") + .HasColumnType("int"); + + b.Property("SignedPublicKey") + .HasColumnType("longtext"); + + b.Property("Storage") + .HasColumnType("bigint"); + + b.Property("TwoFactorProviders") + .HasColumnType("longtext"); + + b.Property("TwoFactorRecoveryCode") + .HasMaxLength(32) + .HasColumnType("varchar(32)"); + + b.Property("UsesKeyConnector") + .HasColumnType("tinyint(1)"); + + b.Property("VerifyDevices") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Premium", "PremiumExpirationDate", "RenewalReminderDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("User", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.UserSignatureKeyPair", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("SignatureAlgorithm") + .HasColumnType("tinyint unsigned"); + + b.Property("SigningKey") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.Property("VerifyingKey") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("UserSignatureKeyPair", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Body") + .HasMaxLength(3000) + .HasColumnType("varchar(3000)"); + + b.Property("ClientType") + .HasColumnType("tinyint unsigned"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Global") + .HasColumnType("tinyint(1)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Priority") + .HasColumnType("tinyint unsigned"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("TaskId") + .HasColumnType("char(36)"); + + b.Property("Title") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("TaskId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("ClientType", "Global", "UserId", "OrganizationId", "Priority", "CreationDate") + .IsDescending(false, false, false, false, true, true) + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Notification", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.Property("UserId") + .HasColumnType("char(36)"); + + b.Property("NotificationId") + .HasColumnType("char(36)"); + + b.Property("DeletedDate") + .HasColumnType("datetime(6)"); + + b.Property("ReadDate") + .HasColumnType("datetime(6)"); + + b.HasKey("UserId", "NotificationId") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("NotificationId"); + + b.ToTable("NotificationStatus", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Platform.Installation", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("varchar(150)"); + + b.Property("LastActivityDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.ToTable("Installation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(34) + .HasColumnType("varchar(34)"); + + b.Property("Read") + .HasColumnType("tinyint(1)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Write") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.ToTable("AccessPolicy", (string)null); + + b.HasDiscriminator().HasValue("AccessPolicy"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ClientSecretHash") + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("EncryptedPayload") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("varchar(4000)"); + + b.Property("ExpireAt") + .HasColumnType("datetime(6)"); + + b.Property("Key") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("varchar(4000)"); + + b.Property("ServiceAccountId") + .HasColumnType("char(36)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ServiceAccountId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("DeletedDate") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Project", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("DeletedDate") + .HasColumnType("datetime(6)"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("Note") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Value") + .HasColumnType("longtext"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Secret", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.SecretVersion", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("EditorOrganizationUserId") + .HasColumnType("char(36)"); + + b.Property("EditorServiceAccountId") + .HasColumnType("char(36)"); + + b.Property("SecretId") + .HasColumnType("char(36)"); + + b.Property("Value") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("VersionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("EditorOrganizationUserId") + .HasDatabaseName("IX_SecretVersion_EditorOrganizationUserId"); + + b.HasIndex("EditorServiceAccountId") + .HasDatabaseName("IX_SecretVersion_EditorServiceAccountId"); + + b.HasIndex("SecretId") + .HasDatabaseName("IX_SecretVersion_SecretId"); + + b.ToTable("SecretVersion"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ServiceAccount", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Archives") + .HasColumnType("longtext"); + + b.Property("Attachments") + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Data") + .HasColumnType("longtext"); + + b.Property("DeletedDate") + .HasColumnType("datetime(6)"); + + b.Property("Favorites") + .HasColumnType("longtext"); + + b.Property("Folders") + .HasColumnType("longtext"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Reprompt") + .HasColumnType("tinyint unsigned"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId"); + + b.ToTable("Cipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Folder", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CipherId") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SecurityTask", (string)null); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.Property("ProjectsId") + .HasColumnType("char(36)"); + + b.Property("SecretsId") + .HasColumnType("char(36)"); + + b.HasKey("ProjectsId", "SecretsId"); + + b.HasIndex("SecretsId"); + + b.ToTable("ProjectSecret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedProjectId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedSecretId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedProjectId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedSecretId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedProjectId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedSecretId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Policies") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Device", "ResponseDevice") + .WithMany() + .HasForeignKey("ResponseDeviceId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("ResponseDevice"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantee") + .WithMany() + .HasForeignKey("GranteeId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantor") + .WithMany() + .HasForeignKey("GrantorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Grantee"); + + b.Navigation("Grantor"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoConfigs") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoUsers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("SsoUsers") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Platform.Installation", "Installation") + .WithMany() + .HasForeignKey("InstallationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Installation"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegrationConfiguration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", "OrganizationIntegration") + .WithMany() + .HasForeignKey("OrganizationIntegrationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OrganizationIntegration"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.PasswordHealthReportApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Collections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany("CollectionCiphers") + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionCiphers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Collection"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionGroups") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionUsers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("CollectionUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Groups") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany("GroupUsers") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("GroupUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("ApiKeys") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Connections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Domains") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoredOrganization") + .WithMany() + .HasForeignKey("SponsoredOrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoringOrganization") + .WithMany() + .HasForeignKey("SponsoringOrganizationId"); + + b.Navigation("SponsoredOrganization"); + + b.Navigation("SponsoringOrganization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("OrganizationUsers") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("OrganizationUsers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.PlayItem", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Transactions") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Transactions") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.UserSignatureKeyPair", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", "Task") + .WithMany() + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Task"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", "Notification") + .WithMany() + .HasForeignKey("NotificationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Notification"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ApiKeys") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.SecretVersion", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "EditorOrganizationUser") + .WithMany() + .HasForeignKey("EditorOrganizationUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "EditorServiceAccount") + .WithMany() + .HasForeignKey("EditorServiceAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "Secret") + .WithMany("SecretVersions") + .HasForeignKey("SecretId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EditorOrganizationUser"); + + b.Navigation("EditorServiceAccount"); + + b.Navigation("Secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Ciphers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Ciphers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Folders") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany() + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", null) + .WithMany() + .HasForeignKey("ProjectsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", null) + .WithMany() + .HasForeignKey("SecretsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedProject"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedSecret"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ProjectAccessPolicies") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedProject"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany() + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedProject"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("Ciphers"); + + b.Navigation("Collections"); + + b.Navigation("Connections"); + + b.Navigation("Domains"); + + b.Navigation("Groups"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("Policies"); + + b.Navigation("SsoConfigs"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Navigation("CollectionCiphers"); + + b.Navigation("CollectionGroups"); + + b.Navigation("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Navigation("CollectionUsers"); + + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Navigation("Ciphers"); + + b.Navigation("Folders"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("SecretVersions"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ProjectAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Navigation("CollectionCiphers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/util/MySqlMigrations/Migrations/20260108193951_CreatePlayItem.cs b/util/MySqlMigrations/Migrations/20260108193951_CreatePlayItem.cs new file mode 100644 index 0000000000..d1b69417d4 --- /dev/null +++ b/util/MySqlMigrations/Migrations/20260108193951_CreatePlayItem.cs @@ -0,0 +1,65 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bit.MySqlMigrations.Migrations; + +/// +public partial class CreatePlayItem : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "PlayItem", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + PlayId = table.Column(type: "varchar(256)", maxLength: 256, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + UserId = table.Column(type: "char(36)", nullable: true, collation: "ascii_general_ci"), + OrganizationId = table.Column(type: "char(36)", nullable: true, collation: "ascii_general_ci"), + CreationDate = table.Column(type: "datetime(6)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PlayItem", x => x.Id); + table.CheckConstraint("CK_PlayItem_UserOrOrganization", "(\"UserId\" IS NOT NULL AND \"OrganizationId\" IS NULL) OR (\"UserId\" IS NULL AND \"OrganizationId\" IS NOT NULL)"); + table.ForeignKey( + name: "FK_PlayItem_Organization_OrganizationId", + column: x => x.OrganizationId, + principalTable: "Organization", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_PlayItem_User_UserId", + column: x => x.UserId, + principalTable: "User", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "IX_PlayItem_OrganizationId", + table: "PlayItem", + column: "OrganizationId"); + + migrationBuilder.CreateIndex( + name: "IX_PlayItem_PlayId", + table: "PlayItem", + column: "PlayId"); + + migrationBuilder.CreateIndex( + name: "IX_PlayItem_UserId", + table: "PlayItem", + column: "UserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "PlayItem"); + } +} diff --git a/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs b/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs index b0b88670a1..dda8d5d179 100644 --- a/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs +++ b/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs @@ -282,71 +282,6 @@ namespace Bit.MySqlMigrations.Migrations b.ToTable("Organization", (string)null); }); - modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", b => - { - b.Property("Id") - .HasColumnType("char(36)"); - - b.Property("Configuration") - .HasColumnType("longtext"); - - b.Property("CreationDate") - .HasColumnType("datetime(6)"); - - b.Property("OrganizationId") - .HasColumnType("char(36)"); - - b.Property("RevisionDate") - .HasColumnType("datetime(6)"); - - b.Property("Type") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("OrganizationId") - .HasAnnotation("SqlServer:Clustered", false); - - b.HasIndex("OrganizationId", "Type") - .IsUnique() - .HasAnnotation("SqlServer:Clustered", false); - - b.ToTable("OrganizationIntegration", (string)null); - }); - - modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegrationConfiguration", b => - { - b.Property("Id") - .HasColumnType("char(36)"); - - b.Property("Configuration") - .HasColumnType("longtext"); - - b.Property("CreationDate") - .HasColumnType("datetime(6)"); - - b.Property("EventType") - .HasColumnType("int"); - - b.Property("Filters") - .HasColumnType("longtext"); - - b.Property("OrganizationIntegrationId") - .HasColumnType("char(36)"); - - b.Property("RevisionDate") - .HasColumnType("datetime(6)"); - - b.Property("Template") - .HasColumnType("longtext"); - - b.HasKey("Id"); - - b.HasIndex("OrganizationIntegrationId"); - - b.ToTable("OrganizationIntegrationConfiguration", (string)null); - }); - modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => { b.Property("Id") @@ -626,8 +561,8 @@ namespace Bit.MySqlMigrations.Migrations b.Property("Type") .HasColumnType("tinyint unsigned"); - b.Property("WaitTimeDays") - .HasColumnType("int"); + b.Property("WaitTimeDays") + .HasColumnType("smallint"); b.HasKey("Id"); @@ -1015,6 +950,71 @@ namespace Bit.MySqlMigrations.Migrations b.ToTable("OrganizationApplication", (string)null); }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Configuration") + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationIntegration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegrationConfiguration", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Configuration") + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("EventType") + .HasColumnType("int"); + + b.Property("Filters") + .HasColumnType("longtext"); + + b.Property("OrganizationIntegrationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Template") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationIntegrationId"); + + b.ToTable("OrganizationIntegrationConfiguration", (string)null); + }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => { b.Property("Id") @@ -1627,6 +1627,42 @@ namespace Bit.MySqlMigrations.Migrations b.ToTable("OrganizationUser", (string)null); }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.PlayItem", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("PlayId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("PlayId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("PlayItem", null, t => + { + t.HasCheckConstraint("CK_PlayItem_UserOrOrganization", "(\"UserId\" IS NOT NULL AND \"OrganizationId\" IS NULL) OR (\"UserId\" IS NULL AND \"OrganizationId\" IS NOT NULL)"); + }); + }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => { b.Property("Id") @@ -2607,28 +2643,6 @@ namespace Bit.MySqlMigrations.Migrations b.HasDiscriminator().HasValue("user_service_account"); }); - modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", b => - { - b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") - .WithMany() - .HasForeignKey("OrganizationId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Organization"); - }); - - modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegrationConfiguration", b => - { - b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", "OrganizationIntegration") - .WithMany() - .HasForeignKey("OrganizationIntegrationId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("OrganizationIntegration"); - }); - modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => { b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") @@ -2807,6 +2821,28 @@ namespace Bit.MySqlMigrations.Migrations b.Navigation("Organization"); }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegrationConfiguration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", "OrganizationIntegration") + .WithMany() + .HasForeignKey("OrganizationIntegrationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OrganizationIntegration"); + }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => { b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") @@ -3003,6 +3039,23 @@ namespace Bit.MySqlMigrations.Migrations b.Navigation("User"); }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.PlayItem", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => { b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") diff --git a/util/PostgresMigrations/Migrations/20260108193909_CreatePlayItem.Designer.cs b/util/PostgresMigrations/Migrations/20260108193909_CreatePlayItem.Designer.cs new file mode 100644 index 0000000000..0586bd8a23 --- /dev/null +++ b/util/PostgresMigrations/Migrations/20260108193909_CreatePlayItem.Designer.cs @@ -0,0 +1,3508 @@ +// +using System; +using Bit.Infrastructure.EntityFramework.Repositories; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Bit.PostgresMigrations.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20260108193909_CreatePlayItem")] + partial class CreatePlayItem + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("Npgsql:CollationDefinition:postgresIndetermanisticCollation", "en-u-ks-primary,en-u-ks-primary,icu,False") + .HasAnnotation("ProductVersion", "8.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Bit.Core.Dirt.Reports.Models.Data.OrganizationMemberBaseDetail", b => + { + b.Property("CipherId") + .HasColumnType("uuid"); + + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("CollectionName") + .HasColumnType("text"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("GroupName") + .HasColumnType("text"); + + b.Property("HidePasswords") + .HasColumnType("boolean"); + + b.Property("Manage") + .HasColumnType("boolean"); + + b.Property("ReadOnly") + .HasColumnType("boolean"); + + b.Property("ResetPasswordKey") + .HasColumnType("text"); + + b.Property("TwoFactorProviders") + .HasColumnType("text"); + + b.Property("UserGuid") + .HasColumnType("uuid"); + + b.Property("UserName") + .HasColumnType("text"); + + b.Property("UsesKeyConnector") + .HasColumnType("boolean"); + + b.ToTable("OrganizationMemberBaseDetails"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AllowAdminAccessToAllCollectionItems") + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("BillingEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("BusinessAddress1") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BusinessAddress2") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BusinessAddress3") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BusinessCountry") + .HasMaxLength(2) + .HasColumnType("character varying(2)"); + + b.Property("BusinessName") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BusinessTaxNumber") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Gateway") + .HasColumnType("smallint"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Identifier") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .UseCollation("postgresIndetermanisticCollation"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LimitCollectionCreation") + .HasColumnType("boolean"); + + b.Property("LimitCollectionDeletion") + .HasColumnType("boolean"); + + b.Property("LimitItemDeletion") + .HasColumnType("boolean"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("integer"); + + b.Property("MaxAutoscaleSmSeats") + .HasColumnType("integer"); + + b.Property("MaxAutoscaleSmServiceAccounts") + .HasColumnType("integer"); + + b.Property("MaxCollections") + .HasColumnType("smallint"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OwnersNotifiedOfAutoscaling") + .HasColumnType("timestamp with time zone"); + + b.Property("Plan") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PlanType") + .HasColumnType("smallint"); + + b.Property("PrivateKey") + .HasColumnType("text"); + + b.Property("PublicKey") + .HasColumnType("text"); + + b.Property("ReferenceData") + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Seats") + .HasColumnType("integer"); + + b.Property("SelfHost") + .HasColumnType("boolean"); + + b.Property("SmSeats") + .HasColumnType("integer"); + + b.Property("SmServiceAccounts") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Storage") + .HasColumnType("bigint"); + + b.Property("SyncSeats") + .HasColumnType("boolean"); + + b.Property("TwoFactorProviders") + .HasColumnType("text"); + + b.Property("Use2fa") + .HasColumnType("boolean"); + + b.Property("UseAdminSponsoredFamilies") + .HasColumnType("boolean"); + + b.Property("UseApi") + .HasColumnType("boolean"); + + b.Property("UseAutomaticUserConfirmation") + .HasColumnType("boolean"); + + b.Property("UseCustomPermissions") + .HasColumnType("boolean"); + + b.Property("UseDirectory") + .HasColumnType("boolean"); + + b.Property("UseDisableSmAdsForUsers") + .HasColumnType("boolean"); + + b.Property("UseEvents") + .HasColumnType("boolean"); + + b.Property("UseGroups") + .HasColumnType("boolean"); + + b.Property("UseKeyConnector") + .HasColumnType("boolean"); + + b.Property("UseOrganizationDomains") + .HasColumnType("boolean"); + + b.Property("UsePasswordManager") + .HasColumnType("boolean"); + + b.Property("UsePhishingBlocker") + .HasColumnType("boolean"); + + b.Property("UsePolicies") + .HasColumnType("boolean"); + + b.Property("UseResetPassword") + .HasColumnType("boolean"); + + b.Property("UseRiskInsights") + .HasColumnType("boolean"); + + b.Property("UseScim") + .HasColumnType("boolean"); + + b.Property("UseSecretsManager") + .HasColumnType("boolean"); + + b.Property("UseSso") + .HasColumnType("boolean"); + + b.Property("UseTotp") + .HasColumnType("boolean"); + + b.Property("UsersGetPremium") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Enabled"); + + NpgsqlIndexBuilderExtensions.IncludeProperties(b.HasIndex("Id", "Enabled"), new[] { "UseTotp", "UsersGetPremium" }); + + b.ToTable("Organization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Policy", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BillingEmail") + .HasColumnType("text"); + + b.Property("BillingPhone") + .HasColumnType("text"); + + b.Property("BusinessAddress1") + .HasColumnType("text"); + + b.Property("BusinessAddress2") + .HasColumnType("text"); + + b.Property("BusinessAddress3") + .HasColumnType("text"); + + b.Property("BusinessCountry") + .HasColumnType("text"); + + b.Property("BusinessName") + .HasColumnType("text"); + + b.Property("BusinessTaxNumber") + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DiscountId") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("Gateway") + .HasColumnType("smallint"); + + b.Property("GatewayCustomerId") + .HasColumnType("text"); + + b.Property("GatewaySubscriptionId") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UseEvents") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.ToTable("Provider", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Settings") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderOrganization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("Permissions") + .HasColumnType("text"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId"); + + b.ToTable("ProviderUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccessCode") + .HasMaxLength(25) + .HasColumnType("character varying(25)"); + + b.Property("Approved") + .HasColumnType("boolean"); + + b.Property("AuthenticationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("MasterPasswordHash") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PublicKey") + .HasColumnType("text"); + + b.Property("RequestCountryName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RequestDeviceIdentifier") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("RequestDeviceType") + .HasColumnType("smallint"); + + b.Property("RequestIpAddress") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ResponseDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ResponseDeviceId") + .HasColumnType("uuid"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ResponseDeviceId"); + + b.HasIndex("UserId"); + + b.ToTable("AuthRequest", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("GranteeId") + .HasColumnType("uuid"); + + b.Property("GrantorId") + .HasColumnType("uuid"); + + b.Property("KeyEncrypted") + .HasColumnType("text"); + + b.Property("LastNotificationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RecoveryInitiatedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("WaitTimeDays") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("GranteeId"); + + b.HasIndex("GrantorId"); + + b.ToTable("EmergencyAccess", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.Grant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ConsumedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .IsRequired() + .HasColumnType("text"); + + b.Property("Description") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SessionId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SubjectId") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id") + .HasName("PK_Grant") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpirationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("Grant", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("SsoConfig", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .UseCollation("postgresIndetermanisticCollation"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId"); + + b.HasIndex("OrganizationId", "ExternalId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + NpgsqlIndexBuilderExtensions.IncludeProperties(b.HasIndex("OrganizationId", "ExternalId"), new[] { "UserId" }); + + b.HasIndex("OrganizationId", "UserId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SsoUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AaGuid") + .HasColumnType("uuid"); + + b.Property("Counter") + .HasColumnType("integer"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CredentialId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EncryptedPrivateKey") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("EncryptedPublicKey") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("EncryptedUserKey") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PublicKey") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SupportsPrf") + .HasColumnType("boolean"); + + b.Property("Type") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("WebAuthnCredential", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ClientOrganizationMigrationRecord", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("GatewayCustomerId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("GatewaySubscriptionId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("integer"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PlanType") + .HasColumnType("smallint"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("Seats") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId", "OrganizationId") + .IsUnique(); + + b.ToTable("ClientOrganizationMigrationRecord", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("InstallationId") + .HasColumnType("uuid"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("InstallationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationInstallation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AssignedSeats") + .HasColumnType("integer"); + + b.Property("ClientId") + .HasColumnType("uuid"); + + b.Property("ClientName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("InvoiceId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("InvoiceNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PlanName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("Total") + .HasColumnType("numeric"); + + b.Property("UsedSeats") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderInvoiceItem", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AllocatedSeats") + .HasColumnType("integer"); + + b.Property("PlanType") + .HasColumnType("smallint"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("PurchasedSeats") + .HasColumnType("integer"); + + b.Property("SeatMinimum") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("Id", "PlanType") + .IsUnique(); + + b.ToTable("ProviderPlan", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationApplication", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Applications") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Configuration") + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationIntegration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegrationConfiguration", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Configuration") + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("EventType") + .HasColumnType("integer"); + + b.Property("Filters") + .HasColumnType("text"); + + b.Property("OrganizationIntegrationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Template") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationIntegrationId"); + + b.ToTable("OrganizationIntegrationConfiguration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationAtRiskCount") + .HasColumnType("integer"); + + b.Property("ApplicationCount") + .HasColumnType("integer"); + + b.Property("ApplicationData") + .HasColumnType("text"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CriticalApplicationAtRiskCount") + .HasColumnType("integer"); + + b.Property("CriticalApplicationCount") + .HasColumnType("integer"); + + b.Property("CriticalMemberAtRiskCount") + .HasColumnType("integer"); + + b.Property("CriticalMemberCount") + .HasColumnType("integer"); + + b.Property("CriticalPasswordAtRiskCount") + .HasColumnType("integer"); + + b.Property("CriticalPasswordCount") + .HasColumnType("integer"); + + b.Property("MemberAtRiskCount") + .HasColumnType("integer"); + + b.Property("MemberCount") + .HasColumnType("integer"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PasswordAtRiskCount") + .HasColumnType("integer"); + + b.Property("PasswordCount") + .HasColumnType("integer"); + + b.Property("ReportData") + .IsRequired() + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SummaryData") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationReport", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.PasswordHealthReportApplication", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Uri") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("PasswordHealthReportApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Cache", b => + { + b.Property("Id") + .HasMaxLength(449) + .HasColumnType("character varying(449)"); + + b.Property("AbsoluteExpiration") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SlidingExpirationInSeconds") + .HasColumnType("bigint"); + + b.Property("Value") + .IsRequired() + .HasColumnType("bytea"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpiresAtTime") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Cache", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DefaultUserCollectionEmail") + .HasColumnType("text"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Collection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("CipherId") + .HasColumnType("uuid"); + + b.HasKey("CollectionId", "CipherId"); + + b.HasIndex("CipherId"); + + b.ToTable("CollectionCipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("HidePasswords") + .HasColumnType("boolean"); + + b.Property("Manage") + .HasColumnType("boolean"); + + b.Property("ReadOnly") + .HasColumnType("boolean"); + + b.HasKey("CollectionId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("CollectionGroups"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("OrganizationUserId") + .HasColumnType("uuid"); + + b.Property("HidePasswords") + .HasColumnType("boolean"); + + b.Property("Manage") + .HasColumnType("boolean"); + + b.Property("ReadOnly") + .HasColumnType("boolean"); + + b.HasKey("CollectionId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Active") + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedPrivateKey") + .HasColumnType("text"); + + b.Property("EncryptedPublicKey") + .HasColumnType("text"); + + b.Property("EncryptedUserKey") + .HasColumnType("text"); + + b.Property("Identifier") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PushToken") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Identifier") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "Identifier") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Device", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Event", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ActingUserId") + .HasColumnType("uuid"); + + b.Property("CipherId") + .HasColumnType("uuid"); + + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("DeviceType") + .HasColumnType("smallint"); + + b.Property("DomainName") + .HasColumnType("text"); + + b.Property("GrantedServiceAccountId") + .HasColumnType("uuid"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("InstallationId") + .HasColumnType("uuid"); + + b.Property("IpAddress") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("OrganizationUserId") + .HasColumnType("uuid"); + + b.Property("PolicyId") + .HasColumnType("uuid"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("ProviderOrganizationId") + .HasColumnType("uuid"); + + b.Property("ProviderUserId") + .HasColumnType("uuid"); + + b.Property("SecretId") + .HasColumnType("uuid"); + + b.Property("ServiceAccountId") + .HasColumnType("uuid"); + + b.Property("SystemUser") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("Date", "OrganizationId", "ActingUserId", "CipherId") + .HasDatabaseName("IX_Event_DateOrganizationIdUserId") + .HasAnnotation("SqlServer:Clustered", false) + .HasAnnotation("SqlServer:Include", new[] { "ServiceAccountId", "GrantedServiceAccountId" }); + + b.ToTable("Event", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Group", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("OrganizationUserId") + .HasColumnType("uuid"); + + b.HasKey("GroupId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("GroupUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Config") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationConnection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DomainName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("JobRunCount") + .HasColumnType("integer"); + + b.Property("LastCheckedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("NextRunDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Txt") + .IsRequired() + .HasColumnType("text"); + + b.Property("VerifiedDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationDomain", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("FriendlyName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("IsAdminInitiated") + .HasColumnType("boolean"); + + b.Property("LastSyncDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("OfferedToEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PlanSponsorshipType") + .HasColumnType("smallint"); + + b.Property("SponsoredOrganizationId") + .HasColumnType("uuid"); + + b.Property("SponsoringOrganizationId") + .HasColumnType("uuid"); + + b.Property("SponsoringOrganizationUserId") + .HasColumnType("uuid"); + + b.Property("ToDelete") + .HasColumnType("boolean"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("SponsoredOrganizationId"); + + b.HasIndex("SponsoringOrganizationId"); + + b.HasIndex("SponsoringOrganizationUserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationSponsorship", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccessSecretsManager") + .HasColumnType("boolean"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Permissions") + .HasColumnType("text"); + + b.Property("ResetPasswordKey") + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.PlayItem", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PlayId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("PlayId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("PlayItem", null, t => + { + t.HasCheckConstraint("CK_PlayItem_UserOrOrganization", "(\"UserId\" IS NOT NULL AND \"OrganizationId\" IS NULL) OR (\"UserId\" IS NULL AND \"OrganizationId\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccessCount") + .HasColumnType("integer"); + + b.Property("AuthType") + .HasColumnType("smallint"); + + b.Property("CipherId") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("DeletionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Disabled") + .HasColumnType("boolean"); + + b.Property("Emails") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("HideEmail") + .HasColumnType("boolean"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("MaxAccessCount") + .HasColumnType("integer"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Password") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DeletionDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Send", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.TaxRate", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Active") + .HasColumnType("boolean"); + + b.Property("Country") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PostalCode") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("Rate") + .HasColumnType("numeric"); + + b.Property("State") + .HasMaxLength(2) + .HasColumnType("character varying(2)"); + + b.HasKey("Id"); + + b.ToTable("TaxRate", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Amount") + .HasColumnType("numeric"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Details") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Gateway") + .HasColumnType("smallint"); + + b.Property("GatewayId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PaymentMethodType") + .HasColumnType("smallint"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("Refunded") + .HasColumnType("boolean"); + + b.Property("RefundedAmount") + .HasColumnType("numeric"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId", "CreationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Transaction", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccountRevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("AvatarColor") + .HasMaxLength(7) + .HasColumnType("character varying(7)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Culture") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .UseCollation("postgresIndetermanisticCollation"); + + b.Property("EmailVerified") + .HasColumnType("boolean"); + + b.Property("EquivalentDomains") + .HasColumnType("text"); + + b.Property("ExcludedGlobalEquivalentDomains") + .HasColumnType("text"); + + b.Property("FailedLoginCount") + .HasColumnType("integer"); + + b.Property("ForcePasswordReset") + .HasColumnType("boolean"); + + b.Property("Gateway") + .HasColumnType("smallint"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Kdf") + .HasColumnType("smallint"); + + b.Property("KdfIterations") + .HasColumnType("integer"); + + b.Property("KdfMemory") + .HasColumnType("integer"); + + b.Property("KdfParallelism") + .HasColumnType("integer"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("LastEmailChangeDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastFailedLoginDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastKdfChangeDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastKeyRotationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastPasswordChangeDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("MasterPassword") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("MasterPasswordHint") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Premium") + .HasColumnType("boolean"); + + b.Property("PremiumExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("PrivateKey") + .HasColumnType("text"); + + b.Property("PublicKey") + .HasColumnType("text"); + + b.Property("ReferenceData") + .HasColumnType("text"); + + b.Property("RenewalReminderDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SecurityStamp") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("SecurityState") + .HasColumnType("text"); + + b.Property("SecurityVersion") + .HasColumnType("integer"); + + b.Property("SignedPublicKey") + .HasColumnType("text"); + + b.Property("Storage") + .HasColumnType("bigint"); + + b.Property("TwoFactorProviders") + .HasColumnType("text"); + + b.Property("TwoFactorRecoveryCode") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UsesKeyConnector") + .HasColumnType("boolean"); + + b.Property("VerifyDevices") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Premium", "PremiumExpirationDate", "RenewalReminderDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("User", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.UserSignatureKeyPair", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SignatureAlgorithm") + .HasColumnType("smallint"); + + b.Property("SigningKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("VerifyingKey") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("UserSignatureKeyPair", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Body") + .HasMaxLength(3000) + .HasColumnType("character varying(3000)"); + + b.Property("ClientType") + .HasColumnType("smallint"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Global") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Priority") + .HasColumnType("smallint"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("TaskId") + .HasColumnType("uuid"); + + b.Property("Title") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("TaskId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("ClientType", "Global", "UserId", "OrganizationId", "Priority", "CreationDate") + .IsDescending(false, false, false, false, true, true) + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Notification", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("NotificationId") + .HasColumnType("uuid"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ReadDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("UserId", "NotificationId") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("NotificationId"); + + b.ToTable("NotificationStatus", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Platform.Installation", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("LastActivityDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Installation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(34) + .HasColumnType("character varying(34)"); + + b.Property("Read") + .HasColumnType("boolean"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Write") + .HasColumnType("boolean"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.ToTable("AccessPolicy", (string)null); + + b.HasDiscriminator().HasValue("AccessPolicy"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ClientSecretHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedPayload") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ExpireAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ServiceAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ServiceAccountId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Project", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Secret", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.SecretVersion", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("EditorOrganizationUserId") + .HasColumnType("uuid"); + + b.Property("EditorServiceAccountId") + .HasColumnType("uuid"); + + b.Property("SecretId") + .HasColumnType("uuid"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.Property("VersionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("EditorOrganizationUserId") + .HasDatabaseName("IX_SecretVersion_EditorOrganizationUserId"); + + b.HasIndex("EditorServiceAccountId") + .HasDatabaseName("IX_SecretVersion_EditorServiceAccountId"); + + b.HasIndex("SecretId") + .HasDatabaseName("IX_SecretVersion_SecretId"); + + b.ToTable("SecretVersion"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ServiceAccount", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Archives") + .HasColumnType("text"); + + b.Property("Attachments") + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Favorites") + .HasColumnType("text"); + + b.Property("Folders") + .HasColumnType("text"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Reprompt") + .HasColumnType("smallint"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId"); + + b.ToTable("Cipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Folder", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CipherId") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SecurityTask", (string)null); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.Property("ProjectsId") + .HasColumnType("uuid"); + + b.Property("SecretsId") + .HasColumnType("uuid"); + + b.HasKey("ProjectsId", "SecretsId"); + + b.HasIndex("SecretsId"); + + b.ToTable("ProjectSecret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedProjectId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedSecretId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedProjectId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedSecretId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedProjectId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedSecretId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Policies") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Device", "ResponseDevice") + .WithMany() + .HasForeignKey("ResponseDeviceId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("ResponseDevice"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantee") + .WithMany() + .HasForeignKey("GranteeId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantor") + .WithMany() + .HasForeignKey("GrantorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Grantee"); + + b.Navigation("Grantor"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoConfigs") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoUsers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("SsoUsers") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Platform.Installation", "Installation") + .WithMany() + .HasForeignKey("InstallationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Installation"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegrationConfiguration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", "OrganizationIntegration") + .WithMany() + .HasForeignKey("OrganizationIntegrationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OrganizationIntegration"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.PasswordHealthReportApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Collections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany("CollectionCiphers") + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionCiphers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Collection"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionGroups") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionUsers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("CollectionUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Groups") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany("GroupUsers") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("GroupUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("ApiKeys") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Connections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Domains") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoredOrganization") + .WithMany() + .HasForeignKey("SponsoredOrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoringOrganization") + .WithMany() + .HasForeignKey("SponsoringOrganizationId"); + + b.Navigation("SponsoredOrganization"); + + b.Navigation("SponsoringOrganization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("OrganizationUsers") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("OrganizationUsers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.PlayItem", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Transactions") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Transactions") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.UserSignatureKeyPair", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", "Task") + .WithMany() + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Task"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", "Notification") + .WithMany() + .HasForeignKey("NotificationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Notification"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ApiKeys") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.SecretVersion", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "EditorOrganizationUser") + .WithMany() + .HasForeignKey("EditorOrganizationUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "EditorServiceAccount") + .WithMany() + .HasForeignKey("EditorServiceAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "Secret") + .WithMany("SecretVersions") + .HasForeignKey("SecretId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EditorOrganizationUser"); + + b.Navigation("EditorServiceAccount"); + + b.Navigation("Secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Ciphers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Ciphers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Folders") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany() + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", null) + .WithMany() + .HasForeignKey("ProjectsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", null) + .WithMany() + .HasForeignKey("SecretsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedProject"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedSecret"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ProjectAccessPolicies") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedProject"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany() + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedProject"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("Ciphers"); + + b.Navigation("Collections"); + + b.Navigation("Connections"); + + b.Navigation("Domains"); + + b.Navigation("Groups"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("Policies"); + + b.Navigation("SsoConfigs"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Navigation("CollectionCiphers"); + + b.Navigation("CollectionGroups"); + + b.Navigation("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Navigation("CollectionUsers"); + + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Navigation("Ciphers"); + + b.Navigation("Folders"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("SecretVersions"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ProjectAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Navigation("CollectionCiphers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/util/PostgresMigrations/Migrations/20260108193909_CreatePlayItem.cs b/util/PostgresMigrations/Migrations/20260108193909_CreatePlayItem.cs new file mode 100644 index 0000000000..1708ab301e --- /dev/null +++ b/util/PostgresMigrations/Migrations/20260108193909_CreatePlayItem.cs @@ -0,0 +1,63 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bit.PostgresMigrations.Migrations; + +/// +public partial class CreatePlayItem : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "PlayItem", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + PlayId = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + UserId = table.Column(type: "uuid", nullable: true), + OrganizationId = table.Column(type: "uuid", nullable: true), + CreationDate = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PlayItem", x => x.Id); + table.CheckConstraint("CK_PlayItem_UserOrOrganization", "(\"UserId\" IS NOT NULL AND \"OrganizationId\" IS NULL) OR (\"UserId\" IS NULL AND \"OrganizationId\" IS NOT NULL)"); + table.ForeignKey( + name: "FK_PlayItem_Organization_OrganizationId", + column: x => x.OrganizationId, + principalTable: "Organization", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_PlayItem_User_UserId", + column: x => x.UserId, + principalTable: "User", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_PlayItem_OrganizationId", + table: "PlayItem", + column: "OrganizationId"); + + migrationBuilder.CreateIndex( + name: "IX_PlayItem_PlayId", + table: "PlayItem", + column: "PlayId"); + + migrationBuilder.CreateIndex( + name: "IX_PlayItem_UserId", + table: "PlayItem", + column: "UserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "PlayItem"); + } +} diff --git a/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs b/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs index 2a0b91e25d..f5419fa319 100644 --- a/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs +++ b/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs @@ -285,71 +285,6 @@ namespace Bit.PostgresMigrations.Migrations b.ToTable("Organization", (string)null); }); - modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", b => - { - b.Property("Id") - .HasColumnType("uuid"); - - b.Property("Configuration") - .HasColumnType("text"); - - b.Property("CreationDate") - .HasColumnType("timestamp with time zone"); - - b.Property("OrganizationId") - .HasColumnType("uuid"); - - b.Property("RevisionDate") - .HasColumnType("timestamp with time zone"); - - b.Property("Type") - .HasColumnType("integer"); - - b.HasKey("Id"); - - b.HasIndex("OrganizationId") - .HasAnnotation("SqlServer:Clustered", false); - - b.HasIndex("OrganizationId", "Type") - .IsUnique() - .HasAnnotation("SqlServer:Clustered", false); - - b.ToTable("OrganizationIntegration", (string)null); - }); - - modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegrationConfiguration", b => - { - b.Property("Id") - .HasColumnType("uuid"); - - b.Property("Configuration") - .HasColumnType("text"); - - b.Property("CreationDate") - .HasColumnType("timestamp with time zone"); - - b.Property("EventType") - .HasColumnType("integer"); - - b.Property("Filters") - .HasColumnType("text"); - - b.Property("OrganizationIntegrationId") - .HasColumnType("uuid"); - - b.Property("RevisionDate") - .HasColumnType("timestamp with time zone"); - - b.Property("Template") - .HasColumnType("text"); - - b.HasKey("Id"); - - b.HasIndex("OrganizationIntegrationId"); - - b.ToTable("OrganizationIntegrationConfiguration", (string)null); - }); - modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => { b.Property("Id") @@ -629,8 +564,8 @@ namespace Bit.PostgresMigrations.Migrations b.Property("Type") .HasColumnType("smallint"); - b.Property("WaitTimeDays") - .HasColumnType("integer"); + b.Property("WaitTimeDays") + .HasColumnType("smallint"); b.HasKey("Id"); @@ -1020,6 +955,71 @@ namespace Bit.PostgresMigrations.Migrations b.ToTable("OrganizationApplication", (string)null); }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Configuration") + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationIntegration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegrationConfiguration", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Configuration") + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("EventType") + .HasColumnType("integer"); + + b.Property("Filters") + .HasColumnType("text"); + + b.Property("OrganizationIntegrationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Template") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationIntegrationId"); + + b.ToTable("OrganizationIntegrationConfiguration", (string)null); + }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => { b.Property("Id") @@ -1632,6 +1632,42 @@ namespace Bit.PostgresMigrations.Migrations b.ToTable("OrganizationUser", (string)null); }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.PlayItem", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PlayId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("PlayId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("PlayItem", null, t => + { + t.HasCheckConstraint("CK_PlayItem_UserOrOrganization", "(\"UserId\" IS NOT NULL AND \"OrganizationId\" IS NULL) OR (\"UserId\" IS NULL AND \"OrganizationId\" IS NOT NULL)"); + }); + }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => { b.Property("Id") @@ -2613,28 +2649,6 @@ namespace Bit.PostgresMigrations.Migrations b.HasDiscriminator().HasValue("user_service_account"); }); - modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", b => - { - b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") - .WithMany() - .HasForeignKey("OrganizationId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Organization"); - }); - - modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegrationConfiguration", b => - { - b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", "OrganizationIntegration") - .WithMany() - .HasForeignKey("OrganizationIntegrationId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("OrganizationIntegration"); - }); - modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => { b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") @@ -2813,6 +2827,28 @@ namespace Bit.PostgresMigrations.Migrations b.Navigation("Organization"); }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegrationConfiguration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", "OrganizationIntegration") + .WithMany() + .HasForeignKey("OrganizationIntegrationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OrganizationIntegration"); + }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => { b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") @@ -3009,6 +3045,23 @@ namespace Bit.PostgresMigrations.Migrations b.Navigation("User"); }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.PlayItem", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => { b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") diff --git a/util/RustSdk/RustSdk.csproj b/util/RustSdk/RustSdk.csproj index ba16a55661..14cc017365 100644 --- a/util/RustSdk/RustSdk.csproj +++ b/util/RustSdk/RustSdk.csproj @@ -10,6 +10,14 @@ + + + + + + + Always true @@ -18,23 +26,36 @@ Always true - runtimes/linux-x64/native/libsdk.dylib + runtimes/linux-x64/native/libsdk.so Always true - runtimes/windows-x64/native/libsdk.dylib + runtimes/windows-x64/native/libsdk.dll - - - + + + + Always + true + runtimes/osx-arm64/native/libsdk.dylib + + + Always + true + runtimes/linux-x64/native/libsdk.so + + + Always + true + runtimes/windows-x64/native/libsdk.dll + diff --git a/util/RustSdk/rust-toolchain.toml b/util/RustSdk/rust-toolchain.toml new file mode 100644 index 0000000000..b8889a3bb3 --- /dev/null +++ b/util/RustSdk/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "1.87.0" diff --git a/util/Seeder/Factories/OrganizationSeeder.cs b/util/Seeder/Factories/OrganizationSeeder.cs index 012661501f..3aac87d400 100644 --- a/util/Seeder/Factories/OrganizationSeeder.cs +++ b/util/Seeder/Factories/OrganizationSeeder.cs @@ -1,7 +1,7 @@ using Bit.Core.Billing.Enums; +using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Infrastructure.EntityFramework.AdminConsole.Models; -using Bit.Infrastructure.EntityFramework.Models; namespace Bit.Seeder.Factories; diff --git a/util/Seeder/Factories/UserSeeder.cs b/util/Seeder/Factories/UserSeeder.cs index b24f8273b9..dd5fc159c0 100644 --- a/util/Seeder/Factories/UserSeeder.cs +++ b/util/Seeder/Factories/UserSeeder.cs @@ -1,13 +1,58 @@ -using Bit.Core.Enums; -using Bit.Infrastructure.EntityFramework.Models; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Utilities; using Bit.RustSDK; using Microsoft.AspNetCore.Identity; namespace Bit.Seeder.Factories; -public class UserSeeder +public struct UserData { - public static User CreateUser(string email) + public string Email; + public Guid Id; + public string? Key; + public string? PublicKey; + public string? PrivateKey; + public string? ApiKey; + public KdfType Kdf; + public int KdfIterations; +} + +public class UserSeeder(RustSdkService sdkService, IPasswordHasher passwordHasher, MangleId mangleId) +{ + private string MangleEmail(string email) + { + return $"{mangleId}+{email}"; + } + + public User CreateUser(string email, bool emailVerified = false, bool premium = false) + { + email = MangleEmail(email); + var keys = sdkService.GenerateUserKeys(email, "asdfasdfasdf"); + + var user = new User + { + Id = CoreHelpers.GenerateComb(), + Email = email, + EmailVerified = emailVerified, + MasterPassword = null, + SecurityStamp = "4830e359-e150-4eae-be2a-996c81c5e609", + Key = keys.EncryptedUserKey, + PublicKey = keys.PublicKey, + PrivateKey = keys.PrivateKey, + Premium = premium, + ApiKey = "7gp59kKHt9kMlks0BuNC4IjNXYkljR", + + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = 5_000, + }; + + user.MasterPassword = passwordHasher.HashPassword(user, keys.MasterPasswordHash); + + return user; + } + + public static User CreateUserNoMangle(string email) { return new User { @@ -25,28 +70,35 @@ public class UserSeeder }; } - public static (User user, string userKey) CreateSdkUser(IPasswordHasher passwordHasher, string email) + public Dictionary GetMangleMap(User user, UserData expectedUserData) { - var nativeService = RustSdkServiceFactory.CreateSingleton(); - var keys = nativeService.GenerateUserKeys(email, "asdfasdfasdf"); - - var user = new User + var mangleMap = new Dictionary { - Id = Guid.NewGuid(), - Email = email, - MasterPassword = null, - SecurityStamp = "4830e359-e150-4eae-be2a-996c81c5e609", - Key = keys.EncryptedUserKey, - PublicKey = keys.PublicKey, - PrivateKey = keys.PrivateKey, - ApiKey = "7gp59kKHt9kMlks0BuNC4IjNXYkljR", - - Kdf = KdfType.PBKDF2_SHA256, - KdfIterations = 5_000, + { expectedUserData.Email, MangleEmail(expectedUserData.Email) }, + { expectedUserData.Id.ToString(), user.Id.ToString() }, + { expectedUserData.Kdf.ToString(), user.Kdf.ToString() }, + { expectedUserData.KdfIterations.ToString(), user.KdfIterations.ToString() } }; + if (expectedUserData.Key != null) + { + mangleMap[expectedUserData.Key] = user.Key; + } - user.MasterPassword = passwordHasher.HashPassword(user, keys.MasterPasswordHash); + if (expectedUserData.PublicKey != null) + { + mangleMap[expectedUserData.PublicKey] = user.PublicKey; + } - return (user, keys.Key); + if (expectedUserData.PrivateKey != null) + { + mangleMap[expectedUserData.PrivateKey] = user.PrivateKey; + } + + if (expectedUserData.ApiKey != null) + { + mangleMap[expectedUserData.ApiKey] = user.ApiKey; + } + + return mangleMap; } } diff --git a/util/Seeder/IQuery.cs b/util/Seeder/IQuery.cs new file mode 100644 index 0000000000..adbcd8e59d --- /dev/null +++ b/util/Seeder/IQuery.cs @@ -0,0 +1,60 @@ +namespace Bit.Seeder; + +/// +/// Base interface for query operations in the seeding system. The base interface should not be used directly, rather use `IQuery<TRequest, TResult>`. +/// +/// +/// Queries are synchronous, read-only operations that retrieve data from the seeding context. +/// Unlike scenes which create data, queries fetch existing data based on request parameters. +/// They follow a type-safe pattern using generics to ensure proper request/response handling +/// while maintaining a common non-generic interface for dynamic invocation. +/// +public interface IQuery +{ + /// + /// Gets the type of request this query expects. + /// + /// The request type that this query can process. + Type GetRequestType(); + + /// + /// Executes the query based on the provided request object. + /// + /// The request object containing parameters for the query operation. + /// The query result data as an object. + object Execute(object request); +} + +/// +/// Generic query interface for synchronous, read-only operations with specific request and result types. +/// +/// The type of request object this query accepts. +/// The type of data this query returns. +/// +/// Use this interface when you need to retrieve existing data from the seeding context based on +/// specific request parameters. Queries are synchronous and do not modify data - they only read +/// and return information. The explicit interface implementations allow dynamic invocation while +/// maintaining type safety in the implementation. +/// +public interface IQuery : IQuery where TRequest : class where TResult : class +{ + /// + /// Executes the query based on the provided strongly-typed request and returns typed result data. + /// + /// The request object containing parameters for the query operation. + /// The typed query result data. + TResult Execute(TRequest request); + + /// + /// Gets the request type for this query. + /// + /// The type of TRequest. + Type IQuery.GetRequestType() => typeof(TRequest); + + /// + /// Adapts the non-generic Execute to the strongly-typed version. + /// + /// The request object to cast and process. + /// The typed result cast to object. + object IQuery.Execute(object request) => Execute((TRequest)request); +} diff --git a/util/Seeder/IScene.cs b/util/Seeder/IScene.cs new file mode 100644 index 0000000000..6f513973ba --- /dev/null +++ b/util/Seeder/IScene.cs @@ -0,0 +1,96 @@ +namespace Bit.Seeder; + +/// +/// Base interface for seeding operations. The base interface should not be used directly, rather use `IScene<Request>`. +/// +/// +/// Scenes are components in the seeding system that create and configure test data. They follow +/// a type-safe pattern using generics to ensure proper request/response handling while maintaining +/// a common non-generic interface for dynamic invocation. +/// +public interface IScene +{ + /// + /// Gets the type of request this scene expects. + /// + /// The request type that this scene can process. + Type GetRequestType(); + + /// + /// Seeds data based on the provided request object. + /// + /// The request object containing parameters for the seeding operation. + /// A scene result containing any returned data, mangle map, and entity tracking information. + Task> SeedAsync(object request); +} + +/// +/// Generic scene interface for seeding operations with a specific request type. Does not return a value beyond tracking entities and a mangle map. +/// +/// The type of request object this scene accepts. +/// +/// Use this interface when your scene needs to process a specific request type but doesn't need to +/// return any data beyond the standard mangle map for ID transformations and entity tracking. +/// The explicit interface implementations allow this scene to be invoked dynamically through the +/// base IScene interface while maintaining type safety in the implementation. +/// +public interface IScene : IScene where TRequest : class +{ + /// + /// Seeds data based on the provided strongly-typed request. + /// + /// The request object containing parameters for the seeding operation. + /// A scene result containing the mangle map and entity tracking information. + Task SeedAsync(TRequest request); + + /// + /// Gets the request type for this scene. + /// + /// The type of TRequest. + Type IScene.GetRequestType() => typeof(TRequest); + + /// + /// Adapts the non-generic SeedAsync to the strongly-typed version. + /// + /// The request object to cast and process. + /// A scene result wrapped as an object result. + async Task> IScene.SeedAsync(object request) + { + var result = await SeedAsync((TRequest)request); + return new SceneResult(mangleMap: result.MangleMap); + } +} + +/// +/// Generic scene interface for seeding operations with a specific request type that returns typed data. +/// +/// The type of request object this scene accepts. Must be a reference type. +/// The type of data this scene returns. Must be a reference type. +/// +/// Use this interface when your scene needs to return specific data that can be used by subsequent +/// scenes or test logic. The result is wrapped in a SceneResult that also includes the mangle map +/// and entity tracking information. The explicit interface implementations allow dynamic invocation +/// while preserving type safety in the implementation. +/// +public interface IScene : IScene where TRequest : class where TResult : class +{ + /// + /// Seeds data based on the provided strongly-typed request and returns typed result data. + /// + /// The request object containing parameters for the seeding operation. + /// A scene result containing the typed result data, mangle map, and entity tracking information. + Task> SeedAsync(TRequest request); + + /// + /// Gets the request type for this scene. + /// + /// The type of TRequest. + Type IScene.GetRequestType() => typeof(TRequest); + + /// + /// Adapts the non-generic SeedAsync to the strongly-typed version. + /// + /// The request object to cast and process. + /// A scene result with the typed result cast to object. + async Task> IScene.SeedAsync(object request) => (SceneResult)await SeedAsync((TRequest)request); +} diff --git a/util/Seeder/MangleId.cs b/util/Seeder/MangleId.cs new file mode 100644 index 0000000000..e1be47f4d2 --- /dev/null +++ b/util/Seeder/MangleId.cs @@ -0,0 +1,19 @@ +namespace Bit.Seeder; + +/// +/// Helper for generating unique identifier suffixes to prevent collisions in test data. +/// "Mangling" adds a random suffix to test data identifiers (usernames, emails, org names, etc.) +/// to ensure uniqueness across multiple test runs and parallel test executions. +/// +public class MangleId +{ + public readonly string Value; + + public MangleId() + { + // Generate a short random string (6 char) to use as the mangle ID + Value = Random.Shared.NextInt64().ToString("x").Substring(0, 8); + } + + public override string ToString() => Value; +} diff --git a/util/Seeder/Queries/EmergencyAccessInviteQuery.cs b/util/Seeder/Queries/EmergencyAccessInviteQuery.cs new file mode 100644 index 0000000000..95d96a9a50 --- /dev/null +++ b/util/Seeder/Queries/EmergencyAccessInviteQuery.cs @@ -0,0 +1,35 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.Auth.Models.Business.Tokenables; +using Bit.Core.Tokens; +using Bit.Infrastructure.EntityFramework.Repositories; + +namespace Bit.Seeder.Queries; + +/// +/// Retrieves all emergency access invite urls for the provided email. +/// +public class EmergencyAccessInviteQuery( + DatabaseContext db, + IDataProtectorTokenFactory dataProtectorTokenizer) + : IQuery> +{ + public class Request + { + [Required] + public required string Email { get; set; } + } + + public IEnumerable Execute(Request request) + { + var invites = db.EmergencyAccesses + .Where(ea => ea.Email == request.Email).ToList().Select(ea => + { + var token = dataProtectorTokenizer.Protect( + new EmergencyAccessInviteTokenable(ea, hoursTillExpiration: 1) + ); + return $"/accept-emergency?id={ea.Id}&name=Dummy&email={ea.Email}&token={token}"; + }); + + return invites; + } +} diff --git a/util/Seeder/Recipes/OrganizationWithUsersRecipe.cs b/util/Seeder/Recipes/OrganizationWithUsersRecipe.cs index 7678c3a9ce..87fcc1967b 100644 --- a/util/Seeder/Recipes/OrganizationWithUsersRecipe.cs +++ b/util/Seeder/Recipes/OrganizationWithUsersRecipe.cs @@ -1,5 +1,5 @@ -using Bit.Core.Enums; -using Bit.Infrastructure.EntityFramework.Models; +using Bit.Core.Entities; +using Bit.Core.Enums; using Bit.Infrastructure.EntityFramework.Repositories; using Bit.Seeder.Factories; using LinqToDB.EntityFrameworkCore; @@ -12,14 +12,14 @@ public class OrganizationWithUsersRecipe(DatabaseContext db) { var seats = Math.Max(users + 1, 1000); var organization = OrganizationSeeder.CreateEnterprise(name, domain, seats); - var ownerUser = UserSeeder.CreateUser($"owner@{domain}"); + var ownerUser = UserSeeder.CreateUserNoMangle($"owner@{domain}"); var ownerOrgUser = organization.CreateOrganizationUser(ownerUser, OrganizationUserType.Owner, OrganizationUserStatusType.Confirmed); var additionalUsers = new List(); var additionalOrgUsers = new List(); for (var i = 0; i < users; i++) { - var additionalUser = UserSeeder.CreateUser($"user{i}@{domain}"); + var additionalUser = UserSeeder.CreateUserNoMangle($"user{i}@{domain}"); additionalUsers.Add(additionalUser); additionalOrgUsers.Add(organization.CreateOrganizationUser(additionalUser, OrganizationUserType.User, usersStatus)); } diff --git a/util/Seeder/SceneResult.cs b/util/Seeder/SceneResult.cs new file mode 100644 index 0000000000..7ac004f55e --- /dev/null +++ b/util/Seeder/SceneResult.cs @@ -0,0 +1,28 @@ +namespace Bit.Seeder; + +/// +/// Helper for exposing a interface with a SeedAsync method. +/// +public class SceneResult(Dictionary mangleMap) + : SceneResult(result: null, mangleMap: mangleMap); + +/// +/// Generic result from executing a Scene. +/// Contains custom scene-specific data and a mangle map that maps magic strings from the +/// request to their mangled (collision-free) values inserted into the database. +/// +/// The type of custom result data returned by the scene. +public class SceneResult(TResult result, Dictionary mangleMap) +{ + public TResult Result { get; init; } = result; + public Dictionary MangleMap { get; init; } = mangleMap; + + public static explicit operator SceneResult(SceneResult v) + { + var result = v.Result; + + return result is null + ? new SceneResult(result: null, mangleMap: v.MangleMap) + : new SceneResult(result: result, mangleMap: v.MangleMap); + } +} diff --git a/util/Seeder/Scenes/SingleUserScene.cs b/util/Seeder/Scenes/SingleUserScene.cs new file mode 100644 index 0000000000..df941c7f59 --- /dev/null +++ b/util/Seeder/Scenes/SingleUserScene.cs @@ -0,0 +1,38 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.Repositories; +using Bit.Seeder.Factories; + +namespace Bit.Seeder.Scenes; + +/// +/// Creates a single user using the provided account details. +/// +public class SingleUserScene(UserSeeder userSeeder, IUserRepository userRepository) : IScene +{ + public class Request + { + [Required] + public required string Email { get; set; } + public bool EmailVerified { get; set; } = false; + public bool Premium { get; set; } = false; + } + + public async Task SeedAsync(Request request) + { + var user = userSeeder.CreateUser(request.Email, request.EmailVerified, request.Premium); + + await userRepository.CreateAsync(user); + + return new SceneResult(mangleMap: userSeeder.GetMangleMap(user, new UserData + { + Email = request.Email, + Id = user.Id, + Key = user.Key, + PublicKey = user.PublicKey, + PrivateKey = user.PrivateKey, + ApiKey = user.ApiKey, + Kdf = user.Kdf, + KdfIterations = user.KdfIterations, + })); + } +} diff --git a/util/Seeder/Seeder.csproj b/util/Seeder/Seeder.csproj index 4d7fbab767..fd6e26c1ee 100644 --- a/util/Seeder/Seeder.csproj +++ b/util/Seeder/Seeder.csproj @@ -12,10 +12,6 @@ false - - - - diff --git a/util/SeederApi/Commands/DestroyBatchScenesCommand.cs b/util/SeederApi/Commands/DestroyBatchScenesCommand.cs new file mode 100644 index 0000000000..50f6142a98 --- /dev/null +++ b/util/SeederApi/Commands/DestroyBatchScenesCommand.cs @@ -0,0 +1,36 @@ +using Bit.SeederApi.Commands.Interfaces; + +namespace Bit.SeederApi.Commands; + +public class DestroyBatchScenesCommand( + ILogger logger, + IDestroySceneCommand destroySceneCommand) : IDestroyBatchScenesCommand +{ + public async Task DestroyAsync(IEnumerable playIds) + { + var exceptions = new List(); + + var deleteTasks = playIds.Select(async playId => + { + try + { + await destroySceneCommand.DestroyAsync(playId); + } + catch (Exception ex) + { + lock (exceptions) + { + exceptions.Add(ex); + } + logger.LogError(ex, "Error deleting seeded data: {PlayId}", playId); + } + }); + + await Task.WhenAll(deleteTasks); + + if (exceptions.Count > 0) + { + throw new AggregateException("One or more errors occurred while deleting seeded data", exceptions); + } + } +} diff --git a/util/SeederApi/Commands/DestroySceneCommand.cs b/util/SeederApi/Commands/DestroySceneCommand.cs new file mode 100644 index 0000000000..0e0f4edd6d --- /dev/null +++ b/util/SeederApi/Commands/DestroySceneCommand.cs @@ -0,0 +1,57 @@ +using Bit.Core.Repositories; +using Bit.Infrastructure.EntityFramework.Repositories; +using Bit.SeederApi.Commands.Interfaces; +using Bit.SeederApi.Services; + +namespace Bit.SeederApi.Commands; + +public class DestroySceneCommand( + DatabaseContext databaseContext, + ILogger logger, + IUserRepository userRepository, + IPlayItemRepository playItemRepository, + IOrganizationRepository organizationRepository) : IDestroySceneCommand +{ + public async Task DestroyAsync(string playId) + { + // Note, delete cascade will remove PlayItem entries + + var playItem = await playItemRepository.GetByPlayIdAsync(playId); + var userIds = playItem.Select(pd => pd.UserId).Distinct().ToList(); + var organizationIds = playItem.Select(pd => pd.OrganizationId).Distinct().ToList(); + + // Delete Users before Organizations to respect foreign key constraints + if (userIds.Count > 0) + { + var users = databaseContext.Users.Where(u => userIds.Contains(u.Id)); + await userRepository.DeleteManyAsync(users); + } + + if (organizationIds.Count > 0) + { + var organizations = databaseContext.Organizations.Where(o => organizationIds.Contains(o.Id)); + var aggregateException = new AggregateException(); + foreach (var org in organizations) + { + try + { + await organizationRepository.DeleteAsync(org); + } + catch (Exception ex) + { + aggregateException = new AggregateException(aggregateException, ex); + } + } + if (aggregateException.InnerExceptions.Count > 0) + { + throw new SceneExecutionException( + $"One or more errors occurred while deleting organizations for seed ID {playId}", + aggregateException); + } + } + + logger.LogInformation("Successfully destroyed seeded data with ID {PlayId}", playId); + + return new { PlayId = playId }; + } +} diff --git a/util/SeederApi/Commands/Interfaces/IDestroyBatchScenesCommand.cs b/util/SeederApi/Commands/Interfaces/IDestroyBatchScenesCommand.cs new file mode 100644 index 0000000000..ce43f26a54 --- /dev/null +++ b/util/SeederApi/Commands/Interfaces/IDestroyBatchScenesCommand.cs @@ -0,0 +1,14 @@ +namespace Bit.SeederApi.Commands.Interfaces; + +/// +/// Command for destroying multiple scenes in parallel. +/// +public interface IDestroyBatchScenesCommand +{ + /// + /// Destroys multiple scenes by their play IDs in parallel. + /// + /// The list of play IDs to destroy + /// Thrown when one or more scenes fail to destroy + Task DestroyAsync(IEnumerable playIds); +} diff --git a/util/SeederApi/Commands/Interfaces/IDestroySceneCommand.cs b/util/SeederApi/Commands/Interfaces/IDestroySceneCommand.cs new file mode 100644 index 0000000000..a3b0800bb2 --- /dev/null +++ b/util/SeederApi/Commands/Interfaces/IDestroySceneCommand.cs @@ -0,0 +1,15 @@ +namespace Bit.SeederApi.Commands.Interfaces; + +/// +/// Command for destroying data created by a single scene. +/// +public interface IDestroySceneCommand +{ + /// + /// Destroys data created by a scene using the seeded data ID. + /// + /// The ID of the seeded data to destroy + /// The result of the destroy operation + /// Thrown when there's an error destroying the seeded data + Task DestroyAsync(string playId); +} diff --git a/util/SeederApi/Controllers/InfoController.cs b/util/SeederApi/Controllers/InfoController.cs new file mode 100644 index 0000000000..de4a264ddb --- /dev/null +++ b/util/SeederApi/Controllers/InfoController.cs @@ -0,0 +1,20 @@ +using Bit.Core.Utilities; +using Microsoft.AspNetCore.Mvc; + +namespace Bit.SeederApi.Controllers; + +public class InfoController : Controller +{ + [HttpGet("~/alive")] + [HttpGet("~/now")] + public DateTime GetAlive() + { + return DateTime.UtcNow; + } + + [HttpGet("~/version")] + public JsonResult GetVersion() + { + return Json(AssemblyHelpers.GetVersion()); + } +} diff --git a/util/SeederApi/Controllers/QueryController.cs b/util/SeederApi/Controllers/QueryController.cs new file mode 100644 index 0000000000..22bf84e5b7 --- /dev/null +++ b/util/SeederApi/Controllers/QueryController.cs @@ -0,0 +1,32 @@ +using Bit.SeederApi.Execution; +using Bit.SeederApi.Models.Request; +using Bit.SeederApi.Services; +using Microsoft.AspNetCore.Mvc; + +namespace Bit.SeederApi.Controllers; + +[Route("query")] +public class QueryController(ILogger logger, IQueryExecutor queryExecutor) : Controller +{ + [HttpPost] + public IActionResult Query([FromBody] QueryRequestModel request) + { + logger.LogInformation("Executing query: {Query}", request.Template); + + try + { + var result = queryExecutor.Execute(request.Template, request.Arguments); + + return Json(result); + } + catch (QueryNotFoundException ex) + { + return NotFound(new { Error = ex.Message }); + } + catch (QueryExecutionException ex) + { + logger.LogError(ex, "Error executing query: {Query}", request.Template); + return BadRequest(new { Error = ex.Message, Details = ex.InnerException?.Message }); + } + } +} diff --git a/util/SeederApi/Controllers/SeedController.cs b/util/SeederApi/Controllers/SeedController.cs new file mode 100644 index 0000000000..44f0dbaf2c --- /dev/null +++ b/util/SeederApi/Controllers/SeedController.cs @@ -0,0 +1,100 @@ +using Bit.SeederApi.Commands.Interfaces; +using Bit.SeederApi.Execution; +using Bit.SeederApi.Models.Request; +using Bit.SeederApi.Queries.Interfaces; +using Bit.SeederApi.Services; +using Microsoft.AspNetCore.Mvc; + +namespace Bit.SeederApi.Controllers; + +[Route("seed")] +public class SeedController( + ILogger logger, + ISceneExecutor sceneExecutor, + IDestroySceneCommand destroySceneCommand, + IDestroyBatchScenesCommand destroyBatchScenesCommand, + IGetAllPlayIdsQuery getAllPlayIdsQuery) : Controller +{ + [HttpPost] + public async Task SeedAsync([FromBody] SeedRequestModel request) + { + logger.LogInformation("Received seed request with template: {Template}", request.Template); + + try + { + var response = await sceneExecutor.ExecuteAsync(request.Template, request.Arguments); + + return Json(response); + } + catch (SceneNotFoundException ex) + { + return NotFound(new { Error = ex.Message }); + } + catch (SceneExecutionException ex) + { + logger.LogError(ex, "Error executing scene: {Template}", request.Template); + return BadRequest(new { Error = ex.Message, Details = ex.InnerException?.Message }); + } + } + + [HttpDelete("batch")] + public async Task DeleteBatchAsync([FromBody] List playIds) + { + logger.LogInformation("Deleting batch of seeded data with IDs: {PlayIds}", string.Join(", ", playIds)); + + try + { + await destroyBatchScenesCommand.DestroyAsync(playIds); + return Ok(new { Message = "Batch delete completed successfully" }); + } + catch (AggregateException ex) + { + return BadRequest(new + { + Error = ex.Message, + Details = ex.InnerExceptions.Select(e => e.Message).ToList() + }); + } + } + + [HttpDelete("{playId}")] + public async Task DeleteAsync([FromRoute] string playId) + { + logger.LogInformation("Deleting seeded data with ID: {PlayId}", playId); + + try + { + var result = await destroySceneCommand.DestroyAsync(playId); + + return Json(result); + } + catch (SceneExecutionException ex) + { + logger.LogError(ex, "Error deleting seeded data: {PlayId}", playId); + return BadRequest(new { Error = ex.Message, Details = ex.InnerException?.Message }); + } + } + + + [HttpDelete] + public async Task DeleteAllAsync() + { + logger.LogInformation("Deleting all seeded data"); + + var playIds = getAllPlayIdsQuery.GetAllPlayIds(); + + try + { + await destroyBatchScenesCommand.DestroyAsync(playIds); + return NoContent(); + } + catch (AggregateException ex) + { + return BadRequest(new + { + Error = ex.Message, + Details = ex.InnerExceptions.Select(e => e.Message).ToList() + }); + } + } +} diff --git a/util/SeederApi/Execution/IQueryExecutor.cs b/util/SeederApi/Execution/IQueryExecutor.cs new file mode 100644 index 0000000000..ebd971bbb7 --- /dev/null +++ b/util/SeederApi/Execution/IQueryExecutor.cs @@ -0,0 +1,22 @@ +using System.Text.Json; + +namespace Bit.SeederApi.Execution; + +/// +/// Executor for dynamically resolving and executing queries by name. +/// This is an infrastructure component that orchestrates query execution, +/// not a domain-level query. +/// +public interface IQueryExecutor +{ + /// + /// Executes a query with the given query name and arguments. + /// Queries are read-only and do not track entities or create seed IDs. + /// + /// The name of the query (e.g., "EmergencyAccessInviteQuery") + /// Optional JSON arguments to pass to the query's Execute method + /// The result of the query execution + /// Thrown when the query is not found + /// Thrown when there's an error executing the query + object Execute(string queryName, JsonElement? arguments); +} diff --git a/util/SeederApi/Execution/ISceneExecutor.cs b/util/SeederApi/Execution/ISceneExecutor.cs new file mode 100644 index 0000000000..f15909ea79 --- /dev/null +++ b/util/SeederApi/Execution/ISceneExecutor.cs @@ -0,0 +1,22 @@ +using System.Text.Json; +using Bit.SeederApi.Models.Response; + +namespace Bit.SeederApi.Execution; + +/// +/// Executor for dynamically resolving and executing scenes by template name. +/// This is an infrastructure component that orchestrates scene execution, +/// not a domain-level command. +/// +public interface ISceneExecutor +{ + /// + /// Executes a scene with the given template name and arguments. + /// + /// The name of the scene template (e.g., "SingleUserScene") + /// Optional JSON arguments to pass to the scene's Seed method + /// A scene response model containing the result and mangle map + /// Thrown when the scene template is not found + /// Thrown when there's an error executing the scene + Task ExecuteAsync(string templateName, JsonElement? arguments); +} diff --git a/util/SeederApi/Execution/JsonConfiguration.cs b/util/SeederApi/Execution/JsonConfiguration.cs new file mode 100644 index 0000000000..beef36e62a --- /dev/null +++ b/util/SeederApi/Execution/JsonConfiguration.cs @@ -0,0 +1,19 @@ +using System.Text.Json; + +namespace Bit.SeederApi.Execution; + +/// +/// Provides shared JSON serialization configuration for executors. +/// +internal static class JsonConfiguration +{ + /// + /// Standard JSON serializer options used for deserializing scene and query request models. + /// Uses case-insensitive property matching and camelCase naming policy. + /// + internal static readonly JsonSerializerOptions Options = new() + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; +} diff --git a/util/SeederApi/Execution/QueryExecutor.cs b/util/SeederApi/Execution/QueryExecutor.cs new file mode 100644 index 0000000000..5473586c22 --- /dev/null +++ b/util/SeederApi/Execution/QueryExecutor.cs @@ -0,0 +1,77 @@ +using System.Text.Json; +using Bit.Seeder; +using Bit.SeederApi.Services; + +namespace Bit.SeederApi.Execution; + +public class QueryExecutor( + ILogger logger, + IServiceProvider serviceProvider) : IQueryExecutor +{ + + public object Execute(string queryName, JsonElement? arguments) + { + try + { + var query = serviceProvider.GetKeyedService(queryName) + ?? throw new QueryNotFoundException(queryName); + + var requestType = query.GetRequestType(); + var requestModel = DeserializeRequestModel(queryName, requestType, arguments); + var result = query.Execute(requestModel); + + logger.LogInformation("Successfully executed query: {QueryName}", queryName); + return result; + } + catch (Exception ex) when (ex is not QueryNotFoundException and not QueryExecutionException) + { + logger.LogError(ex, "Unexpected error executing query: {QueryName}", queryName); + throw new QueryExecutionException( + $"An unexpected error occurred while executing query '{queryName}'", + ex.InnerException ?? ex); + } + } + + private object DeserializeRequestModel(string queryName, Type requestType, JsonElement? arguments) + { + if (arguments == null) + { + return CreateDefaultRequestModel(queryName, requestType); + } + + try + { + var requestModel = JsonSerializer.Deserialize(arguments.Value.GetRawText(), requestType, JsonConfiguration.Options); + if (requestModel == null) + { + throw new QueryExecutionException( + $"Failed to deserialize request model for query '{queryName}'"); + } + return requestModel; + } + catch (JsonException ex) + { + throw new QueryExecutionException( + $"Failed to deserialize request model for query '{queryName}': {ex.Message}", ex); + } + } + + private object CreateDefaultRequestModel(string queryName, Type requestType) + { + try + { + var requestModel = Activator.CreateInstance(requestType); + if (requestModel == null) + { + throw new QueryExecutionException( + $"Arguments are required for query '{queryName}'"); + } + return requestModel; + } + catch + { + throw new QueryExecutionException( + $"Arguments are required for query '{queryName}'"); + } + } +} diff --git a/util/SeederApi/Execution/SceneExecutor.cs b/util/SeederApi/Execution/SceneExecutor.cs new file mode 100644 index 0000000000..f31dd7d943 --- /dev/null +++ b/util/SeederApi/Execution/SceneExecutor.cs @@ -0,0 +1,78 @@ +using System.Text.Json; +using Bit.Seeder; +using Bit.SeederApi.Models.Response; +using Bit.SeederApi.Services; + +namespace Bit.SeederApi.Execution; + +public class SceneExecutor( + ILogger logger, + IServiceProvider serviceProvider) : ISceneExecutor +{ + + public async Task ExecuteAsync(string templateName, JsonElement? arguments) + { + try + { + var scene = serviceProvider.GetKeyedService(templateName) + ?? throw new SceneNotFoundException(templateName); + + var requestType = scene.GetRequestType(); + var requestModel = DeserializeRequestModel(templateName, requestType, arguments); + var result = await scene.SeedAsync(requestModel); + + logger.LogInformation("Successfully executed scene: {TemplateName}", templateName); + return SceneResponseModel.FromSceneResult(result); + } + catch (Exception ex) when (ex is not SceneNotFoundException and not SceneExecutionException) + { + logger.LogError(ex, "Unexpected error executing scene: {TemplateName}", templateName); + throw new SceneExecutionException( + $"An unexpected error occurred while executing scene '{templateName}'", + ex.InnerException ?? ex); + } + } + + private object DeserializeRequestModel(string templateName, Type requestType, JsonElement? arguments) + { + if (arguments == null) + { + return CreateDefaultRequestModel(templateName, requestType); + } + + try + { + var requestModel = JsonSerializer.Deserialize(arguments.Value.GetRawText(), requestType, JsonConfiguration.Options); + if (requestModel == null) + { + throw new SceneExecutionException( + $"Failed to deserialize request model for scene '{templateName}'"); + } + return requestModel; + } + catch (JsonException ex) + { + throw new SceneExecutionException( + $"Failed to deserialize request model for scene '{templateName}': {ex.Message}", ex); + } + } + + private object CreateDefaultRequestModel(string templateName, Type requestType) + { + try + { + var requestModel = Activator.CreateInstance(requestType); + if (requestModel == null) + { + throw new SceneExecutionException( + $"Arguments are required for scene '{templateName}'"); + } + return requestModel; + } + catch + { + throw new SceneExecutionException( + $"Arguments are required for scene '{templateName}'"); + } + } +} diff --git a/util/SeederApi/Extensions/ServiceCollectionExtensions.cs b/util/SeederApi/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000000..052da28dfc --- /dev/null +++ b/util/SeederApi/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,76 @@ +using System.Reflection; +using Bit.Seeder; +using Bit.SeederApi.Commands; +using Bit.SeederApi.Commands.Interfaces; +using Bit.SeederApi.Execution; +using Bit.SeederApi.Queries; +using Bit.SeederApi.Queries.Interfaces; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Bit.SeederApi.Extensions; + +public static class ServiceCollectionExtensions +{ + /// + /// Registers SeederApi executors, commands, and queries. + /// + public static IServiceCollection AddSeederApiServices(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + + services.AddScoped(); + services.AddScoped(); + + services.AddScoped(); + + return services; + } + + /// + /// Dynamically registers all scene types that implement IScene<TRequest> from the Seeder assembly. + /// Scenes are registered as keyed scoped services using their class name as the key. + /// + public static IServiceCollection AddScenes(this IServiceCollection services) + { + var iSceneType1 = typeof(IScene<>); + var iSceneType2 = typeof(IScene<,>); + var isIScene = (Type t) => t == iSceneType1 || t == iSceneType2; + + var seederAssembly = Assembly.Load("Seeder"); + var sceneTypes = seederAssembly.GetTypes() + .Where(t => t is { IsClass: true, IsAbstract: false } && + t.GetInterfaces().Any(i => i.IsGenericType && + isIScene(i.GetGenericTypeDefinition()))); + + foreach (var sceneType in sceneTypes) + { + services.TryAddScoped(sceneType); + services.TryAddKeyedScoped(typeof(IScene), sceneType.Name, (sp, _) => sp.GetRequiredService(sceneType)); + } + + return services; + } + + /// + /// Dynamically registers all query types that implement IQuery<TRequest> from the Seeder assembly. + /// Queries are registered as keyed scoped services using their class name as the key. + /// + public static IServiceCollection AddQueries(this IServiceCollection services) + { + var iQueryType = typeof(IQuery<,>); + var seederAssembly = Assembly.Load("Seeder"); + var queryTypes = seederAssembly.GetTypes() + .Where(t => t is { IsClass: true, IsAbstract: false } && + t.GetInterfaces().Any(i => i.IsGenericType && + i.GetGenericTypeDefinition() == iQueryType)); + + foreach (var queryType in queryTypes) + { + services.TryAddScoped(queryType); + services.TryAddKeyedScoped(typeof(IQuery), queryType.Name, (sp, _) => sp.GetRequiredService(queryType)); + } + + return services; + } +} diff --git a/util/SeederApi/Models/Request/QueryRequestModel.cs b/util/SeederApi/Models/Request/QueryRequestModel.cs new file mode 100644 index 0000000000..38751bc21b --- /dev/null +++ b/util/SeederApi/Models/Request/QueryRequestModel.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json; + +namespace Bit.SeederApi.Models.Request; + +public class QueryRequestModel +{ + [Required] + public required string Template { get; set; } + public JsonElement? Arguments { get; set; } +} diff --git a/util/SeederApi/Models/Request/SeedRequestModel.cs b/util/SeederApi/Models/Request/SeedRequestModel.cs new file mode 100644 index 0000000000..404af97ebe --- /dev/null +++ b/util/SeederApi/Models/Request/SeedRequestModel.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json; + +namespace Bit.SeederApi.Models.Request; + +public class SeedRequestModel +{ + [Required] + public required string Template { get; set; } + public JsonElement? Arguments { get; set; } +} diff --git a/util/SeederApi/Models/Response/SeedResponseModel.cs b/util/SeederApi/Models/Response/SeedResponseModel.cs new file mode 100644 index 0000000000..a8913c76c8 --- /dev/null +++ b/util/SeederApi/Models/Response/SeedResponseModel.cs @@ -0,0 +1,18 @@ +using Bit.Seeder; + +namespace Bit.SeederApi.Models.Response; + +public class SceneResponseModel +{ + public required Dictionary? MangleMap { get; init; } + public required object? Result { get; init; } + + public static SceneResponseModel FromSceneResult(SceneResult sceneResult) + { + return new SceneResponseModel + { + Result = sceneResult.Result, + MangleMap = sceneResult.MangleMap, + }; + } +} diff --git a/util/SeederApi/Program.cs b/util/SeederApi/Program.cs new file mode 100644 index 0000000000..2067df307a --- /dev/null +++ b/util/SeederApi/Program.cs @@ -0,0 +1,20 @@ +using Bit.Core.Utilities; + +namespace Bit.SeederApi; + +public class Program +{ + public static void Main(string[] args) + { + Host + .CreateDefaultBuilder(args) + .ConfigureCustomAppConfiguration(args) + .ConfigureWebHostDefaults(webBuilder => + { + webBuilder.UseStartup(); + }) + .AddSerilogFileLogging() + .Build() + .Run(); + } +} diff --git a/util/SeederApi/Properties/launchSettings.json b/util/SeederApi/Properties/launchSettings.json new file mode 100644 index 0000000000..95cd77e255 --- /dev/null +++ b/util/SeederApi/Properties/launchSettings.json @@ -0,0 +1,37 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:5047", + "sslPort": 0 + } + }, + "profiles": { + "SeederApi": { + "commandName": "Project", + "dotnetRunMessages": true, + "applicationUrl": "http://localhost:5047", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "SeederApi-SelfHost": { + "commandName": "Project", + "dotnetRunMessages": true, + "applicationUrl": "http://localhost:5048", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "developSelfHosted": "true" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/util/SeederApi/Queries/GetAllPlayIdsQuery.cs b/util/SeederApi/Queries/GetAllPlayIdsQuery.cs new file mode 100644 index 0000000000..7bc72e5b07 --- /dev/null +++ b/util/SeederApi/Queries/GetAllPlayIdsQuery.cs @@ -0,0 +1,15 @@ +using Bit.Infrastructure.EntityFramework.Repositories; +using Bit.SeederApi.Queries.Interfaces; + +namespace Bit.SeederApi.Queries; + +public class GetAllPlayIdsQuery(DatabaseContext databaseContext) : IGetAllPlayIdsQuery +{ + public List GetAllPlayIds() + { + return databaseContext.PlayItem + .Select(pd => pd.PlayId) + .Distinct() + .ToList(); + } +} diff --git a/util/SeederApi/Queries/Interfaces/IGetAllPlayIdsQuery.cs b/util/SeederApi/Queries/Interfaces/IGetAllPlayIdsQuery.cs new file mode 100644 index 0000000000..ea9c44991a --- /dev/null +++ b/util/SeederApi/Queries/Interfaces/IGetAllPlayIdsQuery.cs @@ -0,0 +1,13 @@ +namespace Bit.SeederApi.Queries.Interfaces; + +/// +/// Query for retrieving all play IDs for currently tracked seeded data. +/// +public interface IGetAllPlayIdsQuery +{ + /// + /// Retrieves all play IDs for currently tracked seeded data. + /// + /// A list of play IDs representing active seeded data that can be destroyed. + List GetAllPlayIds(); +} diff --git a/util/SeederApi/README.md b/util/SeederApi/README.md new file mode 100644 index 0000000000..a5a5d4ab9f --- /dev/null +++ b/util/SeederApi/README.md @@ -0,0 +1,185 @@ +# SeederApi + +A web API for dynamically seeding and querying test data in the Bitwarden database during development and testing. + +## Overview + +The SeederApi provides HTTP endpoints to execute [Seeder](../Seeder/README.md) scenes and queries, enabling automated test data +generation and retrieval through a RESTful interface. This is particularly useful for integration testing, local +development workflows, and automated test environments. + +## Architecture + +The SeederApi consists of three main components: + +1. **Controllers** - HTTP endpoints for seeding, querying, and managing test data +2. **Services** - Business logic for scene and query execution +3. **Models** - Request/response models for API communication + +### Key Components + +- **SeedController** (`/seed`) - Creates and destroys seeded test data +- **QueryController** (`/query`) - Executes read-only queries against existing data +- **InfoController** (`/alive`, `/version`) - Health check and version information +- **SceneService** - Manages scene execution and cleanup with play ID tracking +- **QueryService** - Executes read-only query operations + +## How To Use + +### Starting the API + +```bash +cd util/SeederApi +dotnet run +``` + +The API will start on the configured port (typically `http://localhost:5000`). + +### Seeding Data + +Send a POST request to `/seed` with a scene template name and optional arguments. Include the `X-Play-Id` header to +track the seeded data for later cleanup: + +```bash +curl -X POST http://localhost:5000/seed \ + -H "Content-Type: application/json" \ + -H "X-Play-Id: test-run-123" \ + -d '{ + "template": "SingleUserScene", + "arguments": { + "email": "test@example.com" + } + }' +``` + +**Response:** + +```json +{ + "mangleMap": { + "test@example.com": "1854b016+test@example.com", + "42bcf05d-7ad0-4e27-8b53-b3b700acc664": "42bcf05d-7ad0-4e27-8b53-b3b700acc664" + }, + "result": null +} +``` + +The `result` contains the data returned by the scene, and `mangleMap` contains ID mappings if ID mangling is enabled. +Use the `X-Play-Id` header value to later destroy the seeded data. + +### Querying Data + +Send a POST request to `/query` to execute read-only queries: + +```bash +curl -X POST http://localhost:5000/query \ + -H "Content-Type: application/json" \ + -d '{ + "template": "EmergencyAccessInviteQuery", + "arguments": { + "email": "test@example.com" + } + }' +``` + +**Response:** + +```json +["/accept-emergency?..."] +``` + +### Destroying Seeded Data + +#### Delete by Play ID + +Use the same play ID value you provided in the `X-Play-Id` header: + +```bash +curl -X DELETE http://localhost:5000/seed/test-run-123 +``` + +#### Delete Multiple by Play IDs + +```bash +curl -X DELETE http://localhost:5000/seed/batch \ + -H "Content-Type: application/json" \ + -d '["test-run-123", "test-run-456"]' +``` + +#### Delete All Seeded Data + +```bash +curl -X DELETE http://localhost:5000/seed +``` + +### Health Checks + +```bash +# Check if API is alive +curl http://localhost:5000/alive + +# Get API version +curl http://localhost:5000/version +``` + +## Creating Scenes and Queries + +Scenes and queries are defined in the [Seeder](../Seeder/README.md) project. The SeederApi automatically discovers and registers all +classes implementing the scene and query interfaces. + +## Configuration + +The SeederApi uses the standard Bitwarden configuration system: + +- `appsettings.json` - Base configuration +- `appsettings.Development.json` - Development overrides +- `dev/secrets.json` - Local secrets (database connection strings, etc.) +- User Secrets ID: `bitwarden-seeder-api` + +### Required Settings + +The SeederApi requires the following configuration: + +- **Database Connection** - Connection string to the Bitwarden database +- **Global Settings** - Standard Bitwarden `GlobalSettings` configuration + +## Play ID Tracking + +Certain entities such as Users and Organizations are tracked when created by a request including a PlayId. This enables +entities to be deleted after using the PlayId. + +### The X-Play-Id Header + +**Important:** All seed requests should include the `X-Play-Id` header: + +```bash +-H "X-Play-Id: your-unique-identifier" +``` + +The play ID can be any string that uniquely identifies your test run or session. Common patterns: + +### How Play ID Tracking Works + +When `TestPlayIdTrackingEnabled` is enabled in GlobalSettings, the `PlayIdMiddleware` +(see `src/SharedWeb/Utilities/PlayIdMiddleware.cs:7-23`) automatically: + +1. **Extracts** the `X-Play-Id` header from incoming requests +2. **Sets** the play ID in the `PlayIdService` for the request scope +3. **Tracks** all entities (users, organizations, etc.) created during the request +4. **Associates** them with the play ID in the `PlayItem` table +5. **Enables** complete cleanup via the delete endpoints + +This tracking works for **any API request** that includes the `X-Play-Id` header, not just SeederApi endpoints. This means +you can track entities created through: + +- **Scene executions** - Data seeded via `/seed` endpoint +- **Regular API operations** - Users signing up, creating organizations, inviting members, etc. +- **Integration tests** - Any HTTP requests to the Bitwarden API during test execution + +Without the `X-Play-Id` header, entities will not be tracked and cannot be cleaned up using the delete endpoints. + +## Security Considerations + +> [!WARNING] +> The SeederApi is intended for **development and testing environments only**. Never deploy this API to production +> environments. diff --git a/util/SeederApi/SeederApi.csproj b/util/SeederApi/SeederApi.csproj new file mode 100644 index 0000000000..53e9941c1c --- /dev/null +++ b/util/SeederApi/SeederApi.csproj @@ -0,0 +1,16 @@ + + + + bitwarden-seeder-api + net8.0 + enable + enable + false + + + + + + + + diff --git a/util/SeederApi/Services/QueryExceptions.cs b/util/SeederApi/Services/QueryExceptions.cs new file mode 100644 index 0000000000..beb0625cbb --- /dev/null +++ b/util/SeederApi/Services/QueryExceptions.cs @@ -0,0 +1,10 @@ +namespace Bit.SeederApi.Services; + +public class QueryNotFoundException(string query) : Exception($"Query '{query}' not found"); + +public class QueryExecutionException : Exception +{ + public QueryExecutionException(string message) : base(message) { } + public QueryExecutionException(string message, Exception innerException) + : base(message, innerException) { } +} diff --git a/util/SeederApi/Services/SceneExceptions.cs b/util/SeederApi/Services/SceneExceptions.cs new file mode 100644 index 0000000000..2d8da19629 --- /dev/null +++ b/util/SeederApi/Services/SceneExceptions.cs @@ -0,0 +1,10 @@ +namespace Bit.SeederApi.Services; + +public class SceneNotFoundException(string scene) : Exception($"Scene '{scene}' not found"); + +public class SceneExecutionException : Exception +{ + public SceneExecutionException(string message) : base(message) { } + public SceneExecutionException(string message, Exception innerException) + : base(message, innerException) { } +} diff --git a/util/SeederApi/Startup.cs b/util/SeederApi/Startup.cs new file mode 100644 index 0000000000..420078f509 --- /dev/null +++ b/util/SeederApi/Startup.cs @@ -0,0 +1,80 @@ +using System.Globalization; +using Bit.Core.Settings; +using Bit.Seeder; +using Bit.Seeder.Factories; +using Bit.SeederApi.Extensions; +using Bit.SharedWeb.Utilities; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Bit.SeederApi; + +public class Startup +{ + public Startup(IWebHostEnvironment env, IConfiguration configuration) + { + CultureInfo.DefaultThreadCurrentCulture = new CultureInfo("en-US"); + Configuration = configuration; + Environment = env; + } + + public IConfiguration Configuration { get; private set; } + public IWebHostEnvironment Environment { get; set; } + + public void ConfigureServices(IServiceCollection services) + { + services.AddOptions(); + + var globalSettings = services.AddGlobalSettingsServices(Configuration, Environment); + + services.AddCustomDataProtectionServices(Environment, globalSettings); + + services.AddTokenizers(); + services.AddDatabaseRepositories(globalSettings); + services.AddTestPlayIdTracking(globalSettings); + + services.TryAddSingleton(); + + services.AddScoped, PasswordHasher>(); + + services.AddSingleton(); + services.AddScoped(); + + services.AddSeederApiServices(); + + services.AddScoped(_ => new MangleId()); + services.AddScenes(); + services.AddQueries(); + + services.AddControllers(); + } + + public void Configure( + IApplicationBuilder app, + IWebHostEnvironment env, + IHostApplicationLifetime appLifetime, + GlobalSettings globalSettings) + { + if (env.IsProduction()) + { + throw new InvalidOperationException( + "SeederApi cannot be run in production environments. This service is intended for test data generation only."); + } + + if (globalSettings.TestPlayIdTrackingEnabled) + { + app.UseMiddleware(); + } + + if (!env.IsDevelopment()) + { + app.UseExceptionHandler("/Home/Error"); + } + + app.UseRouting(); + app.UseEndpoints(endpoints => + { + endpoints.MapControllerRoute(name: "default", pattern: "{controller=Seed}/{action=Index}/{id?}"); + }); + } +} diff --git a/util/SeederApi/appsettings.Development.json b/util/SeederApi/appsettings.Development.json new file mode 100644 index 0000000000..0c208ae918 --- /dev/null +++ b/util/SeederApi/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/util/SeederApi/appsettings.json b/util/SeederApi/appsettings.json new file mode 100644 index 0000000000..79388a1bb0 --- /dev/null +++ b/util/SeederApi/appsettings.json @@ -0,0 +1,11 @@ +{ + "globalSettings": { + "projectName": "SeederApi" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/util/SqliteMigrations/Migrations/20260108193841_CreatePlayItem.Designer.cs b/util/SqliteMigrations/Migrations/20260108193841_CreatePlayItem.Designer.cs new file mode 100644 index 0000000000..eac0b557a4 --- /dev/null +++ b/util/SqliteMigrations/Migrations/20260108193841_CreatePlayItem.Designer.cs @@ -0,0 +1,3491 @@ +// +using System; +using Bit.Infrastructure.EntityFramework.Repositories; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Bit.SqliteMigrations.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20260108193841_CreatePlayItem")] + partial class CreatePlayItem + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.8"); + + modelBuilder.Entity("Bit.Core.Dirt.Reports.Models.Data.OrganizationMemberBaseDetail", b => + { + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("CollectionName") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("GroupName") + .HasColumnType("TEXT"); + + b.Property("HidePasswords") + .HasColumnType("INTEGER"); + + b.Property("Manage") + .HasColumnType("INTEGER"); + + b.Property("ReadOnly") + .HasColumnType("INTEGER"); + + b.Property("ResetPasswordKey") + .HasColumnType("TEXT"); + + b.Property("TwoFactorProviders") + .HasColumnType("TEXT"); + + b.Property("UserGuid") + .HasColumnType("TEXT"); + + b.Property("UserName") + .HasColumnType("TEXT"); + + b.Property("UsesKeyConnector") + .HasColumnType("INTEGER"); + + b.ToTable("OrganizationMemberBaseDetails"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AllowAdminAccessToAllCollectionItems") + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("BillingEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("BusinessAddress1") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("BusinessAddress2") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("BusinessAddress3") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("BusinessCountry") + .HasMaxLength(2) + .HasColumnType("TEXT"); + + b.Property("BusinessName") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("BusinessTaxNumber") + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("Gateway") + .HasColumnType("INTEGER"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Identifier") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LimitCollectionCreation") + .HasColumnType("INTEGER"); + + b.Property("LimitCollectionDeletion") + .HasColumnType("INTEGER"); + + b.Property("LimitItemDeletion") + .HasColumnType("INTEGER"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("INTEGER"); + + b.Property("MaxAutoscaleSmSeats") + .HasColumnType("INTEGER"); + + b.Property("MaxAutoscaleSmServiceAccounts") + .HasColumnType("INTEGER"); + + b.Property("MaxCollections") + .HasColumnType("INTEGER"); + + b.Property("MaxStorageGb") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("OwnersNotifiedOfAutoscaling") + .HasColumnType("TEXT"); + + b.Property("Plan") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PlanType") + .HasColumnType("INTEGER"); + + b.Property("PrivateKey") + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .HasColumnType("TEXT"); + + b.Property("ReferenceData") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Seats") + .HasColumnType("INTEGER"); + + b.Property("SelfHost") + .HasColumnType("INTEGER"); + + b.Property("SmSeats") + .HasColumnType("INTEGER"); + + b.Property("SmServiceAccounts") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Storage") + .HasColumnType("INTEGER"); + + b.Property("SyncSeats") + .HasColumnType("INTEGER"); + + b.Property("TwoFactorProviders") + .HasColumnType("TEXT"); + + b.Property("Use2fa") + .HasColumnType("INTEGER"); + + b.Property("UseAdminSponsoredFamilies") + .HasColumnType("INTEGER"); + + b.Property("UseApi") + .HasColumnType("INTEGER"); + + b.Property("UseAutomaticUserConfirmation") + .HasColumnType("INTEGER"); + + b.Property("UseCustomPermissions") + .HasColumnType("INTEGER"); + + b.Property("UseDirectory") + .HasColumnType("INTEGER"); + + b.Property("UseDisableSmAdsForUsers") + .HasColumnType("INTEGER"); + + b.Property("UseEvents") + .HasColumnType("INTEGER"); + + b.Property("UseGroups") + .HasColumnType("INTEGER"); + + b.Property("UseKeyConnector") + .HasColumnType("INTEGER"); + + b.Property("UseOrganizationDomains") + .HasColumnType("INTEGER"); + + b.Property("UsePasswordManager") + .HasColumnType("INTEGER"); + + b.Property("UsePhishingBlocker") + .HasColumnType("INTEGER"); + + b.Property("UsePolicies") + .HasColumnType("INTEGER"); + + b.Property("UseResetPassword") + .HasColumnType("INTEGER"); + + b.Property("UseRiskInsights") + .HasColumnType("INTEGER"); + + b.Property("UseScim") + .HasColumnType("INTEGER"); + + b.Property("UseSecretsManager") + .HasColumnType("INTEGER"); + + b.Property("UseSso") + .HasColumnType("INTEGER"); + + b.Property("UseTotp") + .HasColumnType("INTEGER"); + + b.Property("UsersGetPremium") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Enabled") + .HasAnnotation("Npgsql:IndexInclude", new[] { "UseTotp", "UsersGetPremium" }); + + b.ToTable("Organization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Policy", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("BillingEmail") + .HasColumnType("TEXT"); + + b.Property("BillingPhone") + .HasColumnType("TEXT"); + + b.Property("BusinessAddress1") + .HasColumnType("TEXT"); + + b.Property("BusinessAddress2") + .HasColumnType("TEXT"); + + b.Property("BusinessAddress3") + .HasColumnType("TEXT"); + + b.Property("BusinessCountry") + .HasColumnType("TEXT"); + + b.Property("BusinessName") + .HasColumnType("TEXT"); + + b.Property("BusinessTaxNumber") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DiscountId") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Gateway") + .HasColumnType("INTEGER"); + + b.Property("GatewayCustomerId") + .HasColumnType("TEXT"); + + b.Property("GatewaySubscriptionId") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UseEvents") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Provider", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Settings") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderOrganization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("Permissions") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId"); + + b.ToTable("ProviderUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessCode") + .HasMaxLength(25) + .HasColumnType("TEXT"); + + b.Property("Approved") + .HasColumnType("INTEGER"); + + b.Property("AuthenticationDate") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("MasterPasswordHash") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .HasColumnType("TEXT"); + + b.Property("RequestCountryName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("RequestDeviceIdentifier") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("RequestDeviceType") + .HasColumnType("INTEGER"); + + b.Property("RequestIpAddress") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ResponseDate") + .HasColumnType("TEXT"); + + b.Property("ResponseDeviceId") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ResponseDeviceId"); + + b.HasIndex("UserId"); + + b.ToTable("AuthRequest", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("GranteeId") + .HasColumnType("TEXT"); + + b.Property("GrantorId") + .HasColumnType("TEXT"); + + b.Property("KeyEncrypted") + .HasColumnType("TEXT"); + + b.Property("LastNotificationDate") + .HasColumnType("TEXT"); + + b.Property("RecoveryInitiatedDate") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("WaitTimeDays") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GranteeId"); + + b.HasIndex("GrantorId"); + + b.ToTable("EmergencyAccess", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.Grant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ConsumedDate") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SessionId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SubjectId") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasName("PK_Grant") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpirationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("Grant", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("SsoConfig", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId"); + + b.HasIndex("OrganizationId", "ExternalId") + .IsUnique() + .HasAnnotation("Npgsql:IndexInclude", new[] { "UserId" }) + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "UserId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SsoUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AaGuid") + .HasColumnType("TEXT"); + + b.Property("Counter") + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("CredentialId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EncryptedPrivateKey") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EncryptedPublicKey") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EncryptedUserKey") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("SupportsPrf") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("WebAuthnCredential", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ClientOrganizationMigrationRecord", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("GatewayCustomerId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("GatewaySubscriptionId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("INTEGER"); + + b.Property("MaxStorageGb") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PlanType") + .HasColumnType("INTEGER"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("Seats") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId", "OrganizationId") + .IsUnique(); + + b.ToTable("ClientOrganizationMigrationRecord", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("InstallationId") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("InstallationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationInstallation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AssignedSeats") + .HasColumnType("INTEGER"); + + b.Property("ClientId") + .HasColumnType("TEXT"); + + b.Property("ClientName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("InvoiceId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("InvoiceNumber") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PlanName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("Total") + .HasColumnType("TEXT"); + + b.Property("UsedSeats") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderInvoiceItem", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AllocatedSeats") + .HasColumnType("INTEGER"); + + b.Property("PlanType") + .HasColumnType("INTEGER"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("PurchasedSeats") + .HasColumnType("INTEGER"); + + b.Property("SeatMinimum") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("Id", "PlanType") + .IsUnique(); + + b.ToTable("ProviderPlan", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationApplication", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Applications") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Configuration") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationIntegration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegrationConfiguration", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Configuration") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("EventType") + .HasColumnType("INTEGER"); + + b.Property("Filters") + .HasColumnType("TEXT"); + + b.Property("OrganizationIntegrationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Template") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationIntegrationId"); + + b.ToTable("OrganizationIntegrationConfiguration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ApplicationAtRiskCount") + .HasColumnType("INTEGER"); + + b.Property("ApplicationCount") + .HasColumnType("INTEGER"); + + b.Property("ApplicationData") + .HasColumnType("TEXT"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("CriticalApplicationAtRiskCount") + .HasColumnType("INTEGER"); + + b.Property("CriticalApplicationCount") + .HasColumnType("INTEGER"); + + b.Property("CriticalMemberAtRiskCount") + .HasColumnType("INTEGER"); + + b.Property("CriticalMemberCount") + .HasColumnType("INTEGER"); + + b.Property("CriticalPasswordAtRiskCount") + .HasColumnType("INTEGER"); + + b.Property("CriticalPasswordCount") + .HasColumnType("INTEGER"); + + b.Property("MemberAtRiskCount") + .HasColumnType("INTEGER"); + + b.Property("MemberCount") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PasswordAtRiskCount") + .HasColumnType("INTEGER"); + + b.Property("PasswordCount") + .HasColumnType("INTEGER"); + + b.Property("ReportData") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("SummaryData") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationReport", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.PasswordHealthReportApplication", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Uri") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("PasswordHealthReportApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Cache", b => + { + b.Property("Id") + .HasMaxLength(449) + .HasColumnType("TEXT"); + + b.Property("AbsoluteExpiration") + .HasColumnType("TEXT"); + + b.Property("ExpiresAtTime") + .HasColumnType("TEXT"); + + b.Property("SlidingExpirationInSeconds") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasColumnType("BLOB"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpiresAtTime") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Cache", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DefaultUserCollectionEmail") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Collection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.HasKey("CollectionId", "CipherId"); + + b.HasIndex("CipherId"); + + b.ToTable("CollectionCipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("HidePasswords") + .HasColumnType("INTEGER"); + + b.Property("Manage") + .HasColumnType("INTEGER"); + + b.Property("ReadOnly") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("CollectionGroups"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("OrganizationUserId") + .HasColumnType("TEXT"); + + b.Property("HidePasswords") + .HasColumnType("INTEGER"); + + b.Property("Manage") + .HasColumnType("INTEGER"); + + b.Property("ReadOnly") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Active") + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("EncryptedPrivateKey") + .HasColumnType("TEXT"); + + b.Property("EncryptedPublicKey") + .HasColumnType("TEXT"); + + b.Property("EncryptedUserKey") + .HasColumnType("TEXT"); + + b.Property("Identifier") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PushToken") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Identifier") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "Identifier") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Device", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Event", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ActingUserId") + .HasColumnType("TEXT"); + + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("DeviceType") + .HasColumnType("INTEGER"); + + b.Property("DomainName") + .HasColumnType("TEXT"); + + b.Property("GrantedServiceAccountId") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("InstallationId") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("OrganizationUserId") + .HasColumnType("TEXT"); + + b.Property("PolicyId") + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("ProviderOrganizationId") + .HasColumnType("TEXT"); + + b.Property("ProviderUserId") + .HasColumnType("TEXT"); + + b.Property("SecretId") + .HasColumnType("TEXT"); + + b.Property("ServiceAccountId") + .HasColumnType("TEXT"); + + b.Property("SystemUser") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("Date", "OrganizationId", "ActingUserId", "CipherId") + .HasDatabaseName("IX_Event_DateOrganizationIdUserId") + .HasAnnotation("SqlServer:Clustered", false) + .HasAnnotation("SqlServer:Include", new[] { "ServiceAccountId", "GrantedServiceAccountId" }); + + b.ToTable("Event", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Group", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("OrganizationUserId") + .HasColumnType("TEXT"); + + b.HasKey("GroupId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("GroupUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Config") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationConnection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DomainName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("JobRunCount") + .HasColumnType("INTEGER"); + + b.Property("LastCheckedDate") + .HasColumnType("TEXT"); + + b.Property("NextRunDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Txt") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("VerifiedDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationDomain", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("FriendlyName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAdminInitiated") + .HasColumnType("INTEGER"); + + b.Property("LastSyncDate") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("OfferedToEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PlanSponsorshipType") + .HasColumnType("INTEGER"); + + b.Property("SponsoredOrganizationId") + .HasColumnType("TEXT"); + + b.Property("SponsoringOrganizationId") + .HasColumnType("TEXT"); + + b.Property("SponsoringOrganizationUserId") + .HasColumnType("TEXT"); + + b.Property("ToDelete") + .HasColumnType("INTEGER"); + + b.Property("ValidUntil") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SponsoredOrganizationId"); + + b.HasIndex("SponsoringOrganizationId"); + + b.HasIndex("SponsoringOrganizationUserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationSponsorship", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessSecretsManager") + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Permissions") + .HasColumnType("TEXT"); + + b.Property("ResetPasswordKey") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.PlayItem", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PlayId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("PlayId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("PlayItem", null, t => + { + t.HasCheckConstraint("CK_PlayItem_UserOrOrganization", "(\"UserId\" IS NOT NULL AND \"OrganizationId\" IS NULL) OR (\"UserId\" IS NULL AND \"OrganizationId\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessCount") + .HasColumnType("INTEGER"); + + b.Property("AuthType") + .HasColumnType("INTEGER"); + + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("DeletionDate") + .HasColumnType("TEXT"); + + b.Property("Disabled") + .HasColumnType("INTEGER"); + + b.Property("Emails") + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("HideEmail") + .HasColumnType("INTEGER"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("MaxAccessCount") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeletionDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Send", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.TaxRate", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("Active") + .HasColumnType("INTEGER"); + + b.Property("Country") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PostalCode") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("Rate") + .HasColumnType("TEXT"); + + b.Property("State") + .HasMaxLength(2) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("TaxRate", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Amount") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Gateway") + .HasColumnType("INTEGER"); + + b.Property("GatewayId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PaymentMethodType") + .HasColumnType("INTEGER"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("Refunded") + .HasColumnType("INTEGER"); + + b.Property("RefundedAmount") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId", "CreationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Transaction", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccountRevisionDate") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("AvatarColor") + .HasMaxLength(7) + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Culture") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailVerified") + .HasColumnType("INTEGER"); + + b.Property("EquivalentDomains") + .HasColumnType("TEXT"); + + b.Property("ExcludedGlobalEquivalentDomains") + .HasColumnType("TEXT"); + + b.Property("FailedLoginCount") + .HasColumnType("INTEGER"); + + b.Property("ForcePasswordReset") + .HasColumnType("INTEGER"); + + b.Property("Gateway") + .HasColumnType("INTEGER"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Kdf") + .HasColumnType("INTEGER"); + + b.Property("KdfIterations") + .HasColumnType("INTEGER"); + + b.Property("KdfMemory") + .HasColumnType("INTEGER"); + + b.Property("KdfParallelism") + .HasColumnType("INTEGER"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("LastEmailChangeDate") + .HasColumnType("TEXT"); + + b.Property("LastFailedLoginDate") + .HasColumnType("TEXT"); + + b.Property("LastKdfChangeDate") + .HasColumnType("TEXT"); + + b.Property("LastKeyRotationDate") + .HasColumnType("TEXT"); + + b.Property("LastPasswordChangeDate") + .HasColumnType("TEXT"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("MasterPassword") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("MasterPasswordHint") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("MaxStorageGb") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Premium") + .HasColumnType("INTEGER"); + + b.Property("PremiumExpirationDate") + .HasColumnType("TEXT"); + + b.Property("PrivateKey") + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .HasColumnType("TEXT"); + + b.Property("ReferenceData") + .HasColumnType("TEXT"); + + b.Property("RenewalReminderDate") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("SecurityState") + .HasColumnType("TEXT"); + + b.Property("SecurityVersion") + .HasColumnType("INTEGER"); + + b.Property("SignedPublicKey") + .HasColumnType("TEXT"); + + b.Property("Storage") + .HasColumnType("INTEGER"); + + b.Property("TwoFactorProviders") + .HasColumnType("TEXT"); + + b.Property("TwoFactorRecoveryCode") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UsesKeyConnector") + .HasColumnType("INTEGER"); + + b.Property("VerifyDevices") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Premium", "PremiumExpirationDate", "RenewalReminderDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("User", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.UserSignatureKeyPair", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("SignatureAlgorithm") + .HasColumnType("INTEGER"); + + b.Property("SigningKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("VerifyingKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("UserSignatureKeyPair", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Body") + .HasMaxLength(3000) + .HasColumnType("TEXT"); + + b.Property("ClientType") + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Global") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Priority") + .HasColumnType("INTEGER"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("TaskId") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("TaskId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("ClientType", "Global", "UserId", "OrganizationId", "Priority", "CreationDate") + .IsDescending(false, false, false, false, true, true) + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Notification", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("NotificationId") + .HasColumnType("TEXT"); + + b.Property("DeletedDate") + .HasColumnType("TEXT"); + + b.Property("ReadDate") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "NotificationId") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("NotificationId"); + + b.ToTable("NotificationStatus", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Platform.Installation", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("TEXT"); + + b.Property("LastActivityDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Installation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(34) + .HasColumnType("TEXT"); + + b.Property("Read") + .HasColumnType("INTEGER"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Write") + .HasColumnType("INTEGER"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.ToTable("AccessPolicy", (string)null); + + b.HasDiscriminator().HasValue("AccessPolicy"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ClientSecretHash") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("EncryptedPayload") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("ExpireAt") + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("ServiceAccountId") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ServiceAccountId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DeletedDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Project", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DeletedDate") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("Note") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Secret", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.SecretVersion", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("EditorOrganizationUserId") + .HasColumnType("TEXT"); + + b.Property("EditorServiceAccountId") + .HasColumnType("TEXT"); + + b.Property("SecretId") + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("VersionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("EditorOrganizationUserId") + .HasDatabaseName("IX_SecretVersion_EditorOrganizationUserId"); + + b.HasIndex("EditorServiceAccountId") + .HasDatabaseName("IX_SecretVersion_EditorServiceAccountId"); + + b.HasIndex("SecretId") + .HasDatabaseName("IX_SecretVersion_SecretId"); + + b.ToTable("SecretVersion"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ServiceAccount", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Archives") + .HasColumnType("TEXT"); + + b.Property("Attachments") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("DeletedDate") + .HasColumnType("TEXT"); + + b.Property("Favorites") + .HasColumnType("TEXT"); + + b.Property("Folders") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Reprompt") + .HasColumnType("INTEGER"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId"); + + b.ToTable("Cipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Folder", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SecurityTask", (string)null); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.Property("ProjectsId") + .HasColumnType("TEXT"); + + b.Property("SecretsId") + .HasColumnType("TEXT"); + + b.HasKey("ProjectsId", "SecretsId"); + + b.HasIndex("SecretsId"); + + b.ToTable("ProjectSecret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedProjectId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedSecretId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedProjectId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedSecretId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedProjectId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedSecretId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Policies") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Device", "ResponseDevice") + .WithMany() + .HasForeignKey("ResponseDeviceId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("ResponseDevice"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantee") + .WithMany() + .HasForeignKey("GranteeId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantor") + .WithMany() + .HasForeignKey("GrantorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Grantee"); + + b.Navigation("Grantor"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoConfigs") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoUsers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("SsoUsers") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Platform.Installation", "Installation") + .WithMany() + .HasForeignKey("InstallationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Installation"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegrationConfiguration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", "OrganizationIntegration") + .WithMany() + .HasForeignKey("OrganizationIntegrationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OrganizationIntegration"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.PasswordHealthReportApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Collections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany("CollectionCiphers") + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionCiphers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Collection"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionGroups") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionUsers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("CollectionUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Groups") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany("GroupUsers") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("GroupUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("ApiKeys") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Connections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Domains") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoredOrganization") + .WithMany() + .HasForeignKey("SponsoredOrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoringOrganization") + .WithMany() + .HasForeignKey("SponsoringOrganizationId"); + + b.Navigation("SponsoredOrganization"); + + b.Navigation("SponsoringOrganization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("OrganizationUsers") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("OrganizationUsers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.PlayItem", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Transactions") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Transactions") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.UserSignatureKeyPair", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", "Task") + .WithMany() + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Task"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", "Notification") + .WithMany() + .HasForeignKey("NotificationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Notification"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ApiKeys") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.SecretVersion", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "EditorOrganizationUser") + .WithMany() + .HasForeignKey("EditorOrganizationUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "EditorServiceAccount") + .WithMany() + .HasForeignKey("EditorServiceAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "Secret") + .WithMany("SecretVersions") + .HasForeignKey("SecretId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EditorOrganizationUser"); + + b.Navigation("EditorServiceAccount"); + + b.Navigation("Secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Ciphers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Ciphers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Folders") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany() + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", null) + .WithMany() + .HasForeignKey("ProjectsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", null) + .WithMany() + .HasForeignKey("SecretsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedProject"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedSecret"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ProjectAccessPolicies") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedProject"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany() + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedProject"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("Ciphers"); + + b.Navigation("Collections"); + + b.Navigation("Connections"); + + b.Navigation("Domains"); + + b.Navigation("Groups"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("Policies"); + + b.Navigation("SsoConfigs"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Navigation("CollectionCiphers"); + + b.Navigation("CollectionGroups"); + + b.Navigation("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Navigation("CollectionUsers"); + + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Navigation("Ciphers"); + + b.Navigation("Folders"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("SecretVersions"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ProjectAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Navigation("CollectionCiphers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/util/SqliteMigrations/Migrations/20260108193841_CreatePlayItem.cs b/util/SqliteMigrations/Migrations/20260108193841_CreatePlayItem.cs new file mode 100644 index 0000000000..ae62e38f23 --- /dev/null +++ b/util/SqliteMigrations/Migrations/20260108193841_CreatePlayItem.cs @@ -0,0 +1,63 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bit.SqliteMigrations.Migrations; + +/// +public partial class CreatePlayItem : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "PlayItem", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + PlayId = table.Column(type: "TEXT", maxLength: 256, nullable: false), + UserId = table.Column(type: "TEXT", nullable: true), + OrganizationId = table.Column(type: "TEXT", nullable: true), + CreationDate = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PlayItem", x => x.Id); + table.CheckConstraint("CK_PlayItem_UserOrOrganization", "(\"UserId\" IS NOT NULL AND \"OrganizationId\" IS NULL) OR (\"UserId\" IS NULL AND \"OrganizationId\" IS NOT NULL)"); + table.ForeignKey( + name: "FK_PlayItem_Organization_OrganizationId", + column: x => x.OrganizationId, + principalTable: "Organization", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_PlayItem_User_UserId", + column: x => x.UserId, + principalTable: "User", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_PlayItem_OrganizationId", + table: "PlayItem", + column: "OrganizationId"); + + migrationBuilder.CreateIndex( + name: "IX_PlayItem_PlayId", + table: "PlayItem", + column: "PlayId"); + + migrationBuilder.CreateIndex( + name: "IX_PlayItem_UserId", + table: "PlayItem", + column: "UserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "PlayItem"); + } +} diff --git a/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs b/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs index a30b959ce9..4605ae8939 100644 --- a/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs +++ b/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs @@ -277,71 +277,6 @@ namespace Bit.SqliteMigrations.Migrations b.ToTable("Organization", (string)null); }); - modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", b => - { - b.Property("Id") - .HasColumnType("TEXT"); - - b.Property("Configuration") - .HasColumnType("TEXT"); - - b.Property("CreationDate") - .HasColumnType("TEXT"); - - b.Property("OrganizationId") - .HasColumnType("TEXT"); - - b.Property("RevisionDate") - .HasColumnType("TEXT"); - - b.Property("Type") - .HasColumnType("INTEGER"); - - b.HasKey("Id"); - - b.HasIndex("OrganizationId") - .HasAnnotation("SqlServer:Clustered", false); - - b.HasIndex("OrganizationId", "Type") - .IsUnique() - .HasAnnotation("SqlServer:Clustered", false); - - b.ToTable("OrganizationIntegration", (string)null); - }); - - modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegrationConfiguration", b => - { - b.Property("Id") - .HasColumnType("TEXT"); - - b.Property("Configuration") - .HasColumnType("TEXT"); - - b.Property("CreationDate") - .HasColumnType("TEXT"); - - b.Property("EventType") - .HasColumnType("INTEGER"); - - b.Property("Filters") - .HasColumnType("TEXT"); - - b.Property("OrganizationIntegrationId") - .HasColumnType("TEXT"); - - b.Property("RevisionDate") - .HasColumnType("TEXT"); - - b.Property("Template") - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("OrganizationIntegrationId"); - - b.ToTable("OrganizationIntegrationConfiguration", (string)null); - }); - modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => { b.Property("Id") @@ -621,7 +556,7 @@ namespace Bit.SqliteMigrations.Migrations b.Property("Type") .HasColumnType("INTEGER"); - b.Property("WaitTimeDays") + b.Property("WaitTimeDays") .HasColumnType("INTEGER"); b.HasKey("Id"); @@ -1004,6 +939,71 @@ namespace Bit.SqliteMigrations.Migrations b.ToTable("OrganizationApplication", (string)null); }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Configuration") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationIntegration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegrationConfiguration", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Configuration") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("EventType") + .HasColumnType("INTEGER"); + + b.Property("Filters") + .HasColumnType("TEXT"); + + b.Property("OrganizationIntegrationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Template") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationIntegrationId"); + + b.ToTable("OrganizationIntegrationConfiguration", (string)null); + }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => { b.Property("Id") @@ -1616,6 +1616,42 @@ namespace Bit.SqliteMigrations.Migrations b.ToTable("OrganizationUser", (string)null); }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.PlayItem", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PlayId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("PlayId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("PlayItem", null, t => + { + t.HasCheckConstraint("CK_PlayItem_UserOrOrganization", "(\"UserId\" IS NOT NULL AND \"OrganizationId\" IS NULL) OR (\"UserId\" IS NULL AND \"OrganizationId\" IS NOT NULL)"); + }); + }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => { b.Property("Id") @@ -2596,28 +2632,6 @@ namespace Bit.SqliteMigrations.Migrations b.HasDiscriminator().HasValue("user_service_account"); }); - modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", b => - { - b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") - .WithMany() - .HasForeignKey("OrganizationId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Organization"); - }); - - modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegrationConfiguration", b => - { - b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", "OrganizationIntegration") - .WithMany() - .HasForeignKey("OrganizationIntegrationId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("OrganizationIntegration"); - }); - modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => { b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") @@ -2796,6 +2810,28 @@ namespace Bit.SqliteMigrations.Migrations b.Navigation("Organization"); }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegrationConfiguration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", "OrganizationIntegration") + .WithMany() + .HasForeignKey("OrganizationIntegrationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OrganizationIntegration"); + }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => { b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") @@ -2992,6 +3028,23 @@ namespace Bit.SqliteMigrations.Migrations b.Navigation("User"); }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.PlayItem", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => { b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") From d1fdaa6a2f4620a876e5166c23e6e41bbb819021 Mon Sep 17 00:00:00 2001 From: Oscar Hinton Date: Wed, 14 Jan 2026 15:02:49 +0100 Subject: [PATCH 09/49] Fix lint on main (#6835) --- util/Seeder/Factories/UserSeeder.cs | 5 +++-- util/Seeder/MangleId.cs | 6 ++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/util/Seeder/Factories/UserSeeder.cs b/util/Seeder/Factories/UserSeeder.cs index dd5fc159c0..4fc456981c 100644 --- a/util/Seeder/Factories/UserSeeder.cs +++ b/util/Seeder/Factories/UserSeeder.cs @@ -1,4 +1,5 @@ -using Bit.Core.Entities; +using System.Globalization; +using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Utilities; using Bit.RustSDK; @@ -77,7 +78,7 @@ public class UserSeeder(RustSdkService sdkService, IPasswordHasher /// Helper for generating unique identifier suffixes to prevent collisions in test data. @@ -12,7 +14,7 @@ public class MangleId public MangleId() { // Generate a short random string (6 char) to use as the mangle ID - Value = Random.Shared.NextInt64().ToString("x").Substring(0, 8); + Value = Random.Shared.NextInt64().ToString("x", CultureInfo.InvariantCulture).Substring(0, 8); } public override string ToString() => Value; From 02cec2d9fea5651b76169343d3212b49d41b1fe3 Mon Sep 17 00:00:00 2001 From: Jared McCannon Date: Wed, 14 Jan 2026 10:42:06 -0600 Subject: [PATCH 10/49] Removing unused feature flag. (#6836) --- src/Core/Constants.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 47e7eb40bd..c42db8bbef 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -142,7 +142,6 @@ public static class FeatureFlagKeys public const string PM23845_VNextApplicationCache = "pm-24957-refactor-memory-application-cache"; public const string BlockClaimedDomainAccountCreation = "pm-28297-block-uninvited-claimed-domain-registration"; public const string IncreaseBulkReinviteLimitForCloud = "pm-28251-increase-bulk-reinvite-limit-for-cloud"; - public const string BulkRevokeUsersV2 = "pm-28456-bulk-revoke-users-v2"; public const string PremiumAccessQuery = "pm-21411-premium-access-query"; /* Architecture */ From 2224b1e0c697311c49f66382828bb47003a5b9de Mon Sep 17 00:00:00 2001 From: Github Actions Date: Wed, 14 Jan 2026 17:05:02 +0000 Subject: [PATCH 11/49] Bumped version to 2026.1.0 --- Directory.Build.props | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index 9438ef3351..e7a8422605 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,7 +3,7 @@ net8.0 - 2025.12.2 + 2026.1.0 Bit.$(MSBuildProjectName) enable @@ -30,4 +30,4 @@ 4.18.1 - + \ No newline at end of file From aa8d7c6775fb333420eb8df18bf5305ce6f1b89a Mon Sep 17 00:00:00 2001 From: Brandon Treston Date: Wed, 14 Jan 2026 12:19:23 -0500 Subject: [PATCH 12/49] [PM-30682] Add missing null check, update tests (#6826) * add missing null check, update tests * CR feedback --- ...maticUserConfirmationPolicyEventHandler.cs | 8 ++- ...UserConfirmationPolicyEventHandlerTests.cs | 52 ++++++++++++++++++- 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandler.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandler.cs index 86c94147f4..213d18c27d 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandler.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandler.cs @@ -74,8 +74,12 @@ public class AutomaticUserConfirmationPolicyEventHandler( private async Task ValidateUserComplianceWithSingleOrgAsync(Guid organizationId, ICollection organizationUsers) { - var hasNonCompliantUser = (await organizationUserRepository.GetManyByManyUsersAsync( - organizationUsers.Select(ou => ou.UserId!.Value))) + var userIds = organizationUsers.Where( + u => u.UserId is not null && + u.Status != OrganizationUserStatusType.Invited) + .Select(u => u.UserId!.Value); + + var hasNonCompliantUser = (await organizationUserRepository.GetManyByManyUsersAsync(userIds)) .Any(uo => uo.OrganizationId != organizationId && uo.Status != OrganizationUserStatusType.Invited); diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandlerTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandlerTests.cs index 3c9fd9a9e9..e2c9de4d6f 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandlerTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandlerTests.cs @@ -283,7 +283,7 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests OrganizationId = policyUpdate.OrganizationId, Type = OrganizationUserType.User, Status = OrganizationUserStatusType.Invited, - UserId = Guid.NewGuid(), + UserId = null, Email = "invited@example.com" }; @@ -302,6 +302,56 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests Assert.True(string.IsNullOrEmpty(result)); } + [Theory, BitAutoData] + public async Task ValidateAsync_EnablingPolicy_MixedUsersWithNullUserId_HandlesCorrectly( + [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, + Guid confirmedUserId, + SutProvider sutProvider) + { + // Arrange + var invitedUser = new OrganizationUserUserDetails + { + Id = Guid.NewGuid(), + OrganizationId = policyUpdate.OrganizationId, + Type = OrganizationUserType.User, + Status = OrganizationUserStatusType.Invited, + UserId = null, + Email = "invited@example.com" + }; + + var confirmedUser = new OrganizationUserUserDetails + { + Id = Guid.NewGuid(), + OrganizationId = policyUpdate.OrganizationId, + Type = OrganizationUserType.User, + Status = OrganizationUserStatusType.Confirmed, + UserId = confirmedUserId, + Email = "confirmed@example.com" + }; + + sutProvider.GetDependency() + .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) + .Returns([invitedUser, confirmedUser]); + + sutProvider.GetDependency() + .GetManyByManyUsersAsync(Arg.Any>()) + .Returns([]); + + sutProvider.GetDependency() + .GetManyByManyUsersAsync(Arg.Any>()) + .Returns([]); + + // Act + var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null); + + // Assert + Assert.True(string.IsNullOrEmpty(result)); + + await sutProvider.GetDependency() + .Received(1) + .GetManyByManyUsersAsync(Arg.Is>(ids => ids.Count() == 1 && ids.First() == confirmedUserId)); + } + [Theory, BitAutoData] public async Task ValidateAsync_EnablingPolicy_RevokedUsersIncluded_InComplianceCheck( [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, From 2a18f276a818bec12bc5a938be723250872bb8a4 Mon Sep 17 00:00:00 2001 From: Vincent Salucci <26154748+vincentsalucci@users.noreply.github.com> Date: Wed, 14 Jan 2026 12:21:49 -0600 Subject: [PATCH 13/49] fix: feature flag key for PremiumAccessQuery, refs PM-21411 (#6834) --- src/Core/Constants.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index c42db8bbef..723707424d 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -142,7 +142,7 @@ public static class FeatureFlagKeys public const string PM23845_VNextApplicationCache = "pm-24957-refactor-memory-application-cache"; public const string BlockClaimedDomainAccountCreation = "pm-28297-block-uninvited-claimed-domain-registration"; public const string IncreaseBulkReinviteLimitForCloud = "pm-28251-increase-bulk-reinvite-limit-for-cloud"; - public const string PremiumAccessQuery = "pm-21411-premium-access-query"; + public const string PremiumAccessQuery = "pm-29495-refactor-premium-interface"; /* Architecture */ public const string DesktopMigrationMilestone1 = "desktop-ui-migration-milestone-1"; From 4bff67ea1231ffe1f2e3e96c16c13818e09ad16e Mon Sep 17 00:00:00 2001 From: Mike Amirault Date: Wed, 14 Jan 2026 15:32:58 -0500 Subject: [PATCH 14/49] [PM-30687] Remove Desktop Send UI refresh feature flag (#6842) --- src/Core/Constants.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 723707424d..cc92397269 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -229,7 +229,6 @@ public static class FeatureFlagKeys /// Enable this flag to share the send view used by the web and browser clients /// on the desktop client. /// - public const string DesktopSendUIRefresh = "desktop-send-ui-refresh"; public const string UseSdkPasswordGenerators = "pm-19976-use-sdk-password-generators"; public const string UseChromiumImporter = "pm-23982-chromium-importer"; public const string ChromiumImporterWithABE = "pm-25855-chromium-importer-abe"; From e1b6e496f9f715325a144e32589674b680f249d2 Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Wed, 14 Jan 2026 14:34:42 -0600 Subject: [PATCH 15/49] fix(start-premium): Need to expand 'customer' on first invoice (#6844) --- ...CreatePremiumCloudHostedSubscriptionCommand.cs | 3 ++- ...ePremiumCloudHostedSubscriptionCommandTests.cs | 15 ++++++++++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs b/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs index d52c79c1ee..764406ee56 100644 --- a/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs +++ b/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs @@ -361,7 +361,8 @@ public class CreatePremiumCloudHostedSubscriptionCommand( var invoice = await stripeAdapter.UpdateInvoiceAsync(subscription.LatestInvoiceId, new InvoiceUpdateOptions { - AutoAdvance = false + AutoAdvance = false, + Expand = ["customer"] }); await braintreeService.PayInvoice(new UserId(userId), invoice); diff --git a/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs b/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs index 55eb69cc64..da287dc02b 100644 --- a/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs +++ b/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs @@ -266,7 +266,10 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Any()); await _subscriberService.Received(1).CreateBraintreeCustomer(user, paymentMethod.Token); await _stripeAdapter.Received(1).UpdateInvoiceAsync(mockSubscription.LatestInvoiceId, - Arg.Is(opts => opts.AutoAdvance == false)); + Arg.Is(opts => + opts.AutoAdvance == false && + opts.Expand != null && + opts.Expand.Contains("customer"))); await _braintreeService.Received(1).PayInvoice(Arg.Any(), mockInvoice); await _userService.Received(1).SaveUserAsync(user); await _pushNotificationService.Received(1).PushSyncVaultAsync(user.Id); @@ -502,7 +505,10 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests Assert.True(user.Premium); Assert.Equal(mockSubscription.GetCurrentPeriodEnd(), user.PremiumExpirationDate); await _stripeAdapter.Received(1).UpdateInvoiceAsync(mockSubscription.LatestInvoiceId, - Arg.Is(opts => opts.AutoAdvance == false)); + Arg.Is(opts => + opts.AutoAdvance == false && + opts.Expand != null && + opts.Expand.Contains("customer"))); await _braintreeService.Received(1).PayInvoice(Arg.Any(), mockInvoice); } @@ -612,7 +618,10 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests Assert.False(user.Premium); Assert.Null(user.PremiumExpirationDate); await _stripeAdapter.Received(1).UpdateInvoiceAsync(mockSubscription.LatestInvoiceId, - Arg.Is(opts => opts.AutoAdvance == false)); + Arg.Is(opts => + opts.AutoAdvance == false && + opts.Expand != null && + opts.Expand.Contains("customer"))); await _braintreeService.Received(1).PayInvoice(Arg.Any(), mockInvoice); } From fa845a4753f1100468d25bc9dd24173b778c7636 Mon Sep 17 00:00:00 2001 From: John Harrington <84741727+harr1424@users.noreply.github.com> Date: Wed, 14 Jan 2026 14:07:46 -0700 Subject: [PATCH 16/49] [Tools] Update SendAuthenticationQuery, add new non-anonymous endpoints, and add PutRemoveAuth endpoint (#6786) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * update send api models to support new `email` field * normalize authentication field evaluation order * document send response converters * add FIXME to remove unused constructor argument * add FIXME to remove unused constructor argument * introduce `tools-send-email-otp-listing` feature flag * add `ISendOwnerQuery` to dependency graph * fix broken tests * added AuthType prop to send related models with test coverage and debt cleanup * dotnet format * add migrations * dotnet format * make SendsController null safe (tech debt) * add AuthType col to Sends table, change Emails col length to 4000, and run migrations * dotnet format * update SPs to expect AuthType * include SP updates in migrations * remove migrations not intended for merge * Revert "remove migrations not intended for merge" This reverts commit 7df56e346ab3402387bebffc1d112d73214632fa. undo migrations removal * extract AuthType inference to util method and remove SQLite file * fix lints * address review comments * fix incorrect assignment and adopt SQL conventions * fix column assignment order in Send_Update.sql * remove space added to email list * assign SQL default value of NULL to AuthType * update SPs to match migration changes * remove FF, update SendAuthQuery, and update tests * new endpoints added but lack test coverage * dotnet format * add PutRemoveAuth endpoint with test coverage and tests for new non-anon endpoints * update RequireFeatureFlag comment for clarity * respond to Claude's findings * add additional validation logic to new auth endpoints * enforce auth policies on individual action methods * remove JsonConverter directive for AuthType * remove tools-send-email-otp-listing feature flag --------- Co-authored-by: ✨ Audrey ✨ Co-authored-by: ✨ Audrey ✨ Co-authored-by: Daniel James Smith <2670567+djsmith85@users.noreply.github.com> Co-authored-by: Alex Dragovich <46065570+itsadrago@users.noreply.github.com> --- src/Api/Tools/Controllers/SendsController.cs | 142 +++- src/Core/Constants.cs | 10 - src/Core/Tools/Enums/AuthType.cs | 5 +- .../Queries/SendAuthenticationQuery.cs | 7 +- .../SendFeatures/Queries/SendOwnerQuery.cs | 14 +- .../Tools/Controllers/SendsControllerTests.cs | 704 +++++++++++++++++- .../Services/SendAuthenticationQueryTests.cs | 22 +- .../Tools/Services/SendOwnerQueryTests.cs | 30 +- 8 files changed, 842 insertions(+), 92 deletions(-) diff --git a/src/Api/Tools/Controllers/SendsController.cs b/src/Api/Tools/Controllers/SendsController.cs index 449d9573fd..f9f71d076d 100644 --- a/src/Api/Tools/Controllers/SendsController.cs +++ b/src/Api/Tools/Controllers/SendsController.cs @@ -5,9 +5,11 @@ using Bit.Api.Tools.Models.Request; using Bit.Api.Tools.Models.Response; using Bit.Api.Utilities; using Bit.Core; +using Bit.Core.Auth.Identity; +using Bit.Core.Auth.UserFeatures.SendAccess; using Bit.Core.Exceptions; +using Bit.Core.Platform.Push; using Bit.Core.Services; -using Bit.Core.Settings; using Bit.Core.Tools.Enums; using Bit.Core.Tools.Models.Data; using Bit.Core.Tools.Repositories; @@ -22,7 +24,6 @@ using Microsoft.AspNetCore.Mvc; namespace Bit.Api.Tools.Controllers; [Route("sends")] -[Authorize("Application")] public class SendsController : Controller { private readonly ISendRepository _sendRepository; @@ -31,11 +32,10 @@ public class SendsController : Controller private readonly ISendFileStorageService _sendFileStorageService; private readonly IAnonymousSendCommand _anonymousSendCommand; private readonly INonAnonymousSendCommand _nonAnonymousSendCommand; - private readonly ISendOwnerQuery _sendOwnerQuery; - private readonly ILogger _logger; - private readonly GlobalSettings _globalSettings; + private readonly IFeatureService _featureService; + private readonly IPushNotificationService _pushNotificationService; public SendsController( ISendRepository sendRepository, @@ -46,7 +46,8 @@ public class SendsController : Controller ISendOwnerQuery sendOwnerQuery, ISendFileStorageService sendFileStorageService, ILogger logger, - GlobalSettings globalSettings) + IFeatureService featureService, + IPushNotificationService pushNotificationService) { _sendRepository = sendRepository; _userService = userService; @@ -56,10 +57,12 @@ public class SendsController : Controller _sendOwnerQuery = sendOwnerQuery; _sendFileStorageService = sendFileStorageService; _logger = logger; - _globalSettings = globalSettings; + _featureService = featureService; + _pushNotificationService = pushNotificationService; } #region Anonymous endpoints + [AllowAnonymous] [HttpPost("access/{id}")] public async Task Access(string id, [FromBody] SendAccessRequestModel model) @@ -73,21 +76,32 @@ public class SendsController : Controller var guid = new Guid(CoreHelpers.Base64UrlDecode(id)); var send = await _sendRepository.GetByIdAsync(guid); + if (send == null) { throw new BadRequestException("Could not locate send"); } + + /* This guard can be removed once feature flag is retired*/ + var sendEmailOtpEnabled = _featureService.IsEnabled(FeatureFlagKeys.SendEmailOTP); + if (sendEmailOtpEnabled && send.AuthType == AuthType.Email && send.Emails is not null) + { + return new UnauthorizedResult(); + } + var sendAuthResult = await _sendAuthorizationService.AccessAsync(send, model.Password); if (sendAuthResult.Equals(SendAccessResult.PasswordRequired)) { return new UnauthorizedResult(); } + if (sendAuthResult.Equals(SendAccessResult.PasswordInvalid)) { await Task.Delay(2000); throw new BadRequestException("Invalid password."); } + if (sendAuthResult.Equals(SendAccessResult.Denied)) { throw new NotFoundException(); @@ -99,6 +113,7 @@ public class SendsController : Controller var creator = await _userService.GetUserByIdAsync(send.UserId.Value); sendResponse.CreatorIdentifier = creator.Email; } + return new ObjectResult(sendResponse); } @@ -122,6 +137,13 @@ public class SendsController : Controller throw new BadRequestException("Could not locate send"); } + /* This guard can be removed once feature flag is retired*/ + var sendEmailOtpEnabled = _featureService.IsEnabled(FeatureFlagKeys.SendEmailOTP); + if (sendEmailOtpEnabled && send.AuthType == AuthType.Email && send.Emails is not null) + { + return new UnauthorizedResult(); + } + var (url, result) = await _anonymousSendCommand.GetSendFileDownloadUrlAsync(send, fileId, model.Password); @@ -129,21 +151,19 @@ public class SendsController : Controller { return new UnauthorizedResult(); } + if (result.Equals(SendAccessResult.PasswordInvalid)) { await Task.Delay(2000); throw new BadRequestException("Invalid password."); } + if (result.Equals(SendAccessResult.Denied)) { throw new NotFoundException(); } - return new ObjectResult(new SendFileDownloadDataResponseModel() - { - Id = fileId, - Url = url, - }); + return new ObjectResult(new SendFileDownloadDataResponseModel() { Id = fileId, Url = url, }); } [AllowAnonymous] @@ -157,7 +177,8 @@ public class SendsController : Controller { try { - var blobName = eventGridEvent.Subject.Split($"{AzureSendFileStorageService.FilesContainerName}/blobs/")[1]; + var blobName = + eventGridEvent.Subject.Split($"{AzureSendFileStorageService.FilesContainerName}/blobs/")[1]; var sendId = AzureSendFileStorageService.SendIdFromBlobName(blobName); var send = await _sendRepository.GetByIdAsync(new Guid(sendId)); if (send == null) @@ -166,6 +187,7 @@ public class SendsController : Controller { await azureSendFileStorageService.DeleteBlobAsync(blobName); } + return; } @@ -173,7 +195,8 @@ public class SendsController : Controller } catch (Exception e) { - _logger.LogError(e, "Uncaught exception occurred while handling event grid event: {Event}", JsonSerializer.Serialize(eventGridEvent)); + _logger.LogError(e, "Uncaught exception occurred while handling event grid event: {Event}", + JsonSerializer.Serialize(eventGridEvent)); return; } } @@ -185,6 +208,7 @@ public class SendsController : Controller #region Non-anonymous endpoints + [Authorize(Policies.Application)] [HttpGet("{id}")] public async Task Get(string id) { @@ -193,6 +217,7 @@ public class SendsController : Controller return new SendResponseModel(send); } + [Authorize(Policies.Application)] [HttpGet("")] public async Task> GetAll() { @@ -203,6 +228,67 @@ public class SendsController : Controller return result; } + [Authorize(Policy = Policies.Send)] + // [RequireFeature(FeatureFlagKeys.SendEmailOTP)] /* Uncomment once client fallback re-try logic is added */ + [HttpPost("access/")] + public async Task AccessUsingAuth() + { + var guid = User.GetSendId(); + var send = await _sendRepository.GetByIdAsync(guid); + if (send == null) + { + throw new BadRequestException("Could not locate send"); + } + if (send.MaxAccessCount.GetValueOrDefault(int.MaxValue) <= send.AccessCount || + send.ExpirationDate.GetValueOrDefault(DateTime.MaxValue) < DateTime.UtcNow || send.Disabled || + send.DeletionDate < DateTime.UtcNow) + { + throw new NotFoundException(); + } + + var sendResponse = new SendAccessResponseModel(send); + if (send.UserId.HasValue && !send.HideEmail.GetValueOrDefault()) + { + var creator = await _userService.GetUserByIdAsync(send.UserId.Value); + sendResponse.CreatorIdentifier = creator.Email; + } + + send.AccessCount++; + await _sendRepository.ReplaceAsync(send); + await _pushNotificationService.PushSyncSendUpdateAsync(send); + + return new ObjectResult(sendResponse); + } + + [Authorize(Policy = Policies.Send)] + // [RequireFeature(FeatureFlagKeys.SendEmailOTP)] /* Uncomment once client fallback re-try logic is added */ + [HttpPost("access/file/{fileId}")] + public async Task GetSendFileDownloadDataUsingAuth(string fileId) + { + var sendId = User.GetSendId(); + var send = await _sendRepository.GetByIdAsync(sendId); + + if (send == null) + { + throw new BadRequestException("Could not locate send"); + } + if (send.MaxAccessCount.GetValueOrDefault(int.MaxValue) <= send.AccessCount || + send.ExpirationDate.GetValueOrDefault(DateTime.MaxValue) < DateTime.UtcNow || send.Disabled || + send.DeletionDate < DateTime.UtcNow) + { + throw new NotFoundException(); + } + + var url = await _sendFileStorageService.GetSendFileDownloadUrlAsync(send, fileId); + + send.AccessCount++; + await _sendRepository.ReplaceAsync(send); + await _pushNotificationService.PushSyncSendUpdateAsync(send); + + return new ObjectResult(new SendFileDownloadDataResponseModel() { Id = fileId, Url = url }); + } + + [Authorize(Policies.Application)] [HttpPost("")] public async Task Post([FromBody] SendRequestModel model) { @@ -213,6 +299,7 @@ public class SendsController : Controller return new SendResponseModel(send); } + [Authorize(Policies.Application)] [HttpPost("file/v2")] public async Task PostFile([FromBody] SendRequestModel model) { @@ -243,6 +330,7 @@ public class SendsController : Controller }; } + [Authorize(Policies.Application)] [HttpGet("{id}/file/{fileId}")] public async Task RenewFileUpload(string id, string fileId) { @@ -267,6 +355,7 @@ public class SendsController : Controller }; } + [Authorize(Policies.Application)] [HttpPost("{id}/file/{fileId}")] [SelfHosted(SelfHostedOnly = true)] [RequestSizeLimit(Constants.FileSize501mb)] @@ -283,12 +372,14 @@ public class SendsController : Controller { throw new BadRequestException("Could not locate send"); } + await Request.GetFileAsync(async (stream) => { await _nonAnonymousSendCommand.UploadFileToExistingSendAsync(stream, send); }); } + [Authorize(Policies.Application)] [HttpPut("{id}")] public async Task Put(string id, [FromBody] SendRequestModel model) { @@ -304,6 +395,7 @@ public class SendsController : Controller return new SendResponseModel(send); } + [Authorize(Policies.Application)] [HttpPut("{id}/remove-password")] public async Task PutRemovePassword(string id) { @@ -322,6 +414,28 @@ public class SendsController : Controller return new SendResponseModel(send); } + // Removes ALL authentication (email or password) if any is present + [Authorize(Policies.Application)] + [HttpPut("{id}/remove-auth")] + public async Task PutRemoveAuth(string id) + { + var userId = _userService.GetProperUserId(User) ?? throw new InvalidOperationException("User ID not found"); + var send = await _sendRepository.GetByIdAsync(new Guid(id)); + if (send == null || send.UserId != userId) + { + throw new NotFoundException(); + } + + // This endpoint exists because PUT preserves existing Password/Emails when not provided. + // This allows clients to update other fields without re-submitting sensitive auth data. + send.Password = null; + send.Emails = null; + send.AuthType = AuthType.None; + await _nonAnonymousSendCommand.SaveSendAsync(send); + return new SendResponseModel(send); + } + + [Authorize(Policies.Application)] [HttpDelete("{id}")] public async Task Delete(string id) { diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index cc92397269..7cf00621c1 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -235,16 +235,6 @@ public static class FeatureFlagKeys public const string SendUIRefresh = "pm-28175-send-ui-refresh"; public const string SendEmailOTP = "pm-19051-send-email-verification"; - /// - /// Enable this flag to output email/OTP authenticated sends from the `GET sends` endpoint. When - /// this flag is disabled, the `GET sends` endpoint omits email/OTP authenticated sends. - /// - /// - /// This flag is server-side only, and only inhibits the endpoint returning all sends. - /// Email/OTP sends can still be created and downloaded through other endpoints. - /// - public const string PM19051_ListEmailOtpSends = "tools-send-email-otp-listing"; - /* Vault Team */ public const string CipherKeyEncryption = "cipher-key-encryption"; public const string PM19941MigrateCipherDomainToSdk = "pm-19941-migrate-cipher-domain-to-sdk"; diff --git a/src/Core/Tools/Enums/AuthType.cs b/src/Core/Tools/Enums/AuthType.cs index 814ebf69b8..4a31275b7a 100644 --- a/src/Core/Tools/Enums/AuthType.cs +++ b/src/Core/Tools/Enums/AuthType.cs @@ -1,11 +1,8 @@ -using System.Text.Json.Serialization; - -namespace Bit.Core.Tools.Enums; +namespace Bit.Core.Tools.Enums; /// /// Specifies the authentication method required to access a Send. /// -[JsonConverter(typeof(JsonStringEnumConverter))] public enum AuthType : byte { /// diff --git a/src/Core/Tools/SendFeatures/Queries/SendAuthenticationQuery.cs b/src/Core/Tools/SendFeatures/Queries/SendAuthenticationQuery.cs index fed7c9e8d4..97c2e64dc5 100644 --- a/src/Core/Tools/SendFeatures/Queries/SendAuthenticationQuery.cs +++ b/src/Core/Tools/SendFeatures/Queries/SendAuthenticationQuery.cs @@ -1,4 +1,5 @@ -using Bit.Core.Tools.Models.Data; +using Bit.Core.Tools.Enums; +using Bit.Core.Tools.Models.Data; using Bit.Core.Tools.Repositories; using Bit.Core.Tools.SendFeatures.Queries.Interfaces; @@ -37,8 +38,8 @@ public class SendAuthenticationQuery : ISendAuthenticationQuery { null => NEVER_AUTHENTICATE, var s when s.AccessCount >= s.MaxAccessCount => NEVER_AUTHENTICATE, - var s when s.Emails is not null => emailOtp(s.Emails), - var s when s.Password is not null => new ResourcePassword(s.Password), + var s when s.AuthType == AuthType.Email && s.Emails is not null => emailOtp(s.Emails), + var s when s.AuthType == AuthType.Password && s.Password is not null => new ResourcePassword(s.Password), _ => NOT_AUTHENTICATED }; diff --git a/src/Core/Tools/SendFeatures/Queries/SendOwnerQuery.cs b/src/Core/Tools/SendFeatures/Queries/SendOwnerQuery.cs index cb539429a5..29bd8f56f9 100644 --- a/src/Core/Tools/SendFeatures/Queries/SendOwnerQuery.cs +++ b/src/Core/Tools/SendFeatures/Queries/SendOwnerQuery.cs @@ -12,7 +12,6 @@ namespace Bit.Core.Tools.SendFeatures.Queries; public class SendOwnerQuery : ISendOwnerQuery { private readonly ISendRepository _repository; - private readonly IFeatureService _features; private readonly IUserService _users; /// @@ -24,10 +23,9 @@ public class SendOwnerQuery : ISendOwnerQuery /// /// Thrown when is . /// - public SendOwnerQuery(ISendRepository sendRepository, IFeatureService features, IUserService users) + public SendOwnerQuery(ISendRepository sendRepository, IUserService users) { _repository = sendRepository; - _features = features ?? throw new ArgumentNullException(nameof(features)); _users = users ?? throw new ArgumentNullException(nameof(users)); } @@ -51,16 +49,6 @@ public class SendOwnerQuery : ISendOwnerQuery var userId = _users.GetProperUserId(user) ?? throw new BadRequestException("invalid user."); var sends = await _repository.GetManyByUserIdAsync(userId); - var removeEmailOtp = !_features.IsEnabled(FeatureFlagKeys.PM19051_ListEmailOtpSends); - if (removeEmailOtp) - { - // reify list to avoid invalidating the enumerator - foreach (var s in sends.Where(s => s.Emails != null).ToList()) - { - sends.Remove(s); - } - } - return sends; } } diff --git a/test/Api.Test/Tools/Controllers/SendsControllerTests.cs b/test/Api.Test/Tools/Controllers/SendsControllerTests.cs index 541e8a4903..e3a9ba4435 100644 --- a/test/Api.Test/Tools/Controllers/SendsControllerTests.cs +++ b/test/Api.Test/Tools/Controllers/SendsControllerTests.cs @@ -8,8 +8,8 @@ using Bit.Api.Tools.Models.Request; using Bit.Api.Tools.Models.Response; using Bit.Core.Entities; using Bit.Core.Exceptions; +using Bit.Core.Platform.Push; using Bit.Core.Services; -using Bit.Core.Settings; using Bit.Core.Tools.Entities; using Bit.Core.Tools.Enums; using Bit.Core.Tools.Models.Data; @@ -28,7 +28,6 @@ namespace Bit.Api.Test.Tools.Controllers; public class SendsControllerTests : IDisposable { private readonly SendsController _sut; - private readonly GlobalSettings _globalSettings; private readonly IUserService _userService; private readonly ISendRepository _sendRepository; private readonly INonAnonymousSendCommand _nonAnonymousSendCommand; @@ -37,6 +36,8 @@ public class SendsControllerTests : IDisposable private readonly ISendAuthorizationService _sendAuthorizationService; private readonly ISendFileStorageService _sendFileStorageService; private readonly ILogger _logger; + private readonly IFeatureService _featureService; + private readonly IPushNotificationService _pushNotificationService; public SendsControllerTests() { @@ -47,8 +48,9 @@ public class SendsControllerTests : IDisposable _sendOwnerQuery = Substitute.For(); _sendAuthorizationService = Substitute.For(); _sendFileStorageService = Substitute.For(); - _globalSettings = new GlobalSettings(); _logger = Substitute.For>(); + _featureService = Substitute.For(); + _pushNotificationService = Substitute.For(); _sut = new SendsController( _sendRepository, @@ -59,7 +61,8 @@ public class SendsControllerTests : IDisposable _sendOwnerQuery, _sendFileStorageService, _logger, - _globalSettings + _featureService, + _pushNotificationService ); } @@ -96,8 +99,8 @@ public class SendsControllerTests : IDisposable { var now = DateTime.UtcNow; var expected = "You cannot have a Send with a deletion date that far " + - "into the future. Adjust the Deletion Date to a value less than 31 days from now " + - "and try again."; + "into the future. Adjust the Deletion Date to a value less than 31 days from now " + + "and try again."; var request = new SendRequestModel() { DeletionDate = now.AddDays(32) }; var exception = await Assert.ThrowsAsync(() => _sut.Post(request)); @@ -109,9 +112,10 @@ public class SendsControllerTests : IDisposable { var now = DateTime.UtcNow; var expected = "You cannot have a Send with a deletion date that far " + - "into the future. Adjust the Deletion Date to a value less than 31 days from now " + - "and try again."; - var request = new SendRequestModel() { Type = SendType.File, FileLength = 1024L, DeletionDate = now.AddDays(32) }; + "into the future. Adjust the Deletion Date to a value less than 31 days from now " + + "and try again."; + var request = + new SendRequestModel() { Type = SendType.File, FileLength = 1024L, DeletionDate = now.AddDays(32) }; var exception = await Assert.ThrowsAsync(() => _sut.PostFile(request)); Assert.Equal(expected, exception.Message); @@ -409,7 +413,8 @@ public class SendsControllerTests : IDisposable } [Theory, AutoData] - public async Task PutRemovePassword_WithWrongUser_ThrowsNotFoundException(Guid userId, Guid otherUserId, Guid sendId) + public async Task PutRemovePassword_WithWrongUser_ThrowsNotFoundException(Guid userId, Guid otherUserId, + Guid sendId) { _userService.GetProperUserId(Arg.Any()).Returns(userId); var existingSend = new Send @@ -753,4 +758,683 @@ public class SendsControllerTests : IDisposable s.Password == null && s.Emails == null)); } + + #region Authenticated Access Endpoints + + [Theory, AutoData] + public async Task AccessUsingAuth_WithValidSend_ReturnsSendAccessResponse(Guid sendId, User creator) + { + var send = new Send + { + Id = sendId, + UserId = creator.Id, + Type = SendType.Text, + Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)), + HideEmail = false, + DeletionDate = DateTime.UtcNow.AddDays(7), + ExpirationDate = null, + Disabled = false, + AccessCount = 0, + MaxAccessCount = null + }; + var user = CreateUserWithSendIdClaim(sendId); + _sut.ControllerContext = CreateControllerContextWithUser(user); + _sendRepository.GetByIdAsync(sendId).Returns(send); + _userService.GetUserByIdAsync(creator.Id).Returns(creator); + + var result = await _sut.AccessUsingAuth(); + + Assert.NotNull(result); + var objectResult = Assert.IsType(result); + var response = Assert.IsType(objectResult.Value); + Assert.Equal(CoreHelpers.Base64UrlEncode(sendId.ToByteArray()), response.Id); + Assert.Equal(creator.Email, response.CreatorIdentifier); + await _sendRepository.Received(1).GetByIdAsync(sendId); + await _userService.Received(1).GetUserByIdAsync(creator.Id); + } + + [Theory, AutoData] + public async Task AccessUsingAuth_WithHideEmail_DoesNotIncludeCreatorIdentifier(Guid sendId, User creator) + { + var send = new Send + { + Id = sendId, + UserId = creator.Id, + Type = SendType.Text, + Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)), + HideEmail = true, + DeletionDate = DateTime.UtcNow.AddDays(7), + ExpirationDate = null, + Disabled = false, + AccessCount = 0, + MaxAccessCount = null + }; + var user = CreateUserWithSendIdClaim(sendId); + _sut.ControllerContext = CreateControllerContextWithUser(user); + _sendRepository.GetByIdAsync(sendId).Returns(send); + + var result = await _sut.AccessUsingAuth(); + + Assert.NotNull(result); + var objectResult = Assert.IsType(result); + var response = Assert.IsType(objectResult.Value); + Assert.Equal(CoreHelpers.Base64UrlEncode(sendId.ToByteArray()), response.Id); + Assert.Null(response.CreatorIdentifier); + await _sendRepository.Received(1).GetByIdAsync(sendId); + await _userService.DidNotReceive().GetUserByIdAsync(Arg.Any()); + } + + [Theory, AutoData] + public async Task AccessUsingAuth_WithNoUserId_DoesNotIncludeCreatorIdentifier(Guid sendId) + { + var send = new Send + { + Id = sendId, + UserId = null, + Type = SendType.Text, + Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)), + HideEmail = false, + DeletionDate = DateTime.UtcNow.AddDays(7), + ExpirationDate = null, + Disabled = false, + AccessCount = 0, + MaxAccessCount = null + }; + var user = CreateUserWithSendIdClaim(sendId); + _sut.ControllerContext = CreateControllerContextWithUser(user); + _sendRepository.GetByIdAsync(sendId).Returns(send); + + var result = await _sut.AccessUsingAuth(); + + Assert.NotNull(result); + var objectResult = Assert.IsType(result); + var response = Assert.IsType(objectResult.Value); + Assert.Equal(CoreHelpers.Base64UrlEncode(sendId.ToByteArray()), response.Id); + Assert.Null(response.CreatorIdentifier); + await _sendRepository.Received(1).GetByIdAsync(sendId); + await _userService.DidNotReceive().GetUserByIdAsync(Arg.Any()); + } + + [Theory, AutoData] + public async Task AccessUsingAuth_WithNonExistentSend_ThrowsBadRequestException(Guid sendId) + { + var user = CreateUserWithSendIdClaim(sendId); + _sut.ControllerContext = CreateControllerContextWithUser(user); + _sendRepository.GetByIdAsync(sendId).Returns((Send)null); + + var exception = + await Assert.ThrowsAsync(() => _sut.AccessUsingAuth()); + + Assert.Equal("Could not locate send", exception.Message); + await _sendRepository.Received(1).GetByIdAsync(sendId); + } + + [Theory, AutoData] + public async Task AccessUsingAuth_WithFileSend_ReturnsCorrectResponse(Guid sendId, User creator) + { + var fileData = new SendFileData("Test File", "Notes", "document.pdf") { Id = "file-123", Size = 2048 }; + var send = new Send + { + Id = sendId, + UserId = creator.Id, + Type = SendType.File, + Data = JsonSerializer.Serialize(fileData), + HideEmail = false, + DeletionDate = DateTime.UtcNow.AddDays(7), + ExpirationDate = null, + Disabled = false, + AccessCount = 0, + MaxAccessCount = null + }; + var user = CreateUserWithSendIdClaim(sendId); + _sut.ControllerContext = CreateControllerContextWithUser(user); + _sendRepository.GetByIdAsync(sendId).Returns(send); + _userService.GetUserByIdAsync(creator.Id).Returns(creator); + + var result = await _sut.AccessUsingAuth(); + + Assert.NotNull(result); + var objectResult = Assert.IsType(result); + var response = Assert.IsType(objectResult.Value); + Assert.Equal(CoreHelpers.Base64UrlEncode(sendId.ToByteArray()), response.Id); + Assert.Equal(SendType.File, response.Type); + Assert.NotNull(response.File); + Assert.Equal("file-123", response.File.Id); + Assert.Equal(creator.Email, response.CreatorIdentifier); + } + + [Theory, AutoData] + public async Task GetSendFileDownloadDataUsingAuth_WithValidFileId_ReturnsDownloadUrl( + Guid sendId, string fileId, string expectedUrl) + { + var fileData = new SendFileData("Test File", "Notes", "document.pdf") { Id = fileId, Size = 2048 }; + var send = new Send + { + Id = sendId, + Type = SendType.File, + Data = JsonSerializer.Serialize(fileData), + DeletionDate = DateTime.UtcNow.AddDays(7), + ExpirationDate = null, + Disabled = false, + AccessCount = 0, + MaxAccessCount = null + }; + var user = CreateUserWithSendIdClaim(sendId); + _sut.ControllerContext = CreateControllerContextWithUser(user); + _sendRepository.GetByIdAsync(sendId).Returns(send); + _sendFileStorageService.GetSendFileDownloadUrlAsync(send, fileId).Returns(expectedUrl); + + var result = await _sut.GetSendFileDownloadDataUsingAuth(fileId); + + Assert.NotNull(result); + var objectResult = Assert.IsType(result); + var response = Assert.IsType(objectResult.Value); + Assert.Equal(fileId, response.Id); + Assert.Equal(expectedUrl, response.Url); + await _sendRepository.Received(1).GetByIdAsync(sendId); + await _sendFileStorageService.Received(1).GetSendFileDownloadUrlAsync(send, fileId); + } + + [Theory, AutoData] + public async Task GetSendFileDownloadDataUsingAuth_WithNonExistentSend_ThrowsBadRequestException( + Guid sendId, string fileId) + { + var user = CreateUserWithSendIdClaim(sendId); + _sut.ControllerContext = CreateControllerContextWithUser(user); + _sendRepository.GetByIdAsync(sendId).Returns((Send)null); + + var exception = + await Assert.ThrowsAsync(() => _sut.GetSendFileDownloadDataUsingAuth(fileId)); + + Assert.Equal("Could not locate send", exception.Message); + await _sendRepository.Received(1).GetByIdAsync(sendId); + await _sendFileStorageService.DidNotReceive() + .GetSendFileDownloadUrlAsync(Arg.Any(), Arg.Any()); + } + + [Theory, AutoData] + public async Task GetSendFileDownloadDataUsingAuth_WithTextSend_StillReturnsResponse( + Guid sendId, string fileId, string expectedUrl) + { + var send = new Send + { + Id = sendId, + Type = SendType.Text, + Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)), + DeletionDate = DateTime.UtcNow.AddDays(7), + ExpirationDate = null, + Disabled = false, + AccessCount = 0, + MaxAccessCount = null + }; + var user = CreateUserWithSendIdClaim(sendId); + _sut.ControllerContext = CreateControllerContextWithUser(user); + _sendRepository.GetByIdAsync(sendId).Returns(send); + _sendFileStorageService.GetSendFileDownloadUrlAsync(send, fileId).Returns(expectedUrl); + + var result = await _sut.GetSendFileDownloadDataUsingAuth(fileId); + + Assert.NotNull(result); + var objectResult = Assert.IsType(result); + var response = Assert.IsType(objectResult.Value); + Assert.Equal(fileId, response.Id); + Assert.Equal(expectedUrl, response.Url); + } + + #region AccessUsingAuth Validation Tests + + [Theory, AutoData] + public async Task AccessUsingAuth_WithExpiredSend_ThrowsNotFoundException(Guid sendId) + { + var send = new Send + { + Id = sendId, + UserId = Guid.NewGuid(), + Type = SendType.Text, + Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)), + DeletionDate = DateTime.UtcNow.AddDays(7), + ExpirationDate = DateTime.UtcNow.AddDays(-1), // Expired yesterday + Disabled = false, + AccessCount = 0, + MaxAccessCount = null + }; + var user = CreateUserWithSendIdClaim(sendId); + _sut.ControllerContext = CreateControllerContextWithUser(user); + _sendRepository.GetByIdAsync(sendId).Returns(send); + + await Assert.ThrowsAsync(() => _sut.AccessUsingAuth()); + + await _sendRepository.Received(1).GetByIdAsync(sendId); + } + + [Theory, AutoData] + public async Task AccessUsingAuth_WithDeletedSend_ThrowsNotFoundException(Guid sendId) + { + var send = new Send + { + Id = sendId, + UserId = Guid.NewGuid(), + Type = SendType.Text, + Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)), + DeletionDate = DateTime.UtcNow.AddDays(-1), // Should have been deleted yesterday + ExpirationDate = null, + Disabled = false, + AccessCount = 0, + MaxAccessCount = null + }; + var user = CreateUserWithSendIdClaim(sendId); + _sut.ControllerContext = CreateControllerContextWithUser(user); + _sendRepository.GetByIdAsync(sendId).Returns(send); + + await Assert.ThrowsAsync(() => _sut.AccessUsingAuth()); + + await _sendRepository.Received(1).GetByIdAsync(sendId); + } + + [Theory, AutoData] + public async Task AccessUsingAuth_WithDisabledSend_ThrowsNotFoundException(Guid sendId) + { + var send = new Send + { + Id = sendId, + UserId = Guid.NewGuid(), + Type = SendType.Text, + Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)), + DeletionDate = DateTime.UtcNow.AddDays(7), + ExpirationDate = null, + Disabled = true, // Disabled + AccessCount = 0, + MaxAccessCount = null + }; + var user = CreateUserWithSendIdClaim(sendId); + _sut.ControllerContext = CreateControllerContextWithUser(user); + _sendRepository.GetByIdAsync(sendId).Returns(send); + + await Assert.ThrowsAsync(() => _sut.AccessUsingAuth()); + + await _sendRepository.Received(1).GetByIdAsync(sendId); + } + + [Theory, AutoData] + public async Task AccessUsingAuth_WithAccessCountExceeded_ThrowsNotFoundException(Guid sendId) + { + var send = new Send + { + Id = sendId, + UserId = Guid.NewGuid(), + Type = SendType.Text, + Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)), + DeletionDate = DateTime.UtcNow.AddDays(7), + ExpirationDate = null, + Disabled = false, + AccessCount = 5, + MaxAccessCount = 5 // Limit reached + }; + var user = CreateUserWithSendIdClaim(sendId); + _sut.ControllerContext = CreateControllerContextWithUser(user); + _sendRepository.GetByIdAsync(sendId).Returns(send); + + await Assert.ThrowsAsync(() => _sut.AccessUsingAuth()); + + await _sendRepository.Received(1).GetByIdAsync(sendId); + } + + #endregion + + #region GetSendFileDownloadDataUsingAuth Validation Tests + + [Theory, AutoData] + public async Task GetSendFileDownloadDataUsingAuth_WithExpiredSend_ThrowsNotFoundException( + Guid sendId, string fileId) + { + var send = new Send + { + Id = sendId, + Type = SendType.File, + Data = JsonSerializer.Serialize(new SendFileData("Test", "Notes", "file.pdf")), + DeletionDate = DateTime.UtcNow.AddDays(7), + ExpirationDate = DateTime.UtcNow.AddDays(-1), // Expired + Disabled = false, + AccessCount = 0, + MaxAccessCount = null + }; + var user = CreateUserWithSendIdClaim(sendId); + _sut.ControllerContext = CreateControllerContextWithUser(user); + _sendRepository.GetByIdAsync(sendId).Returns(send); + + await Assert.ThrowsAsync(() => _sut.GetSendFileDownloadDataUsingAuth(fileId)); + + await _sendRepository.Received(1).GetByIdAsync(sendId); + } + + [Theory, AutoData] + public async Task GetSendFileDownloadDataUsingAuth_WithDeletedSend_ThrowsNotFoundException( + Guid sendId, string fileId) + { + var send = new Send + { + Id = sendId, + Type = SendType.File, + Data = JsonSerializer.Serialize(new SendFileData("Test", "Notes", "file.pdf")), + DeletionDate = DateTime.UtcNow.AddDays(-1), // Deleted + ExpirationDate = null, + Disabled = false, + AccessCount = 0, + MaxAccessCount = null + }; + var user = CreateUserWithSendIdClaim(sendId); + _sut.ControllerContext = CreateControllerContextWithUser(user); + _sendRepository.GetByIdAsync(sendId).Returns(send); + + await Assert.ThrowsAsync(() => _sut.GetSendFileDownloadDataUsingAuth(fileId)); + + await _sendRepository.Received(1).GetByIdAsync(sendId); + } + + [Theory, AutoData] + public async Task GetSendFileDownloadDataUsingAuth_WithDisabledSend_ThrowsNotFoundException( + Guid sendId, string fileId) + { + var send = new Send + { + Id = sendId, + Type = SendType.File, + Data = JsonSerializer.Serialize(new SendFileData("Test", "Notes", "file.pdf")), + DeletionDate = DateTime.UtcNow.AddDays(7), + ExpirationDate = null, + Disabled = true, // Disabled + AccessCount = 0, + MaxAccessCount = null + }; + var user = CreateUserWithSendIdClaim(sendId); + _sut.ControllerContext = CreateControllerContextWithUser(user); + _sendRepository.GetByIdAsync(sendId).Returns(send); + + await Assert.ThrowsAsync(() => _sut.GetSendFileDownloadDataUsingAuth(fileId)); + + await _sendRepository.Received(1).GetByIdAsync(sendId); + } + + [Theory, AutoData] + public async Task GetSendFileDownloadDataUsingAuth_WithAccessCountExceeded_ThrowsNotFoundException( + Guid sendId, string fileId) + { + var send = new Send + { + Id = sendId, + Type = SendType.File, + Data = JsonSerializer.Serialize(new SendFileData("Test", "Notes", "file.pdf")), + DeletionDate = DateTime.UtcNow.AddDays(7), + ExpirationDate = null, + Disabled = false, + AccessCount = 10, + MaxAccessCount = 10 // Limit reached + }; + var user = CreateUserWithSendIdClaim(sendId); + _sut.ControllerContext = CreateControllerContextWithUser(user); + _sendRepository.GetByIdAsync(sendId).Returns(send); + + await Assert.ThrowsAsync(() => _sut.GetSendFileDownloadDataUsingAuth(fileId)); + + await _sendRepository.Received(1).GetByIdAsync(sendId); + } + + #endregion + + #endregion + + #region PutRemoveAuth Tests + + [Theory, AutoData] + public async Task PutRemoveAuth_WithPasswordProtectedSend_RemovesPasswordAndSetsAuthTypeNone(Guid userId, + Guid sendId) + { + _userService.GetProperUserId(Arg.Any()).Returns(userId); + var existingSend = new Send + { + Id = sendId, + UserId = userId, + Type = SendType.Text, + Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)), + Password = "hashed-password", + Emails = null, + AuthType = AuthType.Password + }; + _sendRepository.GetByIdAsync(sendId).Returns(existingSend); + + var result = await _sut.PutRemoveAuth(sendId.ToString()); + + Assert.NotNull(result); + Assert.Equal(sendId, result.Id); + Assert.Equal(AuthType.None, result.AuthType); + Assert.Null(result.Password); + Assert.Null(result.Emails); + await _nonAnonymousSendCommand.Received(1).SaveSendAsync(Arg.Is(s => + s.Id == sendId && + s.Password == null && + s.Emails == null && + s.AuthType == AuthType.None)); + } + + [Theory, AutoData] + public async Task PutRemoveAuth_WithEmailProtectedSend_RemovesEmailsAndSetsAuthTypeNone(Guid userId, Guid sendId) + { + _userService.GetProperUserId(Arg.Any()).Returns(userId); + var existingSend = new Send + { + Id = sendId, + UserId = userId, + Type = SendType.Text, + Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)), + Password = null, + Emails = "test@example.com,user@example.com", + AuthType = AuthType.Email + }; + _sendRepository.GetByIdAsync(sendId).Returns(existingSend); + + var result = await _sut.PutRemoveAuth(sendId.ToString()); + + Assert.NotNull(result); + Assert.Equal(sendId, result.Id); + Assert.Equal(AuthType.None, result.AuthType); + Assert.Null(result.Password); + Assert.Null(result.Emails); + await _nonAnonymousSendCommand.Received(1).SaveSendAsync(Arg.Is(s => + s.Id == sendId && + s.Password == null && + s.Emails == null && + s.AuthType == AuthType.None)); + } + + [Theory, AutoData] + public async Task PutRemoveAuth_WithSendAlreadyHavingNoAuth_StillSucceeds(Guid userId, Guid sendId) + { + _userService.GetProperUserId(Arg.Any()).Returns(userId); + var existingSend = new Send + { + Id = sendId, + UserId = userId, + Type = SendType.Text, + Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)), + Password = null, + Emails = null, + AuthType = AuthType.None + }; + _sendRepository.GetByIdAsync(sendId).Returns(existingSend); + + var result = await _sut.PutRemoveAuth(sendId.ToString()); + + Assert.NotNull(result); + Assert.Equal(sendId, result.Id); + Assert.Equal(AuthType.None, result.AuthType); + Assert.Null(result.Password); + Assert.Null(result.Emails); + await _nonAnonymousSendCommand.Received(1).SaveSendAsync(Arg.Is(s => + s.Id == sendId && + s.Password == null && + s.Emails == null && + s.AuthType == AuthType.None)); + } + + [Theory, AutoData] + public async Task PutRemoveAuth_WithFileSend_RemovesAuthAndPreservesFileData(Guid userId, Guid sendId) + { + _userService.GetProperUserId(Arg.Any()).Returns(userId); + var fileData = new SendFileData("Test File", "Notes", "document.pdf") { Id = "file-123", Size = 2048 }; + var existingSend = new Send + { + Id = sendId, + UserId = userId, + Type = SendType.File, + Data = JsonSerializer.Serialize(fileData), + Password = "hashed-password", + Emails = null, + AuthType = AuthType.Password + }; + _sendRepository.GetByIdAsync(sendId).Returns(existingSend); + + var result = await _sut.PutRemoveAuth(sendId.ToString()); + + Assert.NotNull(result); + Assert.Equal(sendId, result.Id); + Assert.Equal(AuthType.None, result.AuthType); + Assert.Equal(SendType.File, result.Type); + Assert.NotNull(result.File); + Assert.Equal("file-123", result.File.Id); + Assert.Null(result.Password); + Assert.Null(result.Emails); + } + + [Theory, AutoData] + public async Task PutRemoveAuth_WithNonExistentSend_ThrowsNotFoundException(Guid userId, Guid sendId) + { + _userService.GetProperUserId(Arg.Any()).Returns(userId); + _sendRepository.GetByIdAsync(sendId).Returns((Send)null); + + await Assert.ThrowsAsync(() => _sut.PutRemoveAuth(sendId.ToString())); + + await _sendRepository.Received(1).GetByIdAsync(sendId); + await _nonAnonymousSendCommand.DidNotReceive().SaveSendAsync(Arg.Any()); + } + + [Theory, AutoData] + public async Task PutRemoveAuth_WithWrongUser_ThrowsNotFoundException(Guid userId, Guid otherUserId, Guid sendId) + { + _userService.GetProperUserId(Arg.Any()).Returns(userId); + var existingSend = new Send + { + Id = sendId, + UserId = otherUserId, + Type = SendType.Text, + Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)), + Password = "hashed-password", + AuthType = AuthType.Password + }; + _sendRepository.GetByIdAsync(sendId).Returns(existingSend); + + await Assert.ThrowsAsync(() => _sut.PutRemoveAuth(sendId.ToString())); + + await _sendRepository.Received(1).GetByIdAsync(sendId); + await _nonAnonymousSendCommand.DidNotReceive().SaveSendAsync(Arg.Any()); + } + + [Theory, AutoData] + public async Task PutRemoveAuth_WithNullUserId_ThrowsInvalidOperationException(Guid sendId) + { + _userService.GetProperUserId(Arg.Any()).Returns((Guid?)null); + + var exception = + await Assert.ThrowsAsync(() => _sut.PutRemoveAuth(sendId.ToString())); + + Assert.Equal("User ID not found", exception.Message); + await _sendRepository.DidNotReceive().GetByIdAsync(Arg.Any()); + await _nonAnonymousSendCommand.DidNotReceive().SaveSendAsync(Arg.Any()); + } + + [Theory, AutoData] + public async Task PutRemoveAuth_WithSendHavingBothPasswordAndEmails_RemovesBoth(Guid userId, Guid sendId) + { + _userService.GetProperUserId(Arg.Any()).Returns(userId); + var existingSend = new Send + { + Id = sendId, + UserId = userId, + Type = SendType.Text, + Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)), + Password = "hashed-password", + Emails = "test@example.com", + AuthType = AuthType.Password + }; + _sendRepository.GetByIdAsync(sendId).Returns(existingSend); + + var result = await _sut.PutRemoveAuth(sendId.ToString()); + + Assert.NotNull(result); + Assert.Equal(sendId, result.Id); + Assert.Equal(AuthType.None, result.AuthType); + Assert.Null(result.Password); + Assert.Null(result.Emails); + await _nonAnonymousSendCommand.Received(1).SaveSendAsync(Arg.Is(s => + s.Id == sendId && + s.Password == null && + s.Emails == null && + s.AuthType == AuthType.None)); + } + + [Theory, AutoData] + public async Task PutRemoveAuth_PreservesOtherSendProperties(Guid userId, Guid sendId) + { + _userService.GetProperUserId(Arg.Any()).Returns(userId); + var deletionDate = DateTime.UtcNow.AddDays(7); + var expirationDate = DateTime.UtcNow.AddDays(3); + var existingSend = new Send + { + Id = sendId, + UserId = userId, + Type = SendType.Text, + Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)), + Password = "hashed-password", + AuthType = AuthType.Password, + Key = "encryption-key", + MaxAccessCount = 10, + AccessCount = 3, + DeletionDate = deletionDate, + ExpirationDate = expirationDate, + Disabled = false, + HideEmail = true + }; + _sendRepository.GetByIdAsync(sendId).Returns(existingSend); + + var result = await _sut.PutRemoveAuth(sendId.ToString()); + + Assert.NotNull(result); + Assert.Equal(sendId, result.Id); + Assert.Equal(AuthType.None, result.AuthType); + // Verify other properties are preserved + Assert.Equal("encryption-key", result.Key); + Assert.Equal(10, result.MaxAccessCount); + Assert.Equal(3, result.AccessCount); + Assert.Equal(deletionDate, result.DeletionDate); + Assert.Equal(expirationDate, result.ExpirationDate); + Assert.False(result.Disabled); + Assert.True(result.HideEmail); + } + + #endregion + + #region Test Helpers + + private static ClaimsPrincipal CreateUserWithSendIdClaim(Guid sendId) + { + var claims = new List { new Claim("send_id", sendId.ToString()) }; + var identity = new ClaimsIdentity(claims, "TestAuth"); + return new ClaimsPrincipal(identity); + } + + private static ControllerContext CreateControllerContextWithUser(ClaimsPrincipal user) + { + return new ControllerContext { HttpContext = new Microsoft.AspNetCore.Http.DefaultHttpContext { User = user } }; + } + + #endregion } diff --git a/test/Core.Test/Tools/Services/SendAuthenticationQueryTests.cs b/test/Core.Test/Tools/Services/SendAuthenticationQueryTests.cs index c34afc42bd..7901b3c5c0 100644 --- a/test/Core.Test/Tools/Services/SendAuthenticationQueryTests.cs +++ b/test/Core.Test/Tools/Services/SendAuthenticationQueryTests.cs @@ -1,4 +1,5 @@ using Bit.Core.Tools.Entities; +using Bit.Core.Tools.Enums; using Bit.Core.Tools.Models.Data; using Bit.Core.Tools.Repositories; using Bit.Core.Tools.SendFeatures.Queries; @@ -47,7 +48,7 @@ public class SendAuthenticationQueryTests { // Arrange var sendId = Guid.NewGuid(); - var send = CreateSend(accessCount: 0, maxAccessCount: 10, emails: emailString, password: null); + var send = CreateSend(accessCount: 0, maxAccessCount: 10, emails: emailString, password: null, AuthType.Email); _sendRepository.GetByIdAsync(sendId).Returns(send); // Act @@ -63,7 +64,7 @@ public class SendAuthenticationQueryTests { // Arrange var sendId = Guid.NewGuid(); - var send = CreateSend(accessCount: 0, maxAccessCount: 10, emails: "test@example.com", password: "hashedpassword"); + var send = CreateSend(accessCount: 0, maxAccessCount: 10, emails: "test@example.com", password: "hashedpassword", AuthType.Email); _sendRepository.GetByIdAsync(sendId).Returns(send); // Act @@ -78,7 +79,7 @@ public class SendAuthenticationQueryTests { // Arrange var sendId = Guid.NewGuid(); - var send = CreateSend(accessCount: 0, maxAccessCount: 10, emails: null, password: null); + var send = CreateSend(accessCount: 0, maxAccessCount: 10, emails: null, password: null, AuthType.None); _sendRepository.GetByIdAsync(sendId).Returns(send); // Act @@ -105,11 +106,11 @@ public class SendAuthenticationQueryTests public static IEnumerable AuthenticationMethodTestCases() { yield return new object[] { null, typeof(NeverAuthenticate) }; - yield return new object[] { CreateSend(accessCount: 5, maxAccessCount: 5, emails: null, password: null), typeof(NeverAuthenticate) }; - yield return new object[] { CreateSend(accessCount: 6, maxAccessCount: 5, emails: null, password: null), typeof(NeverAuthenticate) }; - yield return new object[] { CreateSend(accessCount: 0, maxAccessCount: 10, emails: "test@example.com", password: null), typeof(EmailOtp) }; - yield return new object[] { CreateSend(accessCount: 0, maxAccessCount: 10, emails: null, password: "hashedpassword"), typeof(ResourcePassword) }; - yield return new object[] { CreateSend(accessCount: 0, maxAccessCount: 10, emails: null, password: null), typeof(NotAuthenticated) }; + yield return new object[] { CreateSend(accessCount: 5, maxAccessCount: 5, emails: null, password: null, AuthType.None), typeof(NeverAuthenticate) }; + yield return new object[] { CreateSend(accessCount: 6, maxAccessCount: 5, emails: null, password: null, AuthType.None), typeof(NeverAuthenticate) }; + yield return new object[] { CreateSend(accessCount: 0, maxAccessCount: 10, emails: "test@example.com", password: null, AuthType.Email), typeof(EmailOtp) }; + yield return new object[] { CreateSend(accessCount: 0, maxAccessCount: 10, emails: null, password: "hashedpassword", AuthType.Password), typeof(ResourcePassword) }; + yield return new object[] { CreateSend(accessCount: 0, maxAccessCount: 10, emails: null, password: null, AuthType.None), typeof(NotAuthenticated) }; } public static IEnumerable EmailParsingTestCases() @@ -121,7 +122,7 @@ public class SendAuthenticationQueryTests yield return new object[] { " , test@example.com, ,other@example.com, ", new[] { "test@example.com", "other@example.com" } }; } - private static Send CreateSend(int accessCount, int? maxAccessCount, string? emails, string? password) + private static Send CreateSend(int accessCount, int? maxAccessCount, string? emails, string? password, AuthType? authType) { return new Send { @@ -129,7 +130,8 @@ public class SendAuthenticationQueryTests AccessCount = accessCount, MaxAccessCount = maxAccessCount, Emails = emails, - Password = password + Password = password, + AuthType = authType }; } } diff --git a/test/Core.Test/Tools/Services/SendOwnerQueryTests.cs b/test/Core.Test/Tools/Services/SendOwnerQueryTests.cs index 9a2f942eb8..71ed27f2ac 100644 --- a/test/Core.Test/Tools/Services/SendOwnerQueryTests.cs +++ b/test/Core.Test/Tools/Services/SendOwnerQueryTests.cs @@ -12,7 +12,6 @@ namespace Bit.Core.Test.Tools.Services; public class SendOwnerQueryTests { private readonly ISendRepository _sendRepository; - private readonly IFeatureService _featureService; private readonly IUserService _userService; private readonly SendOwnerQuery _sendOwnerQuery; private readonly Guid _currentUserId = Guid.NewGuid(); @@ -21,11 +20,10 @@ public class SendOwnerQueryTests public SendOwnerQueryTests() { _sendRepository = Substitute.For(); - _featureService = Substitute.For(); _userService = Substitute.For(); _user = new ClaimsPrincipal(); _userService.GetProperUserId(_user).Returns(_currentUserId); - _sendOwnerQuery = new SendOwnerQuery(_sendRepository, _featureService, _userService); + _sendOwnerQuery = new SendOwnerQuery(_sendRepository, _userService); } [Fact] @@ -84,7 +82,7 @@ public class SendOwnerQueryTests } [Fact] - public async Task GetOwned_WithFeatureFlagEnabled_ReturnsAllSends() + public async Task GetOwned_ReturnsAllSendsIncludingEmailOTP() { // Arrange var sends = new List @@ -94,7 +92,6 @@ public class SendOwnerQueryTests CreateSend(Guid.NewGuid(), _currentUserId, emails: "other@example.com") }; _sendRepository.GetManyByUserIdAsync(_currentUserId).Returns(sends); - _featureService.IsEnabled(FeatureFlagKeys.PM19051_ListEmailOtpSends).Returns(true); // Act var result = await _sendOwnerQuery.GetOwned(_user); @@ -105,28 +102,6 @@ public class SendOwnerQueryTests Assert.Contains(sends[1], result); Assert.Contains(sends[2], result); await _sendRepository.Received(1).GetManyByUserIdAsync(_currentUserId); - _featureService.Received(1).IsEnabled(FeatureFlagKeys.PM19051_ListEmailOtpSends); - } - - [Fact] - public async Task GetOwned_WithFeatureFlagDisabled_FiltersOutEmailOtpSends() - { - // Arrange - var sendWithoutEmails = CreateSend(Guid.NewGuid(), _currentUserId, emails: null); - var sendWithEmails = CreateSend(Guid.NewGuid(), _currentUserId, emails: "test@example.com"); - var sends = new List { sendWithoutEmails, sendWithEmails }; - _sendRepository.GetManyByUserIdAsync(_currentUserId).Returns(sends); - _featureService.IsEnabled(FeatureFlagKeys.PM19051_ListEmailOtpSends).Returns(false); - - // Act - var result = await _sendOwnerQuery.GetOwned(_user); - - // Assert - Assert.Single(result); - Assert.Contains(sendWithoutEmails, result); - Assert.DoesNotContain(sendWithEmails, result); - await _sendRepository.Received(1).GetManyByUserIdAsync(_currentUserId); - _featureService.Received(1).IsEnabled(FeatureFlagKeys.PM19051_ListEmailOtpSends); } [Fact] @@ -147,7 +122,6 @@ public class SendOwnerQueryTests // Arrange var emptySends = new List(); _sendRepository.GetManyByUserIdAsync(_currentUserId).Returns(emptySends); - _featureService.IsEnabled(FeatureFlagKeys.PM19051_ListEmailOtpSends).Returns(true); // Act var result = await _sendOwnerQuery.GetOwned(_user); From 584af2ee3f41d0cf0b1cb9ccd9ca05ae4795792c Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Wed, 14 Jan 2026 16:27:39 -0500 Subject: [PATCH 17/49] Catch general exception for all db types (#6846) * Switch `SqlException` to `DbException` Co-authored-by: rkac-bw <148072202+rkac-bw@users.noreply.github.com> * Fix CA2253 --------- Co-authored-by: rkac-bw <148072202+rkac-bw@users.noreply.github.com> --- .../HostedServices/DatabaseMigrationHostedService.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Admin/HostedServices/DatabaseMigrationHostedService.cs b/src/Admin/HostedServices/DatabaseMigrationHostedService.cs index 219e6846bd..51739ce655 100644 --- a/src/Admin/HostedServices/DatabaseMigrationHostedService.cs +++ b/src/Admin/HostedServices/DatabaseMigrationHostedService.cs @@ -1,5 +1,5 @@ -using Bit.Core.Utilities; -using Microsoft.Data.SqlClient; +using System.Data.Common; +using Bit.Core.Utilities; namespace Bit.Admin.HostedServices; @@ -30,7 +30,7 @@ public class DatabaseMigrationHostedService : IHostedService, IDisposable // TODO: Maybe flip a flag somewhere to indicate migration is complete?? break; } - catch (SqlException e) + catch (DbException e) { if (i >= maxMigrationAttempts) { @@ -40,7 +40,7 @@ public class DatabaseMigrationHostedService : IHostedService, IDisposable else { _logger.LogError(e, - "Database unavailable for migration. Trying again (attempt #{0})...", i + 1); + "Database unavailable for migration. Trying again (attempt #{AttemptNumber})...", i + 1); await Task.Delay(20000, cancellationToken); } } From e22290c52b47c943ae109d81f203dcb2227c82c5 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 14 Jan 2026 16:46:12 -0500 Subject: [PATCH 18/49] [deps] Auth: Update sass to v1.97.2 (#6630) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Ike <137194738+ike-kottlowski@users.noreply.github.com> --- bitwarden_license/src/Sso/package-lock.json | 8 ++++---- bitwarden_license/src/Sso/package.json | 2 +- src/Admin/package-lock.json | 8 ++++---- src/Admin/package.json | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/bitwarden_license/src/Sso/package-lock.json b/bitwarden_license/src/Sso/package-lock.json index f5e0468f87..c4a6d64231 100644 --- a/bitwarden_license/src/Sso/package-lock.json +++ b/bitwarden_license/src/Sso/package-lock.json @@ -17,7 +17,7 @@ "css-loader": "7.1.2", "expose-loader": "5.0.1", "mini-css-extract-plugin": "2.9.2", - "sass": "1.93.2", + "sass": "1.97.2", "sass-loader": "16.0.5", "webpack": "5.102.1", "webpack-cli": "5.1.4" @@ -1874,9 +1874,9 @@ "license": "MIT" }, "node_modules/sass": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.93.2.tgz", - "integrity": "sha512-t+YPtOQHpGW1QWsh1CHQ5cPIr9lbbGZLZnbihP/D/qZj/yuV68m8qarcV17nvkOX81BCrvzAlq2klCQFZghyTg==", + "version": "1.97.2", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.97.2.tgz", + "integrity": "sha512-y5LWb0IlbO4e97Zr7c3mlpabcbBtS+ieiZ9iwDooShpFKWXf62zz5pEPdwrLYm+Bxn1fnbwFGzHuCLSA9tBmrw==", "dev": true, "license": "MIT", "peer": true, diff --git a/bitwarden_license/src/Sso/package.json b/bitwarden_license/src/Sso/package.json index df46444aca..31c66fdccf 100644 --- a/bitwarden_license/src/Sso/package.json +++ b/bitwarden_license/src/Sso/package.json @@ -16,7 +16,7 @@ "css-loader": "7.1.2", "expose-loader": "5.0.1", "mini-css-extract-plugin": "2.9.2", - "sass": "1.93.2", + "sass": "1.97.2", "sass-loader": "16.0.5", "webpack": "5.102.1", "webpack-cli": "5.1.4" diff --git a/src/Admin/package-lock.json b/src/Admin/package-lock.json index 6e0f78e1e6..d509cfc93f 100644 --- a/src/Admin/package-lock.json +++ b/src/Admin/package-lock.json @@ -18,7 +18,7 @@ "css-loader": "7.1.2", "expose-loader": "5.0.1", "mini-css-extract-plugin": "2.9.2", - "sass": "1.93.2", + "sass": "1.97.2", "sass-loader": "16.0.5", "webpack": "5.102.1", "webpack-cli": "5.1.4" @@ -1875,9 +1875,9 @@ "license": "MIT" }, "node_modules/sass": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.93.2.tgz", - "integrity": "sha512-t+YPtOQHpGW1QWsh1CHQ5cPIr9lbbGZLZnbihP/D/qZj/yuV68m8qarcV17nvkOX81BCrvzAlq2klCQFZghyTg==", + "version": "1.97.2", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.97.2.tgz", + "integrity": "sha512-y5LWb0IlbO4e97Zr7c3mlpabcbBtS+ieiZ9iwDooShpFKWXf62zz5pEPdwrLYm+Bxn1fnbwFGzHuCLSA9tBmrw==", "dev": true, "license": "MIT", "peer": true, diff --git a/src/Admin/package.json b/src/Admin/package.json index f6f21e2cf9..5300df9369 100644 --- a/src/Admin/package.json +++ b/src/Admin/package.json @@ -17,7 +17,7 @@ "css-loader": "7.1.2", "expose-loader": "5.0.1", "mini-css-extract-plugin": "2.9.2", - "sass": "1.93.2", + "sass": "1.97.2", "sass-loader": "16.0.5", "webpack": "5.102.1", "webpack-cli": "5.1.4" From 21e9bb3138ba8d212c10ca01a106921657db5c12 Mon Sep 17 00:00:00 2001 From: Dave <3836813+enmande@users.noreply.github.com> Date: Wed, 14 Jan 2026 16:50:37 -0500 Subject: [PATCH 19/49] feat(single sign-on) [PM-23572] Add Persistent Grants to SSO (#6636) * feat(sso-persisted-grants) [PM-23572]: Stub PersistedGrantStore. * feat(sso-persisted-grants) [PM-23572]: Update service reigtration with named cache. * feat(sso-persisted-grants) [PM-23572]: Add unit tests for DistributedCachePersistedGrantStore. * feat(sso-persisted-grants) [PM-23572]: Add additional tests. * feat(sso-persisted-grants) [PM-23572]: Add some additional clarifying comments on ExtendedCache vs InMemoryCaching for Duende. * feat(sso-persistent-grants) [PM-23572]: Spelling in a comment for cache key name. * feat(sso-persisted-grants) [PM-23572]: Add cache key constant and remove explicit skip distributed cache on set for default configuration. --------- Co-authored-by: bnagawiecki <107435978+bnagawiecki@users.noreply.github.com> --- .../DistributedCachePersistedGrantStore.cs | 102 +++++++ ...ersistedGrantsDistributedCacheConstants.cs | 10 + .../Utilities/ServiceCollectionExtensions.cs | 12 + ...istributedCachePersistedGrantStoreTests.cs | 257 ++++++++++++++++++ 4 files changed, 381 insertions(+) create mode 100644 bitwarden_license/src/Sso/IdentityServer/DistributedCachePersistedGrantStore.cs create mode 100644 bitwarden_license/src/Sso/Utilities/PersistedGrantsDistributedCacheConstants.cs create mode 100644 bitwarden_license/test/SSO.Test/IdentityServer/DistributedCachePersistedGrantStoreTests.cs diff --git a/bitwarden_license/src/Sso/IdentityServer/DistributedCachePersistedGrantStore.cs b/bitwarden_license/src/Sso/IdentityServer/DistributedCachePersistedGrantStore.cs new file mode 100644 index 0000000000..ecb2f36cec --- /dev/null +++ b/bitwarden_license/src/Sso/IdentityServer/DistributedCachePersistedGrantStore.cs @@ -0,0 +1,102 @@ +using Bit.Sso.Utilities; +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Stores; +using ZiggyCreatures.Caching.Fusion; + +namespace Bit.Sso.IdentityServer; + +/// +/// Distributed cache-backed persisted grant store for short-lived grants. +/// Uses IFusionCache (which wraps IDistributedCache) for horizontal scaling support, +/// and fall back to in-memory caching if Redis is not configured. +/// Designed for SSO authorization codes which are short-lived (5 minutes) and single-use. +/// +/// +/// This is purposefully a different implementation from how Identity solves Persisted Grants. +/// Because even flavored grant store, e.g., AuthorizationCodeGrantStore, can add intermediary +/// logic to a grant's handling by type, the fact that they all wrap IdentityServer's IPersistedGrantStore +/// leans on IdentityServer's opinion that all grants, regardless of type, go to the same persistence +/// mechanism (cache, database). +/// +/// +public class DistributedCachePersistedGrantStore : IPersistedGrantStore +{ + private readonly IFusionCache _cache; + + public DistributedCachePersistedGrantStore( + [FromKeyedServices(PersistedGrantsDistributedCacheConstants.CacheKey)] IFusionCache cache) + { + _cache = cache; + } + + public async Task GetAsync(string key) + { + var result = await _cache.TryGetAsync(key); + + if (!result.HasValue) + { + return null; + } + + var grant = result.Value; + + // Check if grant has expired - remove expired grants from cache + if (grant.Expiration.HasValue && grant.Expiration.Value < DateTime.UtcNow) + { + await RemoveAsync(key); + return null; + } + + return grant; + } + + public Task> GetAllAsync(PersistedGrantFilter filter) + { + // Cache stores are key-value based and don't support querying by filter criteria. + // This method is typically used for cleanup operations on long-lived grants in databases. + // For SSO's short-lived authorization codes, we rely on TTL expiration instead. + + return Task.FromResult(Enumerable.Empty()); + } + + public Task RemoveAllAsync(PersistedGrantFilter filter) + { + // Revocation Strategy: SSO's logout flow (AccountController.LogoutAsync) only clears local + // authentication cookies and performs federated logout with external IdPs. It does not invoke + // Duende's EndSession or TokenRevocation endpoints. Authorization codes are single-use and expire + // within 5 minutes, making explicit revocation unnecessary for SSO's security model. + // https://docs.duendesoftware.com/identityserver/reference/stores/persisted-grant-store/ + + // Cache stores are key-value based and don't support bulk deletion by filter. + // This method is typically used for cleanup operations on long-lived grants in databases. + // For SSO's short-lived authorization codes, we rely on TTL expiration instead. + + return Task.FromResult(0); + } + + public async Task RemoveAsync(string key) + { + await _cache.RemoveAsync(key); + } + + public async Task StoreAsync(PersistedGrant grant) + { + // Calculate TTL based on grant expiration + var duration = grant.Expiration.HasValue + ? grant.Expiration.Value - DateTime.UtcNow + : TimeSpan.FromMinutes(5); // Default to 5 minutes if no expiration set + + // Ensure positive duration + if (duration <= TimeSpan.Zero) + { + return; + } + + // Cache key "sso-grants:" is configured by service registration. Going through the consumed KeyedService will + // give us a consistent cache key prefix for these grants. + await _cache.SetAsync( + grant.Key, + grant, + new FusionCacheEntryOptions { Duration = duration }); + } +} diff --git a/bitwarden_license/src/Sso/Utilities/PersistedGrantsDistributedCacheConstants.cs b/bitwarden_license/src/Sso/Utilities/PersistedGrantsDistributedCacheConstants.cs new file mode 100644 index 0000000000..3ec45377e3 --- /dev/null +++ b/bitwarden_license/src/Sso/Utilities/PersistedGrantsDistributedCacheConstants.cs @@ -0,0 +1,10 @@ +namespace Bit.Sso.Utilities; + +public static class PersistedGrantsDistributedCacheConstants +{ + /// + /// The SSO Persisted Grant cache key. Identifies the keyed service consumed by the SSO Persisted Grant Store as + /// well as the cache key/namespace for grant storage. + /// + public const string CacheKey = "sso-grants"; +} diff --git a/bitwarden_license/src/Sso/Utilities/ServiceCollectionExtensions.cs b/bitwarden_license/src/Sso/Utilities/ServiceCollectionExtensions.cs index a51a04f5c8..da7a79535e 100644 --- a/bitwarden_license/src/Sso/Utilities/ServiceCollectionExtensions.cs +++ b/bitwarden_license/src/Sso/Utilities/ServiceCollectionExtensions.cs @@ -9,6 +9,7 @@ using Bit.Sso.IdentityServer; using Bit.Sso.Models; using Duende.IdentityServer.Models; using Duende.IdentityServer.ResponseHandling; +using Duende.IdentityServer.Stores; using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Sustainsys.Saml2.AspNetCore2; @@ -77,6 +78,17 @@ public static class ServiceCollectionExtensions }) .AddIdentityServerCertificate(env, globalSettings); + // PM-23572 + // Register named FusionCache for SSO authorization code grants. + // Provides separation of concerns and automatic Redis/in-memory negotiation + // .AddInMemoryCaching should still persist above; this handles configuration caching, etc., + // and is separate from this keyed service, which only serves grant negotiation. + services.AddExtendedCache(PersistedGrantsDistributedCacheConstants.CacheKey, globalSettings); + + // Store authorization codes in distributed cache for horizontal scaling + // Uses named FusionCache which gracefully degrades to in-memory when Redis isn't configured + services.AddSingleton(); + return identityServerBuilder; } } diff --git a/bitwarden_license/test/SSO.Test/IdentityServer/DistributedCachePersistedGrantStoreTests.cs b/bitwarden_license/test/SSO.Test/IdentityServer/DistributedCachePersistedGrantStoreTests.cs new file mode 100644 index 0000000000..c0aa93f068 --- /dev/null +++ b/bitwarden_license/test/SSO.Test/IdentityServer/DistributedCachePersistedGrantStoreTests.cs @@ -0,0 +1,257 @@ +using Bit.Sso.IdentityServer; +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Stores; +using NSubstitute; +using ZiggyCreatures.Caching.Fusion; + +namespace Bit.SSO.Test.IdentityServer; + +public class DistributedCachePersistedGrantStoreTests +{ + private readonly IFusionCache _cache; + private readonly DistributedCachePersistedGrantStore _sut; + + public DistributedCachePersistedGrantStoreTests() + { + _cache = Substitute.For(); + _sut = new DistributedCachePersistedGrantStore(_cache); + } + + [Fact] + public async Task StoreAsync_StoresGrantWithCalculatedTTL() + { + // Arrange + var grant = CreateTestGrant("test-key", expiration: DateTime.UtcNow.AddMinutes(5)); + + // Act + await _sut.StoreAsync(grant); + + // Assert + await _cache.Received(1).SetAsync( + "test-key", + grant, + Arg.Is(opts => + opts.Duration >= TimeSpan.FromMinutes(4.9) && + opts.Duration <= TimeSpan.FromMinutes(5))); + } + + [Fact] + public async Task StoreAsync_WithNoExpiration_UsesDefaultFiveMinuteTTL() + { + // Arrange + var grant = CreateTestGrant("no-expiry-key", expiration: null); + + // Act + await _sut.StoreAsync(grant); + + // Assert + await _cache.Received(1).SetAsync( + "no-expiry-key", + grant, + Arg.Is(opts => opts.Duration == TimeSpan.FromMinutes(5))); + } + + [Fact] + public async Task StoreAsync_WithAlreadyExpiredGrant_DoesNotStore() + { + // Arrange + var expiredGrant = CreateTestGrant("expired-key", expiration: DateTime.UtcNow.AddMinutes(-1)); + + // Act + await _sut.StoreAsync(expiredGrant); + + // Assert + await _cache.DidNotReceive().SetAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task StoreAsync_EnablesDistributedCache() + { + // Arrange + var grant = CreateTestGrant("distributed-key", expiration: DateTime.UtcNow.AddMinutes(5)); + + // Act + await _sut.StoreAsync(grant); + + // Assert + await _cache.Received(1).SetAsync( + "distributed-key", + grant, + Arg.Is(opts => + opts.SkipDistributedCache == false && + opts.SkipDistributedCacheReadWhenStale == false)); + } + + [Fact] + public async Task GetAsync_WithValidGrant_ReturnsGrant() + { + // Arrange + var grant = CreateTestGrant("valid-key", expiration: DateTime.UtcNow.AddMinutes(5)); + _cache.TryGetAsync("valid-key") + .Returns(MaybeValue.FromValue(grant)); + + // Act + var result = await _sut.GetAsync("valid-key"); + + // Assert + Assert.NotNull(result); + Assert.Equal("valid-key", result.Key); + Assert.Equal("authorization_code", result.Type); + Assert.Equal("test-subject", result.SubjectId); + await _cache.DidNotReceive().RemoveAsync(Arg.Any()); + } + + [Fact] + public async Task GetAsync_WithNonExistentKey_ReturnsNull() + { + // Arrange + _cache.TryGetAsync("nonexistent-key") + .Returns(MaybeValue.None); + + // Act + var result = await _sut.GetAsync("nonexistent-key"); + + // Assert + Assert.Null(result); + await _cache.DidNotReceive().RemoveAsync(Arg.Any()); + } + + [Fact] + public async Task GetAsync_WithExpiredGrant_RemovesAndReturnsNull() + { + // Arrange + var expiredGrant = CreateTestGrant("expired-key", expiration: DateTime.UtcNow.AddMinutes(-1)); + _cache.TryGetAsync("expired-key") + .Returns(MaybeValue.FromValue(expiredGrant)); + + // Act + var result = await _sut.GetAsync("expired-key"); + + // Assert + Assert.Null(result); + await _cache.Received(1).RemoveAsync("expired-key"); + } + + [Fact] + public async Task GetAsync_WithNoExpiration_ReturnsGrant() + { + // Arrange + var grant = CreateTestGrant("no-expiry-key", expiration: null); + _cache.TryGetAsync("no-expiry-key") + .Returns(MaybeValue.FromValue(grant)); + + // Act + var result = await _sut.GetAsync("no-expiry-key"); + + // Assert + Assert.NotNull(result); + Assert.Equal("no-expiry-key", result.Key); + Assert.Null(result.Expiration); + await _cache.DidNotReceive().RemoveAsync(Arg.Any()); + } + + [Fact] + public async Task RemoveAsync_RemovesGrantFromCache() + { + // Act + await _sut.RemoveAsync("remove-key"); + + // Assert + await _cache.Received(1).RemoveAsync("remove-key"); + } + + [Fact] + public async Task GetAllAsync_ReturnsEmptyCollection() + { + // Arrange + var filter = new PersistedGrantFilter + { + SubjectId = "test-subject", + SessionId = "test-session", + ClientId = "test-client", + Type = "authorization_code" + }; + + // Act + var result = await _sut.GetAllAsync(filter); + + // Assert + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public async Task RemoveAllAsync_CompletesWithoutError() + { + // Arrange + var filter = new PersistedGrantFilter + { + SubjectId = "test-subject", + ClientId = "test-client" + }; + + // Act & Assert - should not throw + await _sut.RemoveAllAsync(filter); + + // Verify no cache operations were performed + await _cache.DidNotReceive().RemoveAsync(Arg.Any()); + } + + [Fact] + public async Task StoreAsync_PreservesAllGrantProperties() + { + // Arrange + var grant = new PersistedGrant + { + Key = "full-grant-key", + Type = "authorization_code", + SubjectId = "user-123", + SessionId = "session-456", + ClientId = "client-789", + Description = "Test grant", + CreationTime = DateTime.UtcNow.AddMinutes(-1), + Expiration = DateTime.UtcNow.AddMinutes(5), + ConsumedTime = null, + Data = "{\"test\":\"data\"}" + }; + + PersistedGrant? capturedGrant = null; + await _cache.SetAsync( + Arg.Any(), + Arg.Do(g => capturedGrant = g), + Arg.Any()); + + // Act + await _sut.StoreAsync(grant); + + // Assert + Assert.NotNull(capturedGrant); + Assert.Equal(grant.Key, capturedGrant.Key); + Assert.Equal(grant.Type, capturedGrant.Type); + Assert.Equal(grant.SubjectId, capturedGrant.SubjectId); + Assert.Equal(grant.SessionId, capturedGrant.SessionId); + Assert.Equal(grant.ClientId, capturedGrant.ClientId); + Assert.Equal(grant.Description, capturedGrant.Description); + Assert.Equal(grant.CreationTime, capturedGrant.CreationTime); + Assert.Equal(grant.Expiration, capturedGrant.Expiration); + Assert.Equal(grant.ConsumedTime, capturedGrant.ConsumedTime); + Assert.Equal(grant.Data, capturedGrant.Data); + } + + private static PersistedGrant CreateTestGrant(string key, DateTime? expiration) + { + return new PersistedGrant + { + Key = key, + Type = "authorization_code", + SubjectId = "test-subject", + ClientId = "test-client", + CreationTime = DateTime.UtcNow, + Expiration = expiration, + Data = "{\"test\":\"data\"}" + }; + } +} From 9116a0b3fcfa111f02ed42c68ead1237e25ec14e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 14 Jan 2026 17:22:28 -0500 Subject: [PATCH 20/49] [deps] Auth: Update webpack to v5.104.1 (#6701) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Ike <137194738+ike-kottlowski@users.noreply.github.com> --- bitwarden_license/src/Sso/package-lock.json | 86 +++++++++++---------- bitwarden_license/src/Sso/package.json | 2 +- src/Admin/package-lock.json | 86 +++++++++++---------- src/Admin/package.json | 2 +- 4 files changed, 92 insertions(+), 84 deletions(-) diff --git a/bitwarden_license/src/Sso/package-lock.json b/bitwarden_license/src/Sso/package-lock.json index c4a6d64231..efeee7f4ca 100644 --- a/bitwarden_license/src/Sso/package-lock.json +++ b/bitwarden_license/src/Sso/package-lock.json @@ -19,7 +19,7 @@ "mini-css-extract-plugin": "2.9.2", "sass": "1.97.2", "sass-loader": "16.0.5", - "webpack": "5.102.1", + "webpack": "5.104.1", "webpack-cli": "5.1.4" } }, @@ -749,9 +749,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.8.18", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.18.tgz", - "integrity": "sha512-UYmTpOBwgPScZpS4A+YbapwWuBwasxvO/2IOHArSsAhL/+ZdmATBXTex3t+l2hXwLVYK382ibr/nKoY9GKe86w==", + "version": "2.9.13", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.13.tgz", + "integrity": "sha512-WhtvB2NG2wjr04+h77sg3klAIwrgOqnjS49GGudnUPGFFgg7G17y7Qecqp+2Dr5kUDxNRBca0SK7cG8JwzkWDQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -792,9 +792,9 @@ } }, "node_modules/browserslist": { - "version": "4.26.3", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz", - "integrity": "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==", + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", "dev": true, "funding": [ { @@ -813,11 +813,11 @@ "license": "MIT", "peer": true, "dependencies": { - "baseline-browser-mapping": "^2.8.9", - "caniuse-lite": "^1.0.30001746", - "electron-to-chromium": "^1.5.227", - "node-releases": "^2.0.21", - "update-browserslist-db": "^1.1.3" + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" @@ -834,9 +834,9 @@ "license": "MIT" }, "node_modules/caniuse-lite": { - "version": "1.0.30001751", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz", - "integrity": "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==", + "version": "1.0.30001763", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001763.tgz", + "integrity": "sha512-mh/dGtq56uN98LlNX9qdbKnzINhX0QzhiWBFEkFfsFO4QyCvL8YegrJAazCwXIeqkIob8BlZPGM3xdnY+sgmvQ==", "dev": true, "funding": [ { @@ -988,9 +988,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.237", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.237.tgz", - "integrity": "sha512-icUt1NvfhGLar5lSWH3tHNzablaA5js3HVHacQimfP8ViEBOQv+L7DKEuHdbTZ0SKCO1ogTJTIL1Gwk9S6Qvcg==", + "version": "1.5.267", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", + "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", "dev": true, "license": "ISC" }, @@ -1022,9 +1022,9 @@ } }, "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", "dev": true, "license": "MIT" }, @@ -1418,13 +1418,17 @@ } }, "node_modules/loader-runner": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", - "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz", + "integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==", "dev": true, "license": "MIT", "engines": { "node": ">=6.11.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, "node_modules/locate-path": { @@ -1541,9 +1545,9 @@ "optional": true }, "node_modules/node-releases": { - "version": "2.0.26", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.26.tgz", - "integrity": "sha512-S2M9YimhSjBSvYnlr5/+umAnPHE++ODwt5e2Ij6FoX45HA/s4vHdkDx1eax2pAPeAOqu4s9b7ppahsyEFdVqQA==", + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", "dev": true, "license": "MIT" }, @@ -2109,9 +2113,9 @@ } }, "node_modules/terser-webpack-plugin": { - "version": "5.3.14", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz", - "integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==", + "version": "5.3.16", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.16.tgz", + "integrity": "sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==", "dev": true, "license": "MIT", "dependencies": { @@ -2165,9 +2169,9 @@ "license": "MIT" }, "node_modules/update-browserslist-db": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", - "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "dev": true, "funding": [ { @@ -2217,9 +2221,9 @@ } }, "node_modules/webpack": { - "version": "5.102.1", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.102.1.tgz", - "integrity": "sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ==", + "version": "5.104.1", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.104.1.tgz", + "integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==", "dev": true, "license": "MIT", "peer": true, @@ -2232,21 +2236,21 @@ "@webassemblyjs/wasm-parser": "^1.14.1", "acorn": "^8.15.0", "acorn-import-phases": "^1.0.3", - "browserslist": "^4.26.3", + "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.17.3", - "es-module-lexer": "^1.2.1", + "enhanced-resolve": "^5.17.4", + "es-module-lexer": "^2.0.0", "eslint-scope": "5.1.1", "events": "^3.2.0", "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.2.11", "json-parse-even-better-errors": "^2.3.1", - "loader-runner": "^4.2.0", + "loader-runner": "^4.3.1", "mime-types": "^2.1.27", "neo-async": "^2.6.2", "schema-utils": "^4.3.3", "tapable": "^2.3.0", - "terser-webpack-plugin": "^5.3.11", + "terser-webpack-plugin": "^5.3.16", "watchpack": "^2.4.4", "webpack-sources": "^3.3.3" }, diff --git a/bitwarden_license/src/Sso/package.json b/bitwarden_license/src/Sso/package.json index 31c66fdccf..b0a1849421 100644 --- a/bitwarden_license/src/Sso/package.json +++ b/bitwarden_license/src/Sso/package.json @@ -18,7 +18,7 @@ "mini-css-extract-plugin": "2.9.2", "sass": "1.97.2", "sass-loader": "16.0.5", - "webpack": "5.102.1", + "webpack": "5.104.1", "webpack-cli": "5.1.4" } } diff --git a/src/Admin/package-lock.json b/src/Admin/package-lock.json index d509cfc93f..e851daac36 100644 --- a/src/Admin/package-lock.json +++ b/src/Admin/package-lock.json @@ -20,7 +20,7 @@ "mini-css-extract-plugin": "2.9.2", "sass": "1.97.2", "sass-loader": "16.0.5", - "webpack": "5.102.1", + "webpack": "5.104.1", "webpack-cli": "5.1.4" } }, @@ -750,9 +750,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.8.18", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.18.tgz", - "integrity": "sha512-UYmTpOBwgPScZpS4A+YbapwWuBwasxvO/2IOHArSsAhL/+ZdmATBXTex3t+l2hXwLVYK382ibr/nKoY9GKe86w==", + "version": "2.9.13", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.13.tgz", + "integrity": "sha512-WhtvB2NG2wjr04+h77sg3klAIwrgOqnjS49GGudnUPGFFgg7G17y7Qecqp+2Dr5kUDxNRBca0SK7cG8JwzkWDQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -793,9 +793,9 @@ } }, "node_modules/browserslist": { - "version": "4.26.3", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz", - "integrity": "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==", + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", "dev": true, "funding": [ { @@ -814,11 +814,11 @@ "license": "MIT", "peer": true, "dependencies": { - "baseline-browser-mapping": "^2.8.9", - "caniuse-lite": "^1.0.30001746", - "electron-to-chromium": "^1.5.227", - "node-releases": "^2.0.21", - "update-browserslist-db": "^1.1.3" + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" @@ -835,9 +835,9 @@ "license": "MIT" }, "node_modules/caniuse-lite": { - "version": "1.0.30001751", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz", - "integrity": "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==", + "version": "1.0.30001763", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001763.tgz", + "integrity": "sha512-mh/dGtq56uN98LlNX9qdbKnzINhX0QzhiWBFEkFfsFO4QyCvL8YegrJAazCwXIeqkIob8BlZPGM3xdnY+sgmvQ==", "dev": true, "funding": [ { @@ -989,9 +989,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.237", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.237.tgz", - "integrity": "sha512-icUt1NvfhGLar5lSWH3tHNzablaA5js3HVHacQimfP8ViEBOQv+L7DKEuHdbTZ0SKCO1ogTJTIL1Gwk9S6Qvcg==", + "version": "1.5.267", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", + "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", "dev": true, "license": "ISC" }, @@ -1023,9 +1023,9 @@ } }, "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", "dev": true, "license": "MIT" }, @@ -1419,13 +1419,17 @@ } }, "node_modules/loader-runner": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", - "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz", + "integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==", "dev": true, "license": "MIT", "engines": { "node": ">=6.11.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, "node_modules/locate-path": { @@ -1542,9 +1546,9 @@ "optional": true }, "node_modules/node-releases": { - "version": "2.0.26", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.26.tgz", - "integrity": "sha512-S2M9YimhSjBSvYnlr5/+umAnPHE++ODwt5e2Ij6FoX45HA/s4vHdkDx1eax2pAPeAOqu4s9b7ppahsyEFdVqQA==", + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", "dev": true, "license": "MIT" }, @@ -2110,9 +2114,9 @@ } }, "node_modules/terser-webpack-plugin": { - "version": "5.3.14", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz", - "integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==", + "version": "5.3.16", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.16.tgz", + "integrity": "sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==", "dev": true, "license": "MIT", "dependencies": { @@ -2174,9 +2178,9 @@ "license": "MIT" }, "node_modules/update-browserslist-db": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", - "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "dev": true, "funding": [ { @@ -2226,9 +2230,9 @@ } }, "node_modules/webpack": { - "version": "5.102.1", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.102.1.tgz", - "integrity": "sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ==", + "version": "5.104.1", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.104.1.tgz", + "integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==", "dev": true, "license": "MIT", "peer": true, @@ -2241,21 +2245,21 @@ "@webassemblyjs/wasm-parser": "^1.14.1", "acorn": "^8.15.0", "acorn-import-phases": "^1.0.3", - "browserslist": "^4.26.3", + "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.17.3", - "es-module-lexer": "^1.2.1", + "enhanced-resolve": "^5.17.4", + "es-module-lexer": "^2.0.0", "eslint-scope": "5.1.1", "events": "^3.2.0", "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.2.11", "json-parse-even-better-errors": "^2.3.1", - "loader-runner": "^4.2.0", + "loader-runner": "^4.3.1", "mime-types": "^2.1.27", "neo-async": "^2.6.2", "schema-utils": "^4.3.3", "tapable": "^2.3.0", - "terser-webpack-plugin": "^5.3.11", + "terser-webpack-plugin": "^5.3.16", "watchpack": "^2.4.4", "webpack-sources": "^3.3.3" }, diff --git a/src/Admin/package.json b/src/Admin/package.json index 5300df9369..3a3926d6ee 100644 --- a/src/Admin/package.json +++ b/src/Admin/package.json @@ -19,7 +19,7 @@ "mini-css-extract-plugin": "2.9.2", "sass": "1.97.2", "sass-loader": "16.0.5", - "webpack": "5.102.1", + "webpack": "5.104.1", "webpack-cli": "5.1.4" } } From ed5419c76715f91c79182a8d4d800cae29398a30 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 14 Jan 2026 18:17:20 -0500 Subject: [PATCH 21/49] [deps] Auth: Update Microsoft.Extensions.Caching.Cosmos to 1.8.0 (#6326) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Ike <137194738+ike-kottlowski@users.noreply.github.com> --- src/Core/Core.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index 3df438b493..a423d9377d 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -46,7 +46,7 @@ - + From b86a31160a9ab28f37b7757d064875f9025fdd2f Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Wed, 14 Jan 2026 15:55:09 -0800 Subject: [PATCH 22/49] [PM-30448] - remove edit requirement for cipher archiving (#6830) * remove edit requirement for cipher archiving * update cipher_archive/unarchive sql * update cipher_archive/unarchive sql * fix sql * update sql * update sql --- .../Vault/Repositories/CipherRepository.cs | 2 +- .../Cipher/Cipher_Archive.sql | 16 ++-- .../Cipher/Cipher_Unarchive.sql | 16 ++-- .../Repositories/CipherRepositoryTests.cs | 89 +++++++++++++++++++ .../2026-01-12_00_UpdateCipherArchive.sql | 89 +++++++++++++++++++ 5 files changed, 195 insertions(+), 17 deletions(-) create mode 100644 util/Migrator/DbScripts/2026-01-12_00_UpdateCipherArchive.sql diff --git a/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs b/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs index cb9e8f3f13..7b67e4c620 100644 --- a/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs +++ b/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs @@ -801,7 +801,7 @@ public class CipherRepository : Repository suts, + List efUserRepos) + { + foreach (var sut in suts) + { + var i = suts.IndexOf(sut); + + var efUser = await efUserRepos[i].CreateAsync(user); + efUserRepos[i].ClearChangeTracking(); + + cipher.UserId = efUser.Id; + cipher.OrganizationId = null; + + var createdCipher = await sut.CreateAsync(cipher); + sut.ClearChangeTracking(); + + var archiveUtcNow = await sut.ArchiveAsync(new[] { createdCipher.Id }, efUser.Id); + sut.ClearChangeTracking(); + + var savedCipher = await sut.GetByIdAsync(createdCipher.Id); + Assert.NotNull(savedCipher); + + Assert.Equal(archiveUtcNow, savedCipher.RevisionDate); + + Assert.False(string.IsNullOrWhiteSpace(savedCipher.Archives)); + var archives = CoreHelpers.LoadClassFromJsonData>(savedCipher.Archives); + Assert.NotNull(archives); + Assert.True(archives.ContainsKey(efUser.Id)); + Assert.Equal(archiveUtcNow, archives[efUser.Id]); + + var bumpedUser = await efUserRepos[i].GetByIdAsync(efUser.Id); + Assert.Equal(DateTime.UtcNow.ToShortDateString(), bumpedUser.AccountRevisionDate.ToShortDateString()); + } + } + + [CiSkippedTheory, EfUserCipherCustomize, BitAutoData] + public async Task UnarchiveAsync_RemovesUserFromArchivesJsonAndBumpsUserAccountRevisionDate( + Cipher cipher, + User user, + List suts, + List efUserRepos) + { + foreach (var sut in suts) + { + var i = suts.IndexOf(sut); + + var efUser = await efUserRepos[i].CreateAsync(user); + efUserRepos[i].ClearChangeTracking(); + + cipher.UserId = efUser.Id; + cipher.OrganizationId = null; + + var createdCipher = await sut.CreateAsync(cipher); + sut.ClearChangeTracking(); + + // Precondition: archived + await sut.ArchiveAsync(new[] { createdCipher.Id }, efUser.Id); + sut.ClearChangeTracking(); + + var unarchiveUtcNow = await sut.UnarchiveAsync(new[] { createdCipher.Id }, efUser.Id); + sut.ClearChangeTracking(); + + var savedCipher = await sut.GetByIdAsync(createdCipher.Id); + Assert.NotNull(savedCipher); + + Assert.Equal(unarchiveUtcNow, savedCipher.RevisionDate); + + // Archives should be null or not contain this user (repo clears string when map empty) + if (!string.IsNullOrWhiteSpace(savedCipher.Archives)) + { + var archives = CoreHelpers.LoadClassFromJsonData>(savedCipher.Archives) + ?? new Dictionary(); + Assert.False(archives.ContainsKey(efUser.Id)); + } + else + { + Assert.Null(savedCipher.Archives); + } + + var bumpedUser = await efUserRepos[i].GetByIdAsync(efUser.Id); + Assert.Equal(DateTime.UtcNow.ToShortDateString(), bumpedUser.AccountRevisionDate.ToShortDateString()); + } + } } diff --git a/util/Migrator/DbScripts/2026-01-12_00_UpdateCipherArchive.sql b/util/Migrator/DbScripts/2026-01-12_00_UpdateCipherArchive.sql new file mode 100644 index 0000000000..2751bb1408 --- /dev/null +++ b/util/Migrator/DbScripts/2026-01-12_00_UpdateCipherArchive.sql @@ -0,0 +1,89 @@ +CREATE OR ALTER PROCEDURE [dbo].[Cipher_Unarchive] + @Ids AS [dbo].[GuidIdArray] READONLY, + @UserId AS UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + CREATE TABLE #Temp + ( + [Id] UNIQUEIDENTIFIER NOT NULL, + [UserId] UNIQUEIDENTIFIER NULL + ) + + INSERT INTO #Temp + SELECT + ucd.[Id], + ucd.[UserId] + FROM + [dbo].[UserCipherDetails](@UserId) ucd + INNER JOIN @Ids ids ON ids.Id = ucd.[Id] + WHERE + ucd.[ArchivedDate] IS NOT NULL + + DECLARE @UtcNow DATETIME2(7) = SYSUTCDATETIME(); + UPDATE + [dbo].[Cipher] + SET + [Archives] = JSON_MODIFY( + COALESCE([Archives], N'{}'), + CONCAT('$."', @UserId, '"'), + NULL + ), + [RevisionDate] = @UtcNow + FROM [dbo].[Cipher] AS c + INNER JOIN #Temp AS t + ON t.[Id] = c.[Id]; + + EXEC [dbo].[User_BumpAccountRevisionDate] @UserId + + DROP TABLE #Temp + + SELECT @UtcNow +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[Cipher_Archive] + @Ids AS [dbo].[GuidIdArray] READONLY, + @UserId AS UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + CREATE TABLE #Temp + ( + [Id] UNIQUEIDENTIFIER NOT NULL, + [UserId] UNIQUEIDENTIFIER NULL + ) + + INSERT INTO #Temp + SELECT + ucd.[Id], + ucd.[UserId] + FROM + [dbo].[UserCipherDetails](@UserId) ucd + INNER JOIN @Ids ids ON ids.Id = ucd.[Id] + WHERE + ucd.[ArchivedDate] IS NULL + + DECLARE @UtcNow DATETIME2(7) = SYSUTCDATETIME(); + UPDATE + [dbo].[Cipher] + SET + [Archives] = JSON_MODIFY( + COALESCE([Archives], N'{}'), + CONCAT('$."', @UserId, '"'), + CONVERT(NVARCHAR(30), @UtcNow, 127) + ), + [RevisionDate] = @UtcNow + FROM [dbo].[Cipher] AS c + INNER JOIN #Temp AS t + ON t.[Id] = c.[Id]; + + EXEC [dbo].[User_BumpAccountRevisionDate] @UserId + + DROP TABLE #Temp + + SELECT @UtcNow +END +GO From 44249c38e00da27ec83f6b9e8dbca6644b3161b9 Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Thu, 15 Jan 2026 03:52:00 -0500 Subject: [PATCH 23/49] Add some integration tests for the Server project (#6839) * Add some integration tests for the Server project * Not sure why this project got removed? * Format * capture debug output * Update tests to work with the now legacy WebHostBuilder - I accidentally had the updated Program locally and that was why tests were working for me locally * Formatting...again --- bitwarden-server.sln | 8 ++ .../Properties/AssemblyInfo.cs | 1 + .../Server.IntegrationTest.csproj | 23 ++++ test/Server.IntegrationTest/Server.cs | 45 ++++++++ test/Server.IntegrationTest/ServerTests.cs | 102 ++++++++++++++++++ util/Server/Program.cs | 10 +- 6 files changed, 187 insertions(+), 2 deletions(-) create mode 100644 test/Server.IntegrationTest/Properties/AssemblyInfo.cs create mode 100644 test/Server.IntegrationTest/Server.IntegrationTest.csproj create mode 100644 test/Server.IntegrationTest/Server.cs create mode 100644 test/Server.IntegrationTest/ServerTests.cs diff --git a/bitwarden-server.sln b/bitwarden-server.sln index ae9571a4a5..409906e2d0 100644 --- a/bitwarden-server.sln +++ b/bitwarden-server.sln @@ -140,10 +140,13 @@ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SeederApi", "util\SeederApi\SeederApi.csproj", "{9F08DFBB-482B-4C9D-A5F4-6BDA6EC2E68F}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SeederApi.IntegrationTest", "test\SeederApi.IntegrationTest\SeederApi.IntegrationTest.csproj", "{A2E067EF-609C-4D13-895A-E054C61D48BB}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SSO.Test", "bitwarden_license\test\SSO.Test\SSO.Test.csproj", "{7D98784C-C253-43FB-9873-25B65C6250D6}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sso.IntegrationTest", "bitwarden_license\test\Sso.IntegrationTest\Sso.IntegrationTest.csproj", "{FFB09376-595B-6F93-36F0-70CAE90AFECB}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Server.IntegrationTest", "test\Server.IntegrationTest\Server.IntegrationTest.csproj", "{E75E1F10-BC6F-4EB1-BA75-D897C45AEA0D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -372,6 +375,10 @@ Global {FFB09376-595B-6F93-36F0-70CAE90AFECB}.Debug|Any CPU.Build.0 = Debug|Any CPU {FFB09376-595B-6F93-36F0-70CAE90AFECB}.Release|Any CPU.ActiveCfg = Release|Any CPU {FFB09376-595B-6F93-36F0-70CAE90AFECB}.Release|Any CPU.Build.0 = Release|Any CPU + {E75E1F10-BC6F-4EB1-BA75-D897C45AEA0D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E75E1F10-BC6F-4EB1-BA75-D897C45AEA0D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E75E1F10-BC6F-4EB1-BA75-D897C45AEA0D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E75E1F10-BC6F-4EB1-BA75-D897C45AEA0D}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -432,6 +439,7 @@ Global {A2E067EF-609C-4D13-895A-E054C61D48BB} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F} {7D98784C-C253-43FB-9873-25B65C6250D6} = {287CFF34-BBDB-4BC4-AF88-1E19A5A4679B} {FFB09376-595B-6F93-36F0-70CAE90AFECB} = {287CFF34-BBDB-4BC4-AF88-1E19A5A4679B} + {E75E1F10-BC6F-4EB1-BA75-D897C45AEA0D} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {E01CBF68-2E20-425F-9EDB-E0A6510CA92F} diff --git a/test/Server.IntegrationTest/Properties/AssemblyInfo.cs b/test/Server.IntegrationTest/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..80afc76e2e --- /dev/null +++ b/test/Server.IntegrationTest/Properties/AssemblyInfo.cs @@ -0,0 +1 @@ +[assembly: CaptureTrace] diff --git a/test/Server.IntegrationTest/Server.IntegrationTest.csproj b/test/Server.IntegrationTest/Server.IntegrationTest.csproj new file mode 100644 index 0000000000..362ada84a0 --- /dev/null +++ b/test/Server.IntegrationTest/Server.IntegrationTest.csproj @@ -0,0 +1,23 @@ + + + + Exe + enable + + + + + + + + + + + + + + + + + + diff --git a/test/Server.IntegrationTest/Server.cs b/test/Server.IntegrationTest/Server.cs new file mode 100644 index 0000000000..073dbffb5a --- /dev/null +++ b/test/Server.IntegrationTest/Server.cs @@ -0,0 +1,45 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; + +namespace Bit.Server.IntegrationTest; + +public class Server : WebApplicationFactory +{ + public string? ContentRoot { get; set; } + public string? WebRoot { get; set; } + public bool ServeUnknown { get; set; } + public bool? WebVault { get; set; } + public string? AppIdLocation { get; set; } + + protected override IWebHostBuilder? CreateWebHostBuilder() + { + var args = new List + { + "/contentRoot", + ContentRoot ?? "", + "/webRoot", + WebRoot ?? "", + "/serveUnknown", + ServeUnknown.ToString().ToLowerInvariant(), + }; + + if (WebVault.HasValue) + { + args.Add("/webVault"); + args.Add(WebVault.Value.ToString().ToLowerInvariant()); + } + + if (!string.IsNullOrEmpty(AppIdLocation)) + { + args.Add("/appIdLocation"); + args.Add(AppIdLocation); + } + + var builder = WebHostBuilderFactory.CreateFromTypesAssemblyEntryPoint([.. args]) + ?? throw new InvalidProgramException("Could not create builder from assembly."); + + builder.UseSetting("TEST_CONTENTROOT_SERVER", ContentRoot); + return builder; + } +} diff --git a/test/Server.IntegrationTest/ServerTests.cs b/test/Server.IntegrationTest/ServerTests.cs new file mode 100644 index 0000000000..e432f53775 --- /dev/null +++ b/test/Server.IntegrationTest/ServerTests.cs @@ -0,0 +1,102 @@ +using System.Net; +using System.Runtime.CompilerServices; + +namespace Bit.Server.IntegrationTest; + +public class ServerTests +{ + [Fact] + public async Task AttachmentsStyleUse() + { + using var tempDir = new TempDir(); + + await tempDir.WriteAsync("my-file.txt", "Hello!"); + + using var server = new Server + { + ContentRoot = tempDir.Info.FullName, + WebRoot = ".", + ServeUnknown = true, + }; + + var client = server.CreateClient(); + + var response = await client.GetAsync("/my-file.txt", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("Hello!", await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task WebVaultStyleUse() + { + using var tempDir = new TempDir(); + + await tempDir.WriteAsync("index.html", ""); + await tempDir.WriteAsync(Path.Join("app", "file.js"), "AppStuff"); + await tempDir.WriteAsync(Path.Join("locales", "file.json"), "LocalesStuff"); + await tempDir.WriteAsync(Path.Join("fonts", "file.ttf"), "FontsStuff"); + await tempDir.WriteAsync(Path.Join("connectors", "file.js"), "ConnectorsStuff"); + await tempDir.WriteAsync(Path.Join("scripts", "file.js"), "ScriptsStuff"); + await tempDir.WriteAsync(Path.Join("images", "file.avif"), "ImagesStuff"); + await tempDir.WriteAsync(Path.Join("test", "file.json"), "{}"); + + using var server = new Server + { + ContentRoot = tempDir.Info.FullName, + WebRoot = ".", + ServeUnknown = false, + WebVault = true, + AppIdLocation = Path.Join(tempDir.Info.FullName, "test", "file.json"), + }; + + var client = server.CreateClient(); + + // Going to root should return the default file + var response = await client.GetAsync("", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("", await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + // No caching on the default document + Assert.Null(response.Headers.CacheControl?.MaxAge); + + await ExpectMaxAgeAsync("app/file.js", TimeSpan.FromDays(14)); + await ExpectMaxAgeAsync("locales/file.json", TimeSpan.FromDays(14)); + await ExpectMaxAgeAsync("fonts/file.ttf", TimeSpan.FromDays(14)); + await ExpectMaxAgeAsync("connectors/file.js", TimeSpan.FromDays(14)); + await ExpectMaxAgeAsync("scripts/file.js", TimeSpan.FromDays(14)); + await ExpectMaxAgeAsync("images/file.avif", TimeSpan.FromDays(7)); + + response = await client.GetAsync("app-id.json", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("application/json", response.Content.Headers.ContentType?.MediaType); + + async Task ExpectMaxAgeAsync(string path, TimeSpan maxAge) + { + response = await client.GetAsync(path); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(response.Headers.CacheControl); + Assert.Equal(maxAge, response.Headers.CacheControl.MaxAge); + } + } + + private class TempDir([CallerMemberName] string test = null!) : IDisposable + { + public DirectoryInfo Info { get; } = Directory.CreateTempSubdirectory(test); + + public void Dispose() + { + Info.Delete(recursive: true); + } + + public async Task WriteAsync(string fileName, string content) + { + var fullPath = Path.Join(Info.FullName, fileName); + var directory = Path.GetDirectoryName(fullPath); + if (directory != null) + { + Directory.CreateDirectory(directory); + } + + await File.WriteAllTextAsync(fullPath, content, TestContext.Current.CancellationToken); + } + } +} diff --git a/util/Server/Program.cs b/util/Server/Program.cs index a2d7e5f687..3d563830ab 100644 --- a/util/Server/Program.cs +++ b/util/Server/Program.cs @@ -6,6 +6,13 @@ namespace Bit.Server; public class Program { public static void Main(string[] args) + { + var builder = CreateWebHostBuilder(args); + var host = builder.Build(); + host.Run(); + } + + public static IWebHostBuilder CreateWebHostBuilder(string[] args) { var config = new ConfigurationBuilder() .AddCommandLine(args) @@ -37,7 +44,6 @@ public class Program builder.UseWebRoot(webRoot); } - var host = builder.Build(); - host.Run(); + return builder; } } From 2e0e103076f6ec44f267bbe9b2b117f0df2ef79e Mon Sep 17 00:00:00 2001 From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Date: Thu, 15 Jan 2026 09:55:43 +0100 Subject: [PATCH 24/49] Fix the currency culture invariant (#6812) --- src/Core/Billing/Extensions/InvoiceExtensions.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Core/Billing/Extensions/InvoiceExtensions.cs b/src/Core/Billing/Extensions/InvoiceExtensions.cs index d62959c09a..774b6b93b2 100644 --- a/src/Core/Billing/Extensions/InvoiceExtensions.cs +++ b/src/Core/Billing/Extensions/InvoiceExtensions.cs @@ -1,4 +1,5 @@ -using System.Text.RegularExpressions; +using System.Globalization; +using System.Text.RegularExpressions; using Stripe; namespace Bit.Core.Billing.Extensions; @@ -51,7 +52,7 @@ public static class InvoiceExtensions if (string.IsNullOrEmpty(priceInfo) && line.Quantity > 0) { var pricePerItem = (line.Amount / 100m) / line.Quantity; - priceInfo = $"(at ${pricePerItem:F2} / month)"; + priceInfo = string.Format(CultureInfo.InvariantCulture, "(at ${0:F2} / month)", pricePerItem); } var taxDescription = $"{line.Quantity} × Tax {priceInfo}"; @@ -70,7 +71,7 @@ public static class InvoiceExtensions if (tax > 0) { var taxAmount = tax / 100m; - items.Add($"1 × Tax (at ${taxAmount:F2} / month)"); + items.Add(string.Format(CultureInfo.InvariantCulture, "1 × Tax (at ${0:F2} / month)", taxAmount)); } return items; From c7e364a39c072e3d6c9409b7fcd898e5dad130d3 Mon Sep 17 00:00:00 2001 From: rr-bw <102181210+rr-bw@users.noreply.github.com> Date: Thu, 15 Jan 2026 06:00:31 -0800 Subject: [PATCH 25/49] chore(flag): add pm-27086-update-authentication-apis-for-input-password feature flag --- src/Core/Constants.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 7cf00621c1..6f42778b6b 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -164,6 +164,7 @@ public static class FeatureFlagKeys public const string MarketingInitiatedPremiumFlow = "pm-26140-marketing-initiated-premium-flow"; public const string RedirectOnSsoRequired = "pm-1632-redirect-on-sso-required"; public const string PrefetchPasswordPrelogin = "pm-23801-prefetch-password-prelogin"; + public const string PM27086_UpdateAuthenticationApisForInputPassword = "pm-27086-update-authentication-apis-for-input-password"; /* Autofill Team */ public const string SSHAgent = "ssh-agent"; From 8cb80305341098673771e863b8b5266ad19bdce0 Mon Sep 17 00:00:00 2001 From: Patrick-Pimentel-Bitwarden Date: Thu, 15 Jan 2026 15:55:27 -0500 Subject: [PATCH 26/49] feat(register): [PM-27084] Account Register Uses New Data Types (#6715) * feat(register): [PM-27084] Account Register Uses New Data Types - Implementation * test(register): [PM-27084] Account Register Uses New Data Types - Added tests --- .../Request/Accounts/PasswordRequestModel.cs | 6 +- .../SetInitialPasswordRequestModel.cs | 1 - .../Accounts/RegisterFinishRequestModel.cs | 201 ++++++- src/Core/Entities/User.cs | 6 +- .../Models/Api/Request}/KdfRequestModel.cs | 11 +- ...rPasswordAuthenticationDataRequestModel.cs | 6 +- .../MasterPasswordUnlockDataRequestModel.cs | 6 +- .../Data/MasterPasswordAuthenticationData.cs | 5 + ...sterPasswordUnlockAndAuthenticationData.cs | 3 +- .../Models/Data/MasterPasswordUnlockData.cs | 5 + src/Core/Utilities/KdfSettingsValidator.cs | 1 + .../Controllers/AccountsController.cs | 61 ++- .../Controllers/AccountsControllerTest.cs | 4 +- .../Controllers/AccountsControllerTests.cs | 1 - .../SetInitialPasswordRequestModelTests.cs | 1 - .../RegisterFinishRequestModelFixtures.cs | 4 +- .../RegisterFinishRequestModelTests.cs | 183 +++++++ .../Controllers/AccountsControllerTests.cs | 502 +++++++++++++++++- .../Factories/IdentityApplicationFactory.cs | 101 +++- 19 files changed, 1045 insertions(+), 63 deletions(-) rename src/{Api/KeyManagement/Models/Requests => Core/KeyManagement/Models/Api/Request}/KdfRequestModel.cs (59%) rename src/{Api/KeyManagement/Models/Requests => Core/KeyManagement/Models/Api/Request}/MasterPasswordAuthenticationDataRequestModel.cs (71%) rename src/{Api/KeyManagement/Models/Requests => Core/KeyManagement/Models/Api/Request}/MasterPasswordUnlockDataRequestModel.cs (71%) diff --git a/src/Api/Auth/Models/Request/Accounts/PasswordRequestModel.cs b/src/Api/Auth/Models/Request/Accounts/PasswordRequestModel.cs index 8fa51e9f34..ab8c727852 100644 --- a/src/Api/Auth/Models/Request/Accounts/PasswordRequestModel.cs +++ b/src/Api/Auth/Models/Request/Accounts/PasswordRequestModel.cs @@ -1,7 +1,5 @@ -#nullable enable - -using System.ComponentModel.DataAnnotations; -using Bit.Api.KeyManagement.Models.Requests; +using System.ComponentModel.DataAnnotations; +using Bit.Core.KeyManagement.Models.Api.Request; namespace Bit.Api.Auth.Models.Request.Accounts; diff --git a/src/Api/Auth/Models/Request/Accounts/SetInitialPasswordRequestModel.cs b/src/Api/Auth/Models/Request/Accounts/SetInitialPasswordRequestModel.cs index 55ffdca94b..37a7901fee 100644 --- a/src/Api/Auth/Models/Request/Accounts/SetInitialPasswordRequestModel.cs +++ b/src/Api/Auth/Models/Request/Accounts/SetInitialPasswordRequestModel.cs @@ -1,5 +1,4 @@ using System.ComponentModel.DataAnnotations; -using Bit.Api.KeyManagement.Models.Requests; using Bit.Core.Auth.Models.Api.Request.Accounts; using Bit.Core.Auth.Models.Data; using Bit.Core.Entities; diff --git a/src/Core/Auth/Models/Api/Request/Accounts/RegisterFinishRequestModel.cs b/src/Core/Auth/Models/Api/Request/Accounts/RegisterFinishRequestModel.cs index 0ac7dbbcb4..cb66540a6b 100644 --- a/src/Core/Auth/Models/Api/Request/Accounts/RegisterFinishRequestModel.cs +++ b/src/Core/Auth/Models/Api/Request/Accounts/RegisterFinishRequestModel.cs @@ -1,6 +1,6 @@ -#nullable enable -using Bit.Core.Entities; +using Bit.Core.Entities; using Bit.Core.Enums; +using Bit.Core.KeyManagement.Models.Api.Request; using Bit.Core.Utilities; namespace Bit.Core.Auth.Models.Api.Request.Accounts; @@ -21,19 +21,32 @@ public class RegisterFinishRequestModel : IValidatableObject public required string Email { get; set; } public string? EmailVerificationToken { get; set; } + public MasterPasswordAuthenticationDataRequestModel? MasterPasswordAuthentication { get; set; } + public MasterPasswordUnlockDataRequestModel? MasterPasswordUnlock { get; set; } + + // PM-28143 - Remove property below (made optional during migration to MasterPasswordUnlockData) [StringLength(1000)] - public required string MasterPasswordHash { get; set; } + // Made optional but there will still be a thrown error if it does not exist either here or + // in the MasterPasswordAuthenticationData. + public string? MasterPasswordHash { get; set; } [StringLength(50)] public string? MasterPasswordHint { get; set; } - public required string UserSymmetricKey { get; set; } + // PM-28143 - Remove property below (made optional during migration to MasterPasswordUnlockData) + // Made optional but there will still be a thrown error if it does not exist either here or + // in the MasterPasswordAuthenticationData. + public string? UserSymmetricKey { get; set; } public required KeysRequestModel UserAsymmetricKeys { get; set; } - public required KdfType Kdf { get; set; } - public required int KdfIterations { get; set; } + // PM-28143 - Remove line below (made optional during migration to MasterPasswordUnlockData) + public KdfType? Kdf { get; set; } + // PM-28143 - Remove line below (made optional during migration to MasterPasswordUnlockData) + public int? KdfIterations { get; set; } + // PM-28143 - Remove line below public int? KdfMemory { get; set; } + // PM-28143 - Remove line below public int? KdfParallelism { get; set; } public Guid? OrganizationUserId { get; set; } @@ -54,11 +67,14 @@ public class RegisterFinishRequestModel : IValidatableObject { Email = Email, MasterPasswordHint = MasterPasswordHint, - Kdf = Kdf, - KdfIterations = KdfIterations, - KdfMemory = KdfMemory, - KdfParallelism = KdfParallelism, - Key = UserSymmetricKey, + Kdf = (KdfType)(MasterPasswordUnlock?.Kdf.KdfType ?? Kdf)!, + KdfIterations = (int)(MasterPasswordUnlock?.Kdf.Iterations ?? KdfIterations)!, + // KdfMemory and KdfParallelism are optional (only used for Argon2id) + KdfMemory = MasterPasswordUnlock?.Kdf.Memory ?? KdfMemory, + KdfParallelism = MasterPasswordUnlock?.Kdf.Parallelism ?? KdfParallelism, + // PM-28827 To be added when MasterPasswordSalt is added to the user column + // MasterPasswordSalt = MasterPasswordUnlock?.Salt ?? Email.ToLower().Trim(), + Key = MasterPasswordUnlock?.MasterKeyWrappedUserKey ?? UserSymmetricKey }; UserAsymmetricKeys.ToUser(user); @@ -72,7 +88,9 @@ public class RegisterFinishRequestModel : IValidatableObject { return RegisterFinishTokenType.EmailVerification; } - if (!string.IsNullOrEmpty(OrgInviteToken) && OrganizationUserId.HasValue) + if (!string.IsNullOrEmpty(OrgInviteToken) + && OrganizationUserId.HasValue + && OrganizationUserId.Value != Guid.Empty) { return RegisterFinishTokenType.OrganizationInvite; } @@ -80,11 +98,15 @@ public class RegisterFinishRequestModel : IValidatableObject { return RegisterFinishTokenType.OrgSponsoredFreeFamilyPlan; } - if (!string.IsNullOrWhiteSpace(AcceptEmergencyAccessInviteToken) && AcceptEmergencyAccessId.HasValue) + if (!string.IsNullOrWhiteSpace(AcceptEmergencyAccessInviteToken) + && AcceptEmergencyAccessId.HasValue + && AcceptEmergencyAccessId.Value != Guid.Empty) { return RegisterFinishTokenType.EmergencyAccessInvite; } - if (!string.IsNullOrWhiteSpace(ProviderInviteToken) && ProviderUserId.HasValue) + if (!string.IsNullOrWhiteSpace(ProviderInviteToken) + && ProviderUserId.HasValue + && ProviderUserId.Value != Guid.Empty) { return RegisterFinishTokenType.ProviderInvite; } @@ -92,9 +114,156 @@ public class RegisterFinishRequestModel : IValidatableObject throw new InvalidOperationException("Invalid token type."); } - public IEnumerable Validate(ValidationContext validationContext) { - return KdfSettingsValidator.Validate(Kdf, KdfIterations, KdfMemory, KdfParallelism); + // 1. Authentication data containing hash and hash at root level check + if (MasterPasswordAuthentication != null && MasterPasswordHash != null) + { + if (MasterPasswordAuthentication.MasterPasswordAuthenticationHash != MasterPasswordHash) + { + yield return new ValidationResult( + $"{nameof(MasterPasswordAuthentication.MasterPasswordAuthenticationHash)} and root level {nameof(MasterPasswordHash)} provided and are not equal. Only provide one.", + [nameof(MasterPasswordAuthentication.MasterPasswordAuthenticationHash), nameof(MasterPasswordHash)]); + } + } // 1.5 if there is no master password hash that is unacceptable even though they are both optional in the model + else if (MasterPasswordAuthentication == null && MasterPasswordHash == null) + { + yield return new ValidationResult( + $"{nameof(MasterPasswordAuthentication.MasterPasswordAuthenticationHash)} and {nameof(MasterPasswordHash)} not found on request, one needs to be defined.", + [nameof(MasterPasswordAuthentication.MasterPasswordAuthenticationHash), nameof(MasterPasswordHash)]); + } + + // 2. Validate kdf settings. + if (MasterPasswordUnlock != null) + { + foreach (var validationResult in KdfSettingsValidator.Validate(MasterPasswordUnlock.ToData().Kdf)) + { + yield return validationResult; + } + } + + if (MasterPasswordAuthentication != null) + { + foreach (var validationResult in KdfSettingsValidator.Validate(MasterPasswordAuthentication.ToData().Kdf)) + { + yield return validationResult; + } + } + + // 3. Validate root kdf values if kdf values are not in the unlock and authentication. + if (MasterPasswordUnlock == null && MasterPasswordAuthentication == null) + { + var hasMissingRequiredKdfInputs = false; + if (Kdf == null) + { + yield return new ValidationResult($"{nameof(Kdf)} not found on RequestModel", [nameof(Kdf)]); + hasMissingRequiredKdfInputs = true; + } + if (KdfIterations == null) + { + yield return new ValidationResult($"{nameof(KdfIterations)} not found on RequestModel", [nameof(KdfIterations)]); + hasMissingRequiredKdfInputs = true; + } + + if (!hasMissingRequiredKdfInputs) + { + foreach (var validationResult in KdfSettingsValidator.Validate( + Kdf!.Value, + KdfIterations!.Value, + KdfMemory, + KdfParallelism)) + { + yield return validationResult; + } + } + } + else if (MasterPasswordUnlock == null && MasterPasswordAuthentication != null) + { + // Authentication provided but Unlock missing + yield return new ValidationResult($"{nameof(MasterPasswordUnlock)} not found on RequestModel", [nameof(MasterPasswordUnlock)]); + } + else if (MasterPasswordUnlock != null && MasterPasswordAuthentication == null) + { + // Unlock provided but Authentication missing + yield return new ValidationResult($"{nameof(MasterPasswordAuthentication)} not found on RequestModel", [nameof(MasterPasswordAuthentication)]); + } + + // 3. Lastly, validate access token type and presence. Must be done last because of yield break. + RegisterFinishTokenType tokenType; + var tokenTypeResolved = true; + try + { + tokenType = GetTokenType(); + } + catch (InvalidOperationException) + { + tokenTypeResolved = false; + tokenType = default; + } + + if (!tokenTypeResolved) + { + yield return new ValidationResult("No valid registration token provided"); + yield break; + } + + switch (tokenType) + { + case RegisterFinishTokenType.EmailVerification: + if (string.IsNullOrEmpty(EmailVerificationToken)) + { + yield return new ValidationResult( + $"{nameof(EmailVerificationToken)} absent when processing register/finish.", + [nameof(EmailVerificationToken)]); + } + break; + case RegisterFinishTokenType.OrganizationInvite: + if (string.IsNullOrEmpty(OrgInviteToken)) + { + yield return new ValidationResult( + $"{nameof(OrgInviteToken)} absent when processing register/finish.", + [nameof(OrgInviteToken)]); + } + break; + case RegisterFinishTokenType.OrgSponsoredFreeFamilyPlan: + if (string.IsNullOrEmpty(OrgSponsoredFreeFamilyPlanToken)) + { + yield return new ValidationResult( + $"{nameof(OrgSponsoredFreeFamilyPlanToken)} absent when processing register/finish.", + [nameof(OrgSponsoredFreeFamilyPlanToken)]); + } + break; + case RegisterFinishTokenType.EmergencyAccessInvite: + if (string.IsNullOrEmpty(AcceptEmergencyAccessInviteToken)) + { + yield return new ValidationResult( + $"{nameof(AcceptEmergencyAccessInviteToken)} absent when processing register/finish.", + [nameof(AcceptEmergencyAccessInviteToken)]); + } + if (!AcceptEmergencyAccessId.HasValue || AcceptEmergencyAccessId.Value == Guid.Empty) + { + yield return new ValidationResult( + $"{nameof(AcceptEmergencyAccessId)} absent when processing register/finish.", + [nameof(AcceptEmergencyAccessId)]); + } + break; + case RegisterFinishTokenType.ProviderInvite: + if (string.IsNullOrEmpty(ProviderInviteToken)) + { + yield return new ValidationResult( + $"{nameof(ProviderInviteToken)} absent when processing register/finish.", + [nameof(ProviderInviteToken)]); + } + if (!ProviderUserId.HasValue || ProviderUserId.Value == Guid.Empty) + { + yield return new ValidationResult( + $"{nameof(ProviderUserId)} absent when processing register/finish.", + [nameof(ProviderUserId)]); + } + break; + default: + yield return new ValidationResult("Invalid registration finish request"); + break; + } } } diff --git a/src/Core/Entities/User.cs b/src/Core/Entities/User.cs index 669e32bcbe..422dc37c6e 100644 --- a/src/Core/Entities/User.cs +++ b/src/Core/Entities/User.cs @@ -7,8 +7,6 @@ using Bit.Core.KeyManagement.Models.Data; using Bit.Core.Utilities; using Microsoft.AspNetCore.Identity; -#nullable enable - namespace Bit.Core.Entities; public class User : ITableObject, IStorableSubscriber, IRevisable, ITwoFactorProvidersUser @@ -51,7 +49,7 @@ public class User : ITableObject, IStorableSubscriber, IRevisable, ITwoFac public string? Key { get; set; } /// /// The raw public key, without a signature from the user's signature key. - /// + /// public string? PublicKey { get; set; } /// /// User key wrapped private key. @@ -107,6 +105,8 @@ public class User : ITableObject, IStorableSubscriber, IRevisable, ITwoFac public DateTime? LastKeyRotationDate { get; set; } public DateTime? LastEmailChangeDate { get; set; } public bool VerifyDevices { get; set; } = true; + // PM-28827 Uncomment below line. + // public string? MasterPasswordSalt { get; set; } public string GetMasterPasswordSalt() { diff --git a/src/Api/KeyManagement/Models/Requests/KdfRequestModel.cs b/src/Core/KeyManagement/Models/Api/Request/KdfRequestModel.cs similarity index 59% rename from src/Api/KeyManagement/Models/Requests/KdfRequestModel.cs rename to src/Core/KeyManagement/Models/Api/Request/KdfRequestModel.cs index 904304a633..edcd7f760f 100644 --- a/src/Api/KeyManagement/Models/Requests/KdfRequestModel.cs +++ b/src/Core/KeyManagement/Models/Api/Request/KdfRequestModel.cs @@ -1,10 +1,11 @@ using System.ComponentModel.DataAnnotations; using Bit.Core.Enums; using Bit.Core.KeyManagement.Models.Data; +using Bit.Core.Utilities; -namespace Bit.Api.KeyManagement.Models.Requests; +namespace Bit.Core.KeyManagement.Models.Api.Request; -public class KdfRequestModel +public class KdfRequestModel : IValidatableObject { [Required] public required KdfType KdfType { get; init; } @@ -23,4 +24,10 @@ public class KdfRequestModel Parallelism = Parallelism }; } + + public IEnumerable Validate(ValidationContext validationContext) + { + // Generic per-request KDF validation for any request model embedding KdfRequestModel + return KdfSettingsValidator.Validate(ToData()); + } } diff --git a/src/Api/KeyManagement/Models/Requests/MasterPasswordAuthenticationDataRequestModel.cs b/src/Core/KeyManagement/Models/Api/Request/MasterPasswordAuthenticationDataRequestModel.cs similarity index 71% rename from src/Api/KeyManagement/Models/Requests/MasterPasswordAuthenticationDataRequestModel.cs rename to src/Core/KeyManagement/Models/Api/Request/MasterPasswordAuthenticationDataRequestModel.cs index 4f70a1135f..04c22cc3a6 100644 --- a/src/Api/KeyManagement/Models/Requests/MasterPasswordAuthenticationDataRequestModel.cs +++ b/src/Core/KeyManagement/Models/Api/Request/MasterPasswordAuthenticationDataRequestModel.cs @@ -1,8 +1,12 @@ using System.ComponentModel.DataAnnotations; using Bit.Core.KeyManagement.Models.Data; -namespace Bit.Api.KeyManagement.Models.Requests; +namespace Bit.Core.KeyManagement.Models.Api.Request; +/// +/// Use this datatype when interfacing with requests to create a separation of concern. +/// See to use for commands, queries, services. +/// public class MasterPasswordAuthenticationDataRequestModel { public required KdfRequestModel Kdf { get; init; } diff --git a/src/Api/KeyManagement/Models/Requests/MasterPasswordUnlockDataRequestModel.cs b/src/Core/KeyManagement/Models/Api/Request/MasterPasswordUnlockDataRequestModel.cs similarity index 71% rename from src/Api/KeyManagement/Models/Requests/MasterPasswordUnlockDataRequestModel.cs rename to src/Core/KeyManagement/Models/Api/Request/MasterPasswordUnlockDataRequestModel.cs index e1d7863cae..8d7df86374 100644 --- a/src/Api/KeyManagement/Models/Requests/MasterPasswordUnlockDataRequestModel.cs +++ b/src/Core/KeyManagement/Models/Api/Request/MasterPasswordUnlockDataRequestModel.cs @@ -2,8 +2,12 @@ using Bit.Core.KeyManagement.Models.Data; using Bit.Core.Utilities; -namespace Bit.Api.KeyManagement.Models.Requests; +namespace Bit.Core.KeyManagement.Models.Api.Request; +/// +/// Use this datatype when interfacing with requests to create a separation of concern. +/// See to use for commands, queries, services. +/// public class MasterPasswordUnlockDataRequestModel { public required KdfRequestModel Kdf { get; init; } diff --git a/src/Core/KeyManagement/Models/Data/MasterPasswordAuthenticationData.cs b/src/Core/KeyManagement/Models/Data/MasterPasswordAuthenticationData.cs index 1bc7006cef..6e53dfa744 100644 --- a/src/Core/KeyManagement/Models/Data/MasterPasswordAuthenticationData.cs +++ b/src/Core/KeyManagement/Models/Data/MasterPasswordAuthenticationData.cs @@ -1,8 +1,13 @@ using Bit.Core.Entities; using Bit.Core.Exceptions; +using Bit.Core.KeyManagement.Models.Api.Request; namespace Bit.Core.KeyManagement.Models.Data; +/// +/// Use this datatype when interfacing with commands, queries, services to create a separation of concern. +/// See to use for requests. +/// public class MasterPasswordAuthenticationData { public required KdfSettings Kdf { get; init; } diff --git a/src/Core/KeyManagement/Models/Data/MasterPasswordUnlockAndAuthenticationData.cs b/src/Core/KeyManagement/Models/Data/MasterPasswordUnlockAndAuthenticationData.cs index ad3a0b692b..b79ce8bce1 100644 --- a/src/Core/KeyManagement/Models/Data/MasterPasswordUnlockAndAuthenticationData.cs +++ b/src/Core/KeyManagement/Models/Data/MasterPasswordUnlockAndAuthenticationData.cs @@ -1,5 +1,4 @@ -#nullable enable -using Bit.Core.Entities; +using Bit.Core.Entities; using Bit.Core.Enums; namespace Bit.Core.KeyManagement.Models.Data; diff --git a/src/Core/KeyManagement/Models/Data/MasterPasswordUnlockData.cs b/src/Core/KeyManagement/Models/Data/MasterPasswordUnlockData.cs index cb18ed2a78..f8139cba99 100644 --- a/src/Core/KeyManagement/Models/Data/MasterPasswordUnlockData.cs +++ b/src/Core/KeyManagement/Models/Data/MasterPasswordUnlockData.cs @@ -1,8 +1,13 @@ using Bit.Core.Entities; using Bit.Core.Exceptions; +using Bit.Core.KeyManagement.Models.Api.Request; namespace Bit.Core.KeyManagement.Models.Data; +/// +/// Use this datatype when interfacing with commands, queries, services to create a separation of concern. +/// See to use for requests. +/// public class MasterPasswordUnlockData { public required KdfSettings Kdf { get; init; } diff --git a/src/Core/Utilities/KdfSettingsValidator.cs b/src/Core/Utilities/KdfSettingsValidator.cs index f89e8ddb66..e5690ad469 100644 --- a/src/Core/Utilities/KdfSettingsValidator.cs +++ b/src/Core/Utilities/KdfSettingsValidator.cs @@ -6,6 +6,7 @@ namespace Bit.Core.Utilities; public static class KdfSettingsValidator { + // PM-28143 - Remove below when fixing ticket public static IEnumerable Validate(KdfType kdfType, int kdfIterations, int? kdfMemory, int? kdfParallelism) { switch (kdfType) diff --git a/src/Identity/Controllers/AccountsController.cs b/src/Identity/Controllers/AccountsController.cs index b7d4342c1b..e9807fb1fc 100644 --- a/src/Identity/Controllers/AccountsController.cs +++ b/src/Identity/Controllers/AccountsController.cs @@ -1,8 +1,4 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using System.Diagnostics; -using System.Text; +using System.Text; using Bit.Core; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models.Api.Request.Accounts; @@ -42,7 +38,7 @@ public class AccountsController : Controller private readonly IFeatureService _featureService; private readonly IDataProtectorTokenFactory _registrationEmailVerificationTokenDataFactory; - private readonly byte[] _defaultKdfHmacKey = null; + private readonly byte[]? _defaultKdfHmacKey = null; private static readonly List _defaultKdfResults = [ // The first result (index 0) should always return the "normal" default. @@ -145,40 +141,55 @@ public class AccountsController : Controller [HttpPost("register/finish")] public async Task PostRegisterFinish([FromBody] RegisterFinishRequestModel model) { - var user = model.ToUser(); + User user = model.ToUser(); // Users will either have an emailed token or an email verification token - not both. - IdentityResult identityResult = null; + IdentityResult? identityResult = null; + + // PM-28143 - Just use the MasterPasswordAuthenticationData.MasterPasswordAuthenticationHash + string masterPasswordAuthenticationHash = model.MasterPasswordAuthentication?.MasterPasswordAuthenticationHash + ?? model.MasterPasswordHash!; switch (model.GetTokenType()) { case RegisterFinishTokenType.EmailVerification: - identityResult = - await _registerUserCommand.RegisterUserViaEmailVerificationToken(user, model.MasterPasswordHash, - model.EmailVerificationToken); - + identityResult = await _registerUserCommand.RegisterUserViaEmailVerificationToken( + user, + masterPasswordAuthenticationHash, + model.EmailVerificationToken!); return ProcessRegistrationResult(identityResult, user); + case RegisterFinishTokenType.OrganizationInvite: - identityResult = await _registerUserCommand.RegisterUserViaOrganizationInviteToken(user, model.MasterPasswordHash, - model.OrgInviteToken, model.OrganizationUserId); - + identityResult = await _registerUserCommand.RegisterUserViaOrganizationInviteToken( + user, + masterPasswordAuthenticationHash, + model.OrgInviteToken!, + model.OrganizationUserId); return ProcessRegistrationResult(identityResult, user); + case RegisterFinishTokenType.OrgSponsoredFreeFamilyPlan: - identityResult = await _registerUserCommand.RegisterUserViaOrganizationSponsoredFreeFamilyPlanInviteToken(user, model.MasterPasswordHash, model.OrgSponsoredFreeFamilyPlanToken); - + identityResult = await _registerUserCommand.RegisterUserViaOrganizationSponsoredFreeFamilyPlanInviteToken( + user, + masterPasswordAuthenticationHash, + model.OrgSponsoredFreeFamilyPlanToken!); return ProcessRegistrationResult(identityResult, user); + case RegisterFinishTokenType.EmergencyAccessInvite: - Debug.Assert(model.AcceptEmergencyAccessId.HasValue); - identityResult = await _registerUserCommand.RegisterUserViaAcceptEmergencyAccessInviteToken(user, model.MasterPasswordHash, - model.AcceptEmergencyAccessInviteToken, model.AcceptEmergencyAccessId.Value); - + identityResult = await _registerUserCommand.RegisterUserViaAcceptEmergencyAccessInviteToken( + user, + masterPasswordAuthenticationHash, + model.AcceptEmergencyAccessInviteToken!, + (Guid)model.AcceptEmergencyAccessId!); return ProcessRegistrationResult(identityResult, user); + case RegisterFinishTokenType.ProviderInvite: - Debug.Assert(model.ProviderUserId.HasValue); - identityResult = await _registerUserCommand.RegisterUserViaProviderInviteToken(user, model.MasterPasswordHash, - model.ProviderInviteToken, model.ProviderUserId.Value); - + identityResult = await _registerUserCommand.RegisterUserViaProviderInviteToken( + user, + masterPasswordAuthenticationHash, + model.ProviderInviteToken!, + (Guid)model.ProviderUserId!); return ProcessRegistrationResult(identityResult, user); + default: throw new BadRequestException("Invalid registration finish request"); } diff --git a/test/Api.IntegrationTest/Controllers/AccountsControllerTest.cs b/test/Api.IntegrationTest/Controllers/AccountsControllerTest.cs index d055418f3a..9860775e31 100644 --- a/test/Api.IntegrationTest/Controllers/AccountsControllerTest.cs +++ b/test/Api.IntegrationTest/Controllers/AccountsControllerTest.cs @@ -3,7 +3,6 @@ using System.Text.Json; using Bit.Api.Auth.Models.Request.Accounts; using Bit.Api.IntegrationTest.Factories; using Bit.Api.IntegrationTest.Helpers; -using Bit.Api.KeyManagement.Models.Requests; using Bit.Api.Models.Response; using Bit.Core; using Bit.Core.Auth.Entities; @@ -12,6 +11,7 @@ using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.Repositories; using Bit.Core.Entities; using Bit.Core.Enums; +using Bit.Core.KeyManagement.Models.Api.Request; using Bit.Core.KeyManagement.Repositories; using Bit.Core.Models.Data; using Bit.Core.Platform.Push; @@ -378,7 +378,7 @@ public class AccountsControllerTest : IClassFixture, IAsy Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); var content = await response.Content.ReadAsStringAsync(); - Assert.Contains("KDF settings are invalid", content); + Assert.Contains("The model state is invalid", content); } [Fact] diff --git a/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs b/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs index 6cddd341d5..665d1e52c1 100644 --- a/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs +++ b/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs @@ -1,7 +1,6 @@ using System.Security.Claims; using Bit.Api.Auth.Controllers; using Bit.Api.Auth.Models.Request.Accounts; -using Bit.Api.KeyManagement.Models.Requests; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.Models.Api.Request.Accounts; diff --git a/test/Api.Test/Auth/Models/Request/Accounts/SetInitialPasswordRequestModelTests.cs b/test/Api.Test/Auth/Models/Request/Accounts/SetInitialPasswordRequestModelTests.cs index ce8ba1811e..97e69dacbc 100644 --- a/test/Api.Test/Auth/Models/Request/Accounts/SetInitialPasswordRequestModelTests.cs +++ b/test/Api.Test/Auth/Models/Request/Accounts/SetInitialPasswordRequestModelTests.cs @@ -1,6 +1,5 @@ using System.ComponentModel.DataAnnotations; using Bit.Api.Auth.Models.Request.Accounts; -using Bit.Api.KeyManagement.Models.Requests; using Bit.Core.Auth.Models.Api.Request.Accounts; using Bit.Core.Entities; using Bit.Core.Enums; diff --git a/test/Core.Test/Auth/AutoFixture/RegisterFinishRequestModelFixtures.cs b/test/Core.Test/Auth/AutoFixture/RegisterFinishRequestModelFixtures.cs index a751a16f31..22fca7ab59 100644 --- a/test/Core.Test/Auth/AutoFixture/RegisterFinishRequestModelFixtures.cs +++ b/test/Core.Test/Auth/AutoFixture/RegisterFinishRequestModelFixtures.cs @@ -29,7 +29,9 @@ internal class RegisterFinishRequestModelCustomization : ICustomization .With(o => o.OrgInviteToken, OrgInviteToken) .With(o => o.OrgSponsoredFreeFamilyPlanToken, OrgSponsoredFreeFamilyPlanToken) .With(o => o.AcceptEmergencyAccessInviteToken, AcceptEmergencyAccessInviteToken) - .With(o => o.ProviderInviteToken, ProviderInviteToken)); + .With(o => o.ProviderInviteToken, ProviderInviteToken) + .Without(o => o.MasterPasswordAuthentication) + .Without(o => o.MasterPasswordUnlock)); } } diff --git a/test/Core.Test/Auth/Models/Api/Request/Accounts/RegisterFinishRequestModelTests.cs b/test/Core.Test/Auth/Models/Api/Request/Accounts/RegisterFinishRequestModelTests.cs index 588ca878fc..3c099ce962 100644 --- a/test/Core.Test/Auth/Models/Api/Request/Accounts/RegisterFinishRequestModelTests.cs +++ b/test/Core.Test/Auth/Models/Api/Request/Accounts/RegisterFinishRequestModelTests.cs @@ -1,5 +1,6 @@ using Bit.Core.Auth.Models.Api.Request.Accounts; using Bit.Core.Enums; +using Bit.Core.KeyManagement.Models.Api.Request; using Bit.Test.Common.AutoFixture.Attributes; using Xunit; @@ -7,6 +8,17 @@ namespace Bit.Core.Test.Auth.Models.Api.Request.Accounts; public class RegisterFinishRequestModelTests { + private static List Validate(RegisterFinishRequestModel model) + { + var results = new List(); + System.ComponentModel.DataAnnotations.Validator.TryValidateObject( + model, + new System.ComponentModel.DataAnnotations.ValidationContext(model), + results, + true); + return results; + } + [Theory] [BitAutoData] public void GetTokenType_Returns_EmailVerification(string email, string masterPasswordHash, @@ -170,4 +182,175 @@ public class RegisterFinishRequestModelTests Assert.Equal(userAsymmetricKeys.PublicKey, result.PublicKey); Assert.Equal(userAsymmetricKeys.EncryptedPrivateKey, result.PrivateKey); } + + [Fact] + public void Validate_WhenBothAuthAndRootHashProvidedButNotEqual_ReturnsMismatchError() + { + var model = new RegisterFinishRequestModel + { + Email = "user@example.com", + MasterPasswordHash = "root-hash", + UserAsymmetricKeys = new KeysRequestModel { PublicKey = "pk", EncryptedPrivateKey = "sk" }, + // Provide both unlock and authentication with valid KDF so only the mismatch rule fires + MasterPasswordUnlock = new MasterPasswordUnlockDataRequestModel + { + Kdf = new KdfRequestModel { KdfType = KdfType.PBKDF2_SHA256, Iterations = AuthConstants.PBKDF2_ITERATIONS.Default }, + MasterKeyWrappedUserKey = "wrapped", + Salt = "salt" + }, + MasterPasswordAuthentication = new MasterPasswordAuthenticationDataRequestModel + { + Kdf = new KdfRequestModel { KdfType = KdfType.PBKDF2_SHA256, Iterations = AuthConstants.PBKDF2_ITERATIONS.Default }, + MasterPasswordAuthenticationHash = "auth-hash", // different than root + Salt = "salt" + }, + // Provide any valid token so we don't fail token validation + EmailVerificationToken = "token" + }; + + var results = Validate(model); + + Assert.Contains(results, r => + r.ErrorMessage == $"{nameof(MasterPasswordAuthenticationDataRequestModel.MasterPasswordAuthenticationHash)} and root level {nameof(RegisterFinishRequestModel.MasterPasswordHash)} provided and are not equal. Only provide one."); + } + + [Fact] + public void Validate_WhenAuthProvidedButUnlockMissing_ReturnsUnlockMissingError() + { + var model = new RegisterFinishRequestModel + { + Email = "user@example.com", + UserAsymmetricKeys = new KeysRequestModel { PublicKey = "pk", EncryptedPrivateKey = "sk" }, + MasterPasswordAuthentication = new MasterPasswordAuthenticationDataRequestModel + { + Kdf = new KdfRequestModel { KdfType = KdfType.PBKDF2_SHA256, Iterations = AuthConstants.PBKDF2_ITERATIONS.Default }, + MasterPasswordAuthenticationHash = "auth-hash", + Salt = "salt" + }, + EmailVerificationToken = "token" + }; + + var results = Validate(model); + + Assert.Contains(results, r => r.ErrorMessage == "MasterPasswordUnlock not found on RequestModel"); + } + + [Fact] + public void Validate_WhenUnlockProvidedButAuthMissing_ReturnsAuthMissingError() + { + var model = new RegisterFinishRequestModel + { + Email = "user@example.com", + UserAsymmetricKeys = new KeysRequestModel { PublicKey = "pk", EncryptedPrivateKey = "sk" }, + MasterPasswordUnlock = new MasterPasswordUnlockDataRequestModel + { + Kdf = new KdfRequestModel { KdfType = KdfType.PBKDF2_SHA256, Iterations = AuthConstants.PBKDF2_ITERATIONS.Default }, + MasterKeyWrappedUserKey = "wrapped", + Salt = "salt" + }, + EmailVerificationToken = "token" + }; + + var results = Validate(model); + + Assert.Contains(results, r => r.ErrorMessage == "MasterPasswordAuthentication not found on RequestModel"); + } + + [Fact] + public void Validate_WhenNeitherAuthNorUnlock_AndRootKdfMissing_ReturnsBothRootKdfErrors() + { + var model = new RegisterFinishRequestModel + { + Email = "user@example.com", + UserAsymmetricKeys = new KeysRequestModel { PublicKey = "pk", EncryptedPrivateKey = "sk" }, + // No MasterPasswordUnlock, no MasterPasswordAuthentication + // No root Kdf and KdfIterations to trigger both errors + EmailVerificationToken = "token" + }; + + var results = Validate(model); + + Assert.Contains(results, r => r.ErrorMessage == $"{nameof(RegisterFinishRequestModel.Kdf)} not found on RequestModel"); + Assert.Contains(results, r => r.ErrorMessage == $"{nameof(RegisterFinishRequestModel.KdfIterations)} not found on RequestModel"); + } + + [Fact] + public void Validate_WhenAuthAndRootHashBothMissing_ReturnsMissingHashErrorOnly() + { + var model = new RegisterFinishRequestModel + { + Email = "user@example.com", + UserAsymmetricKeys = new KeysRequestModel { PublicKey = "pk", EncryptedPrivateKey = "sk" }, + // Both MasterPasswordAuthentication and MasterPasswordHash are missing + MasterPasswordAuthentication = null, + MasterPasswordHash = null, + // Provide valid root KDF to avoid root KDF errors + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = AuthConstants.PBKDF2_ITERATIONS.Default, + EmailVerificationToken = "token" // avoid token error + }; + + var results = Validate(model); + + // Only the new missing hash error should be present + Assert.Single(results); + Assert.Equal($"{nameof(MasterPasswordAuthenticationDataRequestModel.MasterPasswordAuthenticationHash)} and {nameof(RegisterFinishRequestModel.MasterPasswordHash)} not found on request, one needs to be defined.", results[0].ErrorMessage); + Assert.Contains(nameof(MasterPasswordAuthenticationDataRequestModel.MasterPasswordAuthenticationHash), results[0].MemberNames); + Assert.Contains(nameof(RegisterFinishRequestModel.MasterPasswordHash), results[0].MemberNames); + } + + [Fact] + public void Validate_WhenAllFieldsValidWithSubModels_IsValid() + { + var model = new RegisterFinishRequestModel + { + Email = "user@example.com", + UserAsymmetricKeys = new KeysRequestModel { PublicKey = "pk", EncryptedPrivateKey = "sk" }, + MasterPasswordUnlock = new MasterPasswordUnlockDataRequestModel + { + Kdf = new KdfRequestModel { KdfType = KdfType.PBKDF2_SHA256, Iterations = AuthConstants.PBKDF2_ITERATIONS.Default }, + MasterKeyWrappedUserKey = "wrapped", + Salt = "salt" + }, + MasterPasswordAuthentication = new MasterPasswordAuthenticationDataRequestModel + { + Kdf = new KdfRequestModel { KdfType = KdfType.PBKDF2_SHA256, Iterations = AuthConstants.PBKDF2_ITERATIONS.Default }, + MasterPasswordAuthenticationHash = "auth-hash", + Salt = "salt" + }, + EmailVerificationToken = "token" + }; + + var results = Validate(model); + + Assert.Empty(results); + } + + [Fact] + public void Validate_WhenNoValidRegistrationTokenProvided_ReturnsTokenErrorOnly() + { + var model = new RegisterFinishRequestModel + { + Email = "user@example.com", + UserAsymmetricKeys = new KeysRequestModel { PublicKey = "pk", EncryptedPrivateKey = "sk" }, + MasterPasswordUnlock = new MasterPasswordUnlockDataRequestModel + { + Kdf = new KdfRequestModel { KdfType = KdfType.PBKDF2_SHA256, Iterations = AuthConstants.PBKDF2_ITERATIONS.Default }, + MasterKeyWrappedUserKey = "wrapped", + Salt = "salt" + }, + MasterPasswordAuthentication = new MasterPasswordAuthenticationDataRequestModel + { + Kdf = new KdfRequestModel { KdfType = KdfType.PBKDF2_SHA256, Iterations = AuthConstants.PBKDF2_ITERATIONS.Default }, + MasterPasswordAuthenticationHash = "auth-hash", + Salt = "salt" + } + // No token fields set + }; + + var results = Validate(model); + + Assert.Single(results); + Assert.Equal("No valid registration token provided", results[0].ErrorMessage); + } } diff --git a/test/Identity.Test/Controllers/AccountsControllerTests.cs b/test/Identity.Test/Controllers/AccountsControllerTests.cs index 42e033bdd7..86e461d155 100644 --- a/test/Identity.Test/Controllers/AccountsControllerTests.cs +++ b/test/Identity.Test/Controllers/AccountsControllerTests.cs @@ -1,4 +1,5 @@ -using System.Reflection; +using System.ComponentModel.DataAnnotations; +using System.Reflection; using System.Text; using Bit.Core; using Bit.Core.Auth.Models.Api.Request.Accounts; @@ -9,6 +10,7 @@ using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; +using Bit.Core.KeyManagement.Models.Api.Request; using Bit.Core.Models.Data; using Bit.Core.Repositories; using Bit.Core.Services; @@ -590,6 +592,504 @@ public class AccountsControllerTests : IDisposable await Assert.ThrowsAsync(() => _sut.PostRegisterVerificationEmailClicked(requestModel)); } + // PM-28143 - When removing the old properties, update this test to just test the new properties working + // as expected. + [Theory, BitAutoData] + public async Task PostRegisterFinish_EmailVerification_BothDataForms_ProduceEquivalentOutcomes( + string email, + string emailVerificationToken, + string masterPasswordHash, + string masterKeyWrappedUserKey, + string publicKey, + string encryptedPrivateKey) + { + // Arrange: new-form model (MasterPasswordAuthenticationData + MasterPasswordUnlockData) + + var kdfData = new KdfRequestModel + { + KdfType = KdfType.Argon2id, + Iterations = AuthConstants.ARGON2_ITERATIONS.Default, + Memory = AuthConstants.ARGON2_MEMORY.Default, + Parallelism = AuthConstants.ARGON2_PARALLELISM.Default + }; + + var newModel = new RegisterFinishRequestModel + { + Email = email, + EmailVerificationToken = emailVerificationToken, + MasterPasswordAuthentication = new MasterPasswordAuthenticationDataRequestModel + { + Kdf = kdfData, + MasterPasswordAuthenticationHash = masterPasswordHash, + Salt = email // salt choice is not validated here during registration + }, + MasterPasswordUnlock = new MasterPasswordUnlockDataRequestModel + { + Kdf = kdfData, + MasterKeyWrappedUserKey = masterKeyWrappedUserKey, + Salt = email + }, + UserAsymmetricKeys = new KeysRequestModel + { + PublicKey = publicKey, + EncryptedPrivateKey = encryptedPrivateKey + } + }; + + // Arrange: legacy-form model (MasterPasswordHash + legacy KDF + UserSymmetricKey) + var legacyModel = new RegisterFinishRequestModel + { + Email = email, + EmailVerificationToken = emailVerificationToken, + MasterPasswordHash = masterPasswordHash, + Kdf = KdfType.Argon2id, + KdfIterations = AuthConstants.ARGON2_ITERATIONS.Default, + KdfMemory = AuthConstants.ARGON2_MEMORY.Default, + KdfParallelism = AuthConstants.ARGON2_PARALLELISM.Default, + UserSymmetricKey = masterKeyWrappedUserKey, + UserAsymmetricKeys = new KeysRequestModel + { + PublicKey = publicKey, + EncryptedPrivateKey = encryptedPrivateKey + } + }; + + var newUser = newModel.ToUser(); + var legacyUser = legacyModel.ToUser(); + + _registerUserCommand + .RegisterUserViaEmailVerificationToken(Arg.Any(), masterPasswordHash, emailVerificationToken) + .Returns(Task.FromResult(IdentityResult.Success)); + + // Act: call with new form + var newResult = await _sut.PostRegisterFinish(newModel); + // Act: call with legacy form + var legacyResult = await _sut.PostRegisterFinish(legacyModel); + + // Assert: outcomes are identical in effect (success response) + Assert.NotNull(newResult); + Assert.NotNull(legacyResult); + + // Assert: effective users are equivalent + Assert.Equal(legacyUser.Email, newUser.Email); + Assert.Equal(legacyUser.MasterPasswordHint, newUser.MasterPasswordHint); + Assert.Equal(legacyUser.Kdf, newUser.Kdf); + Assert.Equal(legacyUser.KdfIterations, newUser.KdfIterations); + Assert.Equal(legacyUser.KdfMemory, newUser.KdfMemory); + Assert.Equal(legacyUser.KdfParallelism, newUser.KdfParallelism); + Assert.Equal(legacyUser.Key, newUser.Key); + Assert.Equal(legacyUser.PublicKey, newUser.PublicKey); + Assert.Equal(legacyUser.PrivateKey, newUser.PrivateKey); + + // Assert: hash forwarded identically from both inputs + await _registerUserCommand.Received(2).RegisterUserViaEmailVerificationToken( + Arg.Is(u => + u.Email == newUser.Email && + u.Kdf == newUser.Kdf && + u.KdfIterations == newUser.KdfIterations && + u.KdfMemory == newUser.KdfMemory && + u.KdfParallelism == newUser.KdfParallelism && + u.Key == newUser.Key), + masterPasswordHash, + emailVerificationToken); + + await _registerUserCommand.Received(2).RegisterUserViaEmailVerificationToken( + Arg.Is(u => + u.Email == legacyUser.Email && + u.Kdf == legacyUser.Kdf && + u.KdfIterations == legacyUser.KdfIterations && + u.KdfMemory == legacyUser.KdfMemory && + u.KdfParallelism == legacyUser.KdfParallelism && + u.Key == legacyUser.Key), + masterPasswordHash, + emailVerificationToken); + } + + // PM-28143 - When removing the old properties, update this test to just test the new properties working + // as expected. + [Theory, BitAutoData] + public async Task PostRegisterFinish_OrgInvite_BothDataForms_ProduceEquivalentOutcomes( + string email, + string orgInviteToken, + Guid organizationUserId, + string masterPasswordHash, + string masterKeyWrappedUserKey, + string publicKey, + string encryptedPrivateKey) + { + var kdfData = new KdfRequestModel + { + KdfType = KdfType.Argon2id, + Iterations = AuthConstants.ARGON2_ITERATIONS.Default, + Memory = AuthConstants.ARGON2_MEMORY.Default, + Parallelism = AuthConstants.ARGON2_PARALLELISM.Default + }; + + // Arrange: new-form model (MasterPasswordAuthenticationData + MasterPasswordUnlockData) + var newModel = new RegisterFinishRequestModel + { + Email = email, + OrgInviteToken = orgInviteToken, + OrganizationUserId = organizationUserId, + MasterPasswordAuthentication = new MasterPasswordAuthenticationDataRequestModel + { + Kdf = kdfData, + MasterPasswordAuthenticationHash = masterPasswordHash, + Salt = email + }, + MasterPasswordUnlock = new MasterPasswordUnlockDataRequestModel + { + Kdf = kdfData, + MasterKeyWrappedUserKey = masterKeyWrappedUserKey, + Salt = email + }, + UserAsymmetricKeys = new KeysRequestModel + { + PublicKey = publicKey, + EncryptedPrivateKey = encryptedPrivateKey + } + }; + + // Arrange: legacy-form model (MasterPasswordHash + legacy KDF + UserSymmetricKey) + var legacyModel = new RegisterFinishRequestModel + { + Email = email, + OrgInviteToken = orgInviteToken, + OrganizationUserId = organizationUserId, + MasterPasswordHash = masterPasswordHash, + Kdf = kdfData.KdfType, + KdfIterations = kdfData.Iterations, + KdfMemory = kdfData.Memory, + KdfParallelism = kdfData.Parallelism, + UserSymmetricKey = masterKeyWrappedUserKey, + UserAsymmetricKeys = new KeysRequestModel + { + PublicKey = publicKey, + EncryptedPrivateKey = encryptedPrivateKey + } + }; + + var newUser = newModel.ToUser(); + var legacyUser = legacyModel.ToUser(); + + _registerUserCommand + .RegisterUserViaOrganizationInviteToken(Arg.Any(), masterPasswordHash, orgInviteToken, organizationUserId) + .Returns(Task.FromResult(IdentityResult.Success)); + + // Act + var newResult = await _sut.PostRegisterFinish(newModel); + var legacyResult = await _sut.PostRegisterFinish(legacyModel); + + // Assert success + Assert.NotNull(newResult); + Assert.NotNull(legacyResult); + + // Assert: effective users are equivalent + Assert.Equal(legacyUser.Email, newUser.Email); + Assert.Equal(legacyUser.MasterPasswordHint, newUser.MasterPasswordHint); + Assert.Equal(legacyUser.Kdf, newUser.Kdf); + Assert.Equal(legacyUser.KdfIterations, newUser.KdfIterations); + Assert.Equal(legacyUser.KdfMemory, newUser.KdfMemory); + Assert.Equal(legacyUser.KdfParallelism, newUser.KdfParallelism); + Assert.Equal(legacyUser.Key, newUser.Key); + Assert.Equal(legacyUser.PublicKey, newUser.PublicKey); + Assert.Equal(legacyUser.PrivateKey, newUser.PrivateKey); + + // Assert: hash forwarded identically from both inputs + await _registerUserCommand.Received(2).RegisterUserViaOrganizationInviteToken( + Arg.Is(u => + u.Email == newUser.Email && + u.Kdf == newUser.Kdf && + u.KdfIterations == newUser.KdfIterations && + u.KdfMemory == newUser.KdfMemory && + u.KdfParallelism == newUser.KdfParallelism && + u.Key == newUser.Key), + masterPasswordHash, + orgInviteToken, + organizationUserId); + + await _registerUserCommand.Received(2).RegisterUserViaOrganizationInviteToken( + Arg.Is(u => + u.Email == legacyUser.Email && + u.Kdf == legacyUser.Kdf && + u.KdfIterations == legacyUser.KdfIterations && + u.KdfMemory == legacyUser.KdfMemory && + u.KdfParallelism == legacyUser.KdfParallelism && + u.Key == legacyUser.Key), + masterPasswordHash, + orgInviteToken, + organizationUserId); + } + + [Theory, BitAutoData] + public async Task PostRegisterFinish_NewForm_UsesUnlockDataForKdfAndKey_WhenRootFieldsNull( + string email, + string emailVerificationToken, + string masterPasswordHash, + string masterKeyWrappedUserKey, + int iterations, + string publicKey, + string encryptedPrivateKey) + { + // Arrange: Provide only unlock-data KDF + key; leave root KDF fields null + var unlockKdf = new KdfRequestModel + { + KdfType = KdfType.PBKDF2_SHA256, + Iterations = iterations + }; + + var model = new RegisterFinishRequestModel + { + Email = email, + EmailVerificationToken = emailVerificationToken, + MasterPasswordAuthentication = new MasterPasswordAuthenticationDataRequestModel + { + // present but not used by ToUser for KDF/Key + Kdf = unlockKdf, + MasterPasswordAuthenticationHash = masterPasswordHash, + Salt = email + }, + MasterPasswordUnlock = new MasterPasswordUnlockDataRequestModel + { + Kdf = unlockKdf, + MasterKeyWrappedUserKey = masterKeyWrappedUserKey, + Salt = email + }, + // root KDF fields intentionally null + Kdf = null, + KdfIterations = null, + UserAsymmetricKeys = new KeysRequestModel + { + PublicKey = publicKey, + EncryptedPrivateKey = encryptedPrivateKey + } + }; + + _registerUserCommand + .RegisterUserViaEmailVerificationToken(Arg.Any(), masterPasswordHash, emailVerificationToken) + .Returns(Task.FromResult(IdentityResult.Success)); + + // Act + var _ = await _sut.PostRegisterFinish(model); + + // Assert: The user passed to command uses unlock-data values + await _registerUserCommand.Received(1).RegisterUserViaEmailVerificationToken( + Arg.Is(u => + u.Email == email && + u.Kdf == unlockKdf.KdfType && + u.KdfIterations == unlockKdf.Iterations && + u.Key == masterKeyWrappedUserKey), + masterPasswordHash, + emailVerificationToken); + } + + [Theory, BitAutoData] + public async Task PostRegisterFinish_LegacyForm_UsesRootFields_WhenUnlockDataNull( + string email, + string emailVerificationToken, + string masterPasswordHash, + string legacyKey, + string publicKey, + string encryptedPrivateKey) + { + // Arrange: Provide only legacy root KDF + key; no unlock-data provided + var model = new RegisterFinishRequestModel + { + Email = email, + EmailVerificationToken = emailVerificationToken, + MasterPasswordHash = masterPasswordHash, + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = AuthConstants.PBKDF2_ITERATIONS.Default, + UserSymmetricKey = legacyKey, + MasterPasswordUnlock = null, + UserAsymmetricKeys = new KeysRequestModel + { + PublicKey = publicKey, + EncryptedPrivateKey = encryptedPrivateKey + } + }; + + _registerUserCommand + .RegisterUserViaEmailVerificationToken(Arg.Any(), masterPasswordHash, emailVerificationToken) + .Returns(Task.FromResult(IdentityResult.Success)); + + // Act + var _ = await _sut.PostRegisterFinish(model); + + // Assert: The user passed to command uses root values + await _registerUserCommand.Received(1).RegisterUserViaEmailVerificationToken( + Arg.Is(u => + u.Email == email && + u.Kdf == KdfType.PBKDF2_SHA256 && + u.KdfIterations == AuthConstants.PBKDF2_ITERATIONS.Default && + u.Key == legacyKey), + masterPasswordHash, + emailVerificationToken); + } + + [Theory, BitAutoData] + public void RegisterFinishRequestModel_Validate_Throws_WhenUnlockAndAuthDataMismatch( + string email, + string authHash, + string masterKeyWrappedUserKey, + string publicKey, + string encryptedPrivateKey) + { + // Arrange: authentication and unlock have different KDF and/or salt + var authKdf = new KdfRequestModel + { + KdfType = KdfType.PBKDF2_SHA256, + Iterations = AuthConstants.PBKDF2_ITERATIONS.Default + }; + var unlockKdf = new KdfRequestModel + { + KdfType = KdfType.Argon2id, + Iterations = AuthConstants.ARGON2_ITERATIONS.Default, + Memory = AuthConstants.ARGON2_MEMORY.Default, + Parallelism = AuthConstants.ARGON2_PARALLELISM.Default + }; + + var model = new RegisterFinishRequestModel + { + Email = email, + MasterPasswordAuthentication = new MasterPasswordAuthenticationDataRequestModel + { + Kdf = authKdf, + MasterPasswordAuthenticationHash = authHash, + Salt = email + }, + MasterPasswordUnlock = new MasterPasswordUnlockDataRequestModel + { + Kdf = unlockKdf, + MasterKeyWrappedUserKey = masterKeyWrappedUserKey, + Salt = email + }, + UserAsymmetricKeys = new KeysRequestModel + { + PublicKey = publicKey, + EncryptedPrivateKey = encryptedPrivateKey + } + }; + + // Provide a minimal valid token type to satisfy model-level token validation + model.EmailVerificationToken = "test-token"; + + var ctx = new ValidationContext(model); + + // Act + var results = model.Validate(ctx).ToList(); + + // Assert mismatched auth/unlock is allowed + Assert.Empty(results); + } + + [Theory, BitAutoData] + public void RegisterFinishRequestModel_Validate_Throws_WhenSaltMismatch( + string email, + string authHash, + string masterKeyWrappedUserKey, + string publicKey, + string encryptedPrivateKey) + { + var unlockKdf = new KdfRequestModel + { + KdfType = KdfType.Argon2id, + Iterations = AuthConstants.ARGON2_ITERATIONS.Default, + Memory = AuthConstants.ARGON2_MEMORY.Default, + Parallelism = AuthConstants.ARGON2_PARALLELISM.Default + }; + + var model = new RegisterFinishRequestModel + { + Email = email, + MasterPasswordAuthentication = new MasterPasswordAuthenticationDataRequestModel + { + Kdf = unlockKdf, + MasterPasswordAuthenticationHash = authHash, + Salt = email + }, + MasterPasswordUnlock = new MasterPasswordUnlockDataRequestModel + { + Kdf = unlockKdf, + MasterKeyWrappedUserKey = masterKeyWrappedUserKey, + // Intentionally different salt to force mismatch + Salt = email + ".mismatch" + }, + UserAsymmetricKeys = new KeysRequestModel + { + PublicKey = publicKey, + EncryptedPrivateKey = encryptedPrivateKey + } + }; + + // Provide a minimal valid token type to satisfy model-level token validation + model.EmailVerificationToken = "test-token"; + + var ctx = new ValidationContext(model); + + // Act + var results = model.Validate(ctx).ToList(); + + // Assert mismatched salts between auth/unlock are allowed + Assert.Empty(results); + } + + [Theory, BitAutoData] + public void RegisterFinishRequestModel_Validate_Throws_WhenAuthHashAndRootHashMismatch( + string email, + string authHash, + string differentRootHash, + string masterKeyWrappedUserKey, + string publicKey, + string encryptedPrivateKey) + { + // Arrange: same KDF/salt, but authentication hash differs from legacy root hash + var kdf = new KdfRequestModel + { + KdfType = KdfType.PBKDF2_SHA256, + Iterations = AuthConstants.PBKDF2_ITERATIONS.Default + }; + + var model = new RegisterFinishRequestModel + { + Email = email, + MasterPasswordAuthentication = new MasterPasswordAuthenticationDataRequestModel + { + Kdf = kdf, + MasterPasswordAuthenticationHash = authHash, + Salt = email + }, + MasterPasswordUnlock = new MasterPasswordUnlockDataRequestModel + { + Kdf = kdf, + MasterKeyWrappedUserKey = masterKeyWrappedUserKey, + Salt = email + }, + // Intentionally set the legacy field to a different value to trigger the throw + MasterPasswordHash = differentRootHash, + UserAsymmetricKeys = new KeysRequestModel + { + PublicKey = publicKey, + EncryptedPrivateKey = encryptedPrivateKey + } + }; + + // Provide a minimal valid token type to satisfy model-level token validation + model.EmailVerificationToken = "test-token"; + + var ctx = new ValidationContext(model); + + // Act + var results = model.Validate(ctx).ToList(); + + // Assert: validation result exists with expected message and member names + var mismatchResult = Assert.Single(results.Where(r => + r.ErrorMessage == + "MasterPasswordAuthenticationHash and root level MasterPasswordHash provided and are not equal. Only provide one.")); + Assert.Contains("MasterPasswordAuthenticationHash", mismatchResult.MemberNames); + Assert.Contains("MasterPasswordHash", mismatchResult.MemberNames); + } + private void SetDefaultKdfHmacKey(byte[]? newKey) { var fieldInfo = typeof(AccountsController).GetField("_defaultKdfHmacKey", BindingFlags.NonPublic | BindingFlags.Instance); diff --git a/test/IntegrationTestCommon/Factories/IdentityApplicationFactory.cs b/test/IntegrationTestCommon/Factories/IdentityApplicationFactory.cs index ba12d1e1f4..e190dda427 100644 --- a/test/IntegrationTestCommon/Factories/IdentityApplicationFactory.cs +++ b/test/IntegrationTestCommon/Factories/IdentityApplicationFactory.cs @@ -3,10 +3,13 @@ using System.Collections.Concurrent; using System.Net.Http.Json; +using System.Text; using System.Text.Json; +using Bit.Core; using Bit.Core.Auth.Models.Api.Request.Accounts; using Bit.Core.Entities; using Bit.Core.Enums; +using Bit.Core.KeyManagement.Models.Api.Request; using Bit.Core.Services; using Bit.Identity; using Bit.Test.Common.Helpers; @@ -23,6 +26,7 @@ public class IdentityApplicationFactory : WebApplicationFactoryBase public const string DefaultDeviceIdentifier = "92b9d953-b9b6-4eaf-9d3e-11d57144dfeb"; public const string DefaultUserEmail = "DefaultEmail@bitwarden.com"; public const string DefaultUserPasswordHash = "default_password_hash"; + private const string DefaultEncryptedString = "2.3Uk+WNBIoU5xzmVFNcoWzz==|1MsPIYuRfdOHfu/0uY6H2Q==|/98sp4wb6pHP1VTZ9JcNCYgQjEUMFPlqJgCwRk1YXKg="; /// /// A dictionary to store registration tokens for email verification. We cannot substitute the IMailService more than once, so @@ -195,6 +199,68 @@ public class IdentityApplicationFactory : WebApplicationFactoryBase RegisterFinishRequestModel requestModel, bool marketingEmails = true) { + // Ensure required fields for registration finish are present. + // Prefer legacy-path defaults (root fields) to minimize changes to tests. + // PM-28143 - When MasterPasswordAuthenticationData is required, delete all handling of MasterPasswordHash. + requestModel.MasterPasswordHash ??= DefaultUserPasswordHash; + // PM-28143 - When KDF is sourced exclusively from MasterPasswordUnlockData, delete the root Kdf defaults below. + requestModel.Kdf ??= KdfType.PBKDF2_SHA256; + requestModel.KdfIterations ??= AuthConstants.PBKDF2_ITERATIONS.Default; + // Ensure a symmetric key is provided when no unlock data is present + // PM-28143 - When MasterPasswordUnlockData is required, delete the UserSymmetricKey fallback block below. + if (requestModel.MasterPasswordUnlock == null && string.IsNullOrWhiteSpace(requestModel.UserSymmetricKey)) + { + requestModel.UserSymmetricKey = "user_symmetric_key"; + } + + // Align unlock/auth data KDF with root KDF so login uses the provided master password hash. + // PM-28143 - After removing root Kdf fields, build KDF exclusively from MasterPasswordUnlockData.Kdf and delete this alignment section. + var effectiveKdfType = requestModel.Kdf ?? KdfType.PBKDF2_SHA256; + var effectiveIterations = requestModel.KdfIterations ?? AuthConstants.PBKDF2_ITERATIONS.Default; + int? effectiveMemory = null; + int? effectiveParallelism = null; + if (effectiveKdfType == KdfType.Argon2id) + { + effectiveIterations = AuthConstants.ARGON2_ITERATIONS.InsideRange(effectiveIterations) + ? effectiveIterations + : AuthConstants.ARGON2_ITERATIONS.Default; + effectiveMemory = AuthConstants.ARGON2_MEMORY.Default; + effectiveParallelism = AuthConstants.ARGON2_PARALLELISM.Default; + } + + var alignedKdf = new KdfRequestModel + { + KdfType = effectiveKdfType, + Iterations = effectiveIterations, + Memory = effectiveMemory, + Parallelism = effectiveParallelism + }; + + if (requestModel.MasterPasswordUnlock != null) + { + var unlock = requestModel.MasterPasswordUnlock; + // Always force a valid encrypted string for tests to avoid model validation failures. + requestModel.MasterPasswordUnlock = new MasterPasswordUnlockDataRequestModel + { + Kdf = alignedKdf, + MasterKeyWrappedUserKey = unlock.MasterKeyWrappedUserKey, + Salt = string.IsNullOrWhiteSpace(unlock.Salt) ? requestModel.Email : unlock.Salt + }; + } + + if (requestModel.MasterPasswordAuthentication != null) + { + // Ensure registration uses the same hash the tests will provide at login. + // PM-28143 - When MasterPasswordAuthenticationData is the only source of the auth hash, + // stop overriding it from MasterPasswordHash and delete this whole reassignment block. + requestModel.MasterPasswordAuthentication = new MasterPasswordAuthenticationDataRequestModel + { + Kdf = alignedKdf, + MasterPasswordAuthenticationHash = requestModel.MasterPasswordHash, + Salt = requestModel.Email + }; + } + var sendVerificationEmailReqModel = new RegisterSendVerificationEmailRequestModel { Email = requestModel.Email, @@ -211,8 +277,11 @@ public class IdentityApplicationFactory : WebApplicationFactoryBase requestModel.EmailVerificationToken = RegistrationTokens[requestModel.Email]; var postRegisterFinishHttpContext = await PostRegisterFinishAsync(requestModel); - - Assert.Equal(StatusCodes.Status200OK, postRegisterFinishHttpContext.Response.StatusCode); + if (postRegisterFinishHttpContext.Response.StatusCode != StatusCodes.Status200OK) + { + var body = await ReadResponseBodyAsync(postRegisterFinishHttpContext); + Assert.Fail($"register/finish failed (status {postRegisterFinishHttpContext.Response.StatusCode}). Body: {body}"); + } var database = GetDatabaseContext(); var user = await database.Users @@ -222,4 +291,32 @@ public class IdentityApplicationFactory : WebApplicationFactoryBase return user; } + + private static async Task ReadResponseBodyAsync(HttpContext ctx) + { + try + { + if (ctx?.Response?.Body == null) + { + return ""; + } + var stream = ctx.Response.Body; + if (stream.CanSeek) + { + stream.Seek(0, SeekOrigin.Begin); + } + using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, leaveOpen: true); + var text = await reader.ReadToEndAsync(); + if (stream.CanSeek) + { + stream.Seek(0, SeekOrigin.Begin); + } + return string.IsNullOrWhiteSpace(text) ? "" : text; + } + catch (Exception ex) + { + return $""; + } + } + } From 029a5f6a2df73f74c7f095dfba60b475edbec244 Mon Sep 17 00:00:00 2001 From: Patrick-Pimentel-Bitwarden Date: Thu, 15 Jan 2026 16:19:16 -0500 Subject: [PATCH 27/49] Revert "feat(register): [PM-27084] Account Register Uses New Data Types (#6715)" (#6854) This reverts commit 8cb80305341098673771e863b8b5266ad19bdce0. --- .../Request/Accounts/PasswordRequestModel.cs | 6 +- .../SetInitialPasswordRequestModel.cs | 1 + .../Models/Requests}/KdfRequestModel.cs | 11 +- ...rPasswordAuthenticationDataRequestModel.cs | 6 +- .../MasterPasswordUnlockDataRequestModel.cs | 6 +- .../Accounts/RegisterFinishRequestModel.cs | 201 +------ src/Core/Entities/User.cs | 6 +- .../Data/MasterPasswordAuthenticationData.cs | 5 - ...sterPasswordUnlockAndAuthenticationData.cs | 3 +- .../Models/Data/MasterPasswordUnlockData.cs | 5 - src/Core/Utilities/KdfSettingsValidator.cs | 1 - .../Controllers/AccountsController.cs | 61 +-- .../Controllers/AccountsControllerTest.cs | 4 +- .../Controllers/AccountsControllerTests.cs | 1 + .../SetInitialPasswordRequestModelTests.cs | 1 + .../RegisterFinishRequestModelFixtures.cs | 4 +- .../RegisterFinishRequestModelTests.cs | 183 ------- .../Controllers/AccountsControllerTests.cs | 502 +----------------- .../Factories/IdentityApplicationFactory.cs | 101 +--- 19 files changed, 63 insertions(+), 1045 deletions(-) rename src/{Core/KeyManagement/Models/Api/Request => Api/KeyManagement/Models/Requests}/KdfRequestModel.cs (59%) rename src/{Core/KeyManagement/Models/Api/Request => Api/KeyManagement/Models/Requests}/MasterPasswordAuthenticationDataRequestModel.cs (71%) rename src/{Core/KeyManagement/Models/Api/Request => Api/KeyManagement/Models/Requests}/MasterPasswordUnlockDataRequestModel.cs (71%) diff --git a/src/Api/Auth/Models/Request/Accounts/PasswordRequestModel.cs b/src/Api/Auth/Models/Request/Accounts/PasswordRequestModel.cs index ab8c727852..8fa51e9f34 100644 --- a/src/Api/Auth/Models/Request/Accounts/PasswordRequestModel.cs +++ b/src/Api/Auth/Models/Request/Accounts/PasswordRequestModel.cs @@ -1,5 +1,7 @@ -using System.ComponentModel.DataAnnotations; -using Bit.Core.KeyManagement.Models.Api.Request; +#nullable enable + +using System.ComponentModel.DataAnnotations; +using Bit.Api.KeyManagement.Models.Requests; namespace Bit.Api.Auth.Models.Request.Accounts; diff --git a/src/Api/Auth/Models/Request/Accounts/SetInitialPasswordRequestModel.cs b/src/Api/Auth/Models/Request/Accounts/SetInitialPasswordRequestModel.cs index 37a7901fee..55ffdca94b 100644 --- a/src/Api/Auth/Models/Request/Accounts/SetInitialPasswordRequestModel.cs +++ b/src/Api/Auth/Models/Request/Accounts/SetInitialPasswordRequestModel.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using Bit.Api.KeyManagement.Models.Requests; using Bit.Core.Auth.Models.Api.Request.Accounts; using Bit.Core.Auth.Models.Data; using Bit.Core.Entities; diff --git a/src/Core/KeyManagement/Models/Api/Request/KdfRequestModel.cs b/src/Api/KeyManagement/Models/Requests/KdfRequestModel.cs similarity index 59% rename from src/Core/KeyManagement/Models/Api/Request/KdfRequestModel.cs rename to src/Api/KeyManagement/Models/Requests/KdfRequestModel.cs index edcd7f760f..904304a633 100644 --- a/src/Core/KeyManagement/Models/Api/Request/KdfRequestModel.cs +++ b/src/Api/KeyManagement/Models/Requests/KdfRequestModel.cs @@ -1,11 +1,10 @@ using System.ComponentModel.DataAnnotations; using Bit.Core.Enums; using Bit.Core.KeyManagement.Models.Data; -using Bit.Core.Utilities; -namespace Bit.Core.KeyManagement.Models.Api.Request; +namespace Bit.Api.KeyManagement.Models.Requests; -public class KdfRequestModel : IValidatableObject +public class KdfRequestModel { [Required] public required KdfType KdfType { get; init; } @@ -24,10 +23,4 @@ public class KdfRequestModel : IValidatableObject Parallelism = Parallelism }; } - - public IEnumerable Validate(ValidationContext validationContext) - { - // Generic per-request KDF validation for any request model embedding KdfRequestModel - return KdfSettingsValidator.Validate(ToData()); - } } diff --git a/src/Core/KeyManagement/Models/Api/Request/MasterPasswordAuthenticationDataRequestModel.cs b/src/Api/KeyManagement/Models/Requests/MasterPasswordAuthenticationDataRequestModel.cs similarity index 71% rename from src/Core/KeyManagement/Models/Api/Request/MasterPasswordAuthenticationDataRequestModel.cs rename to src/Api/KeyManagement/Models/Requests/MasterPasswordAuthenticationDataRequestModel.cs index 04c22cc3a6..4f70a1135f 100644 --- a/src/Core/KeyManagement/Models/Api/Request/MasterPasswordAuthenticationDataRequestModel.cs +++ b/src/Api/KeyManagement/Models/Requests/MasterPasswordAuthenticationDataRequestModel.cs @@ -1,12 +1,8 @@ using System.ComponentModel.DataAnnotations; using Bit.Core.KeyManagement.Models.Data; -namespace Bit.Core.KeyManagement.Models.Api.Request; +namespace Bit.Api.KeyManagement.Models.Requests; -/// -/// Use this datatype when interfacing with requests to create a separation of concern. -/// See to use for commands, queries, services. -/// public class MasterPasswordAuthenticationDataRequestModel { public required KdfRequestModel Kdf { get; init; } diff --git a/src/Core/KeyManagement/Models/Api/Request/MasterPasswordUnlockDataRequestModel.cs b/src/Api/KeyManagement/Models/Requests/MasterPasswordUnlockDataRequestModel.cs similarity index 71% rename from src/Core/KeyManagement/Models/Api/Request/MasterPasswordUnlockDataRequestModel.cs rename to src/Api/KeyManagement/Models/Requests/MasterPasswordUnlockDataRequestModel.cs index 8d7df86374..e1d7863cae 100644 --- a/src/Core/KeyManagement/Models/Api/Request/MasterPasswordUnlockDataRequestModel.cs +++ b/src/Api/KeyManagement/Models/Requests/MasterPasswordUnlockDataRequestModel.cs @@ -2,12 +2,8 @@ using Bit.Core.KeyManagement.Models.Data; using Bit.Core.Utilities; -namespace Bit.Core.KeyManagement.Models.Api.Request; +namespace Bit.Api.KeyManagement.Models.Requests; -/// -/// Use this datatype when interfacing with requests to create a separation of concern. -/// See to use for commands, queries, services. -/// public class MasterPasswordUnlockDataRequestModel { public required KdfRequestModel Kdf { get; init; } diff --git a/src/Core/Auth/Models/Api/Request/Accounts/RegisterFinishRequestModel.cs b/src/Core/Auth/Models/Api/Request/Accounts/RegisterFinishRequestModel.cs index cb66540a6b..0ac7dbbcb4 100644 --- a/src/Core/Auth/Models/Api/Request/Accounts/RegisterFinishRequestModel.cs +++ b/src/Core/Auth/Models/Api/Request/Accounts/RegisterFinishRequestModel.cs @@ -1,6 +1,6 @@ -using Bit.Core.Entities; +#nullable enable +using Bit.Core.Entities; using Bit.Core.Enums; -using Bit.Core.KeyManagement.Models.Api.Request; using Bit.Core.Utilities; namespace Bit.Core.Auth.Models.Api.Request.Accounts; @@ -21,32 +21,19 @@ public class RegisterFinishRequestModel : IValidatableObject public required string Email { get; set; } public string? EmailVerificationToken { get; set; } - public MasterPasswordAuthenticationDataRequestModel? MasterPasswordAuthentication { get; set; } - public MasterPasswordUnlockDataRequestModel? MasterPasswordUnlock { get; set; } - - // PM-28143 - Remove property below (made optional during migration to MasterPasswordUnlockData) [StringLength(1000)] - // Made optional but there will still be a thrown error if it does not exist either here or - // in the MasterPasswordAuthenticationData. - public string? MasterPasswordHash { get; set; } + public required string MasterPasswordHash { get; set; } [StringLength(50)] public string? MasterPasswordHint { get; set; } - // PM-28143 - Remove property below (made optional during migration to MasterPasswordUnlockData) - // Made optional but there will still be a thrown error if it does not exist either here or - // in the MasterPasswordAuthenticationData. - public string? UserSymmetricKey { get; set; } + public required string UserSymmetricKey { get; set; } public required KeysRequestModel UserAsymmetricKeys { get; set; } - // PM-28143 - Remove line below (made optional during migration to MasterPasswordUnlockData) - public KdfType? Kdf { get; set; } - // PM-28143 - Remove line below (made optional during migration to MasterPasswordUnlockData) - public int? KdfIterations { get; set; } - // PM-28143 - Remove line below + public required KdfType Kdf { get; set; } + public required int KdfIterations { get; set; } public int? KdfMemory { get; set; } - // PM-28143 - Remove line below public int? KdfParallelism { get; set; } public Guid? OrganizationUserId { get; set; } @@ -67,14 +54,11 @@ public class RegisterFinishRequestModel : IValidatableObject { Email = Email, MasterPasswordHint = MasterPasswordHint, - Kdf = (KdfType)(MasterPasswordUnlock?.Kdf.KdfType ?? Kdf)!, - KdfIterations = (int)(MasterPasswordUnlock?.Kdf.Iterations ?? KdfIterations)!, - // KdfMemory and KdfParallelism are optional (only used for Argon2id) - KdfMemory = MasterPasswordUnlock?.Kdf.Memory ?? KdfMemory, - KdfParallelism = MasterPasswordUnlock?.Kdf.Parallelism ?? KdfParallelism, - // PM-28827 To be added when MasterPasswordSalt is added to the user column - // MasterPasswordSalt = MasterPasswordUnlock?.Salt ?? Email.ToLower().Trim(), - Key = MasterPasswordUnlock?.MasterKeyWrappedUserKey ?? UserSymmetricKey + Kdf = Kdf, + KdfIterations = KdfIterations, + KdfMemory = KdfMemory, + KdfParallelism = KdfParallelism, + Key = UserSymmetricKey, }; UserAsymmetricKeys.ToUser(user); @@ -88,9 +72,7 @@ public class RegisterFinishRequestModel : IValidatableObject { return RegisterFinishTokenType.EmailVerification; } - if (!string.IsNullOrEmpty(OrgInviteToken) - && OrganizationUserId.HasValue - && OrganizationUserId.Value != Guid.Empty) + if (!string.IsNullOrEmpty(OrgInviteToken) && OrganizationUserId.HasValue) { return RegisterFinishTokenType.OrganizationInvite; } @@ -98,15 +80,11 @@ public class RegisterFinishRequestModel : IValidatableObject { return RegisterFinishTokenType.OrgSponsoredFreeFamilyPlan; } - if (!string.IsNullOrWhiteSpace(AcceptEmergencyAccessInviteToken) - && AcceptEmergencyAccessId.HasValue - && AcceptEmergencyAccessId.Value != Guid.Empty) + if (!string.IsNullOrWhiteSpace(AcceptEmergencyAccessInviteToken) && AcceptEmergencyAccessId.HasValue) { return RegisterFinishTokenType.EmergencyAccessInvite; } - if (!string.IsNullOrWhiteSpace(ProviderInviteToken) - && ProviderUserId.HasValue - && ProviderUserId.Value != Guid.Empty) + if (!string.IsNullOrWhiteSpace(ProviderInviteToken) && ProviderUserId.HasValue) { return RegisterFinishTokenType.ProviderInvite; } @@ -114,156 +92,9 @@ public class RegisterFinishRequestModel : IValidatableObject throw new InvalidOperationException("Invalid token type."); } + public IEnumerable Validate(ValidationContext validationContext) { - // 1. Authentication data containing hash and hash at root level check - if (MasterPasswordAuthentication != null && MasterPasswordHash != null) - { - if (MasterPasswordAuthentication.MasterPasswordAuthenticationHash != MasterPasswordHash) - { - yield return new ValidationResult( - $"{nameof(MasterPasswordAuthentication.MasterPasswordAuthenticationHash)} and root level {nameof(MasterPasswordHash)} provided and are not equal. Only provide one.", - [nameof(MasterPasswordAuthentication.MasterPasswordAuthenticationHash), nameof(MasterPasswordHash)]); - } - } // 1.5 if there is no master password hash that is unacceptable even though they are both optional in the model - else if (MasterPasswordAuthentication == null && MasterPasswordHash == null) - { - yield return new ValidationResult( - $"{nameof(MasterPasswordAuthentication.MasterPasswordAuthenticationHash)} and {nameof(MasterPasswordHash)} not found on request, one needs to be defined.", - [nameof(MasterPasswordAuthentication.MasterPasswordAuthenticationHash), nameof(MasterPasswordHash)]); - } - - // 2. Validate kdf settings. - if (MasterPasswordUnlock != null) - { - foreach (var validationResult in KdfSettingsValidator.Validate(MasterPasswordUnlock.ToData().Kdf)) - { - yield return validationResult; - } - } - - if (MasterPasswordAuthentication != null) - { - foreach (var validationResult in KdfSettingsValidator.Validate(MasterPasswordAuthentication.ToData().Kdf)) - { - yield return validationResult; - } - } - - // 3. Validate root kdf values if kdf values are not in the unlock and authentication. - if (MasterPasswordUnlock == null && MasterPasswordAuthentication == null) - { - var hasMissingRequiredKdfInputs = false; - if (Kdf == null) - { - yield return new ValidationResult($"{nameof(Kdf)} not found on RequestModel", [nameof(Kdf)]); - hasMissingRequiredKdfInputs = true; - } - if (KdfIterations == null) - { - yield return new ValidationResult($"{nameof(KdfIterations)} not found on RequestModel", [nameof(KdfIterations)]); - hasMissingRequiredKdfInputs = true; - } - - if (!hasMissingRequiredKdfInputs) - { - foreach (var validationResult in KdfSettingsValidator.Validate( - Kdf!.Value, - KdfIterations!.Value, - KdfMemory, - KdfParallelism)) - { - yield return validationResult; - } - } - } - else if (MasterPasswordUnlock == null && MasterPasswordAuthentication != null) - { - // Authentication provided but Unlock missing - yield return new ValidationResult($"{nameof(MasterPasswordUnlock)} not found on RequestModel", [nameof(MasterPasswordUnlock)]); - } - else if (MasterPasswordUnlock != null && MasterPasswordAuthentication == null) - { - // Unlock provided but Authentication missing - yield return new ValidationResult($"{nameof(MasterPasswordAuthentication)} not found on RequestModel", [nameof(MasterPasswordAuthentication)]); - } - - // 3. Lastly, validate access token type and presence. Must be done last because of yield break. - RegisterFinishTokenType tokenType; - var tokenTypeResolved = true; - try - { - tokenType = GetTokenType(); - } - catch (InvalidOperationException) - { - tokenTypeResolved = false; - tokenType = default; - } - - if (!tokenTypeResolved) - { - yield return new ValidationResult("No valid registration token provided"); - yield break; - } - - switch (tokenType) - { - case RegisterFinishTokenType.EmailVerification: - if (string.IsNullOrEmpty(EmailVerificationToken)) - { - yield return new ValidationResult( - $"{nameof(EmailVerificationToken)} absent when processing register/finish.", - [nameof(EmailVerificationToken)]); - } - break; - case RegisterFinishTokenType.OrganizationInvite: - if (string.IsNullOrEmpty(OrgInviteToken)) - { - yield return new ValidationResult( - $"{nameof(OrgInviteToken)} absent when processing register/finish.", - [nameof(OrgInviteToken)]); - } - break; - case RegisterFinishTokenType.OrgSponsoredFreeFamilyPlan: - if (string.IsNullOrEmpty(OrgSponsoredFreeFamilyPlanToken)) - { - yield return new ValidationResult( - $"{nameof(OrgSponsoredFreeFamilyPlanToken)} absent when processing register/finish.", - [nameof(OrgSponsoredFreeFamilyPlanToken)]); - } - break; - case RegisterFinishTokenType.EmergencyAccessInvite: - if (string.IsNullOrEmpty(AcceptEmergencyAccessInviteToken)) - { - yield return new ValidationResult( - $"{nameof(AcceptEmergencyAccessInviteToken)} absent when processing register/finish.", - [nameof(AcceptEmergencyAccessInviteToken)]); - } - if (!AcceptEmergencyAccessId.HasValue || AcceptEmergencyAccessId.Value == Guid.Empty) - { - yield return new ValidationResult( - $"{nameof(AcceptEmergencyAccessId)} absent when processing register/finish.", - [nameof(AcceptEmergencyAccessId)]); - } - break; - case RegisterFinishTokenType.ProviderInvite: - if (string.IsNullOrEmpty(ProviderInviteToken)) - { - yield return new ValidationResult( - $"{nameof(ProviderInviteToken)} absent when processing register/finish.", - [nameof(ProviderInviteToken)]); - } - if (!ProviderUserId.HasValue || ProviderUserId.Value == Guid.Empty) - { - yield return new ValidationResult( - $"{nameof(ProviderUserId)} absent when processing register/finish.", - [nameof(ProviderUserId)]); - } - break; - default: - yield return new ValidationResult("Invalid registration finish request"); - break; - } + return KdfSettingsValidator.Validate(Kdf, KdfIterations, KdfMemory, KdfParallelism); } } diff --git a/src/Core/Entities/User.cs b/src/Core/Entities/User.cs index 422dc37c6e..669e32bcbe 100644 --- a/src/Core/Entities/User.cs +++ b/src/Core/Entities/User.cs @@ -7,6 +7,8 @@ using Bit.Core.KeyManagement.Models.Data; using Bit.Core.Utilities; using Microsoft.AspNetCore.Identity; +#nullable enable + namespace Bit.Core.Entities; public class User : ITableObject, IStorableSubscriber, IRevisable, ITwoFactorProvidersUser @@ -49,7 +51,7 @@ public class User : ITableObject, IStorableSubscriber, IRevisable, ITwoFac public string? Key { get; set; } /// /// The raw public key, without a signature from the user's signature key. - /// + /// public string? PublicKey { get; set; } /// /// User key wrapped private key. @@ -105,8 +107,6 @@ public class User : ITableObject, IStorableSubscriber, IRevisable, ITwoFac public DateTime? LastKeyRotationDate { get; set; } public DateTime? LastEmailChangeDate { get; set; } public bool VerifyDevices { get; set; } = true; - // PM-28827 Uncomment below line. - // public string? MasterPasswordSalt { get; set; } public string GetMasterPasswordSalt() { diff --git a/src/Core/KeyManagement/Models/Data/MasterPasswordAuthenticationData.cs b/src/Core/KeyManagement/Models/Data/MasterPasswordAuthenticationData.cs index 6e53dfa744..1bc7006cef 100644 --- a/src/Core/KeyManagement/Models/Data/MasterPasswordAuthenticationData.cs +++ b/src/Core/KeyManagement/Models/Data/MasterPasswordAuthenticationData.cs @@ -1,13 +1,8 @@ using Bit.Core.Entities; using Bit.Core.Exceptions; -using Bit.Core.KeyManagement.Models.Api.Request; namespace Bit.Core.KeyManagement.Models.Data; -/// -/// Use this datatype when interfacing with commands, queries, services to create a separation of concern. -/// See to use for requests. -/// public class MasterPasswordAuthenticationData { public required KdfSettings Kdf { get; init; } diff --git a/src/Core/KeyManagement/Models/Data/MasterPasswordUnlockAndAuthenticationData.cs b/src/Core/KeyManagement/Models/Data/MasterPasswordUnlockAndAuthenticationData.cs index b79ce8bce1..ad3a0b692b 100644 --- a/src/Core/KeyManagement/Models/Data/MasterPasswordUnlockAndAuthenticationData.cs +++ b/src/Core/KeyManagement/Models/Data/MasterPasswordUnlockAndAuthenticationData.cs @@ -1,4 +1,5 @@ -using Bit.Core.Entities; +#nullable enable +using Bit.Core.Entities; using Bit.Core.Enums; namespace Bit.Core.KeyManagement.Models.Data; diff --git a/src/Core/KeyManagement/Models/Data/MasterPasswordUnlockData.cs b/src/Core/KeyManagement/Models/Data/MasterPasswordUnlockData.cs index f8139cba99..cb18ed2a78 100644 --- a/src/Core/KeyManagement/Models/Data/MasterPasswordUnlockData.cs +++ b/src/Core/KeyManagement/Models/Data/MasterPasswordUnlockData.cs @@ -1,13 +1,8 @@ using Bit.Core.Entities; using Bit.Core.Exceptions; -using Bit.Core.KeyManagement.Models.Api.Request; namespace Bit.Core.KeyManagement.Models.Data; -/// -/// Use this datatype when interfacing with commands, queries, services to create a separation of concern. -/// See to use for requests. -/// public class MasterPasswordUnlockData { public required KdfSettings Kdf { get; init; } diff --git a/src/Core/Utilities/KdfSettingsValidator.cs b/src/Core/Utilities/KdfSettingsValidator.cs index e5690ad469..f89e8ddb66 100644 --- a/src/Core/Utilities/KdfSettingsValidator.cs +++ b/src/Core/Utilities/KdfSettingsValidator.cs @@ -6,7 +6,6 @@ namespace Bit.Core.Utilities; public static class KdfSettingsValidator { - // PM-28143 - Remove below when fixing ticket public static IEnumerable Validate(KdfType kdfType, int kdfIterations, int? kdfMemory, int? kdfParallelism) { switch (kdfType) diff --git a/src/Identity/Controllers/AccountsController.cs b/src/Identity/Controllers/AccountsController.cs index e9807fb1fc..b7d4342c1b 100644 --- a/src/Identity/Controllers/AccountsController.cs +++ b/src/Identity/Controllers/AccountsController.cs @@ -1,4 +1,8 @@ -using System.Text; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Diagnostics; +using System.Text; using Bit.Core; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models.Api.Request.Accounts; @@ -38,7 +42,7 @@ public class AccountsController : Controller private readonly IFeatureService _featureService; private readonly IDataProtectorTokenFactory _registrationEmailVerificationTokenDataFactory; - private readonly byte[]? _defaultKdfHmacKey = null; + private readonly byte[] _defaultKdfHmacKey = null; private static readonly List _defaultKdfResults = [ // The first result (index 0) should always return the "normal" default. @@ -141,55 +145,40 @@ public class AccountsController : Controller [HttpPost("register/finish")] public async Task PostRegisterFinish([FromBody] RegisterFinishRequestModel model) { - User user = model.ToUser(); + var user = model.ToUser(); // Users will either have an emailed token or an email verification token - not both. - IdentityResult? identityResult = null; - - // PM-28143 - Just use the MasterPasswordAuthenticationData.MasterPasswordAuthenticationHash - string masterPasswordAuthenticationHash = model.MasterPasswordAuthentication?.MasterPasswordAuthenticationHash - ?? model.MasterPasswordHash!; + IdentityResult identityResult = null; switch (model.GetTokenType()) { case RegisterFinishTokenType.EmailVerification: - identityResult = await _registerUserCommand.RegisterUserViaEmailVerificationToken( - user, - masterPasswordAuthenticationHash, - model.EmailVerificationToken!); - return ProcessRegistrationResult(identityResult, user); + identityResult = + await _registerUserCommand.RegisterUserViaEmailVerificationToken(user, model.MasterPasswordHash, + model.EmailVerificationToken); + return ProcessRegistrationResult(identityResult, user); case RegisterFinishTokenType.OrganizationInvite: - identityResult = await _registerUserCommand.RegisterUserViaOrganizationInviteToken( - user, - masterPasswordAuthenticationHash, - model.OrgInviteToken!, - model.OrganizationUserId); - return ProcessRegistrationResult(identityResult, user); + identityResult = await _registerUserCommand.RegisterUserViaOrganizationInviteToken(user, model.MasterPasswordHash, + model.OrgInviteToken, model.OrganizationUserId); + return ProcessRegistrationResult(identityResult, user); case RegisterFinishTokenType.OrgSponsoredFreeFamilyPlan: - identityResult = await _registerUserCommand.RegisterUserViaOrganizationSponsoredFreeFamilyPlanInviteToken( - user, - masterPasswordAuthenticationHash, - model.OrgSponsoredFreeFamilyPlanToken!); - return ProcessRegistrationResult(identityResult, user); + identityResult = await _registerUserCommand.RegisterUserViaOrganizationSponsoredFreeFamilyPlanInviteToken(user, model.MasterPasswordHash, model.OrgSponsoredFreeFamilyPlanToken); + return ProcessRegistrationResult(identityResult, user); case RegisterFinishTokenType.EmergencyAccessInvite: - identityResult = await _registerUserCommand.RegisterUserViaAcceptEmergencyAccessInviteToken( - user, - masterPasswordAuthenticationHash, - model.AcceptEmergencyAccessInviteToken!, - (Guid)model.AcceptEmergencyAccessId!); - return ProcessRegistrationResult(identityResult, user); + Debug.Assert(model.AcceptEmergencyAccessId.HasValue); + identityResult = await _registerUserCommand.RegisterUserViaAcceptEmergencyAccessInviteToken(user, model.MasterPasswordHash, + model.AcceptEmergencyAccessInviteToken, model.AcceptEmergencyAccessId.Value); + return ProcessRegistrationResult(identityResult, user); case RegisterFinishTokenType.ProviderInvite: - identityResult = await _registerUserCommand.RegisterUserViaProviderInviteToken( - user, - masterPasswordAuthenticationHash, - model.ProviderInviteToken!, - (Guid)model.ProviderUserId!); - return ProcessRegistrationResult(identityResult, user); + Debug.Assert(model.ProviderUserId.HasValue); + identityResult = await _registerUserCommand.RegisterUserViaProviderInviteToken(user, model.MasterPasswordHash, + model.ProviderInviteToken, model.ProviderUserId.Value); + return ProcessRegistrationResult(identityResult, user); default: throw new BadRequestException("Invalid registration finish request"); } diff --git a/test/Api.IntegrationTest/Controllers/AccountsControllerTest.cs b/test/Api.IntegrationTest/Controllers/AccountsControllerTest.cs index 9860775e31..d055418f3a 100644 --- a/test/Api.IntegrationTest/Controllers/AccountsControllerTest.cs +++ b/test/Api.IntegrationTest/Controllers/AccountsControllerTest.cs @@ -3,6 +3,7 @@ using System.Text.Json; using Bit.Api.Auth.Models.Request.Accounts; using Bit.Api.IntegrationTest.Factories; using Bit.Api.IntegrationTest.Helpers; +using Bit.Api.KeyManagement.Models.Requests; using Bit.Api.Models.Response; using Bit.Core; using Bit.Core.Auth.Entities; @@ -11,7 +12,6 @@ using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.Repositories; using Bit.Core.Entities; using Bit.Core.Enums; -using Bit.Core.KeyManagement.Models.Api.Request; using Bit.Core.KeyManagement.Repositories; using Bit.Core.Models.Data; using Bit.Core.Platform.Push; @@ -378,7 +378,7 @@ public class AccountsControllerTest : IClassFixture, IAsy Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); var content = await response.Content.ReadAsStringAsync(); - Assert.Contains("The model state is invalid", content); + Assert.Contains("KDF settings are invalid", content); } [Fact] diff --git a/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs b/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs index 665d1e52c1..6cddd341d5 100644 --- a/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs +++ b/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs @@ -1,6 +1,7 @@ using System.Security.Claims; using Bit.Api.Auth.Controllers; using Bit.Api.Auth.Models.Request.Accounts; +using Bit.Api.KeyManagement.Models.Requests; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.Models.Api.Request.Accounts; diff --git a/test/Api.Test/Auth/Models/Request/Accounts/SetInitialPasswordRequestModelTests.cs b/test/Api.Test/Auth/Models/Request/Accounts/SetInitialPasswordRequestModelTests.cs index 97e69dacbc..ce8ba1811e 100644 --- a/test/Api.Test/Auth/Models/Request/Accounts/SetInitialPasswordRequestModelTests.cs +++ b/test/Api.Test/Auth/Models/Request/Accounts/SetInitialPasswordRequestModelTests.cs @@ -1,5 +1,6 @@ using System.ComponentModel.DataAnnotations; using Bit.Api.Auth.Models.Request.Accounts; +using Bit.Api.KeyManagement.Models.Requests; using Bit.Core.Auth.Models.Api.Request.Accounts; using Bit.Core.Entities; using Bit.Core.Enums; diff --git a/test/Core.Test/Auth/AutoFixture/RegisterFinishRequestModelFixtures.cs b/test/Core.Test/Auth/AutoFixture/RegisterFinishRequestModelFixtures.cs index 22fca7ab59..a751a16f31 100644 --- a/test/Core.Test/Auth/AutoFixture/RegisterFinishRequestModelFixtures.cs +++ b/test/Core.Test/Auth/AutoFixture/RegisterFinishRequestModelFixtures.cs @@ -29,9 +29,7 @@ internal class RegisterFinishRequestModelCustomization : ICustomization .With(o => o.OrgInviteToken, OrgInviteToken) .With(o => o.OrgSponsoredFreeFamilyPlanToken, OrgSponsoredFreeFamilyPlanToken) .With(o => o.AcceptEmergencyAccessInviteToken, AcceptEmergencyAccessInviteToken) - .With(o => o.ProviderInviteToken, ProviderInviteToken) - .Without(o => o.MasterPasswordAuthentication) - .Without(o => o.MasterPasswordUnlock)); + .With(o => o.ProviderInviteToken, ProviderInviteToken)); } } diff --git a/test/Core.Test/Auth/Models/Api/Request/Accounts/RegisterFinishRequestModelTests.cs b/test/Core.Test/Auth/Models/Api/Request/Accounts/RegisterFinishRequestModelTests.cs index 3c099ce962..588ca878fc 100644 --- a/test/Core.Test/Auth/Models/Api/Request/Accounts/RegisterFinishRequestModelTests.cs +++ b/test/Core.Test/Auth/Models/Api/Request/Accounts/RegisterFinishRequestModelTests.cs @@ -1,6 +1,5 @@ using Bit.Core.Auth.Models.Api.Request.Accounts; using Bit.Core.Enums; -using Bit.Core.KeyManagement.Models.Api.Request; using Bit.Test.Common.AutoFixture.Attributes; using Xunit; @@ -8,17 +7,6 @@ namespace Bit.Core.Test.Auth.Models.Api.Request.Accounts; public class RegisterFinishRequestModelTests { - private static List Validate(RegisterFinishRequestModel model) - { - var results = new List(); - System.ComponentModel.DataAnnotations.Validator.TryValidateObject( - model, - new System.ComponentModel.DataAnnotations.ValidationContext(model), - results, - true); - return results; - } - [Theory] [BitAutoData] public void GetTokenType_Returns_EmailVerification(string email, string masterPasswordHash, @@ -182,175 +170,4 @@ public class RegisterFinishRequestModelTests Assert.Equal(userAsymmetricKeys.PublicKey, result.PublicKey); Assert.Equal(userAsymmetricKeys.EncryptedPrivateKey, result.PrivateKey); } - - [Fact] - public void Validate_WhenBothAuthAndRootHashProvidedButNotEqual_ReturnsMismatchError() - { - var model = new RegisterFinishRequestModel - { - Email = "user@example.com", - MasterPasswordHash = "root-hash", - UserAsymmetricKeys = new KeysRequestModel { PublicKey = "pk", EncryptedPrivateKey = "sk" }, - // Provide both unlock and authentication with valid KDF so only the mismatch rule fires - MasterPasswordUnlock = new MasterPasswordUnlockDataRequestModel - { - Kdf = new KdfRequestModel { KdfType = KdfType.PBKDF2_SHA256, Iterations = AuthConstants.PBKDF2_ITERATIONS.Default }, - MasterKeyWrappedUserKey = "wrapped", - Salt = "salt" - }, - MasterPasswordAuthentication = new MasterPasswordAuthenticationDataRequestModel - { - Kdf = new KdfRequestModel { KdfType = KdfType.PBKDF2_SHA256, Iterations = AuthConstants.PBKDF2_ITERATIONS.Default }, - MasterPasswordAuthenticationHash = "auth-hash", // different than root - Salt = "salt" - }, - // Provide any valid token so we don't fail token validation - EmailVerificationToken = "token" - }; - - var results = Validate(model); - - Assert.Contains(results, r => - r.ErrorMessage == $"{nameof(MasterPasswordAuthenticationDataRequestModel.MasterPasswordAuthenticationHash)} and root level {nameof(RegisterFinishRequestModel.MasterPasswordHash)} provided and are not equal. Only provide one."); - } - - [Fact] - public void Validate_WhenAuthProvidedButUnlockMissing_ReturnsUnlockMissingError() - { - var model = new RegisterFinishRequestModel - { - Email = "user@example.com", - UserAsymmetricKeys = new KeysRequestModel { PublicKey = "pk", EncryptedPrivateKey = "sk" }, - MasterPasswordAuthentication = new MasterPasswordAuthenticationDataRequestModel - { - Kdf = new KdfRequestModel { KdfType = KdfType.PBKDF2_SHA256, Iterations = AuthConstants.PBKDF2_ITERATIONS.Default }, - MasterPasswordAuthenticationHash = "auth-hash", - Salt = "salt" - }, - EmailVerificationToken = "token" - }; - - var results = Validate(model); - - Assert.Contains(results, r => r.ErrorMessage == "MasterPasswordUnlock not found on RequestModel"); - } - - [Fact] - public void Validate_WhenUnlockProvidedButAuthMissing_ReturnsAuthMissingError() - { - var model = new RegisterFinishRequestModel - { - Email = "user@example.com", - UserAsymmetricKeys = new KeysRequestModel { PublicKey = "pk", EncryptedPrivateKey = "sk" }, - MasterPasswordUnlock = new MasterPasswordUnlockDataRequestModel - { - Kdf = new KdfRequestModel { KdfType = KdfType.PBKDF2_SHA256, Iterations = AuthConstants.PBKDF2_ITERATIONS.Default }, - MasterKeyWrappedUserKey = "wrapped", - Salt = "salt" - }, - EmailVerificationToken = "token" - }; - - var results = Validate(model); - - Assert.Contains(results, r => r.ErrorMessage == "MasterPasswordAuthentication not found on RequestModel"); - } - - [Fact] - public void Validate_WhenNeitherAuthNorUnlock_AndRootKdfMissing_ReturnsBothRootKdfErrors() - { - var model = new RegisterFinishRequestModel - { - Email = "user@example.com", - UserAsymmetricKeys = new KeysRequestModel { PublicKey = "pk", EncryptedPrivateKey = "sk" }, - // No MasterPasswordUnlock, no MasterPasswordAuthentication - // No root Kdf and KdfIterations to trigger both errors - EmailVerificationToken = "token" - }; - - var results = Validate(model); - - Assert.Contains(results, r => r.ErrorMessage == $"{nameof(RegisterFinishRequestModel.Kdf)} not found on RequestModel"); - Assert.Contains(results, r => r.ErrorMessage == $"{nameof(RegisterFinishRequestModel.KdfIterations)} not found on RequestModel"); - } - - [Fact] - public void Validate_WhenAuthAndRootHashBothMissing_ReturnsMissingHashErrorOnly() - { - var model = new RegisterFinishRequestModel - { - Email = "user@example.com", - UserAsymmetricKeys = new KeysRequestModel { PublicKey = "pk", EncryptedPrivateKey = "sk" }, - // Both MasterPasswordAuthentication and MasterPasswordHash are missing - MasterPasswordAuthentication = null, - MasterPasswordHash = null, - // Provide valid root KDF to avoid root KDF errors - Kdf = KdfType.PBKDF2_SHA256, - KdfIterations = AuthConstants.PBKDF2_ITERATIONS.Default, - EmailVerificationToken = "token" // avoid token error - }; - - var results = Validate(model); - - // Only the new missing hash error should be present - Assert.Single(results); - Assert.Equal($"{nameof(MasterPasswordAuthenticationDataRequestModel.MasterPasswordAuthenticationHash)} and {nameof(RegisterFinishRequestModel.MasterPasswordHash)} not found on request, one needs to be defined.", results[0].ErrorMessage); - Assert.Contains(nameof(MasterPasswordAuthenticationDataRequestModel.MasterPasswordAuthenticationHash), results[0].MemberNames); - Assert.Contains(nameof(RegisterFinishRequestModel.MasterPasswordHash), results[0].MemberNames); - } - - [Fact] - public void Validate_WhenAllFieldsValidWithSubModels_IsValid() - { - var model = new RegisterFinishRequestModel - { - Email = "user@example.com", - UserAsymmetricKeys = new KeysRequestModel { PublicKey = "pk", EncryptedPrivateKey = "sk" }, - MasterPasswordUnlock = new MasterPasswordUnlockDataRequestModel - { - Kdf = new KdfRequestModel { KdfType = KdfType.PBKDF2_SHA256, Iterations = AuthConstants.PBKDF2_ITERATIONS.Default }, - MasterKeyWrappedUserKey = "wrapped", - Salt = "salt" - }, - MasterPasswordAuthentication = new MasterPasswordAuthenticationDataRequestModel - { - Kdf = new KdfRequestModel { KdfType = KdfType.PBKDF2_SHA256, Iterations = AuthConstants.PBKDF2_ITERATIONS.Default }, - MasterPasswordAuthenticationHash = "auth-hash", - Salt = "salt" - }, - EmailVerificationToken = "token" - }; - - var results = Validate(model); - - Assert.Empty(results); - } - - [Fact] - public void Validate_WhenNoValidRegistrationTokenProvided_ReturnsTokenErrorOnly() - { - var model = new RegisterFinishRequestModel - { - Email = "user@example.com", - UserAsymmetricKeys = new KeysRequestModel { PublicKey = "pk", EncryptedPrivateKey = "sk" }, - MasterPasswordUnlock = new MasterPasswordUnlockDataRequestModel - { - Kdf = new KdfRequestModel { KdfType = KdfType.PBKDF2_SHA256, Iterations = AuthConstants.PBKDF2_ITERATIONS.Default }, - MasterKeyWrappedUserKey = "wrapped", - Salt = "salt" - }, - MasterPasswordAuthentication = new MasterPasswordAuthenticationDataRequestModel - { - Kdf = new KdfRequestModel { KdfType = KdfType.PBKDF2_SHA256, Iterations = AuthConstants.PBKDF2_ITERATIONS.Default }, - MasterPasswordAuthenticationHash = "auth-hash", - Salt = "salt" - } - // No token fields set - }; - - var results = Validate(model); - - Assert.Single(results); - Assert.Equal("No valid registration token provided", results[0].ErrorMessage); - } } diff --git a/test/Identity.Test/Controllers/AccountsControllerTests.cs b/test/Identity.Test/Controllers/AccountsControllerTests.cs index 86e461d155..42e033bdd7 100644 --- a/test/Identity.Test/Controllers/AccountsControllerTests.cs +++ b/test/Identity.Test/Controllers/AccountsControllerTests.cs @@ -1,5 +1,4 @@ -using System.ComponentModel.DataAnnotations; -using System.Reflection; +using System.Reflection; using System.Text; using Bit.Core; using Bit.Core.Auth.Models.Api.Request.Accounts; @@ -10,7 +9,6 @@ using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; -using Bit.Core.KeyManagement.Models.Api.Request; using Bit.Core.Models.Data; using Bit.Core.Repositories; using Bit.Core.Services; @@ -592,504 +590,6 @@ public class AccountsControllerTests : IDisposable await Assert.ThrowsAsync(() => _sut.PostRegisterVerificationEmailClicked(requestModel)); } - // PM-28143 - When removing the old properties, update this test to just test the new properties working - // as expected. - [Theory, BitAutoData] - public async Task PostRegisterFinish_EmailVerification_BothDataForms_ProduceEquivalentOutcomes( - string email, - string emailVerificationToken, - string masterPasswordHash, - string masterKeyWrappedUserKey, - string publicKey, - string encryptedPrivateKey) - { - // Arrange: new-form model (MasterPasswordAuthenticationData + MasterPasswordUnlockData) - - var kdfData = new KdfRequestModel - { - KdfType = KdfType.Argon2id, - Iterations = AuthConstants.ARGON2_ITERATIONS.Default, - Memory = AuthConstants.ARGON2_MEMORY.Default, - Parallelism = AuthConstants.ARGON2_PARALLELISM.Default - }; - - var newModel = new RegisterFinishRequestModel - { - Email = email, - EmailVerificationToken = emailVerificationToken, - MasterPasswordAuthentication = new MasterPasswordAuthenticationDataRequestModel - { - Kdf = kdfData, - MasterPasswordAuthenticationHash = masterPasswordHash, - Salt = email // salt choice is not validated here during registration - }, - MasterPasswordUnlock = new MasterPasswordUnlockDataRequestModel - { - Kdf = kdfData, - MasterKeyWrappedUserKey = masterKeyWrappedUserKey, - Salt = email - }, - UserAsymmetricKeys = new KeysRequestModel - { - PublicKey = publicKey, - EncryptedPrivateKey = encryptedPrivateKey - } - }; - - // Arrange: legacy-form model (MasterPasswordHash + legacy KDF + UserSymmetricKey) - var legacyModel = new RegisterFinishRequestModel - { - Email = email, - EmailVerificationToken = emailVerificationToken, - MasterPasswordHash = masterPasswordHash, - Kdf = KdfType.Argon2id, - KdfIterations = AuthConstants.ARGON2_ITERATIONS.Default, - KdfMemory = AuthConstants.ARGON2_MEMORY.Default, - KdfParallelism = AuthConstants.ARGON2_PARALLELISM.Default, - UserSymmetricKey = masterKeyWrappedUserKey, - UserAsymmetricKeys = new KeysRequestModel - { - PublicKey = publicKey, - EncryptedPrivateKey = encryptedPrivateKey - } - }; - - var newUser = newModel.ToUser(); - var legacyUser = legacyModel.ToUser(); - - _registerUserCommand - .RegisterUserViaEmailVerificationToken(Arg.Any(), masterPasswordHash, emailVerificationToken) - .Returns(Task.FromResult(IdentityResult.Success)); - - // Act: call with new form - var newResult = await _sut.PostRegisterFinish(newModel); - // Act: call with legacy form - var legacyResult = await _sut.PostRegisterFinish(legacyModel); - - // Assert: outcomes are identical in effect (success response) - Assert.NotNull(newResult); - Assert.NotNull(legacyResult); - - // Assert: effective users are equivalent - Assert.Equal(legacyUser.Email, newUser.Email); - Assert.Equal(legacyUser.MasterPasswordHint, newUser.MasterPasswordHint); - Assert.Equal(legacyUser.Kdf, newUser.Kdf); - Assert.Equal(legacyUser.KdfIterations, newUser.KdfIterations); - Assert.Equal(legacyUser.KdfMemory, newUser.KdfMemory); - Assert.Equal(legacyUser.KdfParallelism, newUser.KdfParallelism); - Assert.Equal(legacyUser.Key, newUser.Key); - Assert.Equal(legacyUser.PublicKey, newUser.PublicKey); - Assert.Equal(legacyUser.PrivateKey, newUser.PrivateKey); - - // Assert: hash forwarded identically from both inputs - await _registerUserCommand.Received(2).RegisterUserViaEmailVerificationToken( - Arg.Is(u => - u.Email == newUser.Email && - u.Kdf == newUser.Kdf && - u.KdfIterations == newUser.KdfIterations && - u.KdfMemory == newUser.KdfMemory && - u.KdfParallelism == newUser.KdfParallelism && - u.Key == newUser.Key), - masterPasswordHash, - emailVerificationToken); - - await _registerUserCommand.Received(2).RegisterUserViaEmailVerificationToken( - Arg.Is(u => - u.Email == legacyUser.Email && - u.Kdf == legacyUser.Kdf && - u.KdfIterations == legacyUser.KdfIterations && - u.KdfMemory == legacyUser.KdfMemory && - u.KdfParallelism == legacyUser.KdfParallelism && - u.Key == legacyUser.Key), - masterPasswordHash, - emailVerificationToken); - } - - // PM-28143 - When removing the old properties, update this test to just test the new properties working - // as expected. - [Theory, BitAutoData] - public async Task PostRegisterFinish_OrgInvite_BothDataForms_ProduceEquivalentOutcomes( - string email, - string orgInviteToken, - Guid organizationUserId, - string masterPasswordHash, - string masterKeyWrappedUserKey, - string publicKey, - string encryptedPrivateKey) - { - var kdfData = new KdfRequestModel - { - KdfType = KdfType.Argon2id, - Iterations = AuthConstants.ARGON2_ITERATIONS.Default, - Memory = AuthConstants.ARGON2_MEMORY.Default, - Parallelism = AuthConstants.ARGON2_PARALLELISM.Default - }; - - // Arrange: new-form model (MasterPasswordAuthenticationData + MasterPasswordUnlockData) - var newModel = new RegisterFinishRequestModel - { - Email = email, - OrgInviteToken = orgInviteToken, - OrganizationUserId = organizationUserId, - MasterPasswordAuthentication = new MasterPasswordAuthenticationDataRequestModel - { - Kdf = kdfData, - MasterPasswordAuthenticationHash = masterPasswordHash, - Salt = email - }, - MasterPasswordUnlock = new MasterPasswordUnlockDataRequestModel - { - Kdf = kdfData, - MasterKeyWrappedUserKey = masterKeyWrappedUserKey, - Salt = email - }, - UserAsymmetricKeys = new KeysRequestModel - { - PublicKey = publicKey, - EncryptedPrivateKey = encryptedPrivateKey - } - }; - - // Arrange: legacy-form model (MasterPasswordHash + legacy KDF + UserSymmetricKey) - var legacyModel = new RegisterFinishRequestModel - { - Email = email, - OrgInviteToken = orgInviteToken, - OrganizationUserId = organizationUserId, - MasterPasswordHash = masterPasswordHash, - Kdf = kdfData.KdfType, - KdfIterations = kdfData.Iterations, - KdfMemory = kdfData.Memory, - KdfParallelism = kdfData.Parallelism, - UserSymmetricKey = masterKeyWrappedUserKey, - UserAsymmetricKeys = new KeysRequestModel - { - PublicKey = publicKey, - EncryptedPrivateKey = encryptedPrivateKey - } - }; - - var newUser = newModel.ToUser(); - var legacyUser = legacyModel.ToUser(); - - _registerUserCommand - .RegisterUserViaOrganizationInviteToken(Arg.Any(), masterPasswordHash, orgInviteToken, organizationUserId) - .Returns(Task.FromResult(IdentityResult.Success)); - - // Act - var newResult = await _sut.PostRegisterFinish(newModel); - var legacyResult = await _sut.PostRegisterFinish(legacyModel); - - // Assert success - Assert.NotNull(newResult); - Assert.NotNull(legacyResult); - - // Assert: effective users are equivalent - Assert.Equal(legacyUser.Email, newUser.Email); - Assert.Equal(legacyUser.MasterPasswordHint, newUser.MasterPasswordHint); - Assert.Equal(legacyUser.Kdf, newUser.Kdf); - Assert.Equal(legacyUser.KdfIterations, newUser.KdfIterations); - Assert.Equal(legacyUser.KdfMemory, newUser.KdfMemory); - Assert.Equal(legacyUser.KdfParallelism, newUser.KdfParallelism); - Assert.Equal(legacyUser.Key, newUser.Key); - Assert.Equal(legacyUser.PublicKey, newUser.PublicKey); - Assert.Equal(legacyUser.PrivateKey, newUser.PrivateKey); - - // Assert: hash forwarded identically from both inputs - await _registerUserCommand.Received(2).RegisterUserViaOrganizationInviteToken( - Arg.Is(u => - u.Email == newUser.Email && - u.Kdf == newUser.Kdf && - u.KdfIterations == newUser.KdfIterations && - u.KdfMemory == newUser.KdfMemory && - u.KdfParallelism == newUser.KdfParallelism && - u.Key == newUser.Key), - masterPasswordHash, - orgInviteToken, - organizationUserId); - - await _registerUserCommand.Received(2).RegisterUserViaOrganizationInviteToken( - Arg.Is(u => - u.Email == legacyUser.Email && - u.Kdf == legacyUser.Kdf && - u.KdfIterations == legacyUser.KdfIterations && - u.KdfMemory == legacyUser.KdfMemory && - u.KdfParallelism == legacyUser.KdfParallelism && - u.Key == legacyUser.Key), - masterPasswordHash, - orgInviteToken, - organizationUserId); - } - - [Theory, BitAutoData] - public async Task PostRegisterFinish_NewForm_UsesUnlockDataForKdfAndKey_WhenRootFieldsNull( - string email, - string emailVerificationToken, - string masterPasswordHash, - string masterKeyWrappedUserKey, - int iterations, - string publicKey, - string encryptedPrivateKey) - { - // Arrange: Provide only unlock-data KDF + key; leave root KDF fields null - var unlockKdf = new KdfRequestModel - { - KdfType = KdfType.PBKDF2_SHA256, - Iterations = iterations - }; - - var model = new RegisterFinishRequestModel - { - Email = email, - EmailVerificationToken = emailVerificationToken, - MasterPasswordAuthentication = new MasterPasswordAuthenticationDataRequestModel - { - // present but not used by ToUser for KDF/Key - Kdf = unlockKdf, - MasterPasswordAuthenticationHash = masterPasswordHash, - Salt = email - }, - MasterPasswordUnlock = new MasterPasswordUnlockDataRequestModel - { - Kdf = unlockKdf, - MasterKeyWrappedUserKey = masterKeyWrappedUserKey, - Salt = email - }, - // root KDF fields intentionally null - Kdf = null, - KdfIterations = null, - UserAsymmetricKeys = new KeysRequestModel - { - PublicKey = publicKey, - EncryptedPrivateKey = encryptedPrivateKey - } - }; - - _registerUserCommand - .RegisterUserViaEmailVerificationToken(Arg.Any(), masterPasswordHash, emailVerificationToken) - .Returns(Task.FromResult(IdentityResult.Success)); - - // Act - var _ = await _sut.PostRegisterFinish(model); - - // Assert: The user passed to command uses unlock-data values - await _registerUserCommand.Received(1).RegisterUserViaEmailVerificationToken( - Arg.Is(u => - u.Email == email && - u.Kdf == unlockKdf.KdfType && - u.KdfIterations == unlockKdf.Iterations && - u.Key == masterKeyWrappedUserKey), - masterPasswordHash, - emailVerificationToken); - } - - [Theory, BitAutoData] - public async Task PostRegisterFinish_LegacyForm_UsesRootFields_WhenUnlockDataNull( - string email, - string emailVerificationToken, - string masterPasswordHash, - string legacyKey, - string publicKey, - string encryptedPrivateKey) - { - // Arrange: Provide only legacy root KDF + key; no unlock-data provided - var model = new RegisterFinishRequestModel - { - Email = email, - EmailVerificationToken = emailVerificationToken, - MasterPasswordHash = masterPasswordHash, - Kdf = KdfType.PBKDF2_SHA256, - KdfIterations = AuthConstants.PBKDF2_ITERATIONS.Default, - UserSymmetricKey = legacyKey, - MasterPasswordUnlock = null, - UserAsymmetricKeys = new KeysRequestModel - { - PublicKey = publicKey, - EncryptedPrivateKey = encryptedPrivateKey - } - }; - - _registerUserCommand - .RegisterUserViaEmailVerificationToken(Arg.Any(), masterPasswordHash, emailVerificationToken) - .Returns(Task.FromResult(IdentityResult.Success)); - - // Act - var _ = await _sut.PostRegisterFinish(model); - - // Assert: The user passed to command uses root values - await _registerUserCommand.Received(1).RegisterUserViaEmailVerificationToken( - Arg.Is(u => - u.Email == email && - u.Kdf == KdfType.PBKDF2_SHA256 && - u.KdfIterations == AuthConstants.PBKDF2_ITERATIONS.Default && - u.Key == legacyKey), - masterPasswordHash, - emailVerificationToken); - } - - [Theory, BitAutoData] - public void RegisterFinishRequestModel_Validate_Throws_WhenUnlockAndAuthDataMismatch( - string email, - string authHash, - string masterKeyWrappedUserKey, - string publicKey, - string encryptedPrivateKey) - { - // Arrange: authentication and unlock have different KDF and/or salt - var authKdf = new KdfRequestModel - { - KdfType = KdfType.PBKDF2_SHA256, - Iterations = AuthConstants.PBKDF2_ITERATIONS.Default - }; - var unlockKdf = new KdfRequestModel - { - KdfType = KdfType.Argon2id, - Iterations = AuthConstants.ARGON2_ITERATIONS.Default, - Memory = AuthConstants.ARGON2_MEMORY.Default, - Parallelism = AuthConstants.ARGON2_PARALLELISM.Default - }; - - var model = new RegisterFinishRequestModel - { - Email = email, - MasterPasswordAuthentication = new MasterPasswordAuthenticationDataRequestModel - { - Kdf = authKdf, - MasterPasswordAuthenticationHash = authHash, - Salt = email - }, - MasterPasswordUnlock = new MasterPasswordUnlockDataRequestModel - { - Kdf = unlockKdf, - MasterKeyWrappedUserKey = masterKeyWrappedUserKey, - Salt = email - }, - UserAsymmetricKeys = new KeysRequestModel - { - PublicKey = publicKey, - EncryptedPrivateKey = encryptedPrivateKey - } - }; - - // Provide a minimal valid token type to satisfy model-level token validation - model.EmailVerificationToken = "test-token"; - - var ctx = new ValidationContext(model); - - // Act - var results = model.Validate(ctx).ToList(); - - // Assert mismatched auth/unlock is allowed - Assert.Empty(results); - } - - [Theory, BitAutoData] - public void RegisterFinishRequestModel_Validate_Throws_WhenSaltMismatch( - string email, - string authHash, - string masterKeyWrappedUserKey, - string publicKey, - string encryptedPrivateKey) - { - var unlockKdf = new KdfRequestModel - { - KdfType = KdfType.Argon2id, - Iterations = AuthConstants.ARGON2_ITERATIONS.Default, - Memory = AuthConstants.ARGON2_MEMORY.Default, - Parallelism = AuthConstants.ARGON2_PARALLELISM.Default - }; - - var model = new RegisterFinishRequestModel - { - Email = email, - MasterPasswordAuthentication = new MasterPasswordAuthenticationDataRequestModel - { - Kdf = unlockKdf, - MasterPasswordAuthenticationHash = authHash, - Salt = email - }, - MasterPasswordUnlock = new MasterPasswordUnlockDataRequestModel - { - Kdf = unlockKdf, - MasterKeyWrappedUserKey = masterKeyWrappedUserKey, - // Intentionally different salt to force mismatch - Salt = email + ".mismatch" - }, - UserAsymmetricKeys = new KeysRequestModel - { - PublicKey = publicKey, - EncryptedPrivateKey = encryptedPrivateKey - } - }; - - // Provide a minimal valid token type to satisfy model-level token validation - model.EmailVerificationToken = "test-token"; - - var ctx = new ValidationContext(model); - - // Act - var results = model.Validate(ctx).ToList(); - - // Assert mismatched salts between auth/unlock are allowed - Assert.Empty(results); - } - - [Theory, BitAutoData] - public void RegisterFinishRequestModel_Validate_Throws_WhenAuthHashAndRootHashMismatch( - string email, - string authHash, - string differentRootHash, - string masterKeyWrappedUserKey, - string publicKey, - string encryptedPrivateKey) - { - // Arrange: same KDF/salt, but authentication hash differs from legacy root hash - var kdf = new KdfRequestModel - { - KdfType = KdfType.PBKDF2_SHA256, - Iterations = AuthConstants.PBKDF2_ITERATIONS.Default - }; - - var model = new RegisterFinishRequestModel - { - Email = email, - MasterPasswordAuthentication = new MasterPasswordAuthenticationDataRequestModel - { - Kdf = kdf, - MasterPasswordAuthenticationHash = authHash, - Salt = email - }, - MasterPasswordUnlock = new MasterPasswordUnlockDataRequestModel - { - Kdf = kdf, - MasterKeyWrappedUserKey = masterKeyWrappedUserKey, - Salt = email - }, - // Intentionally set the legacy field to a different value to trigger the throw - MasterPasswordHash = differentRootHash, - UserAsymmetricKeys = new KeysRequestModel - { - PublicKey = publicKey, - EncryptedPrivateKey = encryptedPrivateKey - } - }; - - // Provide a minimal valid token type to satisfy model-level token validation - model.EmailVerificationToken = "test-token"; - - var ctx = new ValidationContext(model); - - // Act - var results = model.Validate(ctx).ToList(); - - // Assert: validation result exists with expected message and member names - var mismatchResult = Assert.Single(results.Where(r => - r.ErrorMessage == - "MasterPasswordAuthenticationHash and root level MasterPasswordHash provided and are not equal. Only provide one.")); - Assert.Contains("MasterPasswordAuthenticationHash", mismatchResult.MemberNames); - Assert.Contains("MasterPasswordHash", mismatchResult.MemberNames); - } - private void SetDefaultKdfHmacKey(byte[]? newKey) { var fieldInfo = typeof(AccountsController).GetField("_defaultKdfHmacKey", BindingFlags.NonPublic | BindingFlags.Instance); diff --git a/test/IntegrationTestCommon/Factories/IdentityApplicationFactory.cs b/test/IntegrationTestCommon/Factories/IdentityApplicationFactory.cs index e190dda427..ba12d1e1f4 100644 --- a/test/IntegrationTestCommon/Factories/IdentityApplicationFactory.cs +++ b/test/IntegrationTestCommon/Factories/IdentityApplicationFactory.cs @@ -3,13 +3,10 @@ using System.Collections.Concurrent; using System.Net.Http.Json; -using System.Text; using System.Text.Json; -using Bit.Core; using Bit.Core.Auth.Models.Api.Request.Accounts; using Bit.Core.Entities; using Bit.Core.Enums; -using Bit.Core.KeyManagement.Models.Api.Request; using Bit.Core.Services; using Bit.Identity; using Bit.Test.Common.Helpers; @@ -26,7 +23,6 @@ public class IdentityApplicationFactory : WebApplicationFactoryBase public const string DefaultDeviceIdentifier = "92b9d953-b9b6-4eaf-9d3e-11d57144dfeb"; public const string DefaultUserEmail = "DefaultEmail@bitwarden.com"; public const string DefaultUserPasswordHash = "default_password_hash"; - private const string DefaultEncryptedString = "2.3Uk+WNBIoU5xzmVFNcoWzz==|1MsPIYuRfdOHfu/0uY6H2Q==|/98sp4wb6pHP1VTZ9JcNCYgQjEUMFPlqJgCwRk1YXKg="; /// /// A dictionary to store registration tokens for email verification. We cannot substitute the IMailService more than once, so @@ -199,68 +195,6 @@ public class IdentityApplicationFactory : WebApplicationFactoryBase RegisterFinishRequestModel requestModel, bool marketingEmails = true) { - // Ensure required fields for registration finish are present. - // Prefer legacy-path defaults (root fields) to minimize changes to tests. - // PM-28143 - When MasterPasswordAuthenticationData is required, delete all handling of MasterPasswordHash. - requestModel.MasterPasswordHash ??= DefaultUserPasswordHash; - // PM-28143 - When KDF is sourced exclusively from MasterPasswordUnlockData, delete the root Kdf defaults below. - requestModel.Kdf ??= KdfType.PBKDF2_SHA256; - requestModel.KdfIterations ??= AuthConstants.PBKDF2_ITERATIONS.Default; - // Ensure a symmetric key is provided when no unlock data is present - // PM-28143 - When MasterPasswordUnlockData is required, delete the UserSymmetricKey fallback block below. - if (requestModel.MasterPasswordUnlock == null && string.IsNullOrWhiteSpace(requestModel.UserSymmetricKey)) - { - requestModel.UserSymmetricKey = "user_symmetric_key"; - } - - // Align unlock/auth data KDF with root KDF so login uses the provided master password hash. - // PM-28143 - After removing root Kdf fields, build KDF exclusively from MasterPasswordUnlockData.Kdf and delete this alignment section. - var effectiveKdfType = requestModel.Kdf ?? KdfType.PBKDF2_SHA256; - var effectiveIterations = requestModel.KdfIterations ?? AuthConstants.PBKDF2_ITERATIONS.Default; - int? effectiveMemory = null; - int? effectiveParallelism = null; - if (effectiveKdfType == KdfType.Argon2id) - { - effectiveIterations = AuthConstants.ARGON2_ITERATIONS.InsideRange(effectiveIterations) - ? effectiveIterations - : AuthConstants.ARGON2_ITERATIONS.Default; - effectiveMemory = AuthConstants.ARGON2_MEMORY.Default; - effectiveParallelism = AuthConstants.ARGON2_PARALLELISM.Default; - } - - var alignedKdf = new KdfRequestModel - { - KdfType = effectiveKdfType, - Iterations = effectiveIterations, - Memory = effectiveMemory, - Parallelism = effectiveParallelism - }; - - if (requestModel.MasterPasswordUnlock != null) - { - var unlock = requestModel.MasterPasswordUnlock; - // Always force a valid encrypted string for tests to avoid model validation failures. - requestModel.MasterPasswordUnlock = new MasterPasswordUnlockDataRequestModel - { - Kdf = alignedKdf, - MasterKeyWrappedUserKey = unlock.MasterKeyWrappedUserKey, - Salt = string.IsNullOrWhiteSpace(unlock.Salt) ? requestModel.Email : unlock.Salt - }; - } - - if (requestModel.MasterPasswordAuthentication != null) - { - // Ensure registration uses the same hash the tests will provide at login. - // PM-28143 - When MasterPasswordAuthenticationData is the only source of the auth hash, - // stop overriding it from MasterPasswordHash and delete this whole reassignment block. - requestModel.MasterPasswordAuthentication = new MasterPasswordAuthenticationDataRequestModel - { - Kdf = alignedKdf, - MasterPasswordAuthenticationHash = requestModel.MasterPasswordHash, - Salt = requestModel.Email - }; - } - var sendVerificationEmailReqModel = new RegisterSendVerificationEmailRequestModel { Email = requestModel.Email, @@ -277,11 +211,8 @@ public class IdentityApplicationFactory : WebApplicationFactoryBase requestModel.EmailVerificationToken = RegistrationTokens[requestModel.Email]; var postRegisterFinishHttpContext = await PostRegisterFinishAsync(requestModel); - if (postRegisterFinishHttpContext.Response.StatusCode != StatusCodes.Status200OK) - { - var body = await ReadResponseBodyAsync(postRegisterFinishHttpContext); - Assert.Fail($"register/finish failed (status {postRegisterFinishHttpContext.Response.StatusCode}). Body: {body}"); - } + + Assert.Equal(StatusCodes.Status200OK, postRegisterFinishHttpContext.Response.StatusCode); var database = GetDatabaseContext(); var user = await database.Users @@ -291,32 +222,4 @@ public class IdentityApplicationFactory : WebApplicationFactoryBase return user; } - - private static async Task ReadResponseBodyAsync(HttpContext ctx) - { - try - { - if (ctx?.Response?.Body == null) - { - return ""; - } - var stream = ctx.Response.Body; - if (stream.CanSeek) - { - stream.Seek(0, SeekOrigin.Begin); - } - using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, leaveOpen: true); - var text = await reader.ReadToEndAsync(); - if (stream.CanSeek) - { - stream.Seek(0, SeekOrigin.Begin); - } - return string.IsNullOrWhiteSpace(text) ? "" : text; - } - catch (Exception ex) - { - return $""; - } - } - } From 51d90cce3de3bc11bf0b71db91df720ecf622207 Mon Sep 17 00:00:00 2001 From: mkincaid-bw Date: Thu, 15 Jan 2026 13:43:23 -0800 Subject: [PATCH 28/49] Add Entity Framework migration validation to verify_migrations script (#6817) * Add Entity Framework migration validation to verify_migrations script Enhances dev/verify_migrations.ps1 to validate EF migration files in addition to SQL migrations. The script now validates migrations in util/MySqlMigrations, util/PostgresMigrations, and util/SqliteMigrations directories. Validation includes: - Correct naming format (YYYYMMDDHHMMSS_Description.cs) - Both .cs and .Designer.cs files exist as pairs - Chronological ordering of timestamps - Excludes DatabaseContextModelSnapshot.cs files The script provides comprehensive reporting for all migration types with a summary showing which validations passed or failed. Co-Authored-By: Claude Sonnet 4.5 * Fix: Validate all EF migration files instead of silently ignoring malformed names Previously, migration files that didn't match the expected pattern were silently ignored during validation. This could allow incorrectly named files to slip through. Now the script explicitly tracks and reports any migration files that don't match the required YYYYMMDDHHMMSS_Description.cs format, ensuring all new migration files are properly validated. Addresses feedback from PR review to prevent malformed migration files from being overlooked. Co-Authored-By: Claude Sonnet 4.5 --------- Co-authored-by: Claude Sonnet 4.5 --- dev/verify_migrations.ps1 | 320 ++++++++++++++++++++++++++++++++------ 1 file changed, 270 insertions(+), 50 deletions(-) diff --git a/dev/verify_migrations.ps1 b/dev/verify_migrations.ps1 index ad0d34cef1..ce1754e684 100644 --- a/dev/verify_migrations.ps1 +++ b/dev/verify_migrations.ps1 @@ -5,12 +5,19 @@ Validates that new database migration files follow naming conventions and chronological order. .DESCRIPTION - This script validates migration files in util/Migrator/DbScripts/ to ensure: + This script validates migration files to ensure: + + For SQL migrations in util/Migrator/DbScripts/: 1. New migrations follow the naming format: YYYY-MM-DD_NN_Description.sql 2. New migrations are chronologically ordered (filename sorts after existing migrations) 3. Dates use leading zeros (e.g., 2025-01-05, not 2025-1-5) 4. A 2-digit sequence number is included (e.g., _00, _01) + For Entity Framework migrations in util/MySqlMigrations, util/PostgresMigrations, util/SqliteMigrations: + 1. New migrations follow the naming format: YYYYMMDDHHMMSS_Description.cs + 2. Each migration has both .cs and .Designer.cs files + 3. New migrations are chronologically ordered (timestamp sorts after existing migrations) + .PARAMETER BaseRef The base git reference to compare against (e.g., 'main', 'HEAD~1') @@ -58,75 +65,288 @@ $currentMigrations = git ls-tree -r --name-only $CurrentRef -- "$migrationPath/" # Find added migrations $addedMigrations = $currentMigrations | Where-Object { $_ -notin $baseMigrations } +$sqlValidationFailed = $false + if ($addedMigrations.Count -eq 0) { - Write-Host "No new migration files added." - exit 0 + Write-Host "No new SQL migration files added." + Write-Host "" +} +else { + Write-Host "New SQL migration files detected:" + $addedMigrations | ForEach-Object { Write-Host " $_" } + Write-Host "" + + # Get the last migration from base reference + if ($baseMigrations.Count -eq 0) { + Write-Host "No previous SQL migrations found (initial commit?). Skipping chronological validation." + Write-Host "" + } + else { + $lastBaseMigration = Split-Path -Leaf ($baseMigrations | Select-Object -Last 1) + Write-Host "Last SQL migration in base reference: $lastBaseMigration" + Write-Host "" + + # Required format regex: YYYY-MM-DD_NN_Description.sql + $formatRegex = '^[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{2}_.+\.sql$' + + foreach ($migration in $addedMigrations) { + $migrationName = Split-Path -Leaf $migration + + # Validate NEW migration filename format + if ($migrationName -notmatch $formatRegex) { + Write-Host "ERROR: Migration '$migrationName' does not match required format" + Write-Host "Required format: YYYY-MM-DD_NN_Description.sql" + Write-Host " - YYYY: 4-digit year" + Write-Host " - MM: 2-digit month with leading zero (01-12)" + Write-Host " - DD: 2-digit day with leading zero (01-31)" + Write-Host " - NN: 2-digit sequence number (00, 01, 02, etc.)" + Write-Host "Example: 2025-01-15_00_MyMigration.sql" + $sqlValidationFailed = $true + continue + } + + # Compare migration name with last base migration (using ordinal string comparison) + if ([string]::CompareOrdinal($migrationName, $lastBaseMigration) -lt 0) { + Write-Host "ERROR: New migration '$migrationName' is not chronologically after '$lastBaseMigration'" + $sqlValidationFailed = $true + } + else { + Write-Host "OK: '$migrationName' is chronologically after '$lastBaseMigration'" + } + } + + Write-Host "" + } + + if ($sqlValidationFailed) { + Write-Host "FAILED: One or more SQL migrations are incorrectly named or not in chronological order" + Write-Host "" + Write-Host "All new SQL migration files must:" + Write-Host " 1. Follow the naming format: YYYY-MM-DD_NN_Description.sql" + Write-Host " 2. Use leading zeros in dates (e.g., 2025-01-05, not 2025-1-5)" + Write-Host " 3. Include a 2-digit sequence number (e.g., _00, _01)" + Write-Host " 4. Have a filename that sorts after the last migration in base" + Write-Host "" + Write-Host "To fix this issue:" + Write-Host " 1. Locate your migration file(s) in util/Migrator/DbScripts/" + Write-Host " 2. Rename to follow format: YYYY-MM-DD_NN_Description.sql" + Write-Host " 3. Ensure the date is after $lastBaseMigration" + Write-Host "" + Write-Host "Example: 2025-01-15_00_AddNewFeature.sql" + } + else { + Write-Host "SUCCESS: All new SQL migrations are correctly named and in chronological order" + } + + Write-Host "" } -Write-Host "New migration files detected:" -$addedMigrations | ForEach-Object { Write-Host " $_" } +# =========================================================================================== +# Validate Entity Framework Migrations +# =========================================================================================== + +Write-Host "===================================================================" +Write-Host "Validating Entity Framework Migrations" +Write-Host "===================================================================" Write-Host "" -# Get the last migration from base reference -if ($baseMigrations.Count -eq 0) { - Write-Host "No previous migrations found (initial commit?). Skipping validation." - exit 0 -} +$efMigrationPaths = @( + @{ Path = "util/MySqlMigrations/Migrations"; Name = "MySQL" }, + @{ Path = "util/PostgresMigrations/Migrations"; Name = "Postgres" }, + @{ Path = "util/SqliteMigrations/Migrations"; Name = "SQLite" } +) -$lastBaseMigration = Split-Path -Leaf ($baseMigrations | Select-Object -Last 1) -Write-Host "Last migration in base reference: $lastBaseMigration" -Write-Host "" +$efValidationFailed = $false -# Required format regex: YYYY-MM-DD_NN_Description.sql -$formatRegex = '^[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{2}_.+\.sql$' +foreach ($migrationPathInfo in $efMigrationPaths) { + $efPath = $migrationPathInfo.Path + $dbName = $migrationPathInfo.Name -$validationFailed = $false + Write-Host "-------------------------------------------------------------------" + Write-Host "Checking $dbName EF migrations in $efPath" + Write-Host "-------------------------------------------------------------------" + Write-Host "" -foreach ($migration in $addedMigrations) { - $migrationName = Split-Path -Leaf $migration + # Get list of migrations from base reference + try { + $baseMigrations = git ls-tree -r --name-only $BaseRef -- "$efPath/" 2>$null | Where-Object { $_ -like "*.cs" -and $_ -notlike "*DatabaseContextModelSnapshot.cs" } | Sort-Object + if ($LASTEXITCODE -ne 0) { + Write-Host "Warning: Could not retrieve $dbName migrations from base reference '$BaseRef'" + $baseMigrations = @() + } + } + catch { + Write-Host "Warning: Could not retrieve $dbName migrations from base reference '$BaseRef'" + $baseMigrations = @() + } - # Validate NEW migration filename format - if ($migrationName -notmatch $formatRegex) { - Write-Host "ERROR: Migration '$migrationName' does not match required format" - Write-Host "Required format: YYYY-MM-DD_NN_Description.sql" - Write-Host " - YYYY: 4-digit year" - Write-Host " - MM: 2-digit month with leading zero (01-12)" - Write-Host " - DD: 2-digit day with leading zero (01-31)" - Write-Host " - NN: 2-digit sequence number (00, 01, 02, etc.)" - Write-Host "Example: 2025-01-15_00_MyMigration.sql" - $validationFailed = $true + # Get list of migrations from current reference + $currentMigrations = git ls-tree -r --name-only $CurrentRef -- "$efPath/" | Where-Object { $_ -like "*.cs" -and $_ -notlike "*DatabaseContextModelSnapshot.cs" } | Sort-Object + + # Find added migrations + $addedMigrations = $currentMigrations | Where-Object { $_ -notin $baseMigrations } + + if ($addedMigrations.Count -eq 0) { + Write-Host "No new $dbName EF migration files added." + Write-Host "" continue } - # Compare migration name with last base migration (using ordinal string comparison) - if ([string]::CompareOrdinal($migrationName, $lastBaseMigration) -lt 0) { - Write-Host "ERROR: New migration '$migrationName' is not chronologically after '$lastBaseMigration'" - $validationFailed = $true + Write-Host "New $dbName EF migration files detected:" + $addedMigrations | ForEach-Object { Write-Host " $_" } + Write-Host "" + + # Get the last migration from base reference + if ($baseMigrations.Count -eq 0) { + Write-Host "No previous $dbName migrations found. Skipping chronological validation." + Write-Host "" } else { - Write-Host "OK: '$migrationName' is chronologically after '$lastBaseMigration'" + $lastBaseMigration = Split-Path -Leaf ($baseMigrations | Select-Object -Last 1) + Write-Host "Last $dbName migration in base reference: $lastBaseMigration" + Write-Host "" } + + # Required format regex: YYYYMMDDHHMMSS_Description.cs or YYYYMMDDHHMMSS_Description.Designer.cs + $efFormatRegex = '^[0-9]{14}_.+\.cs$' + + # Group migrations by base name (without .Designer.cs suffix) + $migrationGroups = @{} + $unmatchedFiles = @() + + foreach ($migration in $addedMigrations) { + $migrationName = Split-Path -Leaf $migration + + # Extract base name (remove .Designer.cs or .cs) + if ($migrationName -match '^([0-9]{14}_.+?)(?:\.Designer)?\.cs$') { + $baseName = $matches[1] + if (-not $migrationGroups.ContainsKey($baseName)) { + $migrationGroups[$baseName] = @() + } + $migrationGroups[$baseName] += $migrationName + } + else { + # Track files that don't match the expected pattern + $unmatchedFiles += $migrationName + } + } + + # Flag any files that don't match the expected pattern + if ($unmatchedFiles.Count -gt 0) { + Write-Host "ERROR: The following migration files do not match the required format:" + foreach ($unmatchedFile in $unmatchedFiles) { + Write-Host " - $unmatchedFile" + } + Write-Host "" + Write-Host "Required format: YYYYMMDDHHMMSS_Description.cs or YYYYMMDDHHMMSS_Description.Designer.cs" + Write-Host " - YYYYMMDDHHMMSS: 14-digit timestamp (Year, Month, Day, Hour, Minute, Second)" + Write-Host " - Description: Descriptive name using PascalCase" + Write-Host "Example: 20250115120000_AddNewFeature.cs and 20250115120000_AddNewFeature.Designer.cs" + Write-Host "" + $efValidationFailed = $true + } + + foreach ($baseName in $migrationGroups.Keys | Sort-Object) { + $files = $migrationGroups[$baseName] + + # Validate format + $mainFile = "$baseName.cs" + $designerFile = "$baseName.Designer.cs" + + if ($mainFile -notmatch $efFormatRegex) { + Write-Host "ERROR: Migration '$mainFile' does not match required format" + Write-Host "Required format: YYYYMMDDHHMMSS_Description.cs" + Write-Host " - YYYYMMDDHHMMSS: 14-digit timestamp (Year, Month, Day, Hour, Minute, Second)" + Write-Host "Example: 20250115120000_AddNewFeature.cs" + $efValidationFailed = $true + continue + } + + # Check that both .cs and .Designer.cs files exist + $hasCsFile = $files -contains $mainFile + $hasDesignerFile = $files -contains $designerFile + + if (-not $hasCsFile) { + Write-Host "ERROR: Missing main migration file: $mainFile" + $efValidationFailed = $true + } + + if (-not $hasDesignerFile) { + Write-Host "ERROR: Missing designer file: $designerFile" + Write-Host "Each EF migration must have both a .cs and .Designer.cs file" + $efValidationFailed = $true + } + + if (-not $hasCsFile -or -not $hasDesignerFile) { + continue + } + + # Compare migration timestamp with last base migration (using ordinal string comparison) + if ($baseMigrations.Count -gt 0) { + if ([string]::CompareOrdinal($mainFile, $lastBaseMigration) -lt 0) { + Write-Host "ERROR: New migration '$mainFile' is not chronologically after '$lastBaseMigration'" + $efValidationFailed = $true + } + else { + Write-Host "OK: '$mainFile' is chronologically after '$lastBaseMigration'" + } + } + else { + Write-Host "OK: '$mainFile' (no previous migrations to compare)" + } + } + + Write-Host "" +} + +if ($efValidationFailed) { + Write-Host "FAILED: One or more EF migrations are incorrectly named or not in chronological order" + Write-Host "" + Write-Host "All new EF migration files must:" + Write-Host " 1. Follow the naming format: YYYYMMDDHHMMSS_Description.cs" + Write-Host " 2. Include both .cs and .Designer.cs files" + Write-Host " 3. Have a timestamp that sorts after the last migration in base" + Write-Host "" + Write-Host "To fix this issue:" + Write-Host " 1. Locate your migration file(s) in the respective Migrations directory" + Write-Host " 2. Ensure both .cs and .Designer.cs files exist" + Write-Host " 3. Rename to follow format: YYYYMMDDHHMMSS_Description.cs" + Write-Host " 4. Ensure the timestamp is after the last migration" + Write-Host "" + Write-Host "Example: 20250115120000_AddNewFeature.cs and 20250115120000_AddNewFeature.Designer.cs" +} +else { + Write-Host "SUCCESS: All new EF migrations are correctly named and in chronological order" } Write-Host "" +Write-Host "===================================================================" +Write-Host "Validation Summary" +Write-Host "===================================================================" + +if ($sqlValidationFailed -or $efValidationFailed) { + if ($sqlValidationFailed) { + Write-Host "❌ SQL migrations validation FAILED" + } + else { + Write-Host "✓ SQL migrations validation PASSED" + } + + if ($efValidationFailed) { + Write-Host "❌ EF migrations validation FAILED" + } + else { + Write-Host "✓ EF migrations validation PASSED" + } -if ($validationFailed) { - Write-Host "FAILED: One or more migrations are incorrectly named or not in chronological order" Write-Host "" - Write-Host "All new migration files must:" - Write-Host " 1. Follow the naming format: YYYY-MM-DD_NN_Description.sql" - Write-Host " 2. Use leading zeros in dates (e.g., 2025-01-05, not 2025-1-5)" - Write-Host " 3. Include a 2-digit sequence number (e.g., _00, _01)" - Write-Host " 4. Have a filename that sorts after the last migration in base" - Write-Host "" - Write-Host "To fix this issue:" - Write-Host " 1. Locate your migration file(s) in util/Migrator/DbScripts/" - Write-Host " 2. Rename to follow format: YYYY-MM-DD_NN_Description.sql" - Write-Host " 3. Ensure the date is after $lastBaseMigration" - Write-Host "" - Write-Host "Example: 2025-01-15_00_AddNewFeature.sql" + Write-Host "OVERALL RESULT: FAILED" exit 1 } - -Write-Host "SUCCESS: All new migrations are correctly named and in chronological order" -exit 0 +else { + Write-Host "✓ SQL migrations validation PASSED" + Write-Host "✓ EF migrations validation PASSED" + Write-Host "" + Write-Host "OVERALL RESULT: SUCCESS" + exit 0 +} From ebb0712e335385b25a191e3fa4574db9e8caf410 Mon Sep 17 00:00:00 2001 From: Thomas Rittson <31796059+eliykat@users.noreply.github.com> Date: Fri, 16 Jan 2026 08:49:25 +1000 Subject: [PATCH 29/49] [PM-28555] Add idempotent sproc to create My Items collections (#6801) * Add sproc to create multiple default collections. SqlBulkCopy implementation is overkill for most cases. This provides a lighter weight sproc implementation for smaller data sets. * DRY up collection arrangement * DRY up tests because bulk and non-bulk share same behavior * use EF native AddRange instead of bulk insert, because we expect smaller data sizes on self-host --- .../Collections/CollectionUtils.cs | 53 ++++++++++++ ...maticallyConfirmOrganizationUserCommand.cs | 19 +---- .../ConfirmOrganizationUserCommand.cs | 22 ++--- ...rganizationDataOwnershipPolicyValidator.cs | 5 +- .../Repositories/ICollectionRepository.cs | 17 +++- src/Infrastructure.Dapper/DapperHelpers.cs | 15 ++++ .../Repositories/CollectionRepository.cs | 82 ++++++++++--------- .../Repositories/CollectionRepository.cs | 49 ++--------- .../Repositories/DatabaseContext.cs | 2 - .../Collection_CreateDefaultCollections.sql | 69 ++++++++++++++++ .../AutomaticallyConfirmUsersCommandTests.cs | 21 ++--- .../ConfirmOrganizationUserCommandTests.cs | 29 ++----- ...zationDataOwnershipPolicyValidatorTests.cs | 24 +++--- .../CreateDefaultCollectionsBulkTests.cs | 53 ++++++++++++ ...=> CreateDefaultCollectionsSharedTests.cs} | 62 +++++++------- .../CreateDefaultCollectionsTests.cs | 52 ++++++++++++ ...00_Collection_CreateDefaultCollections.sql | 70 ++++++++++++++++ 17 files changed, 449 insertions(+), 195 deletions(-) create mode 100644 src/Core/AdminConsole/OrganizationFeatures/Collections/CollectionUtils.cs create mode 100644 src/Sql/dbo/AdminConsole/Stored Procedures/Collection_CreateDefaultCollections.sql create mode 100644 test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CreateDefaultCollectionsBulkTests.cs rename test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/{UpsertDefaultCollectionsTests.cs => CreateDefaultCollectionsSharedTests.cs} (69%) create mode 100644 test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CreateDefaultCollectionsTests.cs create mode 100644 util/Migrator/DbScripts/2026-01-13_00_Collection_CreateDefaultCollections.sql diff --git a/src/Core/AdminConsole/OrganizationFeatures/Collections/CollectionUtils.cs b/src/Core/AdminConsole/OrganizationFeatures/Collections/CollectionUtils.cs new file mode 100644 index 0000000000..116992146f --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Collections/CollectionUtils.cs @@ -0,0 +1,53 @@ +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Utilities; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Collections; + +public static class CollectionUtils +{ + /// + /// Arranges Collection and CollectionUser objects to create default user collections. + /// + /// The organization ID. + /// The IDs for organization users who need default collections. + /// The encrypted string to use as the default collection name. + /// A tuple containing the collections and collection users. + public static (ICollection collections, ICollection collectionUsers) + BuildDefaultUserCollections(Guid organizationId, IEnumerable organizationUserIds, + string defaultCollectionName) + { + var now = DateTime.UtcNow; + + var collectionUsers = new List(); + var collections = new List(); + + foreach (var orgUserId in organizationUserIds) + { + var collectionId = CoreHelpers.GenerateComb(); + + collections.Add(new Collection + { + Id = collectionId, + OrganizationId = organizationId, + Name = defaultCollectionName, + CreationDate = now, + RevisionDate = now, + Type = CollectionType.DefaultUserCollection, + DefaultUserCollectionEmail = null + + }); + + collectionUsers.Add(new CollectionUser + { + CollectionId = collectionId, + OrganizationUserId = orgUserId, + ReadOnly = false, + HidePasswords = false, + Manage = true, + }); + } + + return (collections, collectionUsers); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/AutomaticallyConfirmOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/AutomaticallyConfirmOrganizationUserCommand.cs index 1b488677ae..0292381857 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/AutomaticallyConfirmOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/AutomaticallyConfirmOrganizationUserCommand.cs @@ -4,9 +4,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.OrganizationConfirmation; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; -using Bit.Core.Entities; using Bit.Core.Enums; -using Bit.Core.Models.Data; using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; @@ -83,19 +81,10 @@ public class AutomaticallyConfirmOrganizationUserCommand(IOrganizationUserReposi return; } - await collectionRepository.CreateAsync( - new Collection - { - OrganizationId = request.Organization!.Id, - Name = request.DefaultUserCollectionName, - Type = CollectionType.DefaultUserCollection - }, - groups: null, - [new CollectionAccessSelection - { - Id = request.OrganizationUser!.Id, - Manage = true - }]); + await collectionRepository.CreateDefaultCollectionsAsync( + request.Organization!.Id, + [request.OrganizationUser!.Id], + request.DefaultUserCollectionName); } catch (Exception ex) { diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs index 0b82ac7ea4..02f3346ba6 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs @@ -14,7 +14,6 @@ using Bit.Core.Billing.Enums; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; -using Bit.Core.Models.Data; using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; @@ -294,21 +293,10 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand return; } - var defaultCollection = new Collection - { - OrganizationId = organizationUser.OrganizationId, - Name = defaultUserCollectionName, - Type = CollectionType.DefaultUserCollection - }; - var collectionUser = new CollectionAccessSelection - { - Id = organizationUser.Id, - ReadOnly = false, - HidePasswords = false, - Manage = true - }; - - await _collectionRepository.CreateAsync(defaultCollection, groups: null, users: [collectionUser]); + await _collectionRepository.CreateDefaultCollectionsAsync( + organizationUser.OrganizationId, + [organizationUser.Id], + defaultUserCollectionName); } /// @@ -339,7 +327,7 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand return; } - await _collectionRepository.UpsertDefaultCollectionsAsync(organizationId, eligibleOrganizationUserIds, defaultUserCollectionName); + await _collectionRepository.CreateDefaultCollectionsAsync(organizationId, eligibleOrganizationUserIds, defaultUserCollectionName); } /// diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidator.cs index 7a47baa65a..104a5751ff 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidator.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidator.cs @@ -57,14 +57,15 @@ public class OrganizationDataOwnershipPolicyValidator( var userOrgIds = requirements .Select(requirement => requirement.GetDefaultCollectionRequestOnPolicyEnable(policyUpdate.OrganizationId)) .Where(request => request.ShouldCreateDefaultCollection) - .Select(request => request.OrganizationUserId); + .Select(request => request.OrganizationUserId) + .ToList(); if (!userOrgIds.Any()) { return; } - await collectionRepository.UpsertDefaultCollectionsAsync( + await collectionRepository.CreateDefaultCollectionsBulkAsync( policyUpdate.OrganizationId, userOrgIds, defaultCollectionName); diff --git a/src/Core/Repositories/ICollectionRepository.cs b/src/Core/Repositories/ICollectionRepository.cs index f86147ca7d..3f3b71d2d5 100644 --- a/src/Core/Repositories/ICollectionRepository.cs +++ b/src/Core/Repositories/ICollectionRepository.cs @@ -64,11 +64,22 @@ public interface ICollectionRepository : IRepository IEnumerable users, IEnumerable groups); /// - /// Creates default user collections for the specified organization users if they do not already have one. + /// Creates default user collections for the specified organization users. + /// Filters internally to only create collections for users who don't already have one. /// /// The Organization ID. /// The Organization User IDs to create default collections for. /// The encrypted string to use as the default collection name. - /// - Task UpsertDefaultCollectionsAsync(Guid organizationId, IEnumerable organizationUserIds, string defaultCollectionName); + Task CreateDefaultCollectionsAsync(Guid organizationId, IEnumerable organizationUserIds, string defaultCollectionName); + + /// + /// Creates default user collections for the specified organization users using bulk insert operations. + /// Use this if you need to create collections for > ~1k users. + /// Filters internally to only create collections for users who don't already have one. + /// + /// The Organization ID. + /// The Organization User IDs to create default collections for. + /// The encrypted string to use as the default collection name. + Task CreateDefaultCollectionsBulkAsync(Guid organizationId, IEnumerable organizationUserIds, string defaultCollectionName); + } diff --git a/src/Infrastructure.Dapper/DapperHelpers.cs b/src/Infrastructure.Dapper/DapperHelpers.cs index 9a119e1e32..4384a6f752 100644 --- a/src/Infrastructure.Dapper/DapperHelpers.cs +++ b/src/Infrastructure.Dapper/DapperHelpers.cs @@ -160,6 +160,21 @@ public static class DapperHelpers return ids.ToArrayTVP("GuidId"); } + public static DataTable ToTwoGuidIdArrayTVP(this IEnumerable<(Guid id1, Guid id2)> values) + { + var table = new DataTable(); + table.SetTypeName("[dbo].[TwoGuidIdArray]"); + table.Columns.Add("Id1", typeof(Guid)); + table.Columns.Add("Id2", typeof(Guid)); + + foreach (var value in values) + { + table.Rows.Add(value.id1, value.id2); + } + + return table; + } + public static DataTable ToArrayTVP(this IEnumerable values, string columnName) { var table = new DataTable(); diff --git a/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs b/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs index 9985b41d56..1531703427 100644 --- a/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs +++ b/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs @@ -1,6 +1,7 @@ using System.Data; using System.Diagnostics.CodeAnalysis; using System.Text.Json; +using Bit.Core.AdminConsole.OrganizationFeatures.Collections; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Data; @@ -360,7 +361,45 @@ public class CollectionRepository : Repository, ICollectionRep } } - public async Task UpsertDefaultCollectionsAsync(Guid organizationId, IEnumerable organizationUserIds, string defaultCollectionName) + public async Task CreateDefaultCollectionsAsync(Guid organizationId, IEnumerable organizationUserIds, string defaultCollectionName) + { + organizationUserIds = organizationUserIds.ToList(); + if (!organizationUserIds.Any()) + { + return; + } + + var organizationUserCollectionIds = organizationUserIds + .Select(ou => (ou, CoreHelpers.GenerateComb())) + .ToTwoGuidIdArrayTVP(); + + await using var connection = new SqlConnection(ConnectionString); + await connection.OpenAsync(); + await using var transaction = connection.BeginTransaction(); + + try + { + await connection.ExecuteAsync( + "[dbo].[Collection_CreateDefaultCollections]", + new + { + OrganizationId = organizationId, + DefaultCollectionName = defaultCollectionName, + OrganizationUserCollectionIds = organizationUserCollectionIds + }, + commandType: CommandType.StoredProcedure, + transaction: transaction); + + await transaction.CommitAsync(); + } + catch + { + await transaction.RollbackAsync(); + throw; + } + } + + public async Task CreateDefaultCollectionsBulkAsync(Guid organizationId, IEnumerable organizationUserIds, string defaultCollectionName) { organizationUserIds = organizationUserIds.ToList(); if (!organizationUserIds.Any()) @@ -377,7 +416,8 @@ public class CollectionRepository : Repository, ICollectionRep var missingDefaultCollectionUserIds = organizationUserIds.Except(orgUserIdWithDefaultCollection); - var (collectionUsers, collections) = BuildDefaultCollectionForUsers(organizationId, missingDefaultCollectionUserIds, defaultCollectionName); + var (collections, collectionUsers) = + CollectionUtils.BuildDefaultUserCollections(organizationId, missingDefaultCollectionUserIds, defaultCollectionName); if (!collectionUsers.Any() || !collections.Any()) { @@ -387,11 +427,11 @@ public class CollectionRepository : Repository, ICollectionRep await BulkResourceCreationService.CreateCollectionsAsync(connection, transaction, collections); await BulkResourceCreationService.CreateCollectionsUsersAsync(connection, transaction, collectionUsers); - transaction.Commit(); + await transaction.CommitAsync(); } catch { - transaction.Rollback(); + await transaction.RollbackAsync(); throw; } } @@ -421,40 +461,6 @@ public class CollectionRepository : Repository, ICollectionRep return organizationUserIds.ToHashSet(); } - private (List collectionUser, List collection) BuildDefaultCollectionForUsers(Guid organizationId, IEnumerable missingDefaultCollectionUserIds, string defaultCollectionName) - { - var collectionUsers = new List(); - var collections = new List(); - - foreach (var orgUserId in missingDefaultCollectionUserIds) - { - var collectionId = CoreHelpers.GenerateComb(); - - collections.Add(new Collection - { - Id = collectionId, - OrganizationId = organizationId, - Name = defaultCollectionName, - CreationDate = DateTime.UtcNow, - RevisionDate = DateTime.UtcNow, - Type = CollectionType.DefaultUserCollection, - DefaultUserCollectionEmail = null - - }); - - collectionUsers.Add(new CollectionUser - { - CollectionId = collectionId, - OrganizationUserId = orgUserId, - ReadOnly = false, - HidePasswords = false, - Manage = true, - }); - } - - return (collectionUsers, collections); - } - public class CollectionWithGroupsAndUsers : Collection { public CollectionWithGroupsAndUsers() { } diff --git a/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs b/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs index 5aa156d1f8..74150246b1 100644 --- a/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs @@ -1,11 +1,10 @@ using AutoMapper; +using Bit.Core.AdminConsole.OrganizationFeatures.Collections; using Bit.Core.Enums; using Bit.Core.Models.Data; using Bit.Core.Repositories; -using Bit.Core.Utilities; using Bit.Infrastructure.EntityFramework.Models; using Bit.Infrastructure.EntityFramework.Repositories.Queries; -using LinqToDB.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; @@ -794,7 +793,7 @@ public class CollectionRepository : Repository organizationUserIds, string defaultCollectionName) + public async Task CreateDefaultCollectionsAsync(Guid organizationId, IEnumerable organizationUserIds, string defaultCollectionName) { organizationUserIds = organizationUserIds.ToList(); if (!organizationUserIds.Any()) @@ -808,15 +807,15 @@ public class CollectionRepository : Repository>(collections)); + await dbContext.CollectionUsers.AddRangeAsync(Mapper.Map>(collectionUsers)); await dbContext.SaveChangesAsync(); } @@ -844,37 +843,7 @@ public class CollectionRepository : Repository collectionUser, List collection) BuildDefaultCollectionForUsers(Guid organizationId, IEnumerable missingDefaultCollectionUserIds, string defaultCollectionName) - { - var collectionUsers = new List(); - var collections = new List(); - - foreach (var orgUserId in missingDefaultCollectionUserIds) - { - var collectionId = CoreHelpers.GenerateComb(); - - collections.Add(new Collection - { - Id = collectionId, - OrganizationId = organizationId, - Name = defaultCollectionName, - CreationDate = DateTime.UtcNow, - RevisionDate = DateTime.UtcNow, - Type = CollectionType.DefaultUserCollection, - DefaultUserCollectionEmail = null - - }); - - collectionUsers.Add(new CollectionUser - { - CollectionId = collectionId, - OrganizationUserId = orgUserId, - ReadOnly = false, - HidePasswords = false, - Manage = true, - }); - } - - return (collectionUsers, collections); - } + public Task CreateDefaultCollectionsBulkAsync(Guid organizationId, IEnumerable organizationUserIds, + string defaultCollectionName) => + CreateDefaultCollectionsAsync(organizationId, organizationUserIds, defaultCollectionName); } diff --git a/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs b/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs index 7b67a63912..a0ee0376c0 100644 --- a/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs +++ b/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs @@ -17,8 +17,6 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using DP = Microsoft.AspNetCore.DataProtection; -#nullable enable - namespace Bit.Infrastructure.EntityFramework.Repositories; public class DatabaseContext : DbContext diff --git a/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_CreateDefaultCollections.sql b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_CreateDefaultCollections.sql new file mode 100644 index 0000000000..4e671bd1e4 --- /dev/null +++ b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_CreateDefaultCollections.sql @@ -0,0 +1,69 @@ +-- Creates default user collections for organization users +-- Filters out existing default collections at database level +CREATE PROCEDURE [dbo].[Collection_CreateDefaultCollections] + @OrganizationId UNIQUEIDENTIFIER, + @DefaultCollectionName VARCHAR(MAX), + @OrganizationUserCollectionIds AS [dbo].[TwoGuidIdArray] READONLY -- OrganizationUserId, CollectionId +AS +BEGIN + SET NOCOUNT ON + + DECLARE @Now DATETIME2(7) = GETUTCDATE() + + -- Filter to only users who don't have default collections + SELECT ids.Id1, ids.Id2 + INTO #FilteredIds + FROM @OrganizationUserCollectionIds ids + WHERE NOT EXISTS ( + SELECT 1 + FROM [dbo].[CollectionUser] cu + INNER JOIN [dbo].[Collection] c ON c.Id = cu.CollectionId + WHERE c.OrganizationId = @OrganizationId + AND c.[Type] = 1 -- CollectionType.DefaultUserCollection + AND cu.OrganizationUserId = ids.Id1 + ); + + -- Insert collections only for users who don't have default collections yet + INSERT INTO [dbo].[Collection] + ( + [Id], + [OrganizationId], + [Name], + [CreationDate], + [RevisionDate], + [Type], + [ExternalId], + [DefaultUserCollectionEmail] + ) + SELECT + ids.Id2, -- CollectionId + @OrganizationId, + @DefaultCollectionName, + @Now, + @Now, + 1, -- CollectionType.DefaultUserCollection + NULL, + NULL + FROM + #FilteredIds ids; + + -- Insert collection user mappings + INSERT INTO [dbo].[CollectionUser] + ( + [CollectionId], + [OrganizationUserId], + [ReadOnly], + [HidePasswords], + [Manage] + ) + SELECT + ids.Id2, -- CollectionId + ids.Id1, -- OrganizationUserId + 0, -- ReadOnly = false + 0, -- HidePasswords = false + 1 -- Manage = true + FROM + #FilteredIds ids; + + DROP TABLE #FilteredIds; +END diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUsers/AutomaticallyConfirmUsersCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUsers/AutomaticallyConfirmUsersCommandTests.cs index 180750a9d0..252fb89c87 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUsers/AutomaticallyConfirmUsersCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUsers/AutomaticallyConfirmUsersCommandTests.cs @@ -10,7 +10,6 @@ using Bit.Core.AdminConsole.Utilities.v2; using Bit.Core.AdminConsole.Utilities.v2.Validation; using Bit.Core.Entities; using Bit.Core.Enums; -using Bit.Core.Models.Data; using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; @@ -204,14 +203,10 @@ public class AutomaticallyConfirmUsersCommandTests await sutProvider.GetDependency() .Received(1) - .CreateAsync( - Arg.Is(c => - c.OrganizationId == organization.Id && - c.Name == defaultCollectionName && - c.Type == CollectionType.DefaultUserCollection), - Arg.Is>(groups => groups == null), - Arg.Is>(access => - access.FirstOrDefault(x => x.Id == organizationUser.Id && x.Manage) != null)); + .CreateDefaultCollectionsAsync( + organization.Id, + Arg.Is>(ids => ids.Single() == organizationUser.Id), + defaultCollectionName); } [Theory] @@ -253,9 +248,7 @@ public class AutomaticallyConfirmUsersCommandTests await sutProvider.GetDependency() .DidNotReceive() - .CreateAsync(Arg.Any(), - Arg.Any>(), - Arg.Any>()); + .CreateDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); } [Theory] @@ -291,9 +284,7 @@ public class AutomaticallyConfirmUsersCommandTests var collectionException = new Exception("Collection creation failed"); sutProvider.GetDependency() - .CreateAsync(Arg.Any(), - Arg.Any>(), - Arg.Any>()) + .CreateDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()) .ThrowsAsync(collectionException); // Act diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommandTests.cs index 65359b8304..6643f26eb5 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommandTests.cs @@ -13,7 +13,6 @@ using Bit.Core.Billing.Enums; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; -using Bit.Core.Models.Data; using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Platform.Push; using Bit.Core.Repositories; @@ -493,15 +492,10 @@ public class ConfirmOrganizationUserCommandTests await sutProvider.GetDependency() .Received(1) - .CreateAsync( - Arg.Is(c => - c.Name == collectionName && - c.OrganizationId == organization.Id && - c.Type == CollectionType.DefaultUserCollection), - Arg.Any>(), - Arg.Is>(cu => - cu.Single().Id == orgUser.Id && - cu.Single().Manage)); + .CreateDefaultCollectionsAsync( + organization.Id, + Arg.Is>(ids => ids.Single() == orgUser.Id), + collectionName); } [Theory, BitAutoData] @@ -522,7 +516,7 @@ public class ConfirmOrganizationUserCommandTests await sutProvider.GetDependency() .DidNotReceive() - .UpsertDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); + .CreateDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); } [Theory, BitAutoData] @@ -539,24 +533,15 @@ public class ConfirmOrganizationUserCommandTests sutProvider.GetDependency().GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser }); sutProvider.GetDependency().GetManyAsync(default).ReturnsForAnyArgs(new[] { user }); - var policyDetails = new PolicyDetails - { - OrganizationId = org.Id, - OrganizationUserId = orgUser.Id, - IsProvider = false, - OrganizationUserStatus = orgUser.Status, - OrganizationUserType = orgUser.Type, - PolicyType = PolicyType.OrganizationDataOwnership - }; sutProvider.GetDependency() .GetAsync(orgUser.UserId!.Value) - .Returns(new OrganizationDataOwnershipPolicyRequirement(OrganizationDataOwnershipState.Disabled, [policyDetails])); + .Returns(new OrganizationDataOwnershipPolicyRequirement(OrganizationDataOwnershipState.Disabled, [])); await sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id, collectionName); await sutProvider.GetDependency() .DidNotReceive() - .UpsertDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); + .CreateDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); } [Theory, BitAutoData] diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidatorTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidatorTests.cs index 93cbde89ec..dd2f1d76e8 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidatorTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidatorTests.cs @@ -38,7 +38,7 @@ public class OrganizationDataOwnershipPolicyValidatorTests // Assert await sutProvider.GetDependency() .DidNotReceive() - .UpsertDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); + .CreateDefaultCollectionsBulkAsync(Arg.Any(), Arg.Any>(), Arg.Any()); } [Theory, BitAutoData] @@ -60,7 +60,7 @@ public class OrganizationDataOwnershipPolicyValidatorTests // Assert await sutProvider.GetDependency() .DidNotReceive() - .UpsertDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); + .CreateDefaultCollectionsBulkAsync(Arg.Any(), Arg.Any>(), Arg.Any()); } [Theory, BitAutoData] @@ -86,7 +86,7 @@ public class OrganizationDataOwnershipPolicyValidatorTests // Assert await collectionRepository .DidNotReceive() - .UpsertDefaultCollectionsAsync( + .CreateDefaultCollectionsBulkAsync( Arg.Any(), Arg.Any>(), Arg.Any()); @@ -172,10 +172,10 @@ public class OrganizationDataOwnershipPolicyValidatorTests // Act await sut.ExecuteSideEffectsAsync(policyRequest, postUpdatedPolicy, previousPolicyState); - // Assert + // Assert - Should call with all user IDs (repository does internal filtering) await collectionRepository .Received(1) - .UpsertDefaultCollectionsAsync( + .CreateDefaultCollectionsBulkAsync( policyUpdate.OrganizationId, Arg.Is>(ids => ids.Count() == 3), _defaultUserCollectionName); @@ -210,7 +210,7 @@ public class OrganizationDataOwnershipPolicyValidatorTests // Assert await sutProvider.GetDependency() .DidNotReceive() - .UpsertDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); + .CreateDefaultCollectionsBulkAsync(Arg.Any(), Arg.Any>(), Arg.Any()); } private static IPolicyRepository ArrangePolicyRepository(IEnumerable policyDetails) @@ -251,7 +251,7 @@ public class OrganizationDataOwnershipPolicyValidatorTests // Assert await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .UpsertDefaultCollectionsAsync(default, default, default); + .CreateDefaultCollectionsBulkAsync(default, default, default); } [Theory, BitAutoData] @@ -273,7 +273,7 @@ public class OrganizationDataOwnershipPolicyValidatorTests // Assert await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .UpsertDefaultCollectionsAsync(default, default, default); + .CreateDefaultCollectionsBulkAsync(default, default, default); } [Theory, BitAutoData] @@ -299,7 +299,7 @@ public class OrganizationDataOwnershipPolicyValidatorTests // Assert await collectionRepository .DidNotReceiveWithAnyArgs() - .UpsertDefaultCollectionsAsync( + .CreateDefaultCollectionsBulkAsync( default, default, default); @@ -336,10 +336,10 @@ public class OrganizationDataOwnershipPolicyValidatorTests // Act await sut.ExecutePostUpsertSideEffectAsync(policyRequest, postUpdatedPolicy, previousPolicyState); - // Assert + // Assert - Should call with all user IDs (repository does internal filtering) await collectionRepository .Received(1) - .UpsertDefaultCollectionsAsync( + .CreateDefaultCollectionsBulkAsync( policyUpdate.OrganizationId, Arg.Is>(ids => ids.Count() == 3), _defaultUserCollectionName); @@ -367,6 +367,6 @@ public class OrganizationDataOwnershipPolicyValidatorTests // Assert await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .UpsertDefaultCollectionsAsync(default, default, default); + .CreateDefaultCollectionsBulkAsync(default, default, default); } } diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CreateDefaultCollectionsBulkTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CreateDefaultCollectionsBulkTests.cs new file mode 100644 index 0000000000..712ad7d62e --- /dev/null +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CreateDefaultCollectionsBulkTests.cs @@ -0,0 +1,53 @@ +using Bit.Core.Repositories; +using Xunit; + +namespace Bit.Infrastructure.IntegrationTest.AdminConsole.Repositories.CollectionRepository; + + +public class CreateDefaultCollectionsBulkAsyncTests +{ + [Theory, DatabaseData] + public async Task CreateDefaultCollectionsBulkAsync_CreatesDefaultCollections_Success( + IOrganizationRepository organizationRepository, + IUserRepository userRepository, + IOrganizationUserRepository organizationUserRepository, + ICollectionRepository collectionRepository) + { + await CreateDefaultCollectionsSharedTests.CreatesDefaultCollections_Success( + collectionRepository.CreateDefaultCollectionsBulkAsync, + organizationRepository, + userRepository, + organizationUserRepository, + collectionRepository); + } + + [Theory, DatabaseData] + public async Task CreateDefaultCollectionsBulkAsync_CreatesForNewUsersOnly_AndIgnoresExistingUsers( + IOrganizationRepository organizationRepository, + IUserRepository userRepository, + IOrganizationUserRepository organizationUserRepository, + ICollectionRepository collectionRepository) + { + await CreateDefaultCollectionsSharedTests.CreatesForNewUsersOnly_AndIgnoresExistingUsers( + collectionRepository.CreateDefaultCollectionsBulkAsync, + organizationRepository, + userRepository, + organizationUserRepository, + collectionRepository); + } + + [Theory, DatabaseData] + public async Task CreateDefaultCollectionsBulkAsync_IgnoresAllExistingUsers( + IOrganizationRepository organizationRepository, + IUserRepository userRepository, + IOrganizationUserRepository organizationUserRepository, + ICollectionRepository collectionRepository) + { + await CreateDefaultCollectionsSharedTests.IgnoresAllExistingUsers( + collectionRepository.CreateDefaultCollectionsBulkAsync, + organizationRepository, + userRepository, + organizationUserRepository, + collectionRepository); + } +} diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/UpsertDefaultCollectionsTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CreateDefaultCollectionsSharedTests.cs similarity index 69% rename from test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/UpsertDefaultCollectionsTests.cs rename to test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CreateDefaultCollectionsSharedTests.cs index 64dffa473f..0fb4a5b446 100644 --- a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/UpsertDefaultCollectionsTests.cs +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CreateDefaultCollectionsSharedTests.cs @@ -6,10 +6,14 @@ using Xunit; namespace Bit.Infrastructure.IntegrationTest.AdminConsole.Repositories.CollectionRepository; -public class UpsertDefaultCollectionsTests +/// +/// Shared tests for CreateDefaultCollections methods - both bulk and non-bulk implementations, +/// as they share the same behavior. Both test suites call the tests in this class. +/// +public static class CreateDefaultCollectionsSharedTests { - [Theory, DatabaseData] - public async Task UpsertDefaultCollectionsAsync_ShouldCreateDefaultCollection_WhenUsersDoNotHaveDefaultCollection( + public static async Task CreatesDefaultCollections_Success( + Func, string, Task> createDefaultCollectionsFunc, IOrganizationRepository organizationRepository, IUserRepository userRepository, IOrganizationUserRepository organizationUserRepository, @@ -21,14 +25,13 @@ public class UpsertDefaultCollectionsTests var resultOrganizationUsers = await Task.WhenAll( CreateUserForOrgAsync(userRepository, organizationUserRepository, organization), CreateUserForOrgAsync(userRepository, organizationUserRepository, organization) - ); + ); - - var affectedOrgUserIds = resultOrganizationUsers.Select(organizationUser => organizationUser.Id); + var affectedOrgUserIds = resultOrganizationUsers.Select(organizationUser => organizationUser.Id).ToList(); var defaultCollectionName = $"default-name-{organization.Id}"; // Act - await collectionRepository.UpsertDefaultCollectionsAsync(organization.Id, affectedOrgUserIds, defaultCollectionName); + await createDefaultCollectionsFunc(organization.Id, affectedOrgUserIds, defaultCollectionName); // Assert await AssertAllUsersHaveOneDefaultCollectionAsync(collectionRepository, resultOrganizationUsers, organization.Id); @@ -36,8 +39,8 @@ public class UpsertDefaultCollectionsTests await CleanupAsync(organizationRepository, userRepository, organization, resultOrganizationUsers); } - [Theory, DatabaseData] - public async Task UpsertDefaultCollectionsAsync_ShouldUpsertCreateDefaultCollection_ForUsersWithAndWithoutDefaultCollectionsExist( + public static async Task CreatesForNewUsersOnly_AndIgnoresExistingUsers( + Func, string, Task> createDefaultCollectionsFunc, IOrganizationRepository organizationRepository, IUserRepository userRepository, IOrganizationUserRepository organizationUserRepository, @@ -51,31 +54,30 @@ public class UpsertDefaultCollectionsTests CreateUserForOrgAsync(userRepository, organizationUserRepository, organization) ); - var arrangedOrgUserIds = arrangedOrganizationUsers.Select(organizationUser => organizationUser.Id); + var arrangedOrgUserIds = arrangedOrganizationUsers.Select(organizationUser => organizationUser.Id).ToList(); var defaultCollectionName = $"default-name-{organization.Id}"; + await CreateUsersWithExistingDefaultCollectionsAsync(createDefaultCollectionsFunc, collectionRepository, organization.Id, arrangedOrgUserIds, defaultCollectionName, arrangedOrganizationUsers); - await CreateUsersWithExistingDefaultCollectionsAsync(collectionRepository, organization.Id, arrangedOrgUserIds, defaultCollectionName, arrangedOrganizationUsers); - - var newOrganizationUsers = new List() + var newOrganizationUsers = new List { await CreateUserForOrgAsync(userRepository, organizationUserRepository, organization) }; - var affectedOrgUsers = newOrganizationUsers.Concat(arrangedOrganizationUsers); - var affectedOrgUserIds = affectedOrgUsers.Select(organizationUser => organizationUser.Id); + var affectedOrgUsers = newOrganizationUsers.Concat(arrangedOrganizationUsers).ToList(); + var affectedOrgUserIds = affectedOrgUsers.Select(organizationUser => organizationUser.Id).ToList(); // Act - await collectionRepository.UpsertDefaultCollectionsAsync(organization.Id, affectedOrgUserIds, defaultCollectionName); + await createDefaultCollectionsFunc(organization.Id, affectedOrgUserIds, defaultCollectionName); // Assert - await AssertAllUsersHaveOneDefaultCollectionAsync(collectionRepository, arrangedOrganizationUsers, organization.Id); + await AssertAllUsersHaveOneDefaultCollectionAsync(collectionRepository, affectedOrgUsers, organization.Id); await CleanupAsync(organizationRepository, userRepository, organization, affectedOrgUsers); } - [Theory, DatabaseData] - public async Task UpsertDefaultCollectionsAsync_ShouldNotCreateDefaultCollection_WhenUsersAlreadyHaveOne( + public static async Task IgnoresAllExistingUsers( + Func, string, Task> createDefaultCollectionsFunc, IOrganizationRepository organizationRepository, IUserRepository userRepository, IOrganizationUserRepository organizationUserRepository, @@ -89,26 +91,29 @@ public class UpsertDefaultCollectionsTests CreateUserForOrgAsync(userRepository, organizationUserRepository, organization) ); - var affectedOrgUserIds = resultOrganizationUsers.Select(organizationUser => organizationUser.Id); + var affectedOrgUserIds = resultOrganizationUsers.Select(organizationUser => organizationUser.Id).ToList(); var defaultCollectionName = $"default-name-{organization.Id}"; + await CreateUsersWithExistingDefaultCollectionsAsync(createDefaultCollectionsFunc, collectionRepository, organization.Id, affectedOrgUserIds, defaultCollectionName, resultOrganizationUsers); - await CreateUsersWithExistingDefaultCollectionsAsync(collectionRepository, organization.Id, affectedOrgUserIds, defaultCollectionName, resultOrganizationUsers); + // Act - Try to create again, should silently filter and not create duplicates + await createDefaultCollectionsFunc(organization.Id, affectedOrgUserIds, defaultCollectionName); - // Act - await collectionRepository.UpsertDefaultCollectionsAsync(organization.Id, affectedOrgUserIds, defaultCollectionName); - - // Assert + // Assert - Original collections should remain unchanged, still only one per user await AssertAllUsersHaveOneDefaultCollectionAsync(collectionRepository, resultOrganizationUsers, organization.Id); await CleanupAsync(organizationRepository, userRepository, organization, resultOrganizationUsers); } - private static async Task CreateUsersWithExistingDefaultCollectionsAsync(ICollectionRepository collectionRepository, - Guid organizationId, IEnumerable affectedOrgUserIds, string defaultCollectionName, + private static async Task CreateUsersWithExistingDefaultCollectionsAsync( + Func, string, Task> createDefaultCollectionsFunc, + ICollectionRepository collectionRepository, + Guid organizationId, + IEnumerable affectedOrgUserIds, + string defaultCollectionName, OrganizationUser[] resultOrganizationUsers) { - await collectionRepository.UpsertDefaultCollectionsAsync(organizationId, affectedOrgUserIds, defaultCollectionName); + await createDefaultCollectionsFunc(organizationId, affectedOrgUserIds, defaultCollectionName); await AssertAllUsersHaveOneDefaultCollectionAsync(collectionRepository, resultOrganizationUsers, organizationId); } @@ -131,7 +136,6 @@ public class UpsertDefaultCollectionsTests private static async Task CreateUserForOrgAsync(IUserRepository userRepository, IOrganizationUserRepository organizationUserRepository, Organization organization) { - var user = await userRepository.CreateTestUserAsync(); var orgUser = await organizationUserRepository.CreateTestOrganizationUserAsync(organization, user); diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CreateDefaultCollectionsTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CreateDefaultCollectionsTests.cs new file mode 100644 index 0000000000..bd894e9ca5 --- /dev/null +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CreateDefaultCollectionsTests.cs @@ -0,0 +1,52 @@ +using Bit.Core.Repositories; +using Xunit; + +namespace Bit.Infrastructure.IntegrationTest.AdminConsole.Repositories.CollectionRepository; + +public class CreateDefaultCollectionsAsyncTests +{ + [Theory, DatabaseData] + public async Task CreateDefaultCollectionsAsync_CreatesDefaultCollections_Success( + IOrganizationRepository organizationRepository, + IUserRepository userRepository, + IOrganizationUserRepository organizationUserRepository, + ICollectionRepository collectionRepository) + { + await CreateDefaultCollectionsSharedTests.CreatesDefaultCollections_Success( + collectionRepository.CreateDefaultCollectionsAsync, + organizationRepository, + userRepository, + organizationUserRepository, + collectionRepository); + } + + [Theory, DatabaseData] + public async Task CreateDefaultCollectionsAsync_CreatesForNewUsersOnly_AndIgnoresExistingUsers( + IOrganizationRepository organizationRepository, + IUserRepository userRepository, + IOrganizationUserRepository organizationUserRepository, + ICollectionRepository collectionRepository) + { + await CreateDefaultCollectionsSharedTests.CreatesForNewUsersOnly_AndIgnoresExistingUsers( + collectionRepository.CreateDefaultCollectionsAsync, + organizationRepository, + userRepository, + organizationUserRepository, + collectionRepository); + } + + [Theory, DatabaseData] + public async Task CreateDefaultCollectionsAsync_IgnoresAllExistingUsers( + IOrganizationRepository organizationRepository, + IUserRepository userRepository, + IOrganizationUserRepository organizationUserRepository, + ICollectionRepository collectionRepository) + { + await CreateDefaultCollectionsSharedTests.IgnoresAllExistingUsers( + collectionRepository.CreateDefaultCollectionsAsync, + organizationRepository, + userRepository, + organizationUserRepository, + collectionRepository); + } +} diff --git a/util/Migrator/DbScripts/2026-01-13_00_Collection_CreateDefaultCollections.sql b/util/Migrator/DbScripts/2026-01-13_00_Collection_CreateDefaultCollections.sql new file mode 100644 index 0000000000..c7935db5e8 --- /dev/null +++ b/util/Migrator/DbScripts/2026-01-13_00_Collection_CreateDefaultCollections.sql @@ -0,0 +1,70 @@ +-- Creates default user collections for organization users +-- Filters out existing default collections at database level +CREATE OR ALTER PROCEDURE [dbo].[Collection_CreateDefaultCollections] + @OrganizationId UNIQUEIDENTIFIER, + @DefaultCollectionName VARCHAR(MAX), + @OrganizationUserCollectionIds AS [dbo].[TwoGuidIdArray] READONLY -- OrganizationUserId, CollectionId +AS +BEGIN + SET NOCOUNT ON + + DECLARE @Now DATETIME2(7) = GETUTCDATE() + + -- Filter to only users who don't have default collections + SELECT ids.Id1, ids.Id2 + INTO #FilteredIds + FROM @OrganizationUserCollectionIds ids + WHERE NOT EXISTS ( + SELECT 1 + FROM [dbo].[CollectionUser] cu + INNER JOIN [dbo].[Collection] c ON c.Id = cu.CollectionId + WHERE c.OrganizationId = @OrganizationId + AND c.[Type] = 1 -- CollectionType.DefaultUserCollection + AND cu.OrganizationUserId = ids.Id1 + ); + + -- Insert collections only for users who don't have default collections yet + INSERT INTO [dbo].[Collection] + ( + [Id], + [OrganizationId], + [Name], + [CreationDate], + [RevisionDate], + [Type], + [ExternalId], + [DefaultUserCollectionEmail] + ) + SELECT + ids.Id2, -- CollectionId + @OrganizationId, + @DefaultCollectionName, + @Now, + @Now, + 1, -- CollectionType.DefaultUserCollection + NULL, + NULL + FROM + #FilteredIds ids; + + -- Insert collection user mappings + INSERT INTO [dbo].[CollectionUser] + ( + [CollectionId], + [OrganizationUserId], + [ReadOnly], + [HidePasswords], + [Manage] + ) + SELECT + ids.Id2, -- CollectionId + ids.Id1, -- OrganizationUserId + 0, -- ReadOnly = false + 0, -- HidePasswords = false + 1 -- Manage = true + FROM + #FilteredIds ids; + + DROP TABLE #FilteredIds; +END +GO From aa33a67aeeeea602dbe96a483e1f69a94744fce4 Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Fri, 16 Jan 2026 10:33:17 -0500 Subject: [PATCH 30/49] [PM-30858] Fix excessive logs (#6860) * Add tests showing issue & workaround - `AddSerilogFileLogging_LegacyConfig_InfoLogs_DoNotFillUpFile` fails - `AddSerilogFileLogging_LegacyConfig_WithLevelCustomization_InfoLogs_DoNotFillUpFile` fails - `AddSerilogFileLogging_NewConfig_InfoLogs_DoNotFillUpFile` fails - `AddSerilogFileLogging_NewConfig_WithLevelCustomization_InfoLogs_DoNotFillUpFile` works * Allow customization of LogLevel with legacy path format config * Lower default logging levels * Delete tests now that log levels have been customized --- .../src/Scim/appsettings.Production.json | 6 +- src/Admin/appsettings.Production.json | 6 +- src/Api/appsettings.Production.json | 6 +- src/Core/Utilities/LoggerFactoryExtensions.cs | 38 +++++++----- src/Events/appsettings.Production.json | 6 +- .../appsettings.Production.json | 6 +- src/Icons/appsettings.Production.json | 6 +- src/Identity/appsettings.Production.json | 6 +- src/Notifications/appsettings.Production.json | 6 +- .../Utilities/LoggerFactoryExtensionsTests.cs | 59 ++++++++++++++++++- 10 files changed, 96 insertions(+), 49 deletions(-) diff --git a/bitwarden_license/src/Scim/appsettings.Production.json b/bitwarden_license/src/Scim/appsettings.Production.json index d9efbcda12..a6578c08dc 100644 --- a/bitwarden_license/src/Scim/appsettings.Production.json +++ b/bitwarden_license/src/Scim/appsettings.Production.json @@ -23,11 +23,9 @@ } }, "Logging": { - "IncludeScopes": false, "LogLevel": { - "Default": "Debug", - "System": "Information", - "Microsoft": "Information" + "Default": "Information", + "Microsoft.AspNetCore": "Warning" }, "Console": { "IncludeScopes": true, diff --git a/src/Admin/appsettings.Production.json b/src/Admin/appsettings.Production.json index 9f797f3111..1d852abfed 100644 --- a/src/Admin/appsettings.Production.json +++ b/src/Admin/appsettings.Production.json @@ -20,11 +20,9 @@ } }, "Logging": { - "IncludeScopes": false, "LogLevel": { - "Default": "Debug", - "System": "Information", - "Microsoft": "Information" + "Default": "Information", + "Microsoft.AspNetCore": "Warning" }, "Console": { "IncludeScopes": true, diff --git a/src/Api/appsettings.Production.json b/src/Api/appsettings.Production.json index d9efbcda12..a6578c08dc 100644 --- a/src/Api/appsettings.Production.json +++ b/src/Api/appsettings.Production.json @@ -23,11 +23,9 @@ } }, "Logging": { - "IncludeScopes": false, "LogLevel": { - "Default": "Debug", - "System": "Information", - "Microsoft": "Information" + "Default": "Information", + "Microsoft.AspNetCore": "Warning" }, "Console": { "IncludeScopes": true, diff --git a/src/Core/Utilities/LoggerFactoryExtensions.cs b/src/Core/Utilities/LoggerFactoryExtensions.cs index b950e30d5d..f3330f0792 100644 --- a/src/Core/Utilities/LoggerFactoryExtensions.cs +++ b/src/Core/Utilities/LoggerFactoryExtensions.cs @@ -1,4 +1,5 @@ -using Microsoft.AspNetCore.Hosting; +using System.Globalization; +using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -8,7 +9,7 @@ namespace Bit.Core.Utilities; public static class LoggerFactoryExtensions { /// - /// + /// /// /// /// @@ -21,10 +22,12 @@ public static class LoggerFactoryExtensions return; } + IConfiguration loggingConfiguration; + // If they have begun using the new settings location, use that if (!string.IsNullOrEmpty(context.Configuration["Logging:PathFormat"])) { - logging.AddFile(context.Configuration.GetSection("Logging")); + loggingConfiguration = context.Configuration.GetSection("Logging"); } else { @@ -40,28 +43,35 @@ public static class LoggerFactoryExtensions var projectName = loggingOptions.ProjectName ?? context.HostingEnvironment.ApplicationName; + string pathFormat; + if (loggingOptions.LogRollBySizeLimit.HasValue) { - var pathFormat = loggingOptions.LogDirectoryByProject + pathFormat = loggingOptions.LogDirectoryByProject ? Path.Combine(loggingOptions.LogDirectory, projectName, "log.txt") : Path.Combine(loggingOptions.LogDirectory, $"{projectName.ToLowerInvariant()}.log"); - - logging.AddFile( - pathFormat: pathFormat, - fileSizeLimitBytes: loggingOptions.LogRollBySizeLimit.Value - ); } else { - var pathFormat = loggingOptions.LogDirectoryByProject + pathFormat = loggingOptions.LogDirectoryByProject ? Path.Combine(loggingOptions.LogDirectory, projectName, "{Date}.txt") : Path.Combine(loggingOptions.LogDirectory, $"{projectName.ToLowerInvariant()}_{{Date}}.log"); - - logging.AddFile( - pathFormat: pathFormat - ); } + + // We want to rely on Serilog using the configuration section to have customization of the log levels + // so we make a custom configuration source for them based on the legacy values and allow overrides from + // the new location. + loggingConfiguration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + {"PathFormat", pathFormat}, + {"FileSizeLimitBytes", loggingOptions.LogRollBySizeLimit?.ToString(CultureInfo.InvariantCulture)} + }) + .AddConfiguration(context.Configuration.GetSection("Logging")) + .Build(); } + + logging.AddFile(loggingConfiguration); }); } diff --git a/src/Events/appsettings.Production.json b/src/Events/appsettings.Production.json index 010f02f8cd..9a10621264 100644 --- a/src/Events/appsettings.Production.json +++ b/src/Events/appsettings.Production.json @@ -17,11 +17,9 @@ } }, "Logging": { - "IncludeScopes": false, "LogLevel": { - "Default": "Debug", - "System": "Information", - "Microsoft": "Information" + "Default": "Information", + "Microsoft.AspNetCore": "Warning" }, "Console": { "IncludeScopes": true, diff --git a/src/EventsProcessor/appsettings.Production.json b/src/EventsProcessor/appsettings.Production.json index 1cce4a9ed3..d57bf98b55 100644 --- a/src/EventsProcessor/appsettings.Production.json +++ b/src/EventsProcessor/appsettings.Production.json @@ -1,10 +1,8 @@ { "Logging": { - "IncludeScopes": false, "LogLevel": { - "Default": "Debug", - "System": "Information", - "Microsoft": "Information" + "Default": "Information", + "Microsoft.AspNetCore": "Warning" }, "Console": { "IncludeScopes": true, diff --git a/src/Icons/appsettings.Production.json b/src/Icons/appsettings.Production.json index 828e8c61cc..19d21f7260 100644 --- a/src/Icons/appsettings.Production.json +++ b/src/Icons/appsettings.Production.json @@ -17,11 +17,9 @@ } }, "Logging": { - "IncludeScopes": false, "LogLevel": { - "Default": "Debug", - "System": "Information", - "Microsoft": "Information" + "Default": "Information", + "Microsoft.AspNetCore": "Warning" }, "Console": { "IncludeScopes": true, diff --git a/src/Identity/appsettings.Production.json b/src/Identity/appsettings.Production.json index 4897a7d8b1..14471b5fb6 100644 --- a/src/Identity/appsettings.Production.json +++ b/src/Identity/appsettings.Production.json @@ -20,11 +20,9 @@ } }, "Logging": { - "IncludeScopes": false, "LogLevel": { - "Default": "Debug", - "System": "Information", - "Microsoft": "Information" + "Default": "Information", + "Microsoft.AspNetCore": "Warning" }, "Console": { "IncludeScopes": true, diff --git a/src/Notifications/appsettings.Production.json b/src/Notifications/appsettings.Production.json index 010f02f8cd..735c70e481 100644 --- a/src/Notifications/appsettings.Production.json +++ b/src/Notifications/appsettings.Production.json @@ -17,11 +17,9 @@ } }, "Logging": { - "IncludeScopes": false, "LogLevel": { - "Default": "Debug", - "System": "Information", - "Microsoft": "Information" + "Default": "Information", + "Microsoft": "Warning" }, "Console": { "IncludeScopes": true, diff --git a/test/Core.Test/Utilities/LoggerFactoryExtensionsTests.cs b/test/Core.Test/Utilities/LoggerFactoryExtensionsTests.cs index 81311cb802..ffeb3fa2e7 100644 --- a/test/Core.Test/Utilities/LoggerFactoryExtensionsTests.cs +++ b/test/Core.Test/Utilities/LoggerFactoryExtensionsTests.cs @@ -74,8 +74,7 @@ public class LoggerFactoryExtensionsTests logger.LogWarning("This is a test"); - // Writing to the file is buffered, give it a little time to flush - await Task.Delay(5); + await provider.DisposeAsync(); var logFile = Assert.Single(tempDir.EnumerateFiles("Logs/*.log")); @@ -90,13 +89,67 @@ public class LoggerFactoryExtensionsTests logFileContents ); } + + [Fact] + public async Task AddSerilogFileLogging_LegacyConfig_WithLevelCustomization_InfoLogs_DoNotFillUpFile() + { + await AssertSmallFileAsync((tempDir, config) => + { + config["GlobalSettings:LogDirectory"] = tempDir; + config["Logging:LogLevel:Microsoft.AspNetCore"] = "Warning"; + }); + } + + [Fact] + public async Task AddSerilogFileLogging_NewConfig_WithLevelCustomization_InfoLogs_DoNotFillUpFile() + { + await AssertSmallFileAsync((tempDir, config) => + { + config["Logging:PathFormat"] = Path.Combine(tempDir, "log.txt"); + config["Logging:LogLevel:Microsoft.AspNetCore"] = "Warning"; + }); + } + + private static async Task AssertSmallFileAsync(Action> configure) + { + using var tempDir = new TempDirectory(); + var config = new Dictionary(); + + configure(tempDir.Directory, config); + + var provider = GetServiceProvider(config, "Production"); + + var loggerFactory = provider.GetRequiredService(); + var microsoftLogger = loggerFactory.CreateLogger("Microsoft.AspNetCore.Testing"); + + for (var i = 0; i < 100; i++) + { + microsoftLogger.LogInformation("Tons of useless information"); + } + + var otherLogger = loggerFactory.CreateLogger("Bitwarden"); + + for (var i = 0; i < 5; i++) + { + otherLogger.LogInformation("Mildly more useful information but not as frequent."); + } + + await provider.DisposeAsync(); + + var logFiles = Directory.EnumerateFiles(tempDir.Directory, "*.txt", SearchOption.AllDirectories); + var logFile = Assert.Single(logFiles); + + using var fr = File.OpenRead(logFile); + Assert.InRange(fr.Length, 0, 1024); + } + private static IEnumerable GetProviders(Dictionary initialData, string environment = "Production") { var provider = GetServiceProvider(initialData, environment); return provider.GetServices(); } - private static IServiceProvider GetServiceProvider(Dictionary initialData, string environment) + private static ServiceProvider GetServiceProvider(Dictionary initialData, string environment) { var config = new ConfigurationBuilder() .AddInMemoryCollection(initialData) From 8d30fbcc8abe424e604073e721ceaeb365c4fba1 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Fri, 16 Jan 2026 18:13:57 -0500 Subject: [PATCH 31/49] Billing/pm 30882/defect pm coupon removed on upgrade (#6863) * fix(billing): update coupon check logic * tests(billing): update tests and add plan check test --- .../SubscriptionUpdatedHandler.cs | 11 ++- .../SubscriptionUpdatedHandlerTests.cs | 89 +++++++++++++++++++ 2 files changed, 98 insertions(+), 2 deletions(-) diff --git a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs index c10368d8c0..9e20bd3191 100644 --- a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs +++ b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs @@ -275,17 +275,24 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler .PreviousAttributes .ToObject() as Subscription; + // Get all plan IDs that include Secrets Manager support to check if the organization has secret manager in the + // previous and/or current subscriptions. + var planIdsOfPlansWithSecretManager = (await _pricingClient.ListPlans()) + .Where(orgPlan => orgPlan.SupportsSecretsManager && orgPlan.SecretsManager.StripeSeatPlanId != null) + .Select(orgPlan => orgPlan.SecretsManager.StripeSeatPlanId) + .ToHashSet(); + // This being false doesn't necessarily mean that the organization doesn't subscribe to Secrets Manager. // If there are changes to any subscription item, Stripe sends every item in the subscription, both // changed and unchanged. var previousSubscriptionHasSecretsManager = previousSubscription?.Items is not null && previousSubscription.Items.Any( - previousSubscriptionItem => previousSubscriptionItem.Plan.Id == plan.SecretsManager.StripeSeatPlanId); + previousSubscriptionItem => planIdsOfPlansWithSecretManager.Contains(previousSubscriptionItem.Plan.Id)); var currentSubscriptionHasSecretsManager = subscription.Items.Any( - currentSubscriptionItem => currentSubscriptionItem.Plan.Id == plan.SecretsManager.StripeSeatPlanId); + currentSubscriptionItem => planIdsOfPlansWithSecretManager.Contains(currentSubscriptionItem.Plan.Id)); if (!previousSubscriptionHasSecretsManager || currentSubscriptionHasSecretsManager) { diff --git a/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs b/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs index 182f09e163..2259d846b7 100644 --- a/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs +++ b/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs @@ -11,6 +11,7 @@ using Bit.Core.Billing.Pricing; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; using Bit.Core.Repositories; using Bit.Core.Services; +using Bit.Core.Test.Billing.Mocks; using Bit.Core.Test.Billing.Mocks.Plans; using Microsoft.Extensions.Logging; using Newtonsoft.Json.Linq; @@ -654,6 +655,8 @@ public class SubscriptionUpdatedHandlerTests var plan = new Enterprise2023Plan(true); _pricingClient.GetPlanOrThrow(organization.PlanType) .Returns(plan); + _pricingClient.ListPlans() + .Returns(MockPlans.Plans); var parsedEvent = new Event { @@ -693,6 +696,92 @@ public class SubscriptionUpdatedHandlerTests await _stripeFacade.Received(1).DeleteCustomerDiscount(subscription.CustomerId); await _stripeFacade.Received(1).DeleteSubscriptionDiscount(subscription.Id); } + [Fact] + public async Task + HandleAsync_WhenUpgradingPlan_AndPreviousPlanHasSecretsManagerTrial_AndCurrentPlanHasSecretsManagerTrial_DoesNotRemovePasswordManagerCoupon() + { + // Arrange + var organizationId = Guid.NewGuid(); + var subscription = new Subscription + { + Id = "sub_123", + Status = StripeSubscriptionStatus.Active, + CustomerId = "cus_123", + Items = new StripeList + { + Data = + [ + new SubscriptionItem + { + CurrentPeriodEnd = DateTime.UtcNow.AddDays(10), + Plan = new Plan { Id = "2023-enterprise-org-seat-annually" } + }, + new SubscriptionItem + { + CurrentPeriodEnd = DateTime.UtcNow.AddDays(10), + Plan = new Plan { Id = "secrets-manager-enterprise-seat-annually" } + } + ] + }, + Customer = new Customer + { + Balance = 0, + Discount = new Discount { Coupon = new Coupon { Id = "sm-standalone" } } + }, + Discounts = [new Discount { Coupon = new Coupon { Id = "sm-standalone" } }], + Metadata = new Dictionary { { "organizationId", organizationId.ToString() } } + }; + + // Note: The organization plan is still the previous plan because the subscription is updated before the organization is updated + var organization = new Organization { Id = organizationId, PlanType = PlanType.TeamsAnnually2023 }; + + var plan = new Teams2023Plan(true); + _pricingClient.GetPlanOrThrow(organization.PlanType) + .Returns(plan); + _pricingClient.ListPlans() + .Returns(MockPlans.Plans); + + var parsedEvent = new Event + { + Data = new EventData + { + Object = subscription, + PreviousAttributes = JObject.FromObject(new + { + items = new + { + data = new[] + { + new { plan = new { id = "secrets-manager-teams-seat-annually" } }, + } + }, + Items = new StripeList + { + Data = + [ + new SubscriptionItem { Plan = new Stripe.Plan { Id = "secrets-manager-teams-seat-annually" } }, + ] + } + }) + } + }; + + _stripeEventService.GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) + .Returns(subscription); + + _stripeEventUtilityService.GetIdsFromMetadata(Arg.Any>()) + .Returns(Tuple.Create(organizationId, null, null)); + + _organizationRepository.GetByIdAsync(organizationId) + .Returns(organization); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + await _stripeFacade.DidNotReceive().DeleteCustomerDiscount(subscription.CustomerId); + await _stripeFacade.DidNotReceive().DeleteSubscriptionDiscount(subscription.Id); + } [Theory] [MemberData(nameof(GetNonActiveSubscriptions))] From ad19efcff7a4dacb3d538689479867dd780e14eb Mon Sep 17 00:00:00 2001 From: Thomas Rittson <31796059+eliykat@users.noreply.github.com> Date: Sat, 17 Jan 2026 10:47:21 +1000 Subject: [PATCH 32/49] [PM-22236] Fix invited accounts stuck in intermediate claimed status (#6810) * Exclude invited users from claimed domain checks. These users should be excluded by the JOIN on UserId, but it's a known issue that some invited users have this FK set. --- .../Repositories/IOrganizationRepository.cs | 4 +- .../Repositories/OrganizationRepository.cs | 3 +- ...erReadByClaimedOrganizationDomainsQuery.cs | 2 + ...dByOrganizationIdWithClaimedDomains_V2.sql | 3 +- ...anization_ReadByClaimedUserEmailDomain.sql | 3 +- .../GetByVerifiedUserEmailDomainAsyncTests.cs | 335 ++++++++++++++++++ .../OrganizationRepositoryTests.cs | 267 +------------- ...rganizationWithClaimedDomainsAsyncTests.cs | 197 ++++++++++ .../OrganizationUserRepositoryTests.cs | 194 ---------- ...0_ExcludeInvitedUsersFromClaimedDomain.sql | 24 ++ ...cludeInvitedUsersFromClaimedDomains_V2.sql | 29 ++ 11 files changed, 606 insertions(+), 455 deletions(-) create mode 100644 test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationRepository/GetByVerifiedUserEmailDomainAsyncTests.cs create mode 100644 test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/GetManyByOrganizationWithClaimedDomainsAsyncTests.cs create mode 100644 util/Migrator/DbScripts/2026-01-14_00_ExcludeInvitedUsersFromClaimedDomain.sql create mode 100644 util/Migrator/DbScripts/2026-01-14_01_ExcludeInvitedUsersFromClaimedDomains_V2.sql diff --git a/src/Core/AdminConsole/Repositories/IOrganizationRepository.cs b/src/Core/AdminConsole/Repositories/IOrganizationRepository.cs index da7a77000b..d79923fdd1 100644 --- a/src/Core/AdminConsole/Repositories/IOrganizationRepository.cs +++ b/src/Core/AdminConsole/Repositories/IOrganizationRepository.cs @@ -21,7 +21,9 @@ public interface IOrganizationRepository : IRepository Task> GetOwnerEmailAddressesById(Guid organizationId); /// - /// Gets the organizations that have a verified domain matching the user's email domain. + /// Gets the organizations that have claimed the user's account. Currently, only one organization may claim a user. + /// This requires that the organization has claimed the user's domain and the user is an organization member. + /// It excludes invited members. /// Task> GetByVerifiedUserEmailDomainAsync(Guid userId); diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs index 88410facf5..93c8cd304c 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs @@ -325,7 +325,8 @@ public class OrganizationRepository : Repository od.OrganizationId == _organizationId && od.VerifiedDate != null && diff --git a/src/Sql/dbo/Stored Procedures/OrganizationUser_ReadByOrganizationIdWithClaimedDomains_V2.sql b/src/Sql/dbo/Stored Procedures/OrganizationUser_ReadByOrganizationIdWithClaimedDomains_V2.sql index 64f3d81e08..4f781d2cc9 100644 --- a/src/Sql/dbo/Stored Procedures/OrganizationUser_ReadByOrganizationIdWithClaimedDomains_V2.sql +++ b/src/Sql/dbo/Stored Procedures/OrganizationUser_ReadByOrganizationIdWithClaimedDomains_V2.sql @@ -8,13 +8,14 @@ BEGIN SELECT * FROM [dbo].[OrganizationUserView] WHERE [OrganizationId] = @OrganizationId + AND [Status] != 0 -- Exclude invited users ), UserDomains AS ( SELECT U.[Id], U.[EmailDomain] FROM [dbo].[UserEmailDomainView] U WHERE EXISTS ( SELECT 1 - FROM [dbo].[OrganizationDomainView] OD + FROM [dbo].[OrganizationDomainView] OD WHERE OD.[OrganizationId] = @OrganizationId AND OD.[VerifiedDate] IS NOT NULL AND OD.[DomainName] = U.[EmailDomain] diff --git a/src/Sql/dbo/Stored Procedures/Organization_ReadByClaimedUserEmailDomain.sql b/src/Sql/dbo/Stored Procedures/Organization_ReadByClaimedUserEmailDomain.sql index 583f548c8b..ee14c2c52a 100644 --- a/src/Sql/dbo/Stored Procedures/Organization_ReadByClaimedUserEmailDomain.sql +++ b/src/Sql/dbo/Stored Procedures/Organization_ReadByClaimedUserEmailDomain.sql @@ -6,7 +6,7 @@ BEGIN WITH CTE_User AS ( SELECT - U.*, + U.[Id], SUBSTRING(U.Email, CHARINDEX('@', U.Email) + 1, LEN(U.Email)) AS EmailDomain FROM dbo.[UserView] U WHERE U.[Id] = @UserId @@ -19,4 +19,5 @@ BEGIN WHERE OD.[VerifiedDate] IS NOT NULL AND CU.EmailDomain = OD.[DomainName] AND O.[Enabled] = 1 + AND OU.[Status] != 0 -- Exclude invited users END diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationRepository/GetByVerifiedUserEmailDomainAsyncTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationRepository/GetByVerifiedUserEmailDomainAsyncTests.cs new file mode 100644 index 0000000000..6dd7aafca4 --- /dev/null +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationRepository/GetByVerifiedUserEmailDomainAsyncTests.cs @@ -0,0 +1,335 @@ +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Xunit; + +namespace Bit.Infrastructure.IntegrationTest.AdminConsole.Repositories.OrganizationRepository; + +public class GetByVerifiedUserEmailDomainAsyncTests +{ + [Theory, DatabaseData] + public async Task GetByClaimedUserDomainAsync_WithVerifiedDomain_Success( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + IOrganizationDomainRepository organizationDomainRepository) + { + var id = Guid.NewGuid(); + var domainName = $"{id}.example.com"; + + var user1 = await userRepository.CreateAsync(new User + { + Name = "Test User 1", + Email = $"test+{id}@{domainName}", + ApiKey = "TEST", + SecurityStamp = "stamp", + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = 1, + KdfMemory = 2, + KdfParallelism = 3 + }); + + var user2 = await userRepository.CreateAsync(new User + { + Name = "Test User 2", + Email = $"test+{id}@x-{domainName}", // Different domain + ApiKey = "TEST", + SecurityStamp = "stamp", + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = 1, + KdfMemory = 2, + KdfParallelism = 3 + }); + + var user3 = await userRepository.CreateAsync(new User + { + Name = "Test User 2", + Email = $"test+{id}@{domainName}.example.com", // Different domain + ApiKey = "TEST", + SecurityStamp = "stamp", + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = 1, + KdfMemory = 2, + KdfParallelism = 3 + }); + + var organization = await organizationRepository.CreateTestOrganizationAsync(); + + var organizationDomain = new OrganizationDomain + { + OrganizationId = organization.Id, + DomainName = domainName, + Txt = "btw+12345", + }; + organizationDomain.SetVerifiedDate(); + organizationDomain.SetNextRunDate(12); + organizationDomain.SetJobRunCount(); + await organizationDomainRepository.CreateAsync(organizationDomain); + + await organizationUserRepository.CreateConfirmedTestOrganizationUserAsync(organization, user1); + await organizationUserRepository.CreateConfirmedTestOrganizationUserAsync(organization, user2); + await organizationUserRepository.CreateConfirmedTestOrganizationUserAsync(organization, user3); + + var user1Response = await organizationRepository.GetByVerifiedUserEmailDomainAsync(user1.Id); + var user2Response = await organizationRepository.GetByVerifiedUserEmailDomainAsync(user2.Id); + var user3Response = await organizationRepository.GetByVerifiedUserEmailDomainAsync(user3.Id); + + Assert.NotEmpty(user1Response); + Assert.Equal(organization.Id, user1Response.First().Id); + Assert.Empty(user2Response); + Assert.Empty(user3Response); + } + + [Theory, DatabaseData] + public async Task GetByVerifiedUserEmailDomainAsync_WithUnverifiedDomains_ReturnsEmpty( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + IOrganizationDomainRepository organizationDomainRepository) + { + var id = Guid.NewGuid(); + var domainName = $"{id}.example.com"; + + var user = await userRepository.CreateAsync(new User + { + Name = "Test User", + Email = $"test+{id}@{domainName}", + ApiKey = "TEST", + SecurityStamp = "stamp", + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = 1, + KdfMemory = 2, + KdfParallelism = 3 + }); + + var organization = await organizationRepository.CreateTestOrganizationAsync(); + + var organizationDomain = new OrganizationDomain + { + OrganizationId = organization.Id, + DomainName = domainName, + Txt = "btw+12345", + }; + organizationDomain.SetNextRunDate(12); + organizationDomain.SetJobRunCount(); + await organizationDomainRepository.CreateAsync(organizationDomain); + + await organizationUserRepository.CreateConfirmedTestOrganizationUserAsync(organization, user); + + var result = await organizationRepository.GetByVerifiedUserEmailDomainAsync(user.Id); + + Assert.Empty(result); + } + + [Theory, DatabaseData] + public async Task GetByVerifiedUserEmailDomainAsync_WithMultipleVerifiedDomains_ReturnsAllMatchingOrganizations( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + IOrganizationDomainRepository organizationDomainRepository) + { + var id = Guid.NewGuid(); + var domainName = $"{id}.example.com"; + + var user = await userRepository.CreateAsync(new User + { + Name = "Test User", + Email = $"test+{id}@{domainName}", + ApiKey = "TEST", + SecurityStamp = "stamp", + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = 1, + KdfMemory = 2, + KdfParallelism = 3 + }); + + var organization1 = await organizationRepository.CreateTestOrganizationAsync(); + var organization2 = await organizationRepository.CreateTestOrganizationAsync(); + + var organizationDomain1 = new OrganizationDomain + { + OrganizationId = organization1.Id, + DomainName = domainName, + Txt = "btw+12345", + }; + organizationDomain1.SetNextRunDate(12); + organizationDomain1.SetJobRunCount(); + organizationDomain1.SetVerifiedDate(); + await organizationDomainRepository.CreateAsync(organizationDomain1); + + var organizationDomain2 = new OrganizationDomain + { + OrganizationId = organization2.Id, + DomainName = domainName, + Txt = "btw+67890", + }; + organizationDomain2.SetNextRunDate(12); + organizationDomain2.SetJobRunCount(); + organizationDomain2.SetVerifiedDate(); + await organizationDomainRepository.CreateAsync(organizationDomain2); + + await organizationUserRepository.CreateConfirmedTestOrganizationUserAsync(organization1, user); + await organizationUserRepository.CreateConfirmedTestOrganizationUserAsync(organization2, user); + + var result = await organizationRepository.GetByVerifiedUserEmailDomainAsync(user.Id); + + Assert.Equal(2, result.Count); + Assert.Contains(result, org => org.Id == organization1.Id); + Assert.Contains(result, org => org.Id == organization2.Id); + } + + [Theory, DatabaseData] + public async Task GetByVerifiedUserEmailDomainAsync_WithNonExistentUser_ReturnsEmpty( + IOrganizationRepository organizationRepository) + { + var nonExistentUserId = Guid.NewGuid(); + + var result = await organizationRepository.GetByVerifiedUserEmailDomainAsync(nonExistentUserId); + + Assert.Empty(result); + } + + /// + /// Tests an edge case where some invited users are created linked to a UserId. + /// This is defective behavior, but will take longer to fix - for now, we are defensive and expressly + /// exclude such users from the results without relying on the inner join only. + /// Invited-revoked users linked to a UserId remain intentionally unhandled for now as they have not caused + /// any issues to date and we want to minimize edge cases. + /// We will fix the underlying issue going forward: https://bitwarden.atlassian.net/browse/PM-22405 + /// + [Theory, DatabaseData] + public async Task GetByVerifiedUserEmailDomainAsync_WithInvitedUserWithUserId_ReturnsEmpty( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + IOrganizationDomainRepository organizationDomainRepository) + { + var id = Guid.NewGuid(); + var domainName = $"{id}.example.com"; + + var user = await userRepository.CreateAsync(new User + { + Name = "Test User", + Email = $"test+{id}@{domainName}", + ApiKey = "TEST", + SecurityStamp = "stamp", + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = 1, + KdfMemory = 2, + KdfParallelism = 3 + }); + + var organization = await organizationRepository.CreateTestOrganizationAsync(); + + var organizationDomain = new OrganizationDomain + { + OrganizationId = organization.Id, + DomainName = domainName, + Txt = "btw+12345", + }; + organizationDomain.SetVerifiedDate(); + organizationDomain.SetNextRunDate(12); + organizationDomain.SetJobRunCount(); + await organizationDomainRepository.CreateAsync(organizationDomain); + + // Create invited user with matching email domain but UserId set (edge case) + await organizationUserRepository.CreateAsync(new OrganizationUser + { + OrganizationId = organization.Id, + UserId = user.Id, + Email = user.Email, + Status = OrganizationUserStatusType.Invited, + }); + + var result = await organizationRepository.GetByVerifiedUserEmailDomainAsync(user.Id); + + // Invited users should be excluded even if they have UserId set + Assert.Empty(result); + } + + [Theory, DatabaseData] + public async Task GetByVerifiedUserEmailDomainAsync_WithAcceptedUser_ReturnsOrganization( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + IOrganizationDomainRepository organizationDomainRepository) + { + var id = Guid.NewGuid(); + var domainName = $"{id}.example.com"; + + var user = await userRepository.CreateAsync(new User + { + Name = "Test User", + Email = $"test+{id}@{domainName}", + ApiKey = "TEST", + SecurityStamp = "stamp", + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = 1, + KdfMemory = 2, + KdfParallelism = 3 + }); + + var organization = await organizationRepository.CreateTestOrganizationAsync(); + + var organizationDomain = new OrganizationDomain + { + OrganizationId = organization.Id, + DomainName = domainName, + Txt = "btw+12345", + }; + organizationDomain.SetVerifiedDate(); + organizationDomain.SetNextRunDate(12); + organizationDomain.SetJobRunCount(); + await organizationDomainRepository.CreateAsync(organizationDomain); + + await organizationUserRepository.CreateAcceptedTestOrganizationUserAsync(organization, user); + + var result = await organizationRepository.GetByVerifiedUserEmailDomainAsync(user.Id); + + Assert.NotEmpty(result); + Assert.Equal(organization.Id, result.First().Id); + } + + [Theory, DatabaseData] + public async Task GetByVerifiedUserEmailDomainAsync_WithRevokedUser_ReturnsOrganization( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + IOrganizationDomainRepository organizationDomainRepository) + { + var id = Guid.NewGuid(); + var domainName = $"{id}.example.com"; + + var user = await userRepository.CreateAsync(new User + { + Name = "Test User", + Email = $"test+{id}@{domainName}", + ApiKey = "TEST", + SecurityStamp = "stamp", + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = 1, + KdfMemory = 2, + KdfParallelism = 3 + }); + + var organization = await organizationRepository.CreateTestOrganizationAsync(); + + var organizationDomain = new OrganizationDomain + { + OrganizationId = organization.Id, + DomainName = domainName, + Txt = "btw+12345", + }; + organizationDomain.SetVerifiedDate(); + organizationDomain.SetNextRunDate(12); + organizationDomain.SetJobRunCount(); + await organizationDomainRepository.CreateAsync(organizationDomain); + + await organizationUserRepository.CreateRevokedTestOrganizationUserAsync(organization, user); + + var result = await organizationRepository.GetByVerifiedUserEmailDomainAsync(user.Id); + + Assert.NotEmpty(result); + Assert.Equal(organization.Id, result.First().Id); + } +} diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationRepositoryTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationRepositoryTests.cs index 67e2c1910b..52b1e7484b 100644 --- a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationRepositoryTests.cs @@ -8,254 +8,7 @@ namespace Bit.Infrastructure.IntegrationTest.AdminConsole.Repositories; public class OrganizationRepositoryTests { - [DatabaseTheory, DatabaseData] - public async Task GetByClaimedUserDomainAsync_WithVerifiedDomain_Success( - IUserRepository userRepository, - IOrganizationRepository organizationRepository, - IOrganizationUserRepository organizationUserRepository, - IOrganizationDomainRepository organizationDomainRepository) - { - var id = Guid.NewGuid(); - var domainName = $"{id}.example.com"; - - var user1 = await userRepository.CreateAsync(new User - { - Name = "Test User 1", - Email = $"test+{id}@{domainName}", - ApiKey = "TEST", - SecurityStamp = "stamp", - Kdf = KdfType.PBKDF2_SHA256, - KdfIterations = 1, - KdfMemory = 2, - KdfParallelism = 3 - }); - - var user2 = await userRepository.CreateAsync(new User - { - Name = "Test User 2", - Email = $"test+{id}@x-{domainName}", // Different domain - ApiKey = "TEST", - SecurityStamp = "stamp", - Kdf = KdfType.PBKDF2_SHA256, - KdfIterations = 1, - KdfMemory = 2, - KdfParallelism = 3 - }); - - var user3 = await userRepository.CreateAsync(new User - { - Name = "Test User 2", - Email = $"test+{id}@{domainName}.example.com", // Different domain - ApiKey = "TEST", - SecurityStamp = "stamp", - Kdf = KdfType.PBKDF2_SHA256, - KdfIterations = 1, - KdfMemory = 2, - KdfParallelism = 3 - }); - - var organization = await organizationRepository.CreateAsync(new Organization - { - Name = $"Test Org {id}", - BillingEmail = user1.Email, // TODO: EF does not enforce this being NOT NULL - Plan = "Test", // TODO: EF does not enforce this being NOT NULL - PrivateKey = "privatekey", - }); - - var organizationDomain = new OrganizationDomain - { - OrganizationId = organization.Id, - DomainName = domainName, - Txt = "btw+12345", - }; - organizationDomain.SetVerifiedDate(); - organizationDomain.SetNextRunDate(12); - organizationDomain.SetJobRunCount(); - await organizationDomainRepository.CreateAsync(organizationDomain); - - await organizationUserRepository.CreateAsync(new OrganizationUser - { - OrganizationId = organization.Id, - UserId = user1.Id, - Status = OrganizationUserStatusType.Confirmed, - ResetPasswordKey = "resetpasswordkey1", - }); - - await organizationUserRepository.CreateAsync(new OrganizationUser - { - OrganizationId = organization.Id, - UserId = user2.Id, - Status = OrganizationUserStatusType.Confirmed, - ResetPasswordKey = "resetpasswordkey1", - }); - - await organizationUserRepository.CreateAsync(new OrganizationUser - { - OrganizationId = organization.Id, - UserId = user3.Id, - Status = OrganizationUserStatusType.Confirmed, - ResetPasswordKey = "resetpasswordkey1", - }); - - var user1Response = await organizationRepository.GetByVerifiedUserEmailDomainAsync(user1.Id); - var user2Response = await organizationRepository.GetByVerifiedUserEmailDomainAsync(user2.Id); - var user3Response = await organizationRepository.GetByVerifiedUserEmailDomainAsync(user3.Id); - - Assert.NotEmpty(user1Response); - Assert.Equal(organization.Id, user1Response.First().Id); - Assert.Empty(user2Response); - Assert.Empty(user3Response); - } - - [DatabaseTheory, DatabaseData] - public async Task GetByVerifiedUserEmailDomainAsync_WithUnverifiedDomains_ReturnsEmpty( - IUserRepository userRepository, - IOrganizationRepository organizationRepository, - IOrganizationUserRepository organizationUserRepository, - IOrganizationDomainRepository organizationDomainRepository) - { - var id = Guid.NewGuid(); - var domainName = $"{id}.example.com"; - - var user = await userRepository.CreateAsync(new User - { - Name = "Test User", - Email = $"test+{id}@{domainName}", - ApiKey = "TEST", - SecurityStamp = "stamp", - Kdf = KdfType.PBKDF2_SHA256, - KdfIterations = 1, - KdfMemory = 2, - KdfParallelism = 3 - }); - - var organization = await organizationRepository.CreateAsync(new Organization - { - Name = $"Test Org {id}", - BillingEmail = user.Email, - Plan = "Test", - PrivateKey = "privatekey", - }); - - var organizationDomain = new OrganizationDomain - { - OrganizationId = organization.Id, - DomainName = domainName, - Txt = "btw+12345", - }; - organizationDomain.SetNextRunDate(12); - organizationDomain.SetJobRunCount(); - await organizationDomainRepository.CreateAsync(organizationDomain); - - await organizationUserRepository.CreateAsync(new OrganizationUser - { - OrganizationId = organization.Id, - UserId = user.Id, - Status = OrganizationUserStatusType.Confirmed, - ResetPasswordKey = "resetpasswordkey", - }); - - var result = await organizationRepository.GetByVerifiedUserEmailDomainAsync(user.Id); - - Assert.Empty(result); - } - - [DatabaseTheory, DatabaseData] - public async Task GetByVerifiedUserEmailDomainAsync_WithMultipleVerifiedDomains_ReturnsAllMatchingOrganizations( - IUserRepository userRepository, - IOrganizationRepository organizationRepository, - IOrganizationUserRepository organizationUserRepository, - IOrganizationDomainRepository organizationDomainRepository) - { - var id = Guid.NewGuid(); - var domainName = $"{id}.example.com"; - - var user = await userRepository.CreateAsync(new User - { - Name = "Test User", - Email = $"test+{id}@{domainName}", - ApiKey = "TEST", - SecurityStamp = "stamp", - Kdf = KdfType.PBKDF2_SHA256, - KdfIterations = 1, - KdfMemory = 2, - KdfParallelism = 3 - }); - - var organization1 = await organizationRepository.CreateAsync(new Organization - { - Name = $"Test Org 1 {id}", - BillingEmail = user.Email, - Plan = "Test", - PrivateKey = "privatekey1", - }); - - var organization2 = await organizationRepository.CreateAsync(new Organization - { - Name = $"Test Org 2 {id}", - BillingEmail = user.Email, - Plan = "Test", - PrivateKey = "privatekey2", - }); - - var organizationDomain1 = new OrganizationDomain - { - OrganizationId = organization1.Id, - DomainName = domainName, - Txt = "btw+12345", - }; - organizationDomain1.SetNextRunDate(12); - organizationDomain1.SetJobRunCount(); - organizationDomain1.SetVerifiedDate(); - await organizationDomainRepository.CreateAsync(organizationDomain1); - - var organizationDomain2 = new OrganizationDomain - { - OrganizationId = organization2.Id, - DomainName = domainName, - Txt = "btw+67890", - }; - organizationDomain2.SetNextRunDate(12); - organizationDomain2.SetJobRunCount(); - organizationDomain2.SetVerifiedDate(); - await organizationDomainRepository.CreateAsync(organizationDomain2); - - await organizationUserRepository.CreateAsync(new OrganizationUser - { - OrganizationId = organization1.Id, - UserId = user.Id, - Status = OrganizationUserStatusType.Confirmed, - ResetPasswordKey = "resetpasswordkey1", - }); - - await organizationUserRepository.CreateAsync(new OrganizationUser - { - OrganizationId = organization2.Id, - UserId = user.Id, - Status = OrganizationUserStatusType.Confirmed, - ResetPasswordKey = "resetpasswordkey2", - }); - - var result = await organizationRepository.GetByVerifiedUserEmailDomainAsync(user.Id); - - Assert.Equal(2, result.Count); - Assert.Contains(result, org => org.Id == organization1.Id); - Assert.Contains(result, org => org.Id == organization2.Id); - } - - [DatabaseTheory, DatabaseData] - public async Task GetByVerifiedUserEmailDomainAsync_WithNonExistentUser_ReturnsEmpty( - IOrganizationRepository organizationRepository) - { - var nonExistentUserId = Guid.NewGuid(); - - var result = await organizationRepository.GetByVerifiedUserEmailDomainAsync(nonExistentUserId); - - Assert.Empty(result); - } - - - [DatabaseTheory, DatabaseData] + [Theory, DatabaseData] public async Task GetManyByIdsAsync_ExistingOrganizations_ReturnsOrganizations(IOrganizationRepository organizationRepository) { var email = "test@email.com"; @@ -287,7 +40,7 @@ public class OrganizationRepositoryTests await organizationRepository.DeleteAsync(organization2); } - [DatabaseTheory, DatabaseData] + [Theory, DatabaseData] public async Task GetOccupiedSeatCountByOrganizationIdAsync_WithUsersAndSponsorships_ReturnsCorrectCounts( IUserRepository userRepository, IOrganizationRepository organizationRepository, @@ -356,7 +109,7 @@ public class OrganizationRepositoryTests Assert.Equal(4, result.Total); // Total occupied seats } - [DatabaseTheory, DatabaseData] + [Theory, DatabaseData] public async Task GetOccupiedSeatCountByOrganizationIdAsync_WithNoUsersOrSponsorships_ReturnsZero( IOrganizationRepository organizationRepository) { @@ -372,7 +125,7 @@ public class OrganizationRepositoryTests Assert.Equal(0, result.Total); } - [DatabaseTheory, DatabaseData] + [Theory, DatabaseData] public async Task GetOccupiedSeatCountByOrganizationIdAsync_WithOnlyRevokedUsers_ReturnsZero( IUserRepository userRepository, IOrganizationRepository organizationRepository, @@ -399,7 +152,7 @@ public class OrganizationRepositoryTests Assert.Equal(0, result.Total); } - [DatabaseTheory, DatabaseData] + [Theory, DatabaseData] public async Task GetOccupiedSeatCountByOrganizationIdAsync_WithOnlyExpiredSponsorships_ReturnsZero( IOrganizationRepository organizationRepository, IOrganizationSponsorshipRepository organizationSponsorshipRepository) @@ -424,7 +177,7 @@ public class OrganizationRepositoryTests Assert.Equal(0, result.Total); } - [DatabaseTheory, DatabaseData] + [Theory, DatabaseData] public async Task IncrementSeatCountAsync_IncrementsSeatCount(IOrganizationRepository organizationRepository) { var organization = await organizationRepository.CreateTestOrganizationAsync(); @@ -438,7 +191,7 @@ public class OrganizationRepositoryTests Assert.Equal(8, result.Seats); } - [DatabaseData, DatabaseTheory] + [DatabaseData, Theory] public async Task IncrementSeatCountAsync_GivenOrganizationHasNotChangedSeatCountBefore_WhenUpdatingOrgSeats_ThenSubscriptionUpdateIsSaved( IOrganizationRepository sutRepository) { @@ -462,7 +215,7 @@ public class OrganizationRepositoryTests await sutRepository.DeleteAsync(organization); } - [DatabaseData, DatabaseTheory] + [DatabaseData, Theory] public async Task IncrementSeatCountAsync_GivenOrganizationHasChangedSeatCountBeforeAndRecordExists_WhenUpdatingOrgSeats_ThenSubscriptionUpdateIsSaved( IOrganizationRepository sutRepository) { @@ -487,7 +240,7 @@ public class OrganizationRepositoryTests await sutRepository.DeleteAsync(organization); } - [DatabaseData, DatabaseTheory] + [DatabaseData, Theory] public async Task GetOrganizationsForSubscriptionSyncAsync_GivenOrganizationHasChangedSeatCount_WhenGettingOrgsToUpdate_ThenReturnsOrgSubscriptionUpdate( IOrganizationRepository sutRepository) { @@ -510,7 +263,7 @@ public class OrganizationRepositoryTests await sutRepository.DeleteAsync(organization); } - [DatabaseData, DatabaseTheory] + [DatabaseData, Theory] public async Task UpdateSuccessfulOrganizationSyncStatusAsync_GivenOrganizationHasChangedSeatCount_WhenUpdatingStatus_ThenSuccessfullyUpdatesOrgSoItDoesntSync( IOrganizationRepository sutRepository) { diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/GetManyByOrganizationWithClaimedDomainsAsyncTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/GetManyByOrganizationWithClaimedDomainsAsyncTests.cs new file mode 100644 index 0000000000..6fa395751b --- /dev/null +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/GetManyByOrganizationWithClaimedDomainsAsyncTests.cs @@ -0,0 +1,197 @@ +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Xunit; + +namespace Bit.Infrastructure.IntegrationTest.AdminConsole.Repositories.OrganizationUserRepository; + +public class GetManyByOrganizationWithClaimedDomainsAsyncTests +{ + [Theory, DatabaseData] + public async Task WithVerifiedDomain_WithOneMatchingEmailDomain_ReturnsSingle( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + IOrganizationDomainRepository organizationDomainRepository) + { + var id = Guid.NewGuid(); + var domainName = $"{id}.example.com"; + + var user1 = await userRepository.CreateAsync(new User + { + Name = "Test User 1", + Email = $"test+{id}@{domainName}", + ApiKey = "TEST", + SecurityStamp = "stamp", + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = 1, + KdfMemory = 2, + KdfParallelism = 3 + }); + + var user2 = await userRepository.CreateAsync(new User + { + Name = "Test User 2", + Email = $"test+{id}@x-{domainName}", // Different domain + ApiKey = "TEST", + SecurityStamp = "stamp", + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = 1, + KdfMemory = 2, + KdfParallelism = 3 + }); + + var user3 = await userRepository.CreateAsync(new User + { + Name = "Test User 3", + Email = $"test+{id}@{domainName}.example.com", // Different domain + ApiKey = "TEST", + SecurityStamp = "stamp", + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = 1, + KdfMemory = 2, + KdfParallelism = 3 + }); + + var organization = await organizationRepository.CreateTestOrganizationAsync(); + + var organizationDomain = new OrganizationDomain + { + OrganizationId = organization.Id, + DomainName = domainName, + Txt = "btw+12345", + }; + organizationDomain.SetVerifiedDate(); + organizationDomain.SetNextRunDate(12); + organizationDomain.SetJobRunCount(); + await organizationDomainRepository.CreateAsync(organizationDomain); + + var orgUser1 = await organizationUserRepository.CreateConfirmedTestOrganizationUserAsync(organization, user1); + await organizationUserRepository.CreateConfirmedTestOrganizationUserAsync(organization, user2); + await organizationUserRepository.CreateConfirmedTestOrganizationUserAsync(organization, user3); + + var result = await organizationUserRepository.GetManyByOrganizationWithClaimedDomainsAsync(organization.Id); + + Assert.NotNull(result); + Assert.Single(result); + Assert.Equal(orgUser1.Id, result.Single().Id); + } + + [Theory, DatabaseData] + public async Task WithNoVerifiedDomain_ReturnsEmpty( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + IOrganizationDomainRepository organizationDomainRepository) + { + var id = Guid.NewGuid(); + var domainName = $"{id}.example.com"; + + var user = await userRepository.CreateAsync(new User + { + Name = "Test User", + Email = $"test+{id}@{domainName}", + ApiKey = "TEST", + SecurityStamp = "stamp", + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = 1, + KdfMemory = 2, + KdfParallelism = 3 + }); + + var organization = await organizationRepository.CreateTestOrganizationAsync(); + + // Create domain but do NOT verify it + var organizationDomain = new OrganizationDomain + { + OrganizationId = organization.Id, + DomainName = domainName, + Txt = "btw+12345", + }; + organizationDomain.SetNextRunDate(12); + // Note: NOT calling SetVerifiedDate() + await organizationDomainRepository.CreateAsync(organizationDomain); + + await organizationUserRepository.CreateConfirmedTestOrganizationUserAsync(organization, user); + + var result = await organizationUserRepository.GetManyByOrganizationWithClaimedDomainsAsync(organization.Id); + + Assert.NotNull(result); + Assert.Empty(result); + } + + /// + /// Tests an edge case where some invited users are created linked to a UserId. + /// This is defective behavior, but will take longer to fix - for now, we are defensive and expressly + /// exclude such users from the results without relying on the inner join only. + /// Invited-revoked users linked to a UserId remain intentionally unhandled for now as they have not caused + /// any issues to date and we want to minimize edge cases. + /// We will fix the underlying issue going forward: https://bitwarden.atlassian.net/browse/PM-22405 + /// + [Theory, DatabaseData] + public async Task WithVerifiedDomain_ExcludesInvitedUsers( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + IOrganizationDomainRepository organizationDomainRepository) + { + var id = Guid.NewGuid(); + var domainName = $"{id}.example.com"; + + var invitedUser = await userRepository.CreateAsync(new User + { + Name = "Invited User", + Email = $"invited+{id}@{domainName}", + ApiKey = "TEST", + SecurityStamp = "stamp", + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = 1, + KdfMemory = 2, + KdfParallelism = 3 + }); + + var confirmedUser = await userRepository.CreateAsync(new User + { + Name = "Confirmed User", + Email = $"confirmed+{id}@{domainName}", + ApiKey = "TEST", + SecurityStamp = "stamp", + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = 1, + KdfMemory = 2, + KdfParallelism = 3 + }); + + var organization = await organizationRepository.CreateTestOrganizationAsync(); + + var organizationDomain = new OrganizationDomain + { + OrganizationId = organization.Id, + DomainName = domainName, + Txt = "btw+12345", + }; + organizationDomain.SetVerifiedDate(); + organizationDomain.SetNextRunDate(12); + organizationDomain.SetJobRunCount(); + await organizationDomainRepository.CreateAsync(organizationDomain); + + // Create invited user with UserId set (edge case - should be excluded even with UserId linked) + var invitedOrgUser = await organizationUserRepository.CreateAsync(new OrganizationUser + { + OrganizationId = organization.Id, + UserId = invitedUser.Id, // Edge case: invited user with UserId set + Email = invitedUser.Email, + Status = OrganizationUserStatusType.Invited, + Type = OrganizationUserType.User + }); + + // Create confirmed user linked by UserId only (no Email field set) + var confirmedOrgUser = await organizationUserRepository.CreateConfirmedTestOrganizationUserAsync(organization, confirmedUser); + + var result = await organizationUserRepository.GetManyByOrganizationWithClaimedDomainsAsync(organization.Id); + + Assert.NotNull(result); + var claimedUser = Assert.Single(result); + Assert.Equal(confirmedOrgUser.Id, claimedUser.Id); + } +} diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserRepositoryTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserRepositoryTests.cs index 1c433d0e6e..b77406abf5 100644 --- a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserRepositoryTests.cs @@ -599,136 +599,6 @@ public class OrganizationUserRepositoryTests Assert.Null(orgWithoutSsoDetails.SsoConfig); } - [DatabaseTheory, DatabaseData] - public async Task GetManyByOrganizationWithClaimedDomainsAsync_WithVerifiedDomain_WithOneMatchingEmailDomain_ReturnsSingle( - IUserRepository userRepository, - IOrganizationRepository organizationRepository, - IOrganizationUserRepository organizationUserRepository, - IOrganizationDomainRepository organizationDomainRepository) - { - var id = Guid.NewGuid(); - var domainName = $"{id}.example.com"; - - var user1 = await userRepository.CreateAsync(new User - { - Name = "Test User 1", - Email = $"test+{id}@{domainName}", - ApiKey = "TEST", - SecurityStamp = "stamp", - Kdf = KdfType.PBKDF2_SHA256, - KdfIterations = 1, - KdfMemory = 2, - KdfParallelism = 3 - }); - - var user2 = await userRepository.CreateAsync(new User - { - Name = "Test User 2", - Email = $"test+{id}@x-{domainName}", // Different domain - ApiKey = "TEST", - SecurityStamp = "stamp", - Kdf = KdfType.PBKDF2_SHA256, - KdfIterations = 1, - KdfMemory = 2, - KdfParallelism = 3 - }); - - var user3 = await userRepository.CreateAsync(new User - { - Name = "Test User 2", - Email = $"test+{id}@{domainName}.example.com", // Different domain - ApiKey = "TEST", - SecurityStamp = "stamp", - Kdf = KdfType.PBKDF2_SHA256, - KdfIterations = 1, - KdfMemory = 2, - KdfParallelism = 3 - }); - - var organization = await organizationRepository.CreateAsync(new Organization - { - Name = $"Test Org {id}", - BillingEmail = user1.Email, // TODO: EF does not enforce this being NOT NULL - Plan = "Test", // TODO: EF does not enforce this being NOT NULL - PrivateKey = "privatekey", - UsePolicies = false, - UseSso = false, - UseKeyConnector = false, - UseScim = false, - UseGroups = false, - UseDirectory = false, - UseEvents = false, - UseTotp = false, - Use2fa = false, - UseApi = false, - UseResetPassword = false, - UseSecretsManager = false, - SelfHost = false, - UsersGetPremium = false, - UseCustomPermissions = false, - Enabled = true, - UsePasswordManager = false, - LimitCollectionCreation = false, - LimitCollectionDeletion = false, - LimitItemDeletion = false, - AllowAdminAccessToAllCollectionItems = false, - UseRiskInsights = false, - UseAdminSponsoredFamilies = false, - UsePhishingBlocker = false, - UseDisableSmAdsForUsers = false, - }); - - var organizationDomain = new OrganizationDomain - { - OrganizationId = organization.Id, - DomainName = domainName, - Txt = "btw+12345", - }; - organizationDomain.SetVerifiedDate(); - organizationDomain.SetNextRunDate(12); - organizationDomain.SetJobRunCount(); - await organizationDomainRepository.CreateAsync(organizationDomain); - - var orgUser1 = await organizationUserRepository.CreateAsync(new OrganizationUser - { - Id = CoreHelpers.GenerateComb(), - OrganizationId = organization.Id, - UserId = user1.Id, - Status = OrganizationUserStatusType.Confirmed, - Type = OrganizationUserType.Owner, - ResetPasswordKey = "resetpasswordkey1", - AccessSecretsManager = false - }); - - await organizationUserRepository.CreateAsync(new OrganizationUser - { - Id = CoreHelpers.GenerateComb(), - OrganizationId = organization.Id, - UserId = user2.Id, - Status = OrganizationUserStatusType.Confirmed, - Type = OrganizationUserType.User, - ResetPasswordKey = "resetpasswordkey1", - AccessSecretsManager = false - }); - - await organizationUserRepository.CreateAsync(new OrganizationUser - { - Id = CoreHelpers.GenerateComb(), - OrganizationId = organization.Id, - UserId = user3.Id, - Status = OrganizationUserStatusType.Confirmed, - Type = OrganizationUserType.User, - ResetPasswordKey = "resetpasswordkey1", - AccessSecretsManager = false - }); - - var responseModel = await organizationUserRepository.GetManyByOrganizationWithClaimedDomainsAsync(organization.Id); - - Assert.NotNull(responseModel); - Assert.Single(responseModel); - Assert.Equal(orgUser1.Id, responseModel.Single().Id); - } - [DatabaseTheory, DatabaseData] public async Task CreateManyAsync_NoId_Works(IOrganizationRepository organizationRepository, IUserRepository userRepository, @@ -1237,70 +1107,6 @@ public class OrganizationUserRepositoryTests Assert.DoesNotContain(user1Result.Collections, c => c.Id == defaultUserCollection.Id); } - [DatabaseTheory, DatabaseData] - public async Task GetManyByOrganizationWithClaimedDomainsAsync_WithNoVerifiedDomain_ReturnsEmpty( - IUserRepository userRepository, - IOrganizationRepository organizationRepository, - IOrganizationUserRepository organizationUserRepository, - IOrganizationDomainRepository organizationDomainRepository) - { - var id = Guid.NewGuid(); - var domainName = $"{id}.example.com"; - var requestTime = DateTime.UtcNow; - - var user1 = await userRepository.CreateAsync(new User - { - Id = CoreHelpers.GenerateComb(), - Name = "Test User 1", - Email = $"test+{id}@{domainName}", - ApiKey = "TEST", - SecurityStamp = "stamp", - CreationDate = requestTime, - RevisionDate = requestTime, - AccountRevisionDate = requestTime - }); - - var organization = await organizationRepository.CreateAsync(new Organization - { - Id = CoreHelpers.GenerateComb(), - Name = $"Test Org {id}", - BillingEmail = user1.Email, - Plan = "Test", - Enabled = true, - CreationDate = requestTime, - RevisionDate = requestTime - }); - - // Create domain but do NOT verify it - var organizationDomain = new OrganizationDomain - { - Id = CoreHelpers.GenerateComb(), - OrganizationId = organization.Id, - DomainName = domainName, - Txt = "btw+12345", - CreationDate = requestTime - }; - organizationDomain.SetNextRunDate(12); - // Note: NOT calling SetVerifiedDate() - await organizationDomainRepository.CreateAsync(organizationDomain); - - await organizationUserRepository.CreateAsync(new OrganizationUser - { - Id = CoreHelpers.GenerateComb(), - OrganizationId = organization.Id, - UserId = user1.Id, - Status = OrganizationUserStatusType.Confirmed, - Type = OrganizationUserType.Owner, - CreationDate = requestTime, - RevisionDate = requestTime - }); - - var responseModel = await organizationUserRepository.GetManyByOrganizationWithClaimedDomainsAsync(organization.Id); - - Assert.NotNull(responseModel); - Assert.Empty(responseModel); - } - [DatabaseTheory, DatabaseData] public async Task DeleteAsync_WithNullEmail_DoesNotSetDefaultUserCollectionEmail(IUserRepository userRepository, ICollectionRepository collectionRepository, diff --git a/util/Migrator/DbScripts/2026-01-14_00_ExcludeInvitedUsersFromClaimedDomain.sql b/util/Migrator/DbScripts/2026-01-14_00_ExcludeInvitedUsersFromClaimedDomain.sql new file mode 100644 index 0000000000..788fa02b7c --- /dev/null +++ b/util/Migrator/DbScripts/2026-01-14_00_ExcludeInvitedUsersFromClaimedDomain.sql @@ -0,0 +1,24 @@ +CREATE OR ALTER PROCEDURE [dbo].[Organization_ReadByClaimedUserEmailDomain] + @UserId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON; + + WITH CTE_User AS ( + SELECT + U.[Id], + SUBSTRING(U.Email, CHARINDEX('@', U.Email) + 1, LEN(U.Email)) AS EmailDomain + FROM dbo.[UserView] U + WHERE U.[Id] = @UserId + ) + SELECT O.* + FROM CTE_User CU + INNER JOIN dbo.[OrganizationUserView] OU ON CU.[Id] = OU.[UserId] + INNER JOIN dbo.[OrganizationView] O ON OU.[OrganizationId] = O.[Id] + INNER JOIN dbo.[OrganizationDomainView] OD ON OU.[OrganizationId] = OD.[OrganizationId] + WHERE OD.[VerifiedDate] IS NOT NULL + AND CU.EmailDomain = OD.[DomainName] + AND O.[Enabled] = 1 + AND OU.[Status] != 0 -- Exclude invited users +END +GO diff --git a/util/Migrator/DbScripts/2026-01-14_01_ExcludeInvitedUsersFromClaimedDomains_V2.sql b/util/Migrator/DbScripts/2026-01-14_01_ExcludeInvitedUsersFromClaimedDomains_V2.sql new file mode 100644 index 0000000000..b7be5fd0e0 --- /dev/null +++ b/util/Migrator/DbScripts/2026-01-14_01_ExcludeInvitedUsersFromClaimedDomains_V2.sql @@ -0,0 +1,29 @@ +CREATE OR ALTER PROCEDURE [dbo].[OrganizationUser_ReadByOrganizationIdWithClaimedDomains_V2] + @OrganizationId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON; + + WITH OrgUsers AS ( + SELECT * + FROM [dbo].[OrganizationUserView] + WHERE [OrganizationId] = @OrganizationId + AND [Status] != 0 -- Exclude invited users + ), + UserDomains AS ( + SELECT U.[Id], U.[EmailDomain] + FROM [dbo].[UserEmailDomainView] U + WHERE EXISTS ( + SELECT 1 + FROM [dbo].[OrganizationDomainView] OD + WHERE OD.[OrganizationId] = @OrganizationId + AND OD.[VerifiedDate] IS NOT NULL + AND OD.[DomainName] = U.[EmailDomain] + ) + ) + SELECT OU.* + FROM OrgUsers OU + JOIN UserDomains UD ON OU.[UserId] = UD.[Id] + OPTION (RECOMPILE); +END +GO From c37412bacbdbbe07027ac26cda750ba4ec9be736 Mon Sep 17 00:00:00 2001 From: Todd Martin <106564991+trmartin4@users.noreply.github.com> Date: Tue, 20 Jan 2026 10:03:33 -0500 Subject: [PATCH 33/49] chore(flags): Remove pm-1632-redirect-on-sso-required feature flag * Remove feature flag. * Update test title. * Fixed some test failures. * Fixed tests * Removed method that's no longer used. * Removed unneeded directive. --- src/Core/Constants.cs | 1 - .../RequestValidators/BaseRequestValidator.cs | 91 +--- .../CustomTokenRequestValidator.cs | 11 - .../ResourceOwnerPasswordValidator.cs | 8 - .../WebAuthnGrantValidator.cs | 8 - .../BaseRequestValidatorTests.cs | 396 ++++++------------ .../BaseRequestValidatorTestWrapper.cs | 9 - 7 files changed, 144 insertions(+), 380 deletions(-) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 6f42778b6b..10c68ddc42 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -162,7 +162,6 @@ public static class FeatureFlagKeys public const string MjmlWelcomeEmailTemplates = "pm-21741-mjml-welcome-email"; public const string OrganizationConfirmationEmail = "pm-28402-update-confirmed-to-org-email-template"; public const string MarketingInitiatedPremiumFlow = "pm-26140-marketing-initiated-premium-flow"; - public const string RedirectOnSsoRequired = "pm-1632-redirect-on-sso-required"; public const string PrefetchPasswordPrelogin = "pm-23801-prefetch-password-prelogin"; public const string PM27086_UpdateAuthenticationApisForInputPassword = "pm-27086-update-authentication-apis-for-input-password"; diff --git a/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs index e07446d49f..289feebdb2 100644 --- a/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs @@ -4,7 +4,6 @@ using System.Security.Claims; using Bit.Core; -using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.Entities; @@ -233,56 +232,14 @@ public abstract class BaseRequestValidator where T : class private async Task ValidateSsoAsync(T context, ValidatedTokenRequest request, CustomValidatorRequestContext validatorContext) { - // TODO: Clean up Feature Flag: Remove this if block: PM-28281 - if (!_featureService.IsEnabled(FeatureFlagKeys.RedirectOnSsoRequired)) + var ssoValid = await _ssoRequestValidator.ValidateAsync(validatorContext.User, request, validatorContext); + if (ssoValid) { - validatorContext.SsoRequired = await RequireSsoLoginAsync(validatorContext.User, request.GrantType); - if (!validatorContext.SsoRequired) - { - return true; - } - - // Users without SSO requirement requesting 2FA recovery will be fast-forwarded through login and are - // presented with their 2FA management area as a reminder to re-evaluate their 2FA posture after recovery and - // review their new recovery token if desired. - // SSO users cannot be assumed to be authenticated, and must prove authentication with their IdP after recovery. - // As described in validation order determination, if TwoFactorRequired, the 2FA validation scheme will have been - // evaluated, and recovery will have been performed if requested. - // We will send a descriptive message in these cases so clients can give the appropriate feedback and redirect - // to /login. - if (validatorContext.TwoFactorRequired && - validatorContext.TwoFactorRecoveryRequested) - { - SetSsoResult(context, - new Dictionary - { - { - "ErrorModel", - new ErrorResponseModel( - "Two-factor recovery has been performed. SSO authentication is required.") - } - }); - return false; - } - - SetSsoResult(context, - new Dictionary - { - { "ErrorModel", new ErrorResponseModel("SSO authentication is required.") } - }); - return false; + return true; } - else - { - var ssoValid = await _ssoRequestValidator.ValidateAsync(validatorContext.User, request, validatorContext); - if (ssoValid) - { - return true; - } - SetValidationErrorResult(context, validatorContext); - return ssoValid; - } + SetValidationErrorResult(context, validatorContext); + return ssoValid; } /// @@ -521,9 +478,6 @@ public abstract class BaseRequestValidator where T : class [Obsolete("Consider using SetValidationErrorResult instead.")] protected abstract void SetTwoFactorResult(T context, Dictionary customResponse); - [Obsolete("Consider using SetValidationErrorResult instead.")] - protected abstract void SetSsoResult(T context, Dictionary customResponse); - [Obsolete("Consider using SetValidationErrorResult instead.")] protected abstract void SetErrorResult(T context, Dictionary customResponse); @@ -540,41 +494,6 @@ public abstract class BaseRequestValidator where T : class protected abstract ClaimsPrincipal GetSubject(T context); - /// - /// Check if the user is required to authenticate via SSO. If the user requires SSO, but they are - /// logging in using an API Key (client_credentials) then they are allowed to bypass the SSO requirement. - /// If the GrantType is authorization_code or client_credentials we know the user is trying to login - /// using the SSO flow so they are allowed to continue. - /// - /// user trying to login - /// magic string identifying the grant type requested - /// true if sso required; false if not required or already in process - [Obsolete( - "This method is deprecated and will be removed in future versions, PM-28281. Please use the SsoRequestValidator scheme instead.")] - private async Task RequireSsoLoginAsync(User user, string grantType) - { - if (grantType == "authorization_code" || grantType == "client_credentials") - { - // Already using SSO to authenticate, or logging-in via api key to skip SSO requirement - // allow to authenticate successfully - return false; - } - - // Check if user belongs to any organization with an active SSO policy - var ssoRequired = _featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements) - ? (await PolicyRequirementQuery.GetAsync(user.Id)) - .SsoRequired - : await PolicyService.AnyPoliciesApplicableToUserAsync( - user.Id, PolicyType.RequireSso, OrganizationUserStatusType.Confirmed); - if (ssoRequired) - { - return true; - } - - // Default - SSO is not required - return false; - } - private async Task ResetFailedAuthDetailsAsync(User user) { // Early escape if db hit not necessary diff --git a/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs index 38a4813ecd..2412c52308 100644 --- a/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs @@ -194,17 +194,6 @@ public class CustomTokenRequestValidator : BaseRequestValidator customResponse) - { - Debug.Assert(context.Result is not null); - context.Result.Error = "invalid_grant"; - context.Result.ErrorDescription = "Sso authentication required."; - context.Result.IsError = true; - context.Result.CustomResponse = customResponse; - } - [Obsolete("Consider using SetGrantValidationErrorResult instead.")] protected override void SetErrorResult(CustomTokenRequestValidationContext context, Dictionary customResponse) diff --git a/src/Identity/IdentityServer/RequestValidators/ResourceOwnerPasswordValidator.cs b/src/Identity/IdentityServer/RequestValidators/ResourceOwnerPasswordValidator.cs index ea2c021f63..8bfddf24f3 100644 --- a/src/Identity/IdentityServer/RequestValidators/ResourceOwnerPasswordValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/ResourceOwnerPasswordValidator.cs @@ -152,14 +152,6 @@ public class ResourceOwnerPasswordValidator : BaseRequestValidator customResponse) - { - context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant, "Sso authentication required.", - customResponse); - } - [Obsolete("Consider using SetGrantValidationErrorResult instead.")] protected override void SetErrorResult(ResourceOwnerPasswordValidationContext context, Dictionary customResponse) diff --git a/src/Identity/IdentityServer/RequestValidators/WebAuthnGrantValidator.cs b/src/Identity/IdentityServer/RequestValidators/WebAuthnGrantValidator.cs index e4cd60827e..1563831b81 100644 --- a/src/Identity/IdentityServer/RequestValidators/WebAuthnGrantValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/WebAuthnGrantValidator.cs @@ -142,14 +142,6 @@ public class WebAuthnGrantValidator : BaseRequestValidator customResponse) - { - context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant, "Sso authentication required.", - customResponse); - } - [Obsolete("Consider using SetValidationErrorResult instead.")] protected override void SetErrorResult(ExtensionGrantValidationContext context, Dictionary customResponse) { diff --git a/test/Identity.Test/IdentityServer/BaseRequestValidatorTests.cs b/test/Identity.Test/IdentityServer/BaseRequestValidatorTests.cs index 677382b138..4b6f681096 100644 --- a/test/Identity.Test/IdentityServer/BaseRequestValidatorTests.cs +++ b/test/Identity.Test/IdentityServer/BaseRequestValidatorTests.cs @@ -18,6 +18,7 @@ using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; using Bit.Identity.IdentityServer; +using Bit.Identity.IdentityServer.RequestValidationConstants; using Bit.Identity.IdentityServer.RequestValidators; using Bit.Identity.Test.Wrappers; using Bit.Test.Common.AutoFixture.Attributes; @@ -130,7 +131,7 @@ public class BaseRequestValidatorTests var logs = _logger.Collector.GetSnapshot(true); Assert.Contains(logs, l => l.Level == LogLevel.Warning && l.Message == "Failed login attempt. Is2FARequest: False IpAddress: "); - var errorResponse = (ErrorResponseModel)context.GrantResult.CustomResponse["ErrorModel"]; + var errorResponse = (ErrorResponseModel)context.GrantResult.CustomResponse[CustomResponseConstants.ResponseKeys.ErrorModel]; Assert.Equal("Username or password is incorrect. Try again.", errorResponse.Message); } @@ -161,7 +162,11 @@ public class BaseRequestValidatorTests .ValidateRequestDeviceAsync(tokenRequest, requestContext) .Returns(Task.FromResult(false)); - // 5 -> not legacy user + // 5 -> SSO not required + _ssoRequestValidator.ValidateAsync(requestContext.User, tokenRequest, requestContext) + .Returns(Task.FromResult(true)); + + // 6 -> not legacy user _userService.IsLegacyUser(Arg.Any()) .Returns(false); @@ -203,6 +208,11 @@ public class BaseRequestValidatorTests _userService.IsLegacyUser(Arg.Any()) .Returns(false); + // 6 -> SSO validation passes + _ssoRequestValidator.ValidateAsync(requestContext.User, tokenRequest, requestContext) + .Returns(Task.FromResult(true)); + + // 7 -> setup user account keys _userAccountKeysQuery.Run(Arg.Any()).Returns(new UserAccountKeysData { PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData( @@ -262,6 +272,11 @@ public class BaseRequestValidatorTests _userService.IsLegacyUser(Arg.Any()) .Returns(false); + // 6 -> SSO validation passes + _ssoRequestValidator.ValidateAsync(requestContext.User, tokenRequest, requestContext) + .Returns(Task.FromResult(true)); + + // 7 -> setup user account keys _userAccountKeysQuery.Run(Arg.Any()).Returns(new UserAccountKeysData { PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData( @@ -326,6 +341,9 @@ public class BaseRequestValidatorTests { "TwoFactorProviders2", new Dictionary { { "Email", null } } } })); + _ssoRequestValidator.ValidateAsync(requestContext.User, tokenRequest, requestContext) + .Returns(Task.FromResult(true)); + // Act await _sut.ValidateAsync(context); @@ -368,6 +386,10 @@ public class BaseRequestValidatorTests .VerifyTwoFactorAsync(user, null, TwoFactorProviderType.Email, "invalid_token") .Returns(Task.FromResult(false)); + // 5 -> set up SSO required verification to succeed + _ssoRequestValidator.ValidateAsync(requestContext.User, tokenRequest, requestContext) + .Returns(Task.FromResult(true)); + // Act await _sut.ValidateAsync(context); @@ -396,21 +418,25 @@ public class BaseRequestValidatorTests // 1 -> initial validation passes _sut.isValid = true; - // 2 -> set up 2FA as required + // 2 -> set up SSO required verification to succeed + _ssoRequestValidator.ValidateAsync(requestContext.User, tokenRequest, requestContext) + .Returns(Task.FromResult(true)); + + // 3 -> set up 2FA as required _twoFactorAuthenticationValidator .RequiresTwoFactorAsync(Arg.Any(), tokenRequest) .Returns(Task.FromResult(new Tuple(true, null))); - // 3 -> provide invalid remember token (remember token expired) + // 4 -> provide invalid remember token (remember token expired) tokenRequest.Raw["TwoFactorToken"] = "expired_remember_token"; tokenRequest.Raw["TwoFactorProvider"] = "5"; // Remember provider - // 4 -> set up remember token verification to fail + // 5 -> set up remember token verification to fail _twoFactorAuthenticationValidator .VerifyTwoFactorAsync(user, null, TwoFactorProviderType.Remember, "expired_remember_token") .Returns(Task.FromResult(false)); - // 5 -> set up dummy BuildTwoFactorResultAsync + // 6 -> set up dummy BuildTwoFactorResultAsync var twoFactorResultDict = new Dictionary { { "TwoFactorProviders", new[] { "0", "1" } }, @@ -446,6 +472,19 @@ public class BaseRequestValidatorTests GrantValidationResult grantResult) { // Arrange + + // SsoRequestValidator sets custom response + requestContext.ValidationErrorResult = new ValidationResult + { + IsError = true, + Error = SsoConstants.RequestErrors.SsoRequired, + ErrorDescription = SsoConstants.RequestErrors.SsoRequiredDescription + }; + requestContext.CustomResponse = new Dictionary + { + { CustomResponseConstants.ResponseKeys.ErrorModel, new ErrorResponseModel(SsoConstants.RequestErrors.SsoRequiredDescription) }, + }; + var context = CreateContext(tokenRequest, requestContext, grantResult); _sut.isValid = true; @@ -454,13 +493,17 @@ public class BaseRequestValidatorTests Arg.Any(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed) .Returns(Task.FromResult(true)); + _ssoRequestValidator.ValidateAsync(requestContext.User, tokenRequest, requestContext) + .Returns(Task.FromResult(false)); + // Act await _sut.ValidateAsync(context); // Assert Assert.True(context.GrantResult.IsError); - var errorResponse = (ErrorResponseModel)context.GrantResult.CustomResponse["ErrorModel"]; - Assert.Equal("SSO authentication is required.", errorResponse.Message); + Assert.NotNull(context.GrantResult.CustomResponse); + var errorResponse = (ErrorResponseModel)context.CustomValidatorRequestContext.CustomResponse[CustomResponseConstants.ResponseKeys.ErrorModel]; + Assert.Equal(SsoConstants.RequestErrors.SsoRequiredDescription, errorResponse.Message); } // Test grantTypes with RequireSsoPolicyRequirement when feature flag is enabled @@ -477,6 +520,20 @@ public class BaseRequestValidatorTests { // Arrange _featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true); + + // SsoRequestValidator sets custom response with organization identifier + requestContext.ValidationErrorResult = new ValidationResult + { + IsError = true, + Error = SsoConstants.RequestErrors.SsoRequired, + ErrorDescription = SsoConstants.RequestErrors.SsoRequiredDescription + }; + requestContext.CustomResponse = new Dictionary + { + { CustomResponseConstants.ResponseKeys.ErrorModel, new ErrorResponseModel(SsoConstants.RequestErrors.SsoRequiredDescription) }, + { CustomResponseConstants.ResponseKeys.SsoOrganizationIdentifier, "test-org-identifier" } + }; + var context = CreateContext(tokenRequest, requestContext, grantResult); _sut.isValid = true; @@ -485,6 +542,10 @@ public class BaseRequestValidatorTests var requirement = new RequireSsoPolicyRequirement { SsoRequired = true }; _policyRequirementQuery.GetAsync(Arg.Any()).Returns(requirement); + // Mock the SSO validator to return false + _ssoRequestValidator.ValidateAsync(requestContext.User, tokenRequest, requestContext) + .Returns(Task.FromResult(false)); + // Act await _sut.ValidateAsync(context); @@ -492,8 +553,9 @@ public class BaseRequestValidatorTests await _policyService.DidNotReceive().AnyPoliciesApplicableToUserAsync( Arg.Any(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed); Assert.True(context.GrantResult.IsError); - var errorResponse = (ErrorResponseModel)context.GrantResult.CustomResponse["ErrorModel"]; - Assert.Equal("SSO authentication is required.", errorResponse.Message); + Assert.NotNull(context.GrantResult.CustomResponse); + var errorResponse = (ErrorResponseModel)context.CustomValidatorRequestContext.CustomResponse[CustomResponseConstants.ResponseKeys.ErrorModel]; + Assert.Equal(SsoConstants.RequestErrors.SsoRequiredDescription, errorResponse.Message); } [Theory] @@ -519,6 +581,10 @@ public class BaseRequestValidatorTests var requirement = new RequireSsoPolicyRequirement { SsoRequired = false }; _policyRequirementQuery.GetAsync(Arg.Any()).Returns(requirement); + // SSO validation passes + _ssoRequestValidator.ValidateAsync(requestContext.User, tokenRequest, requestContext) + .Returns(Task.FromResult(true)); + _twoFactorAuthenticationValidator.RequiresTwoFactorAsync(requestContext.User, tokenRequest) .Returns(Task.FromResult(new Tuple(false, null))); _deviceValidator.ValidateRequestDeviceAsync(tokenRequest, requestContext) @@ -561,6 +627,11 @@ public class BaseRequestValidatorTests _policyService.AnyPoliciesApplicableToUserAsync( Arg.Any(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed) .Returns(Task.FromResult(false)); + + // SSO validation passes + _ssoRequestValidator.ValidateAsync(requestContext.User, tokenRequest, requestContext) + .Returns(Task.FromResult(true)); + _twoFactorAuthenticationValidator.RequiresTwoFactorAsync(requestContext.User, tokenRequest) .Returns(Task.FromResult(new Tuple(false, null))); _deviceValidator.ValidateRequestDeviceAsync(tokenRequest, requestContext) @@ -603,6 +674,10 @@ public class BaseRequestValidatorTests context.ValidatedTokenRequest.GrantType = grantType; + // SSO validation passes + _ssoRequestValidator.ValidateAsync(requestContext.User, tokenRequest, requestContext) + .Returns(Task.FromResult(true)); + _twoFactorAuthenticationValidator.RequiresTwoFactorAsync(requestContext.User, tokenRequest) .Returns(Task.FromResult(new Tuple(false, null))); _deviceValidator.ValidateRequestDeviceAsync(tokenRequest, requestContext) @@ -652,13 +727,15 @@ public class BaseRequestValidatorTests .Returns(Task.FromResult(new Tuple(false, null))); _deviceValidator.ValidateRequestDeviceAsync(tokenRequest, requestContext) .Returns(Task.FromResult(true)); + _ssoRequestValidator.ValidateAsync(requestContext.User, tokenRequest, requestContext) + .Returns(Task.FromResult(true)); // Act await _sut.ValidateAsync(context); // Assert Assert.True(context.GrantResult.IsError); - var errorResponse = (ErrorResponseModel)context.GrantResult.CustomResponse["ErrorModel"]; + var errorResponse = (ErrorResponseModel)context.GrantResult.CustomResponse[CustomResponseConstants.ResponseKeys.ErrorModel]; var expectedMessage = "Legacy encryption without a userkey is no longer supported. To recover your account, please contact support"; Assert.Equal(expectedMessage, errorResponse.Message); @@ -694,6 +771,10 @@ public class BaseRequestValidatorTests var context = CreateContext(tokenRequest, requestContext, grantResult); _sut.isValid = true; + // SSO validation passes + _ssoRequestValidator.ValidateAsync(requestContext.User, tokenRequest, requestContext) + .Returns(Task.FromResult(true)); + _twoFactorAuthenticationValidator.RequiresTwoFactorAsync(requestContext.User, tokenRequest) .Returns(Task.FromResult(new Tuple(false, null))); _deviceValidator.ValidateRequestDeviceAsync(tokenRequest, requestContext) @@ -760,6 +841,8 @@ public class BaseRequestValidatorTests .Returns(Task.FromResult(new Tuple(false, null))); _deviceValidator.ValidateRequestDeviceAsync(tokenRequest, requestContext) .Returns(Task.FromResult(true)); + _ssoRequestValidator.ValidateAsync(requestContext.User, tokenRequest, requestContext) + .Returns(Task.FromResult(true)); // Act await _sut.ValidateAsync(context); @@ -833,6 +916,8 @@ public class BaseRequestValidatorTests .Returns(Task.FromResult(new Tuple(false, null))); _deviceValidator.ValidateRequestDeviceAsync(tokenRequest, requestContext) .Returns(Task.FromResult(true)); + _ssoRequestValidator.ValidateAsync(requestContext.User, tokenRequest, requestContext) + .Returns(Task.FromResult(true)); // Act await _sut.ValidateAsync(context); @@ -877,6 +962,8 @@ public class BaseRequestValidatorTests .Returns(Task.FromResult(new Tuple(false, null))); _deviceValidator.ValidateRequestDeviceAsync(tokenRequest, requestContext) .Returns(Task.FromResult(true)); + _ssoRequestValidator.ValidateAsync(requestContext.User, tokenRequest, requestContext) + .Returns(Task.FromResult(true)); // Act await _sut.ValidateAsync(context); @@ -921,6 +1008,8 @@ public class BaseRequestValidatorTests .Returns(Task.FromResult(new Tuple(false, null))); _deviceValidator.ValidateRequestDeviceAsync(tokenRequest, requestContext) .Returns(Task.FromResult(true)); + _ssoRequestValidator.ValidateAsync(requestContext.User, tokenRequest, requestContext) + .Returns(Task.FromResult(true)); // Act await _sut.ValidateAsync(context); @@ -950,6 +1039,19 @@ public class BaseRequestValidatorTests GrantValidationResult grantResult) { // Arrange + + // SsoRequestValidator sets custom response + requestContext.ValidationErrorResult = new ValidationResult + { + IsError = true, + Error = SsoConstants.RequestErrors.SsoRequired, + ErrorDescription = SsoConstants.RequestErrors.SsoRequiredDescription + }; + requestContext.CustomResponse = new Dictionary + { + { CustomResponseConstants.ResponseKeys.ErrorModel, new ErrorResponseModel(SsoConstants.RequestErrors.SsoRequiredDescription) }, + }; + var context = CreateContext(tokenRequest, requestContext, grantResult); var user = requestContext.User; @@ -984,12 +1086,12 @@ public class BaseRequestValidatorTests // Assert Assert.True(context.GrantResult.IsError, "Authentication should fail - SSO required after recovery"); - - var errorResponse = (ErrorResponseModel)context.GrantResult.CustomResponse["ErrorModel"]; + Assert.NotNull(context.GrantResult.CustomResponse); + var errorResponse = (ErrorResponseModel)context.CustomValidatorRequestContext.CustomResponse[CustomResponseConstants.ResponseKeys.ErrorModel]; // Recovery succeeds, then SSO blocks with descriptive message Assert.Equal( - "Two-factor recovery has been performed. SSO authentication is required.", + SsoConstants.RequestErrors.SsoRequiredDescription, errorResponse.Message); // Verify recovery was marked @@ -1050,7 +1152,7 @@ public class BaseRequestValidatorTests // Assert Assert.True(context.GrantResult.IsError, "Authentication should fail - invalid recovery code"); - var errorResponse = (ErrorResponseModel)context.GrantResult.CustomResponse["ErrorModel"]; + var errorResponse = (ErrorResponseModel)context.GrantResult.CustomResponse[CustomResponseConstants.ResponseKeys.ErrorModel]; // 2FA is checked first (due to recovery code request), fails with 2FA error Assert.Equal( @@ -1132,7 +1234,11 @@ public class BaseRequestValidatorTests _userService.IsLegacyUser(Arg.Any()) .Returns(false); - // 8. Setup user account keys for successful login response + // 8. SSO is not required + _ssoRequestValidator.ValidateAsync(requestContext.User, tokenRequest, requestContext) + .Returns(Task.FromResult(true)); + + // 9. Setup user account keys for successful login response _userAccountKeysQuery.Run(Arg.Any()).Returns(new UserAccountKeysData { PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData( @@ -1161,179 +1267,18 @@ public class BaseRequestValidatorTests } /// - /// Tests that when RedirectOnSsoRequired is DISABLED, the legacy SSO validation path is used. - /// This validates the deprecated RequireSsoLoginAsync method is called and SSO requirement - /// is checked using the old PolicyService.AnyPoliciesApplicableToUserAsync approach. + /// Tests that when SSO validation returns a custom response, (e.g., with organization identifier), + /// that custom response is properly propagated to the result. /// [Theory] [BitAutoData] - public async Task ValidateAsync_RedirectOnSsoRequired_Disabled_UsesLegacySsoValidation( + public async Task ValidateAsync_SsoRequired_PropagatesCustomResponse( [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext, GrantValidationResult grantResult) { // Arrange - _featureService.IsEnabled(FeatureFlagKeys.RedirectOnSsoRequired).Returns(false); - - var context = CreateContext(tokenRequest, requestContext, grantResult); - _sut.isValid = true; - - tokenRequest.GrantType = OidcConstants.GrantTypes.Password; - - // SSO is required via legacy path - _policyService.AnyPoliciesApplicableToUserAsync( - Arg.Any(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed) - .Returns(Task.FromResult(true)); - - // Act - await _sut.ValidateAsync(context); - - // Assert - Assert.True(context.GrantResult.IsError); - var errorResponse = (ErrorResponseModel)context.GrantResult.CustomResponse["ErrorModel"]; - Assert.Equal("SSO authentication is required.", errorResponse.Message); - - // Verify legacy path was used - await _policyService.Received(1).AnyPoliciesApplicableToUserAsync( - requestContext.User.Id, PolicyType.RequireSso, OrganizationUserStatusType.Confirmed); - - // Verify new SsoRequestValidator was NOT called - await _ssoRequestValidator.DidNotReceive().ValidateAsync( - Arg.Any(), Arg.Any(), Arg.Any()); - } - - /// - /// Tests that when RedirectOnSsoRequired is ENABLED, the new ISsoRequestValidator is used - /// instead of the legacy RequireSsoLoginAsync method. - /// - [Theory] - [BitAutoData] - public async Task ValidateAsync_RedirectOnSsoRequired_Enabled_UsesNewSsoRequestValidator( - [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, - [AuthFixtures.CustomValidatorRequestContext] - CustomValidatorRequestContext requestContext, - GrantValidationResult grantResult) - { - // Arrange - _featureService.IsEnabled(FeatureFlagKeys.RedirectOnSsoRequired).Returns(true); - - var context = CreateContext(tokenRequest, requestContext, grantResult); - _sut.isValid = true; - - tokenRequest.GrantType = OidcConstants.GrantTypes.Password; - - // Configure SsoRequestValidator to indicate SSO is required - _ssoRequestValidator.ValidateAsync( - Arg.Any(), - Arg.Any(), - Arg.Any()) - .Returns(Task.FromResult(false)); // false = SSO required - - // Set up the ValidationErrorResult that SsoRequestValidator would set - requestContext.ValidationErrorResult = new ValidationResult - { - IsError = true, - Error = "sso_required", - ErrorDescription = "SSO authentication is required." - }; - requestContext.CustomResponse = new Dictionary - { - { "ErrorModel", new ErrorResponseModel("SSO authentication is required.") } - }; - - // Act - await _sut.ValidateAsync(context); - - // Assert - Assert.True(context.GrantResult.IsError); - - // Verify new SsoRequestValidator was called - await _ssoRequestValidator.Received(1).ValidateAsync( - requestContext.User, - tokenRequest, - requestContext); - - // Verify legacy path was NOT used - await _policyService.DidNotReceive().AnyPoliciesApplicableToUserAsync( - Arg.Any(), Arg.Any(), Arg.Any()); - } - - /// - /// Tests that when RedirectOnSsoRequired is ENABLED and SSO is NOT required, - /// authentication continues successfully through the new validation path. - /// - [Theory] - [BitAutoData] - public async Task ValidateAsync_RedirectOnSsoRequired_Enabled_SsoNotRequired_SuccessfulLogin( - [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, - [AuthFixtures.CustomValidatorRequestContext] - CustomValidatorRequestContext requestContext, - GrantValidationResult grantResult) - { - // Arrange - _featureService.IsEnabled(FeatureFlagKeys.RedirectOnSsoRequired).Returns(true); - - var context = CreateContext(tokenRequest, requestContext, grantResult); - _sut.isValid = true; - - tokenRequest.GrantType = OidcConstants.GrantTypes.Password; - tokenRequest.ClientId = "web"; - - // SsoRequestValidator returns true (SSO not required) - _ssoRequestValidator.ValidateAsync( - Arg.Any(), - Arg.Any(), - Arg.Any()) - .Returns(Task.FromResult(true)); - - // No 2FA required - _twoFactorAuthenticationValidator.RequiresTwoFactorAsync(requestContext.User, tokenRequest) - .Returns(Task.FromResult(new Tuple(false, null))); - - // Device validation passes - _deviceValidator.ValidateRequestDeviceAsync(tokenRequest, requestContext) - .Returns(Task.FromResult(true)); - - // User is not legacy - _userService.IsLegacyUser(Arg.Any()).Returns(false); - - _userAccountKeysQuery.Run(Arg.Any()).Returns(new UserAccountKeysData - { - PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData( - "test-private-key", - "test-public-key" - ) - }); - - // Act - await _sut.ValidateAsync(context); - - // Assert - Assert.False(context.GrantResult.IsError); - await _eventService.Received(1).LogUserEventAsync(requestContext.User.Id, EventType.User_LoggedIn); - - // Verify new validator was used - await _ssoRequestValidator.Received(1).ValidateAsync( - requestContext.User, - tokenRequest, - requestContext); - } - - /// - /// Tests that when RedirectOnSsoRequired is ENABLED and SSO validation returns a custom response - /// (e.g., with organization identifier), that custom response is properly propagated to the result. - /// - [Theory] - [BitAutoData] - public async Task ValidateAsync_RedirectOnSsoRequired_Enabled_PropagatesCustomResponse( - [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, - [AuthFixtures.CustomValidatorRequestContext] - CustomValidatorRequestContext requestContext, - GrantValidationResult grantResult) - { - // Arrange - _featureService.IsEnabled(FeatureFlagKeys.RedirectOnSsoRequired).Returns(true); _sut.isValid = true; tokenRequest.GrantType = OidcConstants.GrantTypes.Password; @@ -1342,13 +1287,13 @@ public class BaseRequestValidatorTests requestContext.ValidationErrorResult = new ValidationResult { IsError = true, - Error = "sso_required", - ErrorDescription = "SSO authentication is required." + Error = SsoConstants.RequestErrors.SsoRequired, + ErrorDescription = SsoConstants.RequestErrors.SsoRequiredDescription }; requestContext.CustomResponse = new Dictionary { - { "ErrorModel", new ErrorResponseModel("SSO authentication is required.") }, - { "SsoOrganizationIdentifier", "test-org-identifier" } + { CustomResponseConstants.ResponseKeys.ErrorModel, new ErrorResponseModel(SsoConstants.RequestErrors.SsoRequiredDescription) }, + { CustomResponseConstants.ResponseKeys.SsoOrganizationIdentifier, "test-org-identifier" } }; var context = CreateContext(tokenRequest, requestContext, grantResult); @@ -1365,77 +1310,24 @@ public class BaseRequestValidatorTests // Assert Assert.True(context.GrantResult.IsError); Assert.NotNull(context.GrantResult.CustomResponse); - Assert.Contains("SsoOrganizationIdentifier", context.CustomValidatorRequestContext.CustomResponse); + Assert.Contains(CustomResponseConstants.ResponseKeys.SsoOrganizationIdentifier, context.CustomValidatorRequestContext.CustomResponse); Assert.Equal("test-org-identifier", - context.CustomValidatorRequestContext.CustomResponse["SsoOrganizationIdentifier"]); + context.CustomValidatorRequestContext.CustomResponse[CustomResponseConstants.ResponseKeys.SsoOrganizationIdentifier]); } /// - /// Tests that when RedirectOnSsoRequired is DISABLED and a user with 2FA recovery completes recovery, - /// but SSO is required, the legacy error message is returned (without the recovery-specific message). - /// - [Theory] - [BitAutoData] - public async Task ValidateAsync_RedirectOnSsoRequired_Disabled_RecoveryWithSso_LegacyMessage( - [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, - [AuthFixtures.CustomValidatorRequestContext] - CustomValidatorRequestContext requestContext, - GrantValidationResult grantResult) - { - // Arrange - _featureService.IsEnabled(FeatureFlagKeys.RedirectOnSsoRequired).Returns(false); - - var context = CreateContext(tokenRequest, requestContext, grantResult); - _sut.isValid = true; - - // Recovery code scenario - tokenRequest.Raw["TwoFactorProvider"] = ((int)TwoFactorProviderType.RecoveryCode).ToString(); - tokenRequest.Raw["TwoFactorToken"] = "valid-recovery-code"; - - // 2FA with recovery - _twoFactorAuthenticationValidator - .RequiresTwoFactorAsync(requestContext.User, tokenRequest) - .Returns(Task.FromResult(new Tuple(true, null))); - - _twoFactorAuthenticationValidator - .VerifyTwoFactorAsync(requestContext.User, null, TwoFactorProviderType.RecoveryCode, "valid-recovery-code") - .Returns(Task.FromResult(true)); - - // SSO is required (legacy check) - _policyService.AnyPoliciesApplicableToUserAsync( - Arg.Any(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed) - .Returns(Task.FromResult(true)); - - // Act - await _sut.ValidateAsync(context); - - // Assert - Assert.True(context.GrantResult.IsError); - var errorResponse = (ErrorResponseModel)context.GrantResult.CustomResponse["ErrorModel"]; - - // Legacy behavior: recovery-specific message IS shown even without RedirectOnSsoRequired - Assert.Equal("Two-factor recovery has been performed. SSO authentication is required.", errorResponse.Message); - - // But legacy validation path was used - await _policyService.Received(1).AnyPoliciesApplicableToUserAsync( - requestContext.User.Id, PolicyType.RequireSso, OrganizationUserStatusType.Confirmed); - } - - /// - /// Tests that when RedirectOnSsoRequired is ENABLED and recovery code is used for SSO-required user, + /// Tests that when a recovery code is used for SSO-required user, /// the SsoRequestValidator provides the recovery-specific error message. /// [Theory] [BitAutoData] - public async Task ValidateAsync_RedirectOnSsoRequired_Enabled_RecoveryWithSso_NewValidatorMessage( + public async Task ValidateAsync_RecoveryWithSso_CorrectValidatorMessage( [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext, GrantValidationResult grantResult) { // Arrange - _featureService.IsEnabled(FeatureFlagKeys.RedirectOnSsoRequired).Returns(true); - var context = CreateContext(tokenRequest, requestContext, grantResult); _sut.isValid = true; @@ -1457,14 +1349,14 @@ public class BaseRequestValidatorTests requestContext.ValidationErrorResult = new ValidationResult { IsError = true, - Error = "sso_required", - ErrorDescription = "Two-factor recovery has been performed. SSO authentication is required." + Error = SsoConstants.RequestErrors.SsoRequired, + ErrorDescription = SsoConstants.RequestErrors.SsoTwoFactorRecoveryDescription }; requestContext.CustomResponse = new Dictionary { { - "ErrorModel", - new ErrorResponseModel("Two-factor recovery has been performed. SSO authentication is required.") + CustomResponseConstants.ResponseKeys.ErrorModel, + new ErrorResponseModel(SsoConstants.RequestErrors.SsoTwoFactorRecoveryDescription) } }; @@ -1479,18 +1371,8 @@ public class BaseRequestValidatorTests // Assert Assert.True(context.GrantResult.IsError); - var errorResponse = (ErrorResponseModel)context.CustomValidatorRequestContext.CustomResponse["ErrorModel"]; - Assert.Equal("Two-factor recovery has been performed. SSO authentication is required.", errorResponse.Message); - - // Verify new validator was used - await _ssoRequestValidator.Received(1).ValidateAsync( - requestContext.User, - tokenRequest, - Arg.Is(ctx => ctx.TwoFactorRecoveryRequested)); - - // Verify legacy path was NOT used - await _policyService.DidNotReceive().AnyPoliciesApplicableToUserAsync( - Arg.Any(), Arg.Any(), Arg.Any()); + var errorResponse = (ErrorResponseModel)context.CustomValidatorRequestContext.CustomResponse[CustomResponseConstants.ResponseKeys.ErrorModel]; + Assert.Equal(SsoConstants.RequestErrors.SsoTwoFactorRecoveryDescription, errorResponse.Message); } private BaseRequestValidationContextFake CreateContext( diff --git a/test/Identity.Test/Wrappers/BaseRequestValidatorTestWrapper.cs b/test/Identity.Test/Wrappers/BaseRequestValidatorTestWrapper.cs index b336e4c3c1..ac27c55466 100644 --- a/test/Identity.Test/Wrappers/BaseRequestValidatorTestWrapper.cs +++ b/test/Identity.Test/Wrappers/BaseRequestValidatorTestWrapper.cs @@ -111,15 +111,6 @@ IBaseRequestValidatorTestWrapper context.GrantResult = new GrantValidationResult(TokenRequestErrors.InvalidGrant, customResponse: customResponse); } - [Obsolete] - protected override void SetSsoResult( - BaseRequestValidationContextFake context, - Dictionary customResponse) - { - context.GrantResult = new GrantValidationResult( - TokenRequestErrors.InvalidGrant, "Sso authentication required.", customResponse); - } - protected override Task SetSuccessResult( BaseRequestValidationContextFake context, User user, From 2e4dd061e313caf96e321da374ea5be61d9865d0 Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Tue, 20 Jan 2026 09:18:27 -0600 Subject: [PATCH 34/49] [PM-30855] Pay prorated storage adjustment immediately with Braintree for Premium PayPal users (#6850) * fix: Pay prorated storage invoice immediately with Braintree for PayPal users * Run dotnet format --- .../Commands/UpdatePremiumStorageCommand.cs | 47 +++- src/Core/Billing/Services/IStripeAdapter.cs | 1 + .../Services/Implementations/StripeAdapter.cs | 3 + .../UpdatePremiumStorageCommandTests.cs | 230 +++++++++++++++++- 4 files changed, 263 insertions(+), 18 deletions(-) diff --git a/src/Core/Billing/Premium/Commands/UpdatePremiumStorageCommand.cs b/src/Core/Billing/Premium/Commands/UpdatePremiumStorageCommand.cs index 176c77bf57..219f450f1d 100644 --- a/src/Core/Billing/Premium/Commands/UpdatePremiumStorageCommand.cs +++ b/src/Core/Billing/Premium/Commands/UpdatePremiumStorageCommand.cs @@ -2,6 +2,7 @@ using Bit.Core.Billing.Constants; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; +using Bit.Core.Billing.Subscriptions.Models; using Bit.Core.Entities; using Bit.Core.Services; using Bit.Core.Utilities; @@ -29,6 +30,7 @@ public interface IUpdatePremiumStorageCommand } public class UpdatePremiumStorageCommand( + IBraintreeService braintreeService, IStripeAdapter stripeAdapter, IUserService userService, IPricingClient pricingClient, @@ -49,7 +51,10 @@ public class UpdatePremiumStorageCommand( // Fetch all premium plans and the user's subscription to find which plan they're on var premiumPlans = await pricingClient.ListPremiumPlans(); - var subscription = await stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId); + var subscription = await stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, new SubscriptionGetOptions + { + Expand = ["customer"] + }); // Find the password manager subscription item (seat, not storage) and match it to a plan var passwordManagerItem = subscription.Items.Data.FirstOrDefault(i => @@ -127,13 +132,41 @@ public class UpdatePremiumStorageCommand( }); } - var subscriptionUpdateOptions = new SubscriptionUpdateOptions - { - Items = subscriptionItemOptions, - ProrationBehavior = ProrationBehavior.AlwaysInvoice - }; + var usingPayPal = subscription.Customer.Metadata.ContainsKey(MetadataKeys.BraintreeCustomerId); - await stripeAdapter.UpdateSubscriptionAsync(subscription.Id, subscriptionUpdateOptions); + if (usingPayPal) + { + var options = new SubscriptionUpdateOptions + { + Items = subscriptionItemOptions, + ProrationBehavior = ProrationBehavior.CreateProrations + }; + + await stripeAdapter.UpdateSubscriptionAsync(subscription.Id, options); + + var draftInvoice = await stripeAdapter.CreateInvoiceAsync(new InvoiceCreateOptions + { + Customer = subscription.CustomerId, + Subscription = subscription.Id, + AutoAdvance = false, + CollectionMethod = CollectionMethod.ChargeAutomatically + }); + + var finalizedInvoice = await stripeAdapter.FinalizeInvoiceAsync(draftInvoice.Id, + new InvoiceFinalizeOptions { AutoAdvance = false, Expand = ["customer"] }); + + await braintreeService.PayInvoice(new UserId(user.Id), finalizedInvoice); + } + else + { + var options = new SubscriptionUpdateOptions + { + Items = subscriptionItemOptions, + ProrationBehavior = ProrationBehavior.AlwaysInvoice + }; + + await stripeAdapter.UpdateSubscriptionAsync(subscription.Id, options); + } // Update the user's max storage user.MaxStorageGb = maxStorageGb; diff --git a/src/Core/Billing/Services/IStripeAdapter.cs b/src/Core/Billing/Services/IStripeAdapter.cs index 5ec732920e..12ea3d5a7c 100644 --- a/src/Core/Billing/Services/IStripeAdapter.cs +++ b/src/Core/Billing/Services/IStripeAdapter.cs @@ -24,6 +24,7 @@ public interface IStripeAdapter Task CancelSubscriptionAsync(string id, SubscriptionCancelOptions options = null); Task GetInvoiceAsync(string id, InvoiceGetOptions options); Task> ListInvoicesAsync(StripeInvoiceListOptions options); + Task CreateInvoiceAsync(InvoiceCreateOptions options); Task CreateInvoicePreviewAsync(InvoiceCreatePreviewOptions options); Task> SearchInvoiceAsync(InvoiceSearchOptions options); Task UpdateInvoiceAsync(string id, InvoiceUpdateOptions options); diff --git a/src/Core/Billing/Services/Implementations/StripeAdapter.cs b/src/Core/Billing/Services/Implementations/StripeAdapter.cs index cdc7645042..5b90500021 100644 --- a/src/Core/Billing/Services/Implementations/StripeAdapter.cs +++ b/src/Core/Billing/Services/Implementations/StripeAdapter.cs @@ -116,6 +116,9 @@ public class StripeAdapter : IStripeAdapter return invoices; } + public Task CreateInvoiceAsync(InvoiceCreateOptions options) => + _invoiceService.CreateAsync(options); + public Task CreateInvoicePreviewAsync(InvoiceCreatePreviewOptions options) => _invoiceService.CreatePreviewAsync(options); diff --git a/test/Core.Test/Billing/Premium/Commands/UpdatePremiumStorageCommandTests.cs b/test/Core.Test/Billing/Premium/Commands/UpdatePremiumStorageCommandTests.cs index 7b9b68c757..cd9b323f9d 100644 --- a/test/Core.Test/Billing/Premium/Commands/UpdatePremiumStorageCommandTests.cs +++ b/test/Core.Test/Billing/Premium/Commands/UpdatePremiumStorageCommandTests.cs @@ -1,6 +1,7 @@ using Bit.Core.Billing.Premium.Commands; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; +using Bit.Core.Billing.Subscriptions.Models; using Bit.Core.Entities; using Bit.Core.Services; using Bit.Test.Common.AutoFixture.Attributes; @@ -8,6 +9,7 @@ using Microsoft.Extensions.Logging; using NSubstitute; using Stripe; using Xunit; +using static Bit.Core.Billing.Constants.StripeConstants; using PremiumPlan = Bit.Core.Billing.Pricing.Premium.Plan; using PremiumPurchasable = Bit.Core.Billing.Pricing.Premium.Purchasable; @@ -15,6 +17,7 @@ namespace Bit.Core.Test.Billing.Premium.Commands; public class UpdatePremiumStorageCommandTests { + private readonly IBraintreeService _braintreeService = Substitute.For(); private readonly IStripeAdapter _stripeAdapter = Substitute.For(); private readonly IUserService _userService = Substitute.For(); private readonly IPricingClient _pricingClient = Substitute.For(); @@ -33,13 +36,14 @@ public class UpdatePremiumStorageCommandTests _pricingClient.ListPremiumPlans().Returns([premiumPlan]); _command = new UpdatePremiumStorageCommand( + _braintreeService, _stripeAdapter, _userService, _pricingClient, Substitute.For>()); } - private Subscription CreateMockSubscription(string subscriptionId, int? storageQuantity = null) + private Subscription CreateMockSubscription(string subscriptionId, int? storageQuantity = null, bool isPayPal = false) { var items = new List { @@ -63,9 +67,17 @@ public class UpdatePremiumStorageCommandTests }); } + var customer = new Customer + { + Id = "cus_123", + Metadata = isPayPal ? new Dictionary { { MetadataKeys.BraintreeCustomerId, "braintree_123" } } : new Dictionary() + }; + return new Subscription { Id = subscriptionId, + CustomerId = "cus_123", + Customer = customer, Items = new StripeList { Data = items @@ -97,7 +109,7 @@ public class UpdatePremiumStorageCommandTests user.GatewaySubscriptionId = "sub_123"; var subscription = CreateMockSubscription("sub_123", 4); - _stripeAdapter.GetSubscriptionAsync("sub_123").Returns(subscription); + _stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any()).Returns(subscription); // Act var result = await _command.Run(user, -5); @@ -117,7 +129,7 @@ public class UpdatePremiumStorageCommandTests user.GatewaySubscriptionId = "sub_123"; var subscription = CreateMockSubscription("sub_123", 4); - _stripeAdapter.GetSubscriptionAsync("sub_123").Returns(subscription); + _stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any()).Returns(subscription); // Act var result = await _command.Run(user, 100); @@ -154,7 +166,7 @@ public class UpdatePremiumStorageCommandTests user.GatewaySubscriptionId = "sub_123"; var subscription = CreateMockSubscription("sub_123", 9); - _stripeAdapter.GetSubscriptionAsync("sub_123").Returns(subscription); + _stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any()).Returns(subscription); // Act var result = await _command.Run(user, 0); @@ -176,7 +188,7 @@ public class UpdatePremiumStorageCommandTests user.GatewaySubscriptionId = "sub_123"; var subscription = CreateMockSubscription("sub_123", 4); - _stripeAdapter.GetSubscriptionAsync("sub_123").Returns(subscription); + _stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any()).Returns(subscription); // Act var result = await _command.Run(user, 4); @@ -185,7 +197,7 @@ public class UpdatePremiumStorageCommandTests Assert.True(result.IsT0); // Verify subscription was fetched but NOT updated - await _stripeAdapter.Received(1).GetSubscriptionAsync("sub_123"); + await _stripeAdapter.Received(1).GetSubscriptionAsync("sub_123", Arg.Any()); await _stripeAdapter.DidNotReceive().UpdateSubscriptionAsync(Arg.Any(), Arg.Any()); await _userService.DidNotReceive().SaveUserAsync(Arg.Any()); } @@ -200,7 +212,7 @@ public class UpdatePremiumStorageCommandTests user.GatewaySubscriptionId = "sub_123"; var subscription = CreateMockSubscription("sub_123", 4); - _stripeAdapter.GetSubscriptionAsync("sub_123").Returns(subscription); + _stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any()).Returns(subscription); // Act var result = await _command.Run(user, 9); @@ -233,7 +245,7 @@ public class UpdatePremiumStorageCommandTests user.GatewaySubscriptionId = "sub_123"; var subscription = CreateMockSubscription("sub_123"); - _stripeAdapter.GetSubscriptionAsync("sub_123").Returns(subscription); + _stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any()).Returns(subscription); // Act var result = await _command.Run(user, 9); @@ -262,7 +274,7 @@ public class UpdatePremiumStorageCommandTests user.GatewaySubscriptionId = "sub_123"; var subscription = CreateMockSubscription("sub_123", 9); - _stripeAdapter.GetSubscriptionAsync("sub_123").Returns(subscription); + _stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any()).Returns(subscription); // Act var result = await _command.Run(user, 2); @@ -291,7 +303,7 @@ public class UpdatePremiumStorageCommandTests user.GatewaySubscriptionId = "sub_123"; var subscription = CreateMockSubscription("sub_123", 9); - _stripeAdapter.GetSubscriptionAsync("sub_123").Returns(subscription); + _stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any()).Returns(subscription); // Act var result = await _command.Run(user, 0); @@ -320,7 +332,7 @@ public class UpdatePremiumStorageCommandTests user.GatewaySubscriptionId = "sub_123"; var subscription = CreateMockSubscription("sub_123", 4); - _stripeAdapter.GetSubscriptionAsync("sub_123").Returns(subscription); + _stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any()).Returns(subscription); // Act var result = await _command.Run(user, 99); @@ -335,4 +347,200 @@ public class UpdatePremiumStorageCommandTests await _userService.Received(1).SaveUserAsync(Arg.Is(u => u.MaxStorageGb == 100)); } + + [Theory, BitAutoData] + public async Task Run_IncreaseStorage_PayPal_Success(User user) + { + // Arrange + user.Premium = true; + user.MaxStorageGb = 5; + user.Storage = 2L * 1024 * 1024 * 1024; + user.GatewaySubscriptionId = "sub_123"; + + var subscription = CreateMockSubscription("sub_123", 4, isPayPal: true); + _stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any()).Returns(subscription); + + var draftInvoice = new Invoice { Id = "in_draft" }; + _stripeAdapter.CreateInvoiceAsync(Arg.Any()).Returns(draftInvoice); + + var finalizedInvoice = new Invoice + { + Id = "in_finalized", + Customer = new Customer { Id = "cus_123" } + }; + _stripeAdapter.FinalizeInvoiceAsync("in_draft", Arg.Any()).Returns(finalizedInvoice); + + // Act + var result = await _command.Run(user, 9); + + // Assert + Assert.True(result.IsT0); + + // Verify subscription was updated with CreateProrations + await _stripeAdapter.Received(1).UpdateSubscriptionAsync( + "sub_123", + Arg.Is(opts => + opts.Items.Count == 1 && + opts.Items[0].Id == "si_storage" && + opts.Items[0].Quantity == 9 && + opts.ProrationBehavior == "create_prorations")); + + // Verify draft invoice was created + await _stripeAdapter.Received(1).CreateInvoiceAsync( + Arg.Is(opts => + opts.Customer == "cus_123" && + opts.Subscription == "sub_123" && + opts.AutoAdvance == false && + opts.CollectionMethod == "charge_automatically")); + + // Verify invoice was finalized + await _stripeAdapter.Received(1).FinalizeInvoiceAsync( + "in_draft", + Arg.Is(opts => + opts.AutoAdvance == false && + opts.Expand.Contains("customer"))); + + // Verify Braintree payment was processed + await _braintreeService.Received(1).PayInvoice(Arg.Any(), finalizedInvoice); + + // Verify user was saved + await _userService.Received(1).SaveUserAsync(Arg.Is(u => + u.Id == user.Id && + u.MaxStorageGb == 10)); + } + + [Theory, BitAutoData] + public async Task Run_AddStorageFromZero_PayPal_Success(User user) + { + // Arrange + user.Premium = true; + user.MaxStorageGb = 1; + user.Storage = 500L * 1024 * 1024; + user.GatewaySubscriptionId = "sub_123"; + + var subscription = CreateMockSubscription("sub_123", isPayPal: true); + _stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any()).Returns(subscription); + + var draftInvoice = new Invoice { Id = "in_draft" }; + _stripeAdapter.CreateInvoiceAsync(Arg.Any()).Returns(draftInvoice); + + var finalizedInvoice = new Invoice + { + Id = "in_finalized", + Customer = new Customer { Id = "cus_123" } + }; + _stripeAdapter.FinalizeInvoiceAsync("in_draft", Arg.Any()).Returns(finalizedInvoice); + + // Act + var result = await _command.Run(user, 9); + + // Assert + Assert.True(result.IsT0); + + // Verify subscription was updated with new storage item + await _stripeAdapter.Received(1).UpdateSubscriptionAsync( + "sub_123", + Arg.Is(opts => + opts.Items.Count == 1 && + opts.Items[0].Price == "price_storage" && + opts.Items[0].Quantity == 9 && + opts.ProrationBehavior == "create_prorations")); + + // Verify invoice creation and payment flow + await _stripeAdapter.Received(1).CreateInvoiceAsync(Arg.Any()); + await _stripeAdapter.Received(1).FinalizeInvoiceAsync("in_draft", Arg.Any()); + await _braintreeService.Received(1).PayInvoice(Arg.Any(), finalizedInvoice); + + await _userService.Received(1).SaveUserAsync(Arg.Is(u => u.MaxStorageGb == 10)); + } + + [Theory, BitAutoData] + public async Task Run_DecreaseStorage_PayPal_Success(User user) + { + // Arrange + user.Premium = true; + user.MaxStorageGb = 10; + user.Storage = 2L * 1024 * 1024 * 1024; + user.GatewaySubscriptionId = "sub_123"; + + var subscription = CreateMockSubscription("sub_123", 9, isPayPal: true); + _stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any()).Returns(subscription); + + var draftInvoice = new Invoice { Id = "in_draft" }; + _stripeAdapter.CreateInvoiceAsync(Arg.Any()).Returns(draftInvoice); + + var finalizedInvoice = new Invoice + { + Id = "in_finalized", + Customer = new Customer { Id = "cus_123" } + }; + _stripeAdapter.FinalizeInvoiceAsync("in_draft", Arg.Any()).Returns(finalizedInvoice); + + // Act + var result = await _command.Run(user, 2); + + // Assert + Assert.True(result.IsT0); + + // Verify subscription was updated + await _stripeAdapter.Received(1).UpdateSubscriptionAsync( + "sub_123", + Arg.Is(opts => + opts.Items.Count == 1 && + opts.Items[0].Id == "si_storage" && + opts.Items[0].Quantity == 2 && + opts.ProrationBehavior == "create_prorations")); + + // Verify invoice creation and payment flow + await _stripeAdapter.Received(1).CreateInvoiceAsync(Arg.Any()); + await _stripeAdapter.Received(1).FinalizeInvoiceAsync("in_draft", Arg.Any()); + await _braintreeService.Received(1).PayInvoice(Arg.Any(), finalizedInvoice); + + await _userService.Received(1).SaveUserAsync(Arg.Is(u => u.MaxStorageGb == 3)); + } + + [Theory, BitAutoData] + public async Task Run_RemoveAllAdditionalStorage_PayPal_Success(User user) + { + // Arrange + user.Premium = true; + user.MaxStorageGb = 10; + user.Storage = 500L * 1024 * 1024; + user.GatewaySubscriptionId = "sub_123"; + + var subscription = CreateMockSubscription("sub_123", 9, isPayPal: true); + _stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any()).Returns(subscription); + + var draftInvoice = new Invoice { Id = "in_draft" }; + _stripeAdapter.CreateInvoiceAsync(Arg.Any()).Returns(draftInvoice); + + var finalizedInvoice = new Invoice + { + Id = "in_finalized", + Customer = new Customer { Id = "cus_123" } + }; + _stripeAdapter.FinalizeInvoiceAsync("in_draft", Arg.Any()).Returns(finalizedInvoice); + + // Act + var result = await _command.Run(user, 0); + + // Assert + Assert.True(result.IsT0); + + // Verify subscription item was deleted + await _stripeAdapter.Received(1).UpdateSubscriptionAsync( + "sub_123", + Arg.Is(opts => + opts.Items.Count == 1 && + opts.Items[0].Id == "si_storage" && + opts.Items[0].Deleted == true && + opts.ProrationBehavior == "create_prorations")); + + // Verify invoice creation and payment flow + await _stripeAdapter.Received(1).CreateInvoiceAsync(Arg.Any()); + await _stripeAdapter.Received(1).FinalizeInvoiceAsync("in_draft", Arg.Any()); + await _braintreeService.Received(1).PayInvoice(Arg.Any(), finalizedInvoice); + + await _userService.Received(1).SaveUserAsync(Arg.Is(u => u.MaxStorageGb == 1)); + } } From 439485fc1687692cec131cc413ce4e5a78058cec Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Tue, 20 Jan 2026 09:29:49 -0600 Subject: [PATCH 35/49] Update renewal email copy (#6862) --- .../Renewals/families-2019-renewal.mjml | 2 +- .../Billing/Renewals/premium-renewal.mjml | 2 +- .../Families2019RenewalMailView.html.hbs | 74 +++++++++---------- .../Families2019RenewalMailView.text.hbs | 2 +- .../Premium/PremiumRenewalMailView.html.hbs | 74 +++++++++---------- .../Premium/PremiumRenewalMailView.text.hbs | 2 +- 6 files changed, 78 insertions(+), 78 deletions(-) diff --git a/src/Core/MailTemplates/Mjml/emails/Billing/Renewals/families-2019-renewal.mjml b/src/Core/MailTemplates/Mjml/emails/Billing/Renewals/families-2019-renewal.mjml index 092ae303de..11d82e2039 100644 --- a/src/Core/MailTemplates/Mjml/emails/Billing/Renewals/families-2019-renewal.mjml +++ b/src/Core/MailTemplates/Mjml/emails/Billing/Renewals/families-2019-renewal.mjml @@ -19,7 +19,7 @@ As a long time Bitwarden customer, you will receive a one-time {{DiscountAmount}} loyalty discount for this renewal. - This renewal will now be billed annually at {{DiscountedAnnualRenewalPrice}} + tax. + This year's renewal will now be billed annually at {{DiscountedAnnualRenewalPrice}} + tax. Questions? Contact diff --git a/src/Core/MailTemplates/Mjml/emails/Billing/Renewals/premium-renewal.mjml b/src/Core/MailTemplates/Mjml/emails/Billing/Renewals/premium-renewal.mjml index a460442a7c..1fe48c9ba9 100644 --- a/src/Core/MailTemplates/Mjml/emails/Billing/Renewals/premium-renewal.mjml +++ b/src/Core/MailTemplates/Mjml/emails/Billing/Renewals/premium-renewal.mjml @@ -18,7 +18,7 @@ As an existing Bitwarden customer, you will receive a one-time {{DiscountAmount}} loyalty discount for this renewal. - This renewal now will be {{DiscountedMonthlyRenewalPrice}}/month, billed annually. + This year's renewal now will be {{DiscountedMonthlyRenewalPrice}}/month, billed annually. Questions? Contact diff --git a/src/Core/Models/Mail/Billing/Renewal/Families2019Renewal/Families2019RenewalMailView.html.hbs b/src/Core/Models/Mail/Billing/Renewal/Families2019Renewal/Families2019RenewalMailView.html.hbs index 227613999b..0befde11b5 100644 --- a/src/Core/Models/Mail/Billing/Renewal/Families2019Renewal/Families2019RenewalMailView.html.hbs +++ b/src/Core/Models/Mail/Billing/Renewal/Families2019Renewal/Families2019RenewalMailView.html.hbs @@ -203,7 +203,7 @@
As a long time Bitwarden customer, you will receive a one-time {{DiscountAmount}} loyalty discount for this renewal. - This renewal will now be billed annually at {{DiscountedAnnualRenewalPrice}} + tax.
+ This year's renewal will now be billed annually at {{DiscountedAnnualRenewalPrice}} + tax. @@ -271,12 +271,12 @@ - + -
+
- +
@@ -364,8 +364,8 @@ - -
- + +
@@ -381,13 +381,13 @@
- +
+ - @@ -404,13 +404,13 @@ -
+ - +
- +
+ - @@ -427,13 +427,13 @@ -
+ - +
- +
+ - @@ -450,13 +450,13 @@ -
+ - +
- +
+ - @@ -473,13 +473,13 @@ -
+ - +
- +
+ - @@ -496,13 +496,13 @@ -
+ - +
- +
+ - @@ -519,13 +519,13 @@ -
+ - +
- +
+ - @@ -546,15 +546,15 @@ diff --git a/src/Core/Models/Mail/Billing/Renewal/Families2019Renewal/Families2019RenewalMailView.text.hbs b/src/Core/Models/Mail/Billing/Renewal/Families2019Renewal/Families2019RenewalMailView.text.hbs index 88d64f9acf..7178548772 100644 --- a/src/Core/Models/Mail/Billing/Renewal/Families2019Renewal/Families2019RenewalMailView.text.hbs +++ b/src/Core/Models/Mail/Billing/Renewal/Families2019Renewal/Families2019RenewalMailView.text.hbs @@ -2,6 +2,6 @@ at {{BaseAnnualRenewalPrice}} + tax. As a long time Bitwarden customer, you will receive a one-time {{DiscountAmount}} loyalty discount for this renewal. -This renewal will now be billed annually at {{DiscountedAnnualRenewalPrice}} + tax. +This year's renewal will now be billed annually at {{DiscountedAnnualRenewalPrice}} + tax. Questions? Contact support@bitwarden.com diff --git a/src/Core/Models/Mail/Billing/Renewal/Premium/PremiumRenewalMailView.html.hbs b/src/Core/Models/Mail/Billing/Renewal/Premium/PremiumRenewalMailView.html.hbs index a6b2fda0f7..9ce45ef7fe 100644 --- a/src/Core/Models/Mail/Billing/Renewal/Premium/PremiumRenewalMailView.html.hbs +++ b/src/Core/Models/Mail/Billing/Renewal/Premium/PremiumRenewalMailView.html.hbs @@ -202,7 +202,7 @@ @@ -270,12 +270,12 @@
+ - +
-

+

© 2025 Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa Barbara, CA, USA

Always confirm you are on a trusted Bitwarden domain before logging in:
- bitwarden.com | - Learn why we include this + bitwarden.com | + Learn why we include this

As an existing Bitwarden customer, you will receive a one-time {{DiscountAmount}} loyalty discount for this renewal. - This renewal now will be {{DiscountedMonthlyRenewalPrice}}/month, billed annually.
+ This year's renewal now will be {{DiscountedMonthlyRenewalPrice}}/month, billed annually.
- + -
+
- +
@@ -363,8 +363,8 @@ - -
- + +
@@ -380,13 +380,13 @@
- +
+ - @@ -403,13 +403,13 @@ -
+ - +
- +
+ - @@ -426,13 +426,13 @@ -
+ - +
- +
+ - @@ -449,13 +449,13 @@ -
+ - +
- +
+ - @@ -472,13 +472,13 @@ -
+ - +
- +
+ - @@ -495,13 +495,13 @@ -
+ - +
- +
+ - @@ -518,13 +518,13 @@ -
+ - +
- +
+ - @@ -545,15 +545,15 @@ diff --git a/src/Core/Models/Mail/Billing/Renewal/Premium/PremiumRenewalMailView.text.hbs b/src/Core/Models/Mail/Billing/Renewal/Premium/PremiumRenewalMailView.text.hbs index 41300d0f96..15ad530a07 100644 --- a/src/Core/Models/Mail/Billing/Renewal/Premium/PremiumRenewalMailView.text.hbs +++ b/src/Core/Models/Mail/Billing/Renewal/Premium/PremiumRenewalMailView.text.hbs @@ -1,6 +1,6 @@ Your Bitwarden Premium subscription renews in 15 days. The price is updating to {{BaseMonthlyRenewalPrice}}/month, billed annually. As an existing Bitwarden customer, you will receive a one-time {{DiscountAmount}} loyalty discount for this renewal. -This renewal now will be {{DiscountedMonthlyRenewalPrice}}/month, billed annually. +This year's renewal now will be {{DiscountedMonthlyRenewalPrice}}/month, billed annually. Questions? Contact support@bitwarden.com From 7fb2822e05114f61c54ea2f6f0795b7c7425df3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rui=20Tom=C3=A9?= <108268980+r-tome@users.noreply.github.com> Date: Wed, 21 Jan 2026 11:27:24 +0000 Subject: [PATCH 36/49] =?UTF-8?q?[PM-28023]=C2=A0Fix=20restoring=20revoked?= =?UTF-8?q?=20invited=20users=20in=20Free=20Organizations=20(#6861)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix null reference when restoring invited users in Free orgs Add null check before querying for other free org ownership. Invited users don't have a UserId yet, causing NullReferenceException. * Add regression test for restoring revoked invited users with null UserId. --- .../v1/RestoreOrganizationUserCommand.cs | 2 +- .../RestoreOrganizationUserCommandTests.cs | 33 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs index ec42c8b402..c5b7314730 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs @@ -93,7 +93,7 @@ public class RestoreOrganizationUserCommand( .twoFactorIsEnabled; } - if (organization.PlanType == PlanType.Free) + if (organization.PlanType == PlanType.Free && organizationUser.UserId.HasValue) { await CheckUserForOtherFreeOrganizationOwnershipAsync(organizationUser); } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/RestoreOrganizationUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/RestoreOrganizationUserCommandTests.cs index 4fa5e92abe..a75345a05d 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/RestoreOrganizationUserCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/RestoreOrganizationUserCommandTests.cs @@ -715,6 +715,39 @@ public class RestoreOrganizationUserCommandTests Arg.Is(x => x != OrganizationUserStatusType.Revoked)); } + [Theory, BitAutoData] + public async Task RestoreUser_InvitedUserInFreeOrganization_Success( + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, + SutProvider sutProvider) + { + organization.PlanType = PlanType.Free; + organizationUser.UserId = null; + organizationUser.Key = null; + organizationUser.Status = OrganizationUserStatusType.Revoked; + + RestoreUser_Setup(organization, owner, organizationUser, sutProvider); + sutProvider.GetDependency() + .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts + { + Sponsored = 0, + Users = 1 + }); + + await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id); + + await sutProvider.GetDependency() + .Received(1) + .RestoreAsync(organizationUser.Id, OrganizationUserStatusType.Invited); + await sutProvider.GetDependency() + .Received(1) + .LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .PushSyncOrgKeysAsync(Arg.Any()); + } + [Theory, BitAutoData] public async Task RestoreUsers_Success(Organization organization, [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, From 75a857055e74c452cfdc96cd4c1143439a3d2f8e Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Wed, 21 Jan 2026 11:52:36 -0600 Subject: [PATCH 37/49] [PM-30697] [PM-30698] Renewal email copy updates (#6875) * feat(families-renewal): Update copy * feat(premium-renewal): Add new var, update copy --- .../Services/Implementations/UpcomingInvoiceHandler.cs | 2 +- .../Mjml/emails/Billing/Renewals/families-2019-renewal.mjml | 4 ++-- .../Mjml/emails/Billing/Renewals/premium-renewal.mjml | 4 ++-- .../Families2019Renewal/Families2019RenewalMailView.html.hbs | 4 ++-- .../Families2019Renewal/Families2019RenewalMailView.text.hbs | 4 ++-- .../Mail/Billing/Renewal/Premium/PremiumRenewalMailView.cs | 2 +- .../Billing/Renewal/Premium/PremiumRenewalMailView.html.hbs | 4 ++-- .../Billing/Renewal/Premium/PremiumRenewalMailView.text.hbs | 4 ++-- test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs | 4 ++-- 9 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs index 004828dc48..ae2a76a7ce 100644 --- a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs +++ b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs @@ -627,7 +627,7 @@ public class UpcomingInvoiceHandler( { BaseMonthlyRenewalPrice = (premiumPlan.Seat.Price / 12).ToString("C", new CultureInfo("en-US")), DiscountAmount = $"{coupon.PercentOff}%", - DiscountedMonthlyRenewalPrice = (discountedAnnualRenewalPrice / 12).ToString("C", new CultureInfo("en-US")) + DiscountedAnnualRenewalPrice = discountedAnnualRenewalPrice.ToString("C", new CultureInfo("en-US")) } }; diff --git a/src/Core/MailTemplates/Mjml/emails/Billing/Renewals/families-2019-renewal.mjml b/src/Core/MailTemplates/Mjml/emails/Billing/Renewals/families-2019-renewal.mjml index 11d82e2039..06f60e7724 100644 --- a/src/Core/MailTemplates/Mjml/emails/Billing/Renewals/families-2019-renewal.mjml +++ b/src/Core/MailTemplates/Mjml/emails/Billing/Renewals/families-2019-renewal.mjml @@ -18,8 +18,8 @@ at {{BaseAnnualRenewalPrice}} + tax. - As a long time Bitwarden customer, you will receive a one-time {{DiscountAmount}} loyalty discount for this renewal. - This year's renewal will now be billed annually at {{DiscountedAnnualRenewalPrice}} + tax. + As a long time Bitwarden customer, you will receive a one-time {{DiscountAmount}} loyalty discount for this year's renewal. + This renewal will now be billed annually at {{DiscountedAnnualRenewalPrice}} + tax. Questions? Contact diff --git a/src/Core/MailTemplates/Mjml/emails/Billing/Renewals/premium-renewal.mjml b/src/Core/MailTemplates/Mjml/emails/Billing/Renewals/premium-renewal.mjml index 1fe48c9ba9..defec91f0e 100644 --- a/src/Core/MailTemplates/Mjml/emails/Billing/Renewals/premium-renewal.mjml +++ b/src/Core/MailTemplates/Mjml/emails/Billing/Renewals/premium-renewal.mjml @@ -17,8 +17,8 @@ Your Bitwarden Premium subscription renews in 15 days. The price is updating to {{BaseMonthlyRenewalPrice}}/month, billed annually. - As an existing Bitwarden customer, you will receive a one-time {{DiscountAmount}} loyalty discount for this renewal. - This year's renewal now will be {{DiscountedMonthlyRenewalPrice}}/month, billed annually. + As an existing Bitwarden customer, you will receive a one-time {{DiscountAmount}} loyalty discount for this year's renewal. + This renewal will now be billed annually at {{DiscountedAnnualRenewalPrice}} + tax. Questions? Contact diff --git a/src/Core/Models/Mail/Billing/Renewal/Families2019Renewal/Families2019RenewalMailView.html.hbs b/src/Core/Models/Mail/Billing/Renewal/Families2019Renewal/Families2019RenewalMailView.html.hbs index 0befde11b5..2d7c9edf35 100644 --- a/src/Core/Models/Mail/Billing/Renewal/Families2019Renewal/Families2019RenewalMailView.html.hbs +++ b/src/Core/Models/Mail/Billing/Renewal/Families2019Renewal/Families2019RenewalMailView.html.hbs @@ -202,8 +202,8 @@ diff --git a/src/Core/Models/Mail/Billing/Renewal/Families2019Renewal/Families2019RenewalMailView.text.hbs b/src/Core/Models/Mail/Billing/Renewal/Families2019Renewal/Families2019RenewalMailView.text.hbs index 7178548772..9f40c88329 100644 --- a/src/Core/Models/Mail/Billing/Renewal/Families2019Renewal/Families2019RenewalMailView.text.hbs +++ b/src/Core/Models/Mail/Billing/Renewal/Families2019Renewal/Families2019RenewalMailView.text.hbs @@ -1,7 +1,7 @@ Your Bitwarden Families subscription renews in 15 days. The price is updating to {{BaseMonthlyRenewalPrice}}/month, billed annually at {{BaseAnnualRenewalPrice}} + tax. -As a long time Bitwarden customer, you will receive a one-time {{DiscountAmount}} loyalty discount for this renewal. -This year's renewal will now be billed annually at {{DiscountedAnnualRenewalPrice}} + tax. +As a long time Bitwarden customer, you will receive a one-time {{DiscountAmount}} loyalty discount for this year's renewal. +This renewal will now be billed annually at {{DiscountedAnnualRenewalPrice}} + tax. Questions? Contact support@bitwarden.com diff --git a/src/Core/Models/Mail/Billing/Renewal/Premium/PremiumRenewalMailView.cs b/src/Core/Models/Mail/Billing/Renewal/Premium/PremiumRenewalMailView.cs index 4006c92a63..0798c7dbc8 100644 --- a/src/Core/Models/Mail/Billing/Renewal/Premium/PremiumRenewalMailView.cs +++ b/src/Core/Models/Mail/Billing/Renewal/Premium/PremiumRenewalMailView.cs @@ -5,7 +5,7 @@ namespace Bit.Core.Models.Mail.Billing.Renewal.Premium; public class PremiumRenewalMailView : BaseMailView { public required string BaseMonthlyRenewalPrice { get; set; } - public required string DiscountedMonthlyRenewalPrice { get; set; } + public required string DiscountedAnnualRenewalPrice { get; set; } public required string DiscountAmount { get; set; } } diff --git a/src/Core/Models/Mail/Billing/Renewal/Premium/PremiumRenewalMailView.html.hbs b/src/Core/Models/Mail/Billing/Renewal/Premium/PremiumRenewalMailView.html.hbs index 9ce45ef7fe..db76520eed 100644 --- a/src/Core/Models/Mail/Billing/Renewal/Premium/PremiumRenewalMailView.html.hbs +++ b/src/Core/Models/Mail/Billing/Renewal/Premium/PremiumRenewalMailView.html.hbs @@ -201,8 +201,8 @@ diff --git a/src/Core/Models/Mail/Billing/Renewal/Premium/PremiumRenewalMailView.text.hbs b/src/Core/Models/Mail/Billing/Renewal/Premium/PremiumRenewalMailView.text.hbs index 15ad530a07..4b79826f71 100644 --- a/src/Core/Models/Mail/Billing/Renewal/Premium/PremiumRenewalMailView.text.hbs +++ b/src/Core/Models/Mail/Billing/Renewal/Premium/PremiumRenewalMailView.text.hbs @@ -1,6 +1,6 @@ Your Bitwarden Premium subscription renews in 15 days. The price is updating to {{BaseMonthlyRenewalPrice}}/month, billed annually. -As an existing Bitwarden customer, you will receive a one-time {{DiscountAmount}} loyalty discount for this renewal. -This year's renewal now will be {{DiscountedMonthlyRenewalPrice}}/month, billed annually. +As an existing Bitwarden customer, you will receive a one-time {{DiscountAmount}} loyalty discount for this year's renewal. +This renewal will now be billed annually at {{DiscountedAnnualRenewalPrice}} + tax. Questions? Contact support@bitwarden.com diff --git a/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs b/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs index 3b133c7d37..82d6c8acfd 100644 --- a/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs +++ b/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs @@ -280,7 +280,7 @@ public class UpcomingInvoiceHandlerTests email.ToEmails.Contains("user@example.com") && email.Subject == "Your Bitwarden Premium renewal is updating" && email.View.BaseMonthlyRenewalPrice == (plan.Seat.Price / 12).ToString("C", new CultureInfo("en-US")) && - email.View.DiscountedMonthlyRenewalPrice == (discountedPrice / 12).ToString("C", new CultureInfo("en-US")) && + email.View.DiscountedAnnualRenewalPrice == discountedPrice.ToString("C", new CultureInfo("en-US")) && email.View.DiscountAmount == $"{coupon.PercentOff}%" )); } @@ -2436,7 +2436,7 @@ public class UpcomingInvoiceHandlerTests email.Subject == "Your Bitwarden Premium renewal is updating" && email.View.BaseMonthlyRenewalPrice == (plan.Seat.Price / 12).ToString("C", new CultureInfo("en-US")) && email.View.DiscountAmount == "30%" && - email.View.DiscountedMonthlyRenewalPrice == (expectedDiscountedPrice / 12).ToString("C", new CultureInfo("en-US")) + email.View.DiscountedAnnualRenewalPrice == expectedDiscountedPrice.ToString("C", new CultureInfo("en-US")) )); await _mailService.DidNotReceive().SendInvoiceUpcoming( From b686da18dcdeecd93d2774fa348d16960cc4c959 Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Thu, 22 Jan 2026 09:01:06 -0600 Subject: [PATCH 38/49] [PM-30626] Fetch provided storage from Pricing Service when determining storage limit (#6845) * Fetch provided storage from Pricing Service * Run dotnet format * Gbubemi's feedback --- .../Services/SendValidationService.cs | 22 ++- .../Services/Implementations/CipherService.cs | 23 ++- .../Services/SendValidationServiceTests.cs | 120 +++++++++++ .../Vault/Services/CipherServiceTests.cs | 186 +++++++++++++++++- 4 files changed, 337 insertions(+), 14 deletions(-) create mode 100644 test/Core.Test/Tools/Services/SendValidationServiceTests.cs diff --git a/src/Core/Tools/SendFeatures/Services/SendValidationService.cs b/src/Core/Tools/SendFeatures/Services/SendValidationService.cs index c545c8b35f..bd987bb396 100644 --- a/src/Core/Tools/SendFeatures/Services/SendValidationService.cs +++ b/src/Core/Tools/SendFeatures/Services/SendValidationService.cs @@ -6,6 +6,7 @@ using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Services; +using Bit.Core.Billing.Pricing; using Bit.Core.Context; using Bit.Core.Exceptions; using Bit.Core.Repositories; @@ -27,6 +28,7 @@ public class SendValidationService : ISendValidationService private readonly GlobalSettings _globalSettings; private readonly ICurrentContext _currentContext; private readonly IPolicyRequirementQuery _policyRequirementQuery; + private readonly IPricingClient _pricingClient; @@ -38,7 +40,7 @@ public class SendValidationService : ISendValidationService IUserService userService, IPolicyRequirementQuery policyRequirementQuery, GlobalSettings globalSettings, - + IPricingClient pricingClient, ICurrentContext currentContext) { _userRepository = userRepository; @@ -48,6 +50,7 @@ public class SendValidationService : ISendValidationService _userService = userService; _policyRequirementQuery = policyRequirementQuery; _globalSettings = globalSettings; + _pricingClient = pricingClient; _currentContext = currentContext; } @@ -123,10 +126,19 @@ public class SendValidationService : ISendValidationService } else { - // Users that get access to file storage/premium from their organization get the default - // 1 GB max storage. - short limit = _globalSettings.SelfHosted ? Constants.SelfHostedMaxStorageGb : (short)1; - storageBytesRemaining = user.StorageBytesRemaining(limit); + // Users that get access to file storage/premium from their organization get storage + // based on the current premium plan from the pricing service + short provided; + if (_globalSettings.SelfHosted) + { + provided = Constants.SelfHostedMaxStorageGb; + } + else + { + var premiumPlan = await _pricingClient.GetAvailablePremiumPlan(); + provided = (short)premiumPlan.Storage.Provided; + } + storageBytesRemaining = user.StorageBytesRemaining(provided); } } else if (send.OrganizationId.HasValue) diff --git a/src/Core/Vault/Services/Implementations/CipherService.cs b/src/Core/Vault/Services/Implementations/CipherService.cs index fa2cfbb209..140399a37a 100644 --- a/src/Core/Vault/Services/Implementations/CipherService.cs +++ b/src/Core/Vault/Services/Implementations/CipherService.cs @@ -7,6 +7,7 @@ using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Services; +using Bit.Core.Billing.Pricing; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Platform.Push; @@ -46,6 +47,7 @@ public class CipherService : ICipherService private readonly IPolicyRequirementQuery _policyRequirementQuery; private readonly IApplicationCacheService _applicationCacheService; private readonly IFeatureService _featureService; + private readonly IPricingClient _pricingClient; public CipherService( ICipherRepository cipherRepository, @@ -65,7 +67,8 @@ public class CipherService : ICipherService IGetCipherPermissionsForUserQuery getCipherPermissionsForUserQuery, IPolicyRequirementQuery policyRequirementQuery, IApplicationCacheService applicationCacheService, - IFeatureService featureService) + IFeatureService featureService, + IPricingClient pricingClient) { _cipherRepository = cipherRepository; _folderRepository = folderRepository; @@ -85,6 +88,7 @@ public class CipherService : ICipherService _policyRequirementQuery = policyRequirementQuery; _applicationCacheService = applicationCacheService; _featureService = featureService; + _pricingClient = pricingClient; } public async Task SaveAsync(Cipher cipher, Guid savingUserId, DateTime? lastKnownRevisionDate, @@ -943,10 +947,19 @@ public class CipherService : ICipherService } else { - // Users that get access to file storage/premium from their organization get the default - // 1 GB max storage. - storageBytesRemaining = user.StorageBytesRemaining( - _globalSettings.SelfHosted ? Constants.SelfHostedMaxStorageGb : (short)1); + // Users that get access to file storage/premium from their organization get storage + // based on the current premium plan from the pricing service + short provided; + if (_globalSettings.SelfHosted) + { + provided = Constants.SelfHostedMaxStorageGb; + } + else + { + var premiumPlan = await _pricingClient.GetAvailablePremiumPlan(); + provided = (short)premiumPlan.Storage.Provided; + } + storageBytesRemaining = user.StorageBytesRemaining(provided); } } else if (cipher.OrganizationId.HasValue) diff --git a/test/Core.Test/Tools/Services/SendValidationServiceTests.cs b/test/Core.Test/Tools/Services/SendValidationServiceTests.cs new file mode 100644 index 0000000000..8adce1a29f --- /dev/null +++ b/test/Core.Test/Tools/Services/SendValidationServiceTests.cs @@ -0,0 +1,120 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Pricing; +using Bit.Core.Billing.Pricing.Premium; +using Bit.Core.Entities; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Tools.Entities; +using Bit.Core.Tools.Enums; +using Bit.Core.Tools.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Tools.Services; + +[SutProviderCustomize] +public class SendValidationServiceTests +{ + [Theory, BitAutoData] + public async Task StorageRemainingForSendAsync_OrgGrantedPremiumUser_UsesPricingService( + SutProvider sutProvider, + Send send, + User user) + { + // Arrange + send.UserId = user.Id; + send.OrganizationId = null; + send.Type = SendType.File; + user.Premium = false; + user.Storage = 1024L * 1024L * 1024L; // 1 GB used + user.EmailVerified = true; + + sutProvider.GetDependency().SelfHosted = false; + sutProvider.GetDependency().GetByIdAsync(user.Id).Returns(user); + sutProvider.GetDependency().CanAccessPremium(user).Returns(true); + + var premiumPlan = new Plan + { + Storage = new Purchasable { Provided = 5 } + }; + sutProvider.GetDependency().GetAvailablePremiumPlan().Returns(premiumPlan); + + // Act + var result = await sutProvider.Sut.StorageRemainingForSendAsync(send); + + // Assert + await sutProvider.GetDependency().Received(1).GetAvailablePremiumPlan(); + Assert.True(result > 0); + } + + [Theory, BitAutoData] + public async Task StorageRemainingForSendAsync_IndividualPremium_DoesNotCallPricingService( + SutProvider sutProvider, + Send send, + User user) + { + // Arrange + send.UserId = user.Id; + send.OrganizationId = null; + send.Type = SendType.File; + user.Premium = true; + user.MaxStorageGb = 10; + user.EmailVerified = true; + + sutProvider.GetDependency().GetByIdAsync(user.Id).Returns(user); + sutProvider.GetDependency().CanAccessPremium(user).Returns(true); + + // Act + var result = await sutProvider.Sut.StorageRemainingForSendAsync(send); + + // Assert - should NOT call pricing service for individual premium users + await sutProvider.GetDependency().DidNotReceive().GetAvailablePremiumPlan(); + } + + [Theory, BitAutoData] + public async Task StorageRemainingForSendAsync_SelfHosted_DoesNotCallPricingService( + SutProvider sutProvider, + Send send, + User user) + { + // Arrange + send.UserId = user.Id; + send.OrganizationId = null; + send.Type = SendType.File; + user.Premium = false; + user.EmailVerified = true; + + sutProvider.GetDependency().SelfHosted = true; + sutProvider.GetDependency().GetByIdAsync(user.Id).Returns(user); + sutProvider.GetDependency().CanAccessPremium(user).Returns(true); + + // Act + var result = await sutProvider.Sut.StorageRemainingForSendAsync(send); + + // Assert - should NOT call pricing service for self-hosted + await sutProvider.GetDependency().DidNotReceive().GetAvailablePremiumPlan(); + } + + [Theory, BitAutoData] + public async Task StorageRemainingForSendAsync_OrgSend_DoesNotCallPricingService( + SutProvider sutProvider, + Send send, + Organization org) + { + // Arrange + send.UserId = null; + send.OrganizationId = org.Id; + send.Type = SendType.File; + org.MaxStorageGb = 100; + + sutProvider.GetDependency().GetByIdAsync(org.Id).Returns(org); + + // Act + var result = await sutProvider.Sut.StorageRemainingForSendAsync(send); + + // Assert - should NOT call pricing service for org sends + await sutProvider.GetDependency().DidNotReceive().GetAvailablePremiumPlan(); + } +} diff --git a/test/Core.Test/Vault/Services/CipherServiceTests.cs b/test/Core.Test/Vault/Services/CipherServiceTests.cs index 058c6f68ab..5fc92a9d39 100644 --- a/test/Core.Test/Vault/Services/CipherServiceTests.cs +++ b/test/Core.Test/Vault/Services/CipherServiceTests.cs @@ -6,6 +6,8 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Services; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Pricing; +using Bit.Core.Billing.Pricing.Premium; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -2228,10 +2230,6 @@ public class CipherServiceTests .PushSyncCiphersAsync(deletingUserId); } - - - - [Theory] [OrganizationCipherCustomize] [BitAutoData] @@ -2387,6 +2385,186 @@ public class CipherServiceTests ids.Count() == cipherIds.Length && ids.All(id => cipherIds.Contains(id)))); } + [Theory, BitAutoData] + public async Task CreateAttachmentAsync_UserWithOrgGrantedPremium_UsesStorageFromPricingClient( + SutProvider sutProvider, CipherDetails cipher, Guid savingUserId) + { + var stream = new MemoryStream(new byte[100]); + var fileName = "test.txt"; + var key = "test-key"; + + // Setup cipher with user ownership + cipher.UserId = savingUserId; + cipher.OrganizationId = null; + + // Setup user WITHOUT personal premium (Premium = false), but with org-granted premium access + var user = new User + { + Id = savingUserId, + Premium = false, // User does not have personal premium + MaxStorageGb = null, // No personal storage allocation + Storage = 0 // No storage used yet + }; + + sutProvider.GetDependency() + .GetByIdAsync(savingUserId) + .Returns(user); + + // User has premium access through their organization + sutProvider.GetDependency() + .CanAccessPremium(user) + .Returns(true); + + // Mock GlobalSettings to indicate cloud (not self-hosted) + sutProvider.GetDependency().SelfHosted = false; + + // Mock the PricingClient to return a premium plan with 1 GB of storage + var premiumPlan = new Plan + { + Name = "Premium", + Available = true, + Seat = new Purchasable { StripePriceId = "price_123", Price = 10, Provided = 1 }, + Storage = new Purchasable { StripePriceId = "price_456", Price = 4, Provided = 1 } + }; + sutProvider.GetDependency() + .GetAvailablePremiumPlan() + .Returns(premiumPlan); + + sutProvider.GetDependency() + .UploadNewAttachmentAsync(Arg.Any(), cipher, Arg.Any()) + .Returns(Task.CompletedTask); + + sutProvider.GetDependency() + .ValidateFileAsync(cipher, Arg.Any(), Arg.Any()) + .Returns((true, 100L)); + + sutProvider.GetDependency() + .UpdateAttachmentAsync(Arg.Any()) + .Returns(Task.CompletedTask); + + sutProvider.GetDependency() + .ReplaceAsync(Arg.Any()) + .Returns(Task.CompletedTask); + + // Act + await sutProvider.Sut.CreateAttachmentAsync(cipher, stream, fileName, key, 100, savingUserId, false, cipher.RevisionDate); + + // Assert - PricingClient was called to get the premium plan storage + await sutProvider.GetDependency().Received(1).GetAvailablePremiumPlan(); + + // Assert - Attachment was uploaded successfully + await sutProvider.GetDependency().Received(1) + .UploadNewAttachmentAsync(Arg.Any(), cipher, Arg.Any()); + } + + [Theory, BitAutoData] + public async Task CreateAttachmentAsync_UserWithOrgGrantedPremium_ExceedsStorage_ThrowsBadRequest( + SutProvider sutProvider, CipherDetails cipher, Guid savingUserId) + { + var stream = new MemoryStream(new byte[100]); + var fileName = "test.txt"; + var key = "test-key"; + + // Setup cipher with user ownership + cipher.UserId = savingUserId; + cipher.OrganizationId = null; + + // Setup user WITHOUT personal premium, with org-granted access, but storage is full + var user = new User + { + Id = savingUserId, + Premium = false, + MaxStorageGb = null, + Storage = 1073741824 // 1 GB already used (equals the provided storage) + }; + + sutProvider.GetDependency() + .GetByIdAsync(savingUserId) + .Returns(user); + + sutProvider.GetDependency() + .CanAccessPremium(user) + .Returns(true); + + sutProvider.GetDependency().SelfHosted = false; + + // Premium plan provides 1 GB of storage + var premiumPlan = new Plan + { + Name = "Premium", + Available = true, + Seat = new Purchasable { StripePriceId = "price_123", Price = 10, Provided = 1 }, + Storage = new Purchasable { StripePriceId = "price_456", Price = 4, Provided = 1 } + }; + sutProvider.GetDependency() + .GetAvailablePremiumPlan() + .Returns(premiumPlan); + + // Act & Assert - Should throw because storage is full + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.CreateAttachmentAsync(cipher, stream, fileName, key, 100, savingUserId, false, cipher.RevisionDate)); + Assert.Contains("Not enough storage available", exception.Message); + } + + [Theory, BitAutoData] + public async Task CreateAttachmentAsync_UserWithOrgGrantedPremium_SelfHosted_UsesConstantStorage( + SutProvider sutProvider, CipherDetails cipher, Guid savingUserId) + { + var stream = new MemoryStream(new byte[100]); + var fileName = "test.txt"; + var key = "test-key"; + + // Setup cipher with user ownership + cipher.UserId = savingUserId; + cipher.OrganizationId = null; + + // Setup user WITHOUT personal premium, but with org-granted premium access + var user = new User + { + Id = savingUserId, + Premium = false, + MaxStorageGb = null, + Storage = 0 + }; + + sutProvider.GetDependency() + .GetByIdAsync(savingUserId) + .Returns(user); + + sutProvider.GetDependency() + .CanAccessPremium(user) + .Returns(true); + + // Mock GlobalSettings to indicate self-hosted + sutProvider.GetDependency().SelfHosted = true; + + sutProvider.GetDependency() + .UploadNewAttachmentAsync(Arg.Any(), cipher, Arg.Any()) + .Returns(Task.CompletedTask); + + sutProvider.GetDependency() + .ValidateFileAsync(cipher, Arg.Any(), Arg.Any()) + .Returns((true, 100L)); + + sutProvider.GetDependency() + .UpdateAttachmentAsync(Arg.Any()) + .Returns(Task.CompletedTask); + + sutProvider.GetDependency() + .ReplaceAsync(Arg.Any()) + .Returns(Task.CompletedTask); + + // Act + await sutProvider.Sut.CreateAttachmentAsync(cipher, stream, fileName, key, 100, savingUserId, false, cipher.RevisionDate); + + // Assert - PricingClient should NOT be called for self-hosted + await sutProvider.GetDependency().DidNotReceive().GetAvailablePremiumPlan(); + + // Assert - Attachment was uploaded successfully + await sutProvider.GetDependency().Received(1) + .UploadNewAttachmentAsync(Arg.Any(), cipher, Arg.Any()); + } + private async Task AssertNoActionsAsync(SutProvider sutProvider) { await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().GetManyOrganizationDetailsByOrganizationIdAsync(default); From bab4750caa472449b82681bbc71f14be5db26cf9 Mon Sep 17 00:00:00 2001 From: Vincent Salucci <26154748+vincentsalucci@users.noreply.github.com> Date: Thu, 22 Jan 2026 11:23:18 -0600 Subject: [PATCH 39/49] chore: add feature flag definition, refs PM-26463 (#6882) --- src/Core/Constants.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 10c68ddc42..9ffe199f1d 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -143,6 +143,7 @@ public static class FeatureFlagKeys public const string BlockClaimedDomainAccountCreation = "pm-28297-block-uninvited-claimed-domain-registration"; public const string IncreaseBulkReinviteLimitForCloud = "pm-28251-increase-bulk-reinvite-limit-for-cloud"; public const string PremiumAccessQuery = "pm-29495-refactor-premium-interface"; + public const string RefactorMembersComponent = "pm-29503-refactor-members-inheritance"; /* Architecture */ public const string DesktopMigrationMilestone1 = "desktop-ui-migration-milestone-1"; From 415821f173dcd2756e471a36d36f1b615b948142 Mon Sep 17 00:00:00 2001 From: Derek Nance Date: Thu, 22 Jan 2026 15:20:38 -0600 Subject: [PATCH 40/49] [PM-29142] Config for SSO cookie vending (#6880) This config may be used when a load balancer in front of Bitwarden is first verifying an auth cookie issued by an IdP before proxying the request to Bitwarden. --- dev/secrets.json.example | 10 +++++++++- src/Core/Settings/GlobalSettings.cs | 15 ++++++++++++++- src/Core/Settings/ICommunicationSettings.cs | 7 +++++++ src/Core/Settings/IGlobalSettings.cs | 1 + src/Core/Settings/ISsoCookieVendorSettings.cs | 8 ++++++++ 5 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 src/Core/Settings/ICommunicationSettings.cs create mode 100644 src/Core/Settings/ISsoCookieVendorSettings.cs diff --git a/dev/secrets.json.example b/dev/secrets.json.example index 0d4213aec1..7bf753e938 100644 --- a/dev/secrets.json.example +++ b/dev/secrets.json.example @@ -39,6 +39,14 @@ }, "licenseDirectory": "", "enableNewDeviceVerification": true, - "enableEmailVerification": true + "enableEmailVerification": true, + "communication": { + "bootstrap": "none", + "ssoCookieVendor": { + "idpLoginUrl": "", + "cookieName": "", + "cookieDomain": "" + } + } } } diff --git a/src/Core/Settings/GlobalSettings.cs b/src/Core/Settings/GlobalSettings.cs index 1f4fa6104b..6ccbd1ee85 100644 --- a/src/Core/Settings/GlobalSettings.cs +++ b/src/Core/Settings/GlobalSettings.cs @@ -83,7 +83,6 @@ public class GlobalSettings : IGlobalSettings public virtual ILaunchDarklySettings LaunchDarkly { get; set; } = new LaunchDarklySettings(); public virtual string DevelopmentDirectory { get; set; } public virtual IWebPushSettings WebPush { get; set; } = new WebPushSettings(); - public virtual int SendAccessTokenLifetimeInMinutes { get; set; } = 5; public virtual bool EnableEmailVerification { get; set; } public virtual string KdfDefaultHashKey { get; set; } @@ -93,6 +92,7 @@ public class GlobalSettings : IGlobalSettings public virtual string SendDefaultHashKey { get; set; } public virtual string PricingUri { get; set; } public virtual Fido2Settings Fido2 { get; set; } = new Fido2Settings(); + public virtual ICommunicationSettings Communication { get; set; } = new CommunicationSettings(); public string BuildExternalUri(string explicitValue, string name) { @@ -776,4 +776,17 @@ public class GlobalSettings : IGlobalSettings { public HashSet Origins { get; set; } } + + public class CommunicationSettings : ICommunicationSettings + { + public string Bootstrap { get; set; } = "none"; + public ISsoCookieVendorSettings SsoCookieVendor { get; set; } = new SsoCookieVendorSettings(); + } + + public class SsoCookieVendorSettings : ISsoCookieVendorSettings + { + public string IdpLoginUrl { get; set; } + public string CookieName { get; set; } + public string CookieDomain { get; set; } + } } diff --git a/src/Core/Settings/ICommunicationSettings.cs b/src/Core/Settings/ICommunicationSettings.cs new file mode 100644 index 0000000000..26259a8448 --- /dev/null +++ b/src/Core/Settings/ICommunicationSettings.cs @@ -0,0 +1,7 @@ +namespace Bit.Core.Settings; + +public interface ICommunicationSettings +{ + string Bootstrap { get; set; } + ISsoCookieVendorSettings SsoCookieVendor { get; set; } +} diff --git a/src/Core/Settings/IGlobalSettings.cs b/src/Core/Settings/IGlobalSettings.cs index c316836d09..7f5323fac0 100644 --- a/src/Core/Settings/IGlobalSettings.cs +++ b/src/Core/Settings/IGlobalSettings.cs @@ -29,4 +29,5 @@ public interface IGlobalSettings IWebPushSettings WebPush { get; set; } GlobalSettings.EventLoggingSettings EventLogging { get; set; } GlobalSettings.WebAuthnSettings WebAuthn { get; set; } + ICommunicationSettings Communication { get; set; } } diff --git a/src/Core/Settings/ISsoCookieVendorSettings.cs b/src/Core/Settings/ISsoCookieVendorSettings.cs new file mode 100644 index 0000000000..a9f2169b13 --- /dev/null +++ b/src/Core/Settings/ISsoCookieVendorSettings.cs @@ -0,0 +1,8 @@ +namespace Bit.Core.Settings; + +public interface ISsoCookieVendorSettings +{ + string IdpLoginUrl { get; set; } + string CookieName { get; set; } + string CookieDomain { get; set; } +} From 0cc72127d7fe50f9033931658a112762345141fb Mon Sep 17 00:00:00 2001 From: Mike Amirault Date: Thu, 22 Jan 2026 20:11:56 -0500 Subject: [PATCH 41/49] [PM-26405] Fix cipher favorite info being saved incorrectly on import (#6776) --- .../ImportFeatures/ImportCiphersCommand.cs | 2 +- .../ImportCiphersAsyncCommandTests.cs | 37 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/Core/Tools/ImportFeatures/ImportCiphersCommand.cs b/src/Core/Tools/ImportFeatures/ImportCiphersCommand.cs index fa558f5963..9300e3c4bb 100644 --- a/src/Core/Tools/ImportFeatures/ImportCiphersCommand.cs +++ b/src/Core/Tools/ImportFeatures/ImportCiphersCommand.cs @@ -74,7 +74,7 @@ public class ImportCiphersCommand : IImportCiphersCommand if (cipher.UserId.HasValue && cipher.Favorite) { - cipher.Favorites = $"{{\"{cipher.UserId.ToString().ToUpperInvariant()}\":\"true\"}}"; + cipher.Favorites = $"{{\"{cipher.UserId.ToString().ToUpperInvariant()}\":true}}"; } } diff --git a/test/Core.Test/Tools/ImportFeatures/ImportCiphersAsyncCommandTests.cs b/test/Core.Test/Tools/ImportFeatures/ImportCiphersAsyncCommandTests.cs index b92477e73d..aea06f39a8 100644 --- a/test/Core.Test/Tools/ImportFeatures/ImportCiphersAsyncCommandTests.cs +++ b/test/Core.Test/Tools/ImportFeatures/ImportCiphersAsyncCommandTests.cs @@ -135,6 +135,43 @@ public class ImportCiphersAsyncCommandTests Assert.Equal("You cannot import items into your personal vault because you are a member of an organization which forbids it.", exception.Message); } + [Theory, BitAutoData] + public async Task ImportIntoIndividualVaultAsync_FavoriteCiphers_PersistsFavoriteInfo( + Guid importingUserId, + List ciphers, + SutProvider sutProvider + ) + { + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PolicyRequirements) + .Returns(true); + + sutProvider.GetDependency() + .GetAsync(importingUserId) + .Returns(new OrganizationDataOwnershipPolicyRequirement( + OrganizationDataOwnershipState.Disabled, + [])); + + sutProvider.GetDependency() + .GetManyByUserIdAsync(importingUserId) + .Returns(new List()); + + var folders = new List(); + var folderRelationships = new List>(); + + ciphers.ForEach(c => + { + c.UserId = importingUserId; + c.Favorite = true; + }); + + await sutProvider.Sut.ImportIntoIndividualVaultAsync(folders, ciphers, folderRelationships, importingUserId); + + await sutProvider.GetDependency() + .Received(1) + .CreateAsync(importingUserId, Arg.Is>(ciphers => ciphers.All(c => c.Favorites == $"{{\"{importingUserId.ToString().ToUpperInvariant()}\":true}}")), Arg.Any>()); + } + [Theory, BitAutoData] public async Task ImportIntoOrganizationalVaultAsync_Success( Organization organization, From 93e2c971df7e58920f87ee40d468e5a6abcb38dd Mon Sep 17 00:00:00 2001 From: Dave <3836813+enmande@users.noreply.github.com> Date: Thu, 22 Jan 2026 20:24:15 -0500 Subject: [PATCH 42/49] feat(emergency-access) [PM-29584] Create Email for Emergency Access Removal (#6793) * feat(emergency-access) [PM-29584]: Add email template. * refactor(emergency-access) [PM-29584]: Move Emergency Access to Auth/UserFeatures. * refactor(emergency-access) [PM-29584]: Move EmergencyAccess tests to UserFeatures space. * feat(emergency-access) [PM-29584]: Add compiled EmergencyAccess templates. * test(emergency-access) [PM-29584]: Add mailer-specific tests. * refactor(emergency-access) [PM-29584]: Move mail to UserFeatures area. * feat(emergency-access) [PM-29584]: Update link for help pages, not web vault. * test(emergency-access) [PM-29584]: Update mail tests for new URL and single responsibility. * refactor(emergency-access) [PM-29584]: Add comments for added test. --- .../Controllers/EmergencyAccessController.cs | 2 +- .../Jobs/EmergencyAccessNotificationJob.cs | 2 +- .../Auth/Jobs/EmergencyAccessTimeoutJob.cs | 2 +- .../EmergencyAccess/EmergencyAccessService.cs | 19 +- .../IEmergencyAccessService.cs | 13 +- .../EmergencyAccessRemoveGranteesMailView.cs | 14 + ...gencyAccessRemoveGranteesMailView.html.hbs | 499 ++++++++++++++++++ ...gencyAccessRemoveGranteesMailView.text.hbs | 7 + .../EmergencyAccess/readme.md | 0 .../emergency-access-remove-grantees.mjml | 31 ++ .../Utilities/ServiceCollectionExtensions.cs | 1 + .../EmergencyAccessMailTests.cs | 153 ++++++ .../EmergencyAccessServiceTests.cs | 151 +++--- .../Registration/RegisterUserCommandTests.cs | 8 +- 14 files changed, 802 insertions(+), 100 deletions(-) rename src/Core/Auth/{Services => UserFeatures}/EmergencyAccess/EmergencyAccessService.cs (95%) rename src/Core/Auth/{Services => UserFeatures}/EmergencyAccess/IEmergencyAccessService.cs (93%) create mode 100644 src/Core/Auth/UserFeatures/EmergencyAccess/Mail/EmergencyAccessRemoveGranteesMailView.cs create mode 100644 src/Core/Auth/UserFeatures/EmergencyAccess/Mail/EmergencyAccessRemoveGranteesMailView.html.hbs create mode 100644 src/Core/Auth/UserFeatures/EmergencyAccess/Mail/EmergencyAccessRemoveGranteesMailView.text.hbs rename src/Core/Auth/{Services => UserFeatures}/EmergencyAccess/readme.md (100%) create mode 100644 src/Core/MailTemplates/Mjml/emails/Auth/UserFeatures/EmergencyAccess/emergency-access-remove-grantees.mjml create mode 100644 test/Core.Test/Auth/UserFeatures/EmergencyAccess/EmergencyAccessMailTests.cs rename test/Core.Test/Auth/{Services => UserFeatures/EmergencyAccess}/EmergencyAccessServiceTests.cs (92%) diff --git a/src/Api/Auth/Controllers/EmergencyAccessController.cs b/src/Api/Auth/Controllers/EmergencyAccessController.cs index 016cd82fe2..bd87e82c8a 100644 --- a/src/Api/Auth/Controllers/EmergencyAccessController.cs +++ b/src/Api/Auth/Controllers/EmergencyAccessController.cs @@ -7,7 +7,7 @@ using Bit.Api.Auth.Models.Request; using Bit.Api.Auth.Models.Response; using Bit.Api.Models.Response; using Bit.Api.Vault.Models.Response; -using Bit.Core.Auth.Services; +using Bit.Core.Auth.UserFeatures.EmergencyAccess; using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Core.Services; diff --git a/src/Api/Auth/Jobs/EmergencyAccessNotificationJob.cs b/src/Api/Auth/Jobs/EmergencyAccessNotificationJob.cs index c67cb9db3f..f58eaafaab 100644 --- a/src/Api/Auth/Jobs/EmergencyAccessNotificationJob.cs +++ b/src/Api/Auth/Jobs/EmergencyAccessNotificationJob.cs @@ -1,7 +1,7 @@ // FIXME: Update this file to be null safe and then delete the line below #nullable disable -using Bit.Core.Auth.Services; +using Bit.Core.Auth.UserFeatures.EmergencyAccess; using Bit.Core.Jobs; using Quartz; diff --git a/src/Api/Auth/Jobs/EmergencyAccessTimeoutJob.cs b/src/Api/Auth/Jobs/EmergencyAccessTimeoutJob.cs index f23774f060..63b861d920 100644 --- a/src/Api/Auth/Jobs/EmergencyAccessTimeoutJob.cs +++ b/src/Api/Auth/Jobs/EmergencyAccessTimeoutJob.cs @@ -1,7 +1,7 @@ // FIXME: Update this file to be null safe and then delete the line below #nullable disable -using Bit.Core.Auth.Services; +using Bit.Core.Auth.UserFeatures.EmergencyAccess; using Bit.Core.Jobs; using Quartz; diff --git a/src/Core/Auth/Services/EmergencyAccess/EmergencyAccessService.cs b/src/Core/Auth/UserFeatures/EmergencyAccess/EmergencyAccessService.cs similarity index 95% rename from src/Core/Auth/Services/EmergencyAccess/EmergencyAccessService.cs rename to src/Core/Auth/UserFeatures/EmergencyAccess/EmergencyAccessService.cs index 0072f85e61..6552f4bc69 100644 --- a/src/Core/Auth/Services/EmergencyAccess/EmergencyAccessService.cs +++ b/src/Core/Auth/UserFeatures/EmergencyAccess/EmergencyAccessService.cs @@ -4,7 +4,6 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.Repositories; -using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Models.Data; @@ -19,7 +18,7 @@ using Bit.Core.Vault.Models.Data; using Bit.Core.Vault.Repositories; using Bit.Core.Vault.Services; -namespace Bit.Core.Auth.Services; +namespace Bit.Core.Auth.UserFeatures.EmergencyAccess; public class EmergencyAccessService : IEmergencyAccessService { @@ -61,7 +60,7 @@ public class EmergencyAccessService : IEmergencyAccessService _removeOrganizationUserCommand = removeOrganizationUserCommand; } - public async Task InviteAsync(User grantorUser, string emergencyContactEmail, EmergencyAccessType accessType, int waitTime) + public async Task InviteAsync(User grantorUser, string emergencyContactEmail, EmergencyAccessType accessType, int waitTime) { if (!await _userService.CanAccessPremium(grantorUser)) { @@ -73,7 +72,7 @@ public class EmergencyAccessService : IEmergencyAccessService throw new BadRequestException("You cannot use Emergency Access Takeover because you are using Key Connector."); } - var emergencyAccess = new EmergencyAccess + var emergencyAccess = new Entities.EmergencyAccess { GrantorId = grantorUser.Id, Email = emergencyContactEmail.ToLowerInvariant(), @@ -113,7 +112,7 @@ public class EmergencyAccessService : IEmergencyAccessService await SendInviteAsync(emergencyAccess, NameOrEmail(grantorUser)); } - public async Task AcceptUserAsync(Guid emergencyAccessId, User granteeUser, string token, IUserService userService) + public async Task AcceptUserAsync(Guid emergencyAccessId, User granteeUser, string token, IUserService userService) { var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(emergencyAccessId); if (emergencyAccess == null) @@ -175,7 +174,7 @@ public class EmergencyAccessService : IEmergencyAccessService await _emergencyAccessRepository.DeleteAsync(emergencyAccess); } - public async Task ConfirmUserAsync(Guid emergencyAccessId, string key, Guid grantorId) + public async Task ConfirmUserAsync(Guid emergencyAccessId, string key, Guid grantorId) { var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(emergencyAccessId); if (emergencyAccess == null || emergencyAccess.Status != EmergencyAccessStatusType.Accepted || @@ -201,7 +200,7 @@ public class EmergencyAccessService : IEmergencyAccessService return emergencyAccess; } - public async Task SaveAsync(EmergencyAccess emergencyAccess, User grantorUser) + public async Task SaveAsync(Entities.EmergencyAccess emergencyAccess, User grantorUser) { if (!await _userService.CanAccessPremium(grantorUser)) { @@ -311,7 +310,7 @@ public class EmergencyAccessService : IEmergencyAccessService } // TODO PM-21687: rename this to something like InitiateRecoveryTakeoverAsync - public async Task<(EmergencyAccess, User)> TakeoverAsync(Guid emergencyAccessId, User granteeUser) + public async Task<(Entities.EmergencyAccess, User)> TakeoverAsync(Guid emergencyAccessId, User granteeUser) { var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(emergencyAccessId); @@ -429,7 +428,7 @@ public class EmergencyAccessService : IEmergencyAccessService return await _cipherService.GetAttachmentDownloadDataAsync(cipher, attachmentId); } - private async Task SendInviteAsync(EmergencyAccess emergencyAccess, string invitingUsersName) + private async Task SendInviteAsync(Entities.EmergencyAccess emergencyAccess, string invitingUsersName) { var token = _dataProtectorTokenizer.Protect(new EmergencyAccessInviteTokenable(emergencyAccess, _globalSettings.OrganizationInviteExpirationHours)); await _mailService.SendEmergencyAccessInviteEmailAsync(emergencyAccess, invitingUsersName, token); @@ -449,7 +448,7 @@ public class EmergencyAccessService : IEmergencyAccessService */ //TODO PM-21687: this IsValidRequest() checks the validity based on the granteeUser. There should be a complementary method for the grantorUser private static bool IsValidRequest( - EmergencyAccess availableAccess, + Entities.EmergencyAccess availableAccess, User requestingUser, EmergencyAccessType requestedAccessType) { diff --git a/src/Core/Auth/Services/EmergencyAccess/IEmergencyAccessService.cs b/src/Core/Auth/UserFeatures/EmergencyAccess/IEmergencyAccessService.cs similarity index 93% rename from src/Core/Auth/Services/EmergencyAccess/IEmergencyAccessService.cs rename to src/Core/Auth/UserFeatures/EmergencyAccess/IEmergencyAccessService.cs index de695bbd7d..860ae8bfb6 100644 --- a/src/Core/Auth/Services/EmergencyAccess/IEmergencyAccessService.cs +++ b/src/Core/Auth/UserFeatures/EmergencyAccess/IEmergencyAccessService.cs @@ -1,5 +1,4 @@ using Bit.Core.AdminConsole.Entities; -using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models.Data; using Bit.Core.Entities; @@ -7,7 +6,7 @@ using Bit.Core.Enums; using Bit.Core.Services; using Bit.Core.Vault.Models.Data; -namespace Bit.Core.Auth.Services; +namespace Bit.Core.Auth.UserFeatures.EmergencyAccess; public interface IEmergencyAccessService { @@ -20,7 +19,7 @@ public interface IEmergencyAccessService /// Type of emergency access allowed to the emergency contact /// The amount of time to pass before the invite is auto confirmed /// a new Emergency Access object - Task InviteAsync(User grantorUser, string emergencyContactEmail, EmergencyAccessType accessType, int waitTime); + Task InviteAsync(User grantorUser, string emergencyContactEmail, EmergencyAccessType accessType, int waitTime); /// /// Sends an invite to the emergency contact associated with the emergency access id. /// @@ -37,7 +36,7 @@ public interface IEmergencyAccessService /// the tokenable that was sent via email /// service dependency /// void - Task AcceptUserAsync(Guid emergencyAccessId, User granteeUser, string token, IUserService userService); + Task AcceptUserAsync(Guid emergencyAccessId, User granteeUser, string token, IUserService userService); /// /// The creator of the emergency access request can delete the request. /// @@ -53,7 +52,7 @@ public interface IEmergencyAccessService /// The grantor user key encrypted by the grantee public key; grantee.PubicKey(grantor.User.Key) /// Id of grantor user /// emergency access object associated with the Id passed in - Task ConfirmUserAsync(Guid emergencyAccessId, string key, Guid grantorId); + Task ConfirmUserAsync(Guid emergencyAccessId, string key, Guid grantorId); /// /// Fetches an emergency access object. The grantor user must own the object being fetched. /// @@ -67,7 +66,7 @@ public interface IEmergencyAccessService /// emergency access entity being updated /// grantor user /// void - Task SaveAsync(EmergencyAccess emergencyAccess, User grantorUser); + Task SaveAsync(Entities.EmergencyAccess emergencyAccess, User grantorUser); /// /// Initiates the recovery process. For either Takeover or view. Will send an email to the Grantor User notifying of the initiation. /// @@ -107,7 +106,7 @@ public interface IEmergencyAccessService /// Id of entity being accessed /// grantee user of the emergency access entity /// emergency access entity and the grantorUser - Task<(EmergencyAccess, User)> TakeoverAsync(Guid emergencyAccessId, User granteeUser); + Task<(Entities.EmergencyAccess, User)> TakeoverAsync(Guid emergencyAccessId, User granteeUser); /// /// Updates the grantor's password hash and updates the key for the EmergencyAccess entity. /// diff --git a/src/Core/Auth/UserFeatures/EmergencyAccess/Mail/EmergencyAccessRemoveGranteesMailView.cs b/src/Core/Auth/UserFeatures/EmergencyAccess/Mail/EmergencyAccessRemoveGranteesMailView.cs new file mode 100644 index 0000000000..4d60556785 --- /dev/null +++ b/src/Core/Auth/UserFeatures/EmergencyAccess/Mail/EmergencyAccessRemoveGranteesMailView.cs @@ -0,0 +1,14 @@ +using Bit.Core.Platform.Mail.Mailer; + +namespace Bit.Core.Auth.UserFeatures.EmergencyAccess.Mail; + +public class EmergencyAccessRemoveGranteesMailView : BaseMailView +{ + public required IEnumerable RemovedGranteeNames { get; set; } + public string EmergencyAccessHelpPageUrl => "https://bitwarden.com/help/emergency-access/"; +} + +public class EmergencyAccessRemoveGranteesMail : BaseMail +{ + public override string Subject { get; set; } = "Emergency contacts removed"; +} diff --git a/src/Core/Auth/UserFeatures/EmergencyAccess/Mail/EmergencyAccessRemoveGranteesMailView.html.hbs b/src/Core/Auth/UserFeatures/EmergencyAccess/Mail/EmergencyAccessRemoveGranteesMailView.html.hbs new file mode 100644 index 0000000000..405f2744bd --- /dev/null +++ b/src/Core/Auth/UserFeatures/EmergencyAccess/Mail/EmergencyAccessRemoveGranteesMailView.html.hbs @@ -0,0 +1,499 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+ +
+ - +
-

+

© 2025 Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa Barbara, CA, USA

Always confirm you are on a trusted Bitwarden domain before logging in:
- bitwarden.com | - Learn why we include this + bitwarden.com | + Learn why we include this

-
As a long time Bitwarden customer, you will receive a one-time {{DiscountAmount}} loyalty discount for this renewal. - This year's renewal will now be billed annually at {{DiscountedAnnualRenewalPrice}} + tax.
+
As a long time Bitwarden customer, you will receive a one-time {{DiscountAmount}} loyalty discount for this year's renewal. + This renewal will now be billed annually at {{DiscountedAnnualRenewalPrice}} + tax.
-
As an existing Bitwarden customer, you will receive a one-time {{DiscountAmount}} loyalty discount for this renewal. - This year's renewal now will be {{DiscountedMonthlyRenewalPrice}}/month, billed annually.
+
As an existing Bitwarden customer, you will receive a one-time {{DiscountAmount}} loyalty discount for this year's renewal. + This renewal will now be billed annually at {{DiscountedAnnualRenewalPrice}} + tax.
+ + + + + +
+ + + + + + + + +
+ + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + + + +
+ + + + + + + +
+ + + +
+ +
+ +

+ +

+ +
+ +
+ + + +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ +
+ +
+ + +
+ +
+ + + + + +
+ + +
+ + + + + + + + + + + +
+ + + + + + + +
+ + + +
+ + + + + + + +
+ + +
+ + + + + + + + + +
+ +
The following emergency contacts have been removed from your account: +
    + {{#each RemovedGranteeNames}} +
  • {{this}}
  • + {{/each}} +
+ Learn more about emergency access.
+ +
+ +
+ + +
+ +
+ + + +
+ +
+ + + + + + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + + + +
+ + + + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + +
+ +

+ © 2025 Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa + Barbara, CA, USA +

+

+ Always confirm you are on a trusted Bitwarden domain before logging + in:
+ bitwarden.com | + Learn why we include this +

+ +
+ +
+ + +
+ +
+ + + + + + + + + + \ No newline at end of file diff --git a/src/Core/Auth/UserFeatures/EmergencyAccess/Mail/EmergencyAccessRemoveGranteesMailView.text.hbs b/src/Core/Auth/UserFeatures/EmergencyAccess/Mail/EmergencyAccessRemoveGranteesMailView.text.hbs new file mode 100644 index 0000000000..3c17274f35 --- /dev/null +++ b/src/Core/Auth/UserFeatures/EmergencyAccess/Mail/EmergencyAccessRemoveGranteesMailView.text.hbs @@ -0,0 +1,7 @@ +The following emergency contacts have been removed from your account: + +{{#each RemovedGranteeNames}} + {{this}} +{{/each}} + +Learn more about emergency access at {{EmergencyAccessHelpPageUrl}} diff --git a/src/Core/Auth/Services/EmergencyAccess/readme.md b/src/Core/Auth/UserFeatures/EmergencyAccess/readme.md similarity index 100% rename from src/Core/Auth/Services/EmergencyAccess/readme.md rename to src/Core/Auth/UserFeatures/EmergencyAccess/readme.md diff --git a/src/Core/MailTemplates/Mjml/emails/Auth/UserFeatures/EmergencyAccess/emergency-access-remove-grantees.mjml b/src/Core/MailTemplates/Mjml/emails/Auth/UserFeatures/EmergencyAccess/emergency-access-remove-grantees.mjml new file mode 100644 index 0000000000..3af29a4414 --- /dev/null +++ b/src/Core/MailTemplates/Mjml/emails/Auth/UserFeatures/EmergencyAccess/emergency-access-remove-grantees.mjml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + The following emergency contacts have been removed from your account: +
    + {{#each RemovedGranteeNames}} +
  • {{this}}
  • + {{/each}} +
+ Learn more about emergency access. +
+
+
+
+ + + +
+
diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index 1bb9cb6c7a..5234a257cf 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -22,6 +22,7 @@ using Bit.Core.Auth.Repositories; using Bit.Core.Auth.Services; using Bit.Core.Auth.Services.Implementations; using Bit.Core.Auth.UserFeatures; +using Bit.Core.Auth.UserFeatures.EmergencyAccess; using Bit.Core.Auth.UserFeatures.PasswordValidation; using Bit.Core.Billing.Services; using Bit.Core.Billing.Services.Implementations; diff --git a/test/Core.Test/Auth/UserFeatures/EmergencyAccess/EmergencyAccessMailTests.cs b/test/Core.Test/Auth/UserFeatures/EmergencyAccess/EmergencyAccessMailTests.cs new file mode 100644 index 0000000000..8cb6c2c2fe --- /dev/null +++ b/test/Core.Test/Auth/UserFeatures/EmergencyAccess/EmergencyAccessMailTests.cs @@ -0,0 +1,153 @@ +using Bit.Core.Auth.UserFeatures.EmergencyAccess.Mail; +using Bit.Core.Models.Mail; +using Bit.Core.Platform.Mail.Delivery; +using Bit.Core.Platform.Mail.Mailer; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; +using GlobalSettings = Bit.Core.Settings.GlobalSettings; + +namespace Bit.Core.Test.Auth.UserFeatures.EmergencyAccess; + +[SutProviderCustomize] +public class EmergencyAccessMailTests +{ + // Constant values for all Emergency Access emails + private const string _emergencyAccessHelpUrl = "https://bitwarden.com/help/emergency-access/"; + private const string _emergencyAccessMailSubject = "Emergency contacts removed"; + + /// + /// Documents how to construct and send the emergency access removal email. + /// 1. Inject IMailer into their command/service + /// 2. Construct EmergencyAccessRemoveGranteesMail as shown below + /// 3. Call mailer.SendEmail(mail) + /// + [Theory, BitAutoData] + public async Task SendEmergencyAccessRemoveGranteesEmail_SingleGrantee_Success( + string grantorEmail, + string granteeName) + { + // Arrange + var logger = Substitute.For>(); + var globalSettings = new GlobalSettings { SelfHosted = false }; + var deliveryService = Substitute.For(); + var mailer = new Mailer( + new HandlebarMailRenderer(logger, globalSettings), + deliveryService); + + var mail = new EmergencyAccessRemoveGranteesMail + { + ToEmails = [grantorEmail], + View = new EmergencyAccessRemoveGranteesMailView + { + RemovedGranteeNames = [granteeName] + } + }; + + MailMessage sentMessage = null; + await deliveryService.SendEmailAsync(Arg.Do(message => + sentMessage = message + )); + + // Act + await mailer.SendEmail(mail); + + // Assert + Assert.NotNull(sentMessage); + Assert.Contains(grantorEmail, sentMessage.ToEmails); + + // Verify the content contains the grantee name + Assert.Contains(granteeName, sentMessage.TextContent); + Assert.Contains(granteeName, sentMessage.HtmlContent); + } + + /// + /// Documents handling multiple removed grantees in a single email. + /// + [Theory, BitAutoData] + public async Task SendEmergencyAccessRemoveGranteesEmail_MultipleGrantees_RendersAllNames( + string grantorEmail) + { + // Arrange + var logger = Substitute.For>(); + var globalSettings = new GlobalSettings { SelfHosted = false }; + var deliveryService = Substitute.For(); + var mailer = new Mailer( + new HandlebarMailRenderer(logger, globalSettings), + deliveryService); + + var granteeNames = new[] { "Alice", "Bob", "Carol" }; + + var mail = new EmergencyAccessRemoveGranteesMail + { + ToEmails = [grantorEmail], + View = new EmergencyAccessRemoveGranteesMailView + { + RemovedGranteeNames = granteeNames + } + }; + + MailMessage sentMessage = null; + await deliveryService.SendEmailAsync(Arg.Do(message => + sentMessage = message + )); + + // Act + await mailer.SendEmail(mail); + + // Assert - All grantee names should appear in the email + Assert.NotNull(sentMessage); + foreach (var granteeName in granteeNames) + { + Assert.Contains(granteeName, sentMessage.TextContent); + Assert.Contains(granteeName, sentMessage.HtmlContent); + } + } + + /// + /// Validates the required GranteeNames for the email view model. + /// + [Theory, BitAutoData] + public void EmergencyAccessRemoveGranteesMailView_GranteeNames_AreRequired( + string grantorEmail) + { + // Arrange - Shows the minimum required to construct the email + var mail = new EmergencyAccessRemoveGranteesMail + { + ToEmails = [grantorEmail], // Required: who to send to + View = new EmergencyAccessRemoveGranteesMailView + { + // Required: at least one removed grantee name + RemovedGranteeNames = ["Example Grantee"] + } + }; + + // Assert + Assert.NotNull(mail); + Assert.NotNull(mail.View); + Assert.NotEmpty(mail.View.RemovedGranteeNames); + } + + /// + /// Ensure consistency with help pages link and email subject. + /// + /// + /// + [Theory, BitAutoData] + public void EmergencyAccessRemoveGranteesMailView_SubjectAndHelpLink_MatchesExpectedValues(string grantorEmail, string granteeName) + { + // Arrange + var mail = new EmergencyAccessRemoveGranteesMail + { + ToEmails = [grantorEmail], + View = new EmergencyAccessRemoveGranteesMailView { RemovedGranteeNames = [granteeName] } + }; + + // Assert + Assert.NotNull(mail); + Assert.NotNull(mail.View); + Assert.Equal(_emergencyAccessMailSubject, mail.Subject); + Assert.Equal(_emergencyAccessHelpUrl, mail.View.EmergencyAccessHelpPageUrl); + } +} diff --git a/test/Core.Test/Auth/Services/EmergencyAccessServiceTests.cs b/test/Core.Test/Auth/UserFeatures/EmergencyAccess/EmergencyAccessServiceTests.cs similarity index 92% rename from test/Core.Test/Auth/Services/EmergencyAccessServiceTests.cs rename to test/Core.Test/Auth/UserFeatures/EmergencyAccess/EmergencyAccessServiceTests.cs index 006515aafd..83585e6667 100644 --- a/test/Core.Test/Auth/Services/EmergencyAccessServiceTests.cs +++ b/test/Core.Test/Auth/UserFeatures/EmergencyAccess/EmergencyAccessServiceTests.cs @@ -1,11 +1,10 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.Repositories; -using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Models.Data; -using Bit.Core.Auth.Services; +using Bit.Core.Auth.UserFeatures.EmergencyAccess; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -17,7 +16,7 @@ using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; using Xunit; -namespace Bit.Core.Test.Auth.Services; +namespace Bit.Core.Test.Auth.UserFeatures.EmergencyAccess; [SutProviderCustomize] public class EmergencyAccessServiceTests @@ -68,13 +67,13 @@ public class EmergencyAccessServiceTests Assert.Equal(EmergencyAccessStatusType.Invited, result.Status); await sutProvider.GetDependency() .Received(1) - .CreateAsync(Arg.Any()); + .CreateAsync(Arg.Any()); sutProvider.GetDependency>() .Received(1) .Protect(Arg.Any()); await sutProvider.GetDependency() .Received(1) - .SendEmergencyAccessInviteEmailAsync(Arg.Any(), Arg.Any(), Arg.Any()); + .SendEmergencyAccessInviteEmailAsync(Arg.Any(), Arg.Any(), Arg.Any()); } [Theory, BitAutoData] @@ -98,7 +97,7 @@ public class EmergencyAccessServiceTests User invitingUser, Guid emergencyAccessId) { - EmergencyAccess emergencyAccess = null; + Core.Auth.Entities.EmergencyAccess emergencyAccess = null; sutProvider.GetDependency() .GetByIdAsync(Arg.Any()) @@ -119,7 +118,7 @@ public class EmergencyAccessServiceTests User invitingUser, Guid emergencyAccessId) { - var emergencyAccess = new EmergencyAccess + var emergencyAccess = new Core.Auth.Entities.EmergencyAccess { Status = EmergencyAccessStatusType.Invited, GrantorId = Guid.NewGuid(), @@ -148,7 +147,7 @@ public class EmergencyAccessServiceTests User invitingUser, Guid emergencyAccessId) { - var emergencyAccess = new EmergencyAccess + var emergencyAccess = new Core.Auth.Entities.EmergencyAccess { Status = statusType, GrantorId = invitingUser.Id, @@ -172,7 +171,7 @@ public class EmergencyAccessServiceTests User invitingUser, Guid emergencyAccessId) { - var emergencyAccess = new EmergencyAccess + var emergencyAccess = new Core.Auth.Entities.EmergencyAccess { Status = EmergencyAccessStatusType.Invited, GrantorId = invitingUser.Id, @@ -194,7 +193,7 @@ public class EmergencyAccessServiceTests public async Task AcceptUserAsync_EmergencyAccessNull_ThrowsBadRequest( SutProvider sutProvider, User acceptingUser, string token) { - EmergencyAccess emergencyAccess = null; + Core.Auth.Entities.EmergencyAccess emergencyAccess = null; sutProvider.GetDependency() .GetByIdAsync(Arg.Any()) .Returns(emergencyAccess); @@ -209,7 +208,7 @@ public class EmergencyAccessServiceTests public async Task AcceptUserAsync_CannotUnprotectToken_ThrowsBadRequest( SutProvider sutProvider, User acceptingUser, - EmergencyAccess emergencyAccess, + Core.Auth.Entities.EmergencyAccess emergencyAccess, string token) { sutProvider.GetDependency() @@ -230,8 +229,8 @@ public class EmergencyAccessServiceTests public async Task AcceptUserAsync_TokenDataInvalid_ThrowsBadRequest( SutProvider sutProvider, User acceptingUser, - EmergencyAccess emergencyAccess, - EmergencyAccess wrongEmergencyAccess, + Core.Auth.Entities.EmergencyAccess emergencyAccess, + Core.Auth.Entities.EmergencyAccess wrongEmergencyAccess, string token) { sutProvider.GetDependency() @@ -257,7 +256,7 @@ public class EmergencyAccessServiceTests public async Task AcceptUserAsync_AcceptedStatus_ThrowsBadRequest( SutProvider sutProvider, User acceptingUser, - EmergencyAccess emergencyAccess, + Core.Auth.Entities.EmergencyAccess emergencyAccess, string token) { emergencyAccess.Status = EmergencyAccessStatusType.Accepted; @@ -284,7 +283,7 @@ public class EmergencyAccessServiceTests public async Task AcceptUserAsync_NotInvitedStatus_ThrowsBadRequest( SutProvider sutProvider, User acceptingUser, - EmergencyAccess emergencyAccess, + Core.Auth.Entities.EmergencyAccess emergencyAccess, string token) { emergencyAccess.Status = EmergencyAccessStatusType.Confirmed; @@ -311,7 +310,7 @@ public class EmergencyAccessServiceTests public async Task AcceptUserAsync_EmergencyAccessEmailDoesNotMatch_ThrowsBadRequest( SutProvider sutProvider, User acceptingUser, - EmergencyAccess emergencyAccess, + Core.Auth.Entities.EmergencyAccess emergencyAccess, string token) { emergencyAccess.Status = EmergencyAccessStatusType.Invited; @@ -339,7 +338,7 @@ public class EmergencyAccessServiceTests SutProvider sutProvider, User acceptingUser, User invitingUser, - EmergencyAccess emergencyAccess, + Core.Auth.Entities.EmergencyAccess emergencyAccess, string token) { emergencyAccess.Status = EmergencyAccessStatusType.Invited; @@ -364,7 +363,7 @@ public class EmergencyAccessServiceTests await sutProvider.GetDependency() .Received(1) - .ReplaceAsync(Arg.Is(x => x.Status == EmergencyAccessStatusType.Accepted)); + .ReplaceAsync(Arg.Is(x => x.Status == EmergencyAccessStatusType.Accepted)); await sutProvider.GetDependency() .Received(1) @@ -375,11 +374,11 @@ public class EmergencyAccessServiceTests public async Task DeleteAsync_EmergencyAccessNull_ThrowsBadRequest( SutProvider sutProvider, User invitingUser, - EmergencyAccess emergencyAccess) + Core.Auth.Entities.EmergencyAccess emergencyAccess) { sutProvider.GetDependency() .GetByIdAsync(Arg.Any()) - .Returns((EmergencyAccess)null); + .Returns((Core.Auth.Entities.EmergencyAccess)null); var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.DeleteAsync(emergencyAccess.Id, invitingUser.Id)); @@ -391,7 +390,7 @@ public class EmergencyAccessServiceTests public async Task DeleteAsync_EmergencyAccessGrantorIdNotEqual_ThrowsBadRequest( SutProvider sutProvider, User invitingUser, - EmergencyAccess emergencyAccess) + Core.Auth.Entities.EmergencyAccess emergencyAccess) { emergencyAccess.GrantorId = Guid.NewGuid(); sutProvider.GetDependency() @@ -408,7 +407,7 @@ public class EmergencyAccessServiceTests public async Task DeleteAsync_EmergencyAccessGranteeIdNotEqual_ThrowsBadRequest( SutProvider sutProvider, User invitingUser, - EmergencyAccess emergencyAccess) + Core.Auth.Entities.EmergencyAccess emergencyAccess) { emergencyAccess.GranteeId = Guid.NewGuid(); sutProvider.GetDependency() @@ -425,7 +424,7 @@ public class EmergencyAccessServiceTests public async Task DeleteAsync_EmergencyAccessIsDeleted_Success( SutProvider sutProvider, User user, - EmergencyAccess emergencyAccess) + Core.Auth.Entities.EmergencyAccess emergencyAccess) { emergencyAccess.GranteeId = user.Id; emergencyAccess.GrantorId = user.Id; @@ -443,7 +442,7 @@ public class EmergencyAccessServiceTests [Theory, BitAutoData] public async Task ConfirmUserAsync_EmergencyAccessNull_ThrowsBadRequest( SutProvider sutProvider, - EmergencyAccess emergencyAccess, + Core.Auth.Entities.EmergencyAccess emergencyAccess, string key, User grantorUser) { @@ -451,7 +450,7 @@ public class EmergencyAccessServiceTests emergencyAccess.Status = EmergencyAccessStatusType.RecoveryInitiated; sutProvider.GetDependency() .GetByIdAsync(Arg.Any()) - .Returns((EmergencyAccess)null); + .Returns((Core.Auth.Entities.EmergencyAccess)null); var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.ConfirmUserAsync(emergencyAccess.Id, key, grantorUser.Id)); @@ -463,7 +462,7 @@ public class EmergencyAccessServiceTests [Theory, BitAutoData] public async Task ConfirmUserAsync_EmergencyAccessStatusIsNotAccepted_ThrowsBadRequest( SutProvider sutProvider, - EmergencyAccess emergencyAccess, + Core.Auth.Entities.EmergencyAccess emergencyAccess, string key, User grantorUser) { @@ -484,7 +483,7 @@ public class EmergencyAccessServiceTests [Theory, BitAutoData] public async Task ConfirmUserAsync_EmergencyAccessGrantorIdNotEqualToConfirmingUserId_ThrowsBadRequest( SutProvider sutProvider, - EmergencyAccess emergencyAccess, + Core.Auth.Entities.EmergencyAccess emergencyAccess, string key, User grantorUser) { @@ -505,7 +504,7 @@ public class EmergencyAccessServiceTests SutProvider sutProvider, User confirmingUser, string key) { confirmingUser.UsesKeyConnector = true; - var emergencyAccess = new EmergencyAccess + var emergencyAccess = new Core.Auth.Entities.EmergencyAccess { Status = EmergencyAccessStatusType.Accepted, GrantorId = confirmingUser.Id, @@ -530,7 +529,7 @@ public class EmergencyAccessServiceTests [Theory, BitAutoData] public async Task ConfirmUserAsync_ConfirmsAndReplacesEmergencyAccess_Success( SutProvider sutProvider, - EmergencyAccess emergencyAccess, + Core.Auth.Entities.EmergencyAccess emergencyAccess, string key, User grantorUser, User granteeUser) @@ -553,7 +552,7 @@ public class EmergencyAccessServiceTests await sutProvider.GetDependency() .Received(1) - .ReplaceAsync(Arg.Is(x => x.Status == EmergencyAccessStatusType.Confirmed)); + .ReplaceAsync(Arg.Is(x => x.Status == EmergencyAccessStatusType.Confirmed)); await sutProvider.GetDependency() .Received(1) @@ -564,7 +563,7 @@ public class EmergencyAccessServiceTests public async Task SaveAsync_PremiumCannotUpdate_ThrowsBadRequest( SutProvider sutProvider, User savingUser) { - var emergencyAccess = new EmergencyAccess + var emergencyAccess = new Core.Auth.Entities.EmergencyAccess { Type = EmergencyAccessType.Takeover, GrantorId = savingUser.Id, @@ -586,7 +585,7 @@ public class EmergencyAccessServiceTests SutProvider sutProvider, User savingUser) { savingUser.Premium = true; - var emergencyAccess = new EmergencyAccess + var emergencyAccess = new Core.Auth.Entities.EmergencyAccess { Type = EmergencyAccessType.Takeover, GrantorId = new Guid(), @@ -611,7 +610,7 @@ public class EmergencyAccessServiceTests SutProvider sutProvider, User grantorUser) { grantorUser.UsesKeyConnector = true; - var emergencyAccess = new EmergencyAccess + var emergencyAccess = new Core.Auth.Entities.EmergencyAccess { Type = EmergencyAccessType.Takeover, GrantorId = grantorUser.Id, @@ -633,7 +632,7 @@ public class EmergencyAccessServiceTests SutProvider sutProvider, User grantorUser) { grantorUser.UsesKeyConnector = true; - var emergencyAccess = new EmergencyAccess + var emergencyAccess = new Core.Auth.Entities.EmergencyAccess { Type = EmergencyAccessType.View, GrantorId = grantorUser.Id, @@ -655,7 +654,7 @@ public class EmergencyAccessServiceTests SutProvider sutProvider, User grantorUser) { grantorUser.UsesKeyConnector = false; - var emergencyAccess = new EmergencyAccess + var emergencyAccess = new Core.Auth.Entities.EmergencyAccess { Type = EmergencyAccessType.Takeover, GrantorId = grantorUser.Id, @@ -678,7 +677,7 @@ public class EmergencyAccessServiceTests { sutProvider.GetDependency() .GetByIdAsync(Arg.Any()) - .Returns((EmergencyAccess)null); + .Returns((Core.Auth.Entities.EmergencyAccess)null); var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.InitiateAsync(new Guid(), initiatingUser)); @@ -692,7 +691,7 @@ public class EmergencyAccessServiceTests [Theory, BitAutoData] public async Task InitiateAsync_EmergencyAccessGranteeIdNotEqual_ThrowBadRequest( SutProvider sutProvider, - EmergencyAccess emergencyAccess, + Core.Auth.Entities.EmergencyAccess emergencyAccess, User initiatingUser) { emergencyAccess.GranteeId = new Guid(); @@ -712,7 +711,7 @@ public class EmergencyAccessServiceTests [Theory, BitAutoData] public async Task InitiateAsync_EmergencyAccessStatusIsNotConfirmed_ThrowBadRequest( SutProvider sutProvider, - EmergencyAccess emergencyAccess, + Core.Auth.Entities.EmergencyAccess emergencyAccess, User initiatingUser) { emergencyAccess.GranteeId = initiatingUser.Id; @@ -735,7 +734,7 @@ public class EmergencyAccessServiceTests SutProvider sutProvider, User initiatingUser, User grantor) { grantor.UsesKeyConnector = true; - var emergencyAccess = new EmergencyAccess + var emergencyAccess = new Core.Auth.Entities.EmergencyAccess { Status = EmergencyAccessStatusType.Confirmed, GranteeId = initiatingUser.Id, @@ -764,7 +763,7 @@ public class EmergencyAccessServiceTests SutProvider sutProvider, User initiatingUser, User grantor) { grantor.UsesKeyConnector = true; - var emergencyAccess = new EmergencyAccess + var emergencyAccess = new Core.Auth.Entities.EmergencyAccess { Status = EmergencyAccessStatusType.Confirmed, GranteeId = initiatingUser.Id, @@ -783,14 +782,14 @@ public class EmergencyAccessServiceTests await sutProvider.GetDependency() .Received(1) - .ReplaceAsync(Arg.Is(x => x.Status == EmergencyAccessStatusType.RecoveryInitiated)); + .ReplaceAsync(Arg.Is(x => x.Status == EmergencyAccessStatusType.RecoveryInitiated)); } [Theory, BitAutoData] public async Task InitiateAsync_RequestIsCorrect_Success( SutProvider sutProvider, User initiatingUser, User grantor) { - var emergencyAccess = new EmergencyAccess + var emergencyAccess = new Core.Auth.Entities.EmergencyAccess { Status = EmergencyAccessStatusType.Confirmed, GranteeId = initiatingUser.Id, @@ -809,7 +808,7 @@ public class EmergencyAccessServiceTests await sutProvider.GetDependency() .Received(1) - .ReplaceAsync(Arg.Is(x => x.Status == EmergencyAccessStatusType.RecoveryInitiated)); + .ReplaceAsync(Arg.Is(x => x.Status == EmergencyAccessStatusType.RecoveryInitiated)); } [Theory, BitAutoData] @@ -818,7 +817,7 @@ public class EmergencyAccessServiceTests { sutProvider.GetDependency() .GetByIdAsync(Arg.Any()) - .Returns((EmergencyAccess)null); + .Returns((Core.Auth.Entities.EmergencyAccess)null); var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.ApproveAsync(new Guid(), null)); @@ -829,7 +828,7 @@ public class EmergencyAccessServiceTests [Theory, BitAutoData] public async Task ApproveAsync_EmergencyAccessGrantorIdNotEquatToApproving_ThrowsBadRequest( SutProvider sutProvider, - EmergencyAccess emergencyAccess, + Core.Auth.Entities.EmergencyAccess emergencyAccess, User grantorUser) { emergencyAccess.Status = EmergencyAccessStatusType.RecoveryInitiated; @@ -851,7 +850,7 @@ public class EmergencyAccessServiceTests public async Task ApproveAsync_EmergencyAccessStatusNotRecoveryInitiated_ThrowsBadRequest( EmergencyAccessStatusType statusType, SutProvider sutProvider, - EmergencyAccess emergencyAccess, + Core.Auth.Entities.EmergencyAccess emergencyAccess, User grantorUser) { emergencyAccess.GrantorId = grantorUser.Id; @@ -869,7 +868,7 @@ public class EmergencyAccessServiceTests [Theory, BitAutoData] public async Task ApproveAsync_Success( SutProvider sutProvider, - EmergencyAccess emergencyAccess, + Core.Auth.Entities.EmergencyAccess emergencyAccess, User grantorUser, User granteeUser) { @@ -885,20 +884,20 @@ public class EmergencyAccessServiceTests await sutProvider.Sut.ApproveAsync(emergencyAccess.Id, grantorUser); await sutProvider.GetDependency() .Received(1) - .ReplaceAsync(Arg.Is(x => x.Status == EmergencyAccessStatusType.RecoveryApproved)); + .ReplaceAsync(Arg.Is(x => x.Status == EmergencyAccessStatusType.RecoveryApproved)); } [Theory, BitAutoData] public async Task RejectAsync_EmergencyAccessIdNull_ThrowsBadRequest( SutProvider sutProvider, - EmergencyAccess emergencyAccess, + Core.Auth.Entities.EmergencyAccess emergencyAccess, User GrantorUser) { emergencyAccess.GrantorId = GrantorUser.Id; emergencyAccess.Status = EmergencyAccessStatusType.Accepted; sutProvider.GetDependency() .GetByIdAsync(Arg.Any()) - .Returns((EmergencyAccess)null); + .Returns((Core.Auth.Entities.EmergencyAccess)null); var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.RejectAsync(emergencyAccess.Id, GrantorUser)); @@ -909,7 +908,7 @@ public class EmergencyAccessServiceTests [Theory, BitAutoData] public async Task RejectAsync_EmergencyAccessGrantorIdNotEqualToRequestUser_ThrowsBadRequest( SutProvider sutProvider, - EmergencyAccess emergencyAccess, + Core.Auth.Entities.EmergencyAccess emergencyAccess, User GrantorUser) { emergencyAccess.Status = EmergencyAccessStatusType.Accepted; @@ -930,7 +929,7 @@ public class EmergencyAccessServiceTests public async Task RejectAsync_EmergencyAccessStatusNotValid_ThrowsBadRequest( EmergencyAccessStatusType statusType, SutProvider sutProvider, - EmergencyAccess emergencyAccess, + Core.Auth.Entities.EmergencyAccess emergencyAccess, User GrantorUser) { emergencyAccess.GrantorId = GrantorUser.Id; @@ -951,7 +950,7 @@ public class EmergencyAccessServiceTests public async Task RejectAsync_Success( EmergencyAccessStatusType statusType, SutProvider sutProvider, - EmergencyAccess emergencyAccess, + Core.Auth.Entities.EmergencyAccess emergencyAccess, User GrantorUser, User GranteeUser) { @@ -968,7 +967,7 @@ public class EmergencyAccessServiceTests await sutProvider.GetDependency() .Received(1) - .ReplaceAsync(Arg.Is(x => x.Status == EmergencyAccessStatusType.Confirmed)); + .ReplaceAsync(Arg.Is(x => x.Status == EmergencyAccessStatusType.Confirmed)); } [Theory, BitAutoData] @@ -977,7 +976,7 @@ public class EmergencyAccessServiceTests { sutProvider.GetDependency() .GetByIdAsync(Arg.Any()) - .Returns((EmergencyAccess)null); + .Returns((Core.Auth.Entities.EmergencyAccess)null); var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.GetPoliciesAsync(default, default)); @@ -992,7 +991,7 @@ public class EmergencyAccessServiceTests public async Task GetPoliciesAsync_RequestNotValidStatusType_ThrowsBadRequest( EmergencyAccessStatusType statusType, SutProvider sutProvider, - EmergencyAccess emergencyAccess, + Core.Auth.Entities.EmergencyAccess emergencyAccess, User granteeUser) { emergencyAccess.GranteeId = granteeUser.Id; @@ -1010,7 +1009,7 @@ public class EmergencyAccessServiceTests [Theory, BitAutoData] public async Task GetPoliciesAsync_RequestNotValidType_ThrowsBadRequest( SutProvider sutProvider, - EmergencyAccess emergencyAccess, + Core.Auth.Entities.EmergencyAccess emergencyAccess, User granteeUser) { emergencyAccess.GranteeId = granteeUser.Id; @@ -1032,7 +1031,7 @@ public class EmergencyAccessServiceTests public async Task GetPoliciesAsync_OrganizationUserTypeNotOwner_ReturnsNull( OrganizationUserType userType, SutProvider sutProvider, - EmergencyAccess emergencyAccess, + Core.Auth.Entities.EmergencyAccess emergencyAccess, User granteeUser, User grantorUser, OrganizationUser grantorOrganizationUser) @@ -1062,7 +1061,7 @@ public class EmergencyAccessServiceTests [Theory, BitAutoData] public async Task GetPoliciesAsync_OrganizationUserEmpty_ReturnsNull( SutProvider sutProvider, - EmergencyAccess emergencyAccess, + Core.Auth.Entities.EmergencyAccess emergencyAccess, User granteeUser, User grantorUser) { @@ -1090,7 +1089,7 @@ public class EmergencyAccessServiceTests [Theory, BitAutoData] public async Task GetPoliciesAsync_ReturnsNotNull( SutProvider sutProvider, - EmergencyAccess emergencyAccess, + Core.Auth.Entities.EmergencyAccess emergencyAccess, User granteeUser, User grantorUser, OrganizationUser grantorOrganizationUser) @@ -1127,7 +1126,7 @@ public class EmergencyAccessServiceTests { sutProvider.GetDependency() .GetByIdAsync(Arg.Any()) - .Returns((EmergencyAccess)null); + .Returns((Core.Auth.Entities.EmergencyAccess)null); var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.TakeoverAsync(default, default)); @@ -1138,7 +1137,7 @@ public class EmergencyAccessServiceTests [Theory, BitAutoData] public async Task TakeoverAsync_RequestNotValid_GranteeNotEqualToRequestingUser_ThrowsBadRequest( SutProvider sutProvider, - EmergencyAccess emergencyAccess, + Core.Auth.Entities.EmergencyAccess emergencyAccess, User granteeUser) { emergencyAccess.Status = EmergencyAccessStatusType.RecoveryApproved; @@ -1161,7 +1160,7 @@ public class EmergencyAccessServiceTests public async Task TakeoverAsync_RequestNotValid_StatusType_ThrowsBadRequest( EmergencyAccessStatusType statusType, SutProvider sutProvider, - EmergencyAccess emergencyAccess, + Core.Auth.Entities.EmergencyAccess emergencyAccess, User granteeUser) { emergencyAccess.GranteeId = granteeUser.Id; @@ -1180,7 +1179,7 @@ public class EmergencyAccessServiceTests [Theory, BitAutoData] public async Task TakeoverAsync_RequestNotValid_TypeIsView_ThrowsBadRequest( SutProvider sutProvider, - EmergencyAccess emergencyAccess, + Core.Auth.Entities.EmergencyAccess emergencyAccess, User granteeUser) { emergencyAccess.GranteeId = granteeUser.Id; @@ -1203,7 +1202,7 @@ public class EmergencyAccessServiceTests User grantor) { grantor.UsesKeyConnector = true; - var emergencyAccess = new EmergencyAccess + var emergencyAccess = new Core.Auth.Entities.EmergencyAccess { GrantorId = grantor.Id, GranteeId = granteeUser.Id, @@ -1232,7 +1231,7 @@ public class EmergencyAccessServiceTests User grantor) { grantor.UsesKeyConnector = false; - var emergencyAccess = new EmergencyAccess + var emergencyAccess = new Core.Auth.Entities.EmergencyAccess { GrantorId = grantor.Id, GranteeId = granteeUser.Id, @@ -1260,7 +1259,7 @@ public class EmergencyAccessServiceTests { sutProvider.GetDependency() .GetByIdAsync(Arg.Any()) - .Returns((EmergencyAccess)null); + .Returns((Core.Auth.Entities.EmergencyAccess)null); var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.PasswordAsync(default, default, default, default)); @@ -1271,7 +1270,7 @@ public class EmergencyAccessServiceTests [Theory, BitAutoData] public async Task PasswordAsync_RequestNotValid_GranteeNotEqualToRequestingUser_ThrowsBadRequest( SutProvider sutProvider, - EmergencyAccess emergencyAccess, + Core.Auth.Entities.EmergencyAccess emergencyAccess, User granteeUser) { emergencyAccess.Status = EmergencyAccessStatusType.RecoveryApproved; @@ -1294,7 +1293,7 @@ public class EmergencyAccessServiceTests public async Task PasswordAsync_RequestNotValid_StatusType_ThrowsBadRequest( EmergencyAccessStatusType statusType, SutProvider sutProvider, - EmergencyAccess emergencyAccess, + Core.Auth.Entities.EmergencyAccess emergencyAccess, User granteeUser) { emergencyAccess.GranteeId = granteeUser.Id; @@ -1313,7 +1312,7 @@ public class EmergencyAccessServiceTests [Theory, BitAutoData] public async Task PasswordAsync_RequestNotValid_TypeIsView_ThrowsBadRequest( SutProvider sutProvider, - EmergencyAccess emergencyAccess, + Core.Auth.Entities.EmergencyAccess emergencyAccess, User granteeUser) { emergencyAccess.GranteeId = granteeUser.Id; @@ -1332,7 +1331,7 @@ public class EmergencyAccessServiceTests [Theory, BitAutoData] public async Task PasswordAsync_NonOrgUser_Success( SutProvider sutProvider, - EmergencyAccess emergencyAccess, + Core.Auth.Entities.EmergencyAccess emergencyAccess, User granteeUser, User grantorUser, string key, @@ -1367,7 +1366,7 @@ public class EmergencyAccessServiceTests public async Task PasswordAsync_OrgUser_NotOrganizationOwner_RemovedFromOrganization_Success( OrganizationUserType userType, SutProvider sutProvider, - EmergencyAccess emergencyAccess, + Core.Auth.Entities.EmergencyAccess emergencyAccess, User granteeUser, User grantorUser, OrganizationUser organizationUser, @@ -1408,7 +1407,7 @@ public class EmergencyAccessServiceTests [Theory, BitAutoData] public async Task PasswordAsync_OrgUser_IsOrganizationOwner_NotRemovedFromOrganization_Success( SutProvider sutProvider, - EmergencyAccess emergencyAccess, + Core.Auth.Entities.EmergencyAccess emergencyAccess, User granteeUser, User grantorUser, OrganizationUser organizationUser, @@ -1459,7 +1458,7 @@ public class EmergencyAccessServiceTests Enabled = true } }); - var emergencyAccess = new EmergencyAccess + var emergencyAccess = new Core.Auth.Entities.EmergencyAccess { GrantorId = grantor.Id, GranteeId = requestingUser.Id, @@ -1484,7 +1483,7 @@ public class EmergencyAccessServiceTests [Theory, BitAutoData] public async Task ViewAsync_EmergencyAccessTypeNotView_ThrowsBadRequest( SutProvider sutProvider, - EmergencyAccess emergencyAccess, + Core.Auth.Entities.EmergencyAccess emergencyAccess, User granteeUser) { emergencyAccess.GranteeId = granteeUser.Id; @@ -1500,7 +1499,7 @@ public class EmergencyAccessServiceTests [Theory, BitAutoData] public async Task GetAttachmentDownloadAsync_EmergencyAccessTypeNotView_ThrowsBadRequest( SutProvider sutProvider, - EmergencyAccess emergencyAccess, + Core.Auth.Entities.EmergencyAccess emergencyAccess, User granteeUser) { emergencyAccess.GranteeId = granteeUser.Id; diff --git a/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs b/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs index ae669398c5..b67bfaa131 100644 --- a/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs +++ b/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs @@ -2,7 +2,6 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Repositories; -using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; using Bit.Core.Auth.Models.Business.Tokenables; @@ -23,6 +22,7 @@ using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.WebUtilities; using NSubstitute; using Xunit; +using EmergencyAccessEntity = Bit.Core.Auth.Entities.EmergencyAccess; namespace Bit.Core.Test.Auth.UserFeatures.Registration; @@ -726,7 +726,7 @@ public class RegisterUserCommandTests [BitAutoData] public async Task RegisterUserViaAcceptEmergencyAccessInviteToken_Succeeds( SutProvider sutProvider, User user, string masterPasswordHash, - EmergencyAccess emergencyAccess, string acceptEmergencyAccessInviteToken, Guid acceptEmergencyAccessId) + EmergencyAccessEntity emergencyAccess, string acceptEmergencyAccessInviteToken, Guid acceptEmergencyAccessId) { // Arrange user.Email = $"test+{Guid.NewGuid()}@example.com"; @@ -767,7 +767,7 @@ public class RegisterUserCommandTests [Theory] [BitAutoData] public async Task RegisterUserViaAcceptEmergencyAccessInviteToken_InvalidToken_ThrowsBadRequestException(SutProvider sutProvider, User user, - string masterPasswordHash, EmergencyAccess emergencyAccess, string acceptEmergencyAccessInviteToken, Guid acceptEmergencyAccessId) + string masterPasswordHash, EmergencyAccessEntity emergencyAccess, string acceptEmergencyAccessInviteToken, Guid acceptEmergencyAccessId) { // Arrange user.Email = $"test+{Guid.NewGuid()}@example.com"; @@ -1112,7 +1112,7 @@ public class RegisterUserCommandTests [BitAutoData] public async Task RegisterUserViaAcceptEmergencyAccessInviteToken_BlockedDomain_ThrowsBadRequestException( SutProvider sutProvider, User user, string masterPasswordHash, - EmergencyAccess emergencyAccess, string acceptEmergencyAccessInviteToken, Guid acceptEmergencyAccessId) + EmergencyAccessEntity emergencyAccess, string acceptEmergencyAccessInviteToken, Guid acceptEmergencyAccessId) { // Arrange user.Email = "user@blocked-domain.com"; From bfe2e7717d32b2de3ca6128934b1e5496362ca29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rui=20Tom=C3=A9?= <108268980+r-tome@users.noreply.github.com> Date: Fri, 23 Jan 2026 11:07:56 +0000 Subject: [PATCH 43/49] [PM-30615] Fix Public API List Collections returning Default Collections (#6841) --- .../Controllers/CollectionsController.cs | 5 +- .../Public/CollectionsControllerTests.cs | 61 +++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/src/Api/Public/Controllers/CollectionsController.cs b/src/Api/Public/Controllers/CollectionsController.cs index a567062a5e..28de4dc16d 100644 --- a/src/Api/Public/Controllers/CollectionsController.cs +++ b/src/Api/Public/Controllers/CollectionsController.cs @@ -67,8 +67,9 @@ public class CollectionsController : Controller { var collections = await _collectionRepository.GetManyByOrganizationIdWithAccessAsync(_currentContext.OrganizationId.Value); - var collectionResponses = collections.Select(c => - new CollectionResponseModel(c.Item1, c.Item2.Groups)); + var collectionResponses = collections + .Where(c => c.Item1.Type != CollectionType.DefaultUserCollection) + .Select(c => new CollectionResponseModel(c.Item1, c.Item2.Groups)); var response = new ListResponseModel(collectionResponses); return new JsonResult(response); diff --git a/test/Api.IntegrationTest/Controllers/Public/CollectionsControllerTests.cs b/test/Api.IntegrationTest/Controllers/Public/CollectionsControllerTests.cs index a729abb849..3551ed4efa 100644 --- a/test/Api.IntegrationTest/Controllers/Public/CollectionsControllerTests.cs +++ b/test/Api.IntegrationTest/Controllers/Public/CollectionsControllerTests.cs @@ -6,6 +6,7 @@ using Bit.Api.Models.Public.Response; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Enums; +using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Data; using Bit.Core.Platform.Push; @@ -114,4 +115,64 @@ public class CollectionsControllerTests : IClassFixture, Assert.NotEmpty(result.Item2.Groups); Assert.NotEmpty(result.Item2.Users); } + + [Fact] + public async Task List_ExcludesDefaultUserCollections_IncludesGroupsAndUsers() + { + // Arrange + var collectionRepository = _factory.GetService(); + var groupRepository = _factory.GetService(); + + var defaultCollection = new Collection + { + OrganizationId = _organization.Id, + Name = "My Items", + Type = CollectionType.DefaultUserCollection + }; + await collectionRepository.CreateAsync(defaultCollection, null, null); + + var group = await groupRepository.CreateAsync(new Group + { + OrganizationId = _organization.Id, + Name = "Test Group", + ExternalId = $"test-group-{Guid.NewGuid()}", + }); + + var (_, user) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync( + _factory, + _organization.Id, + OrganizationUserType.User); + + var sharedCollection = await OrganizationTestHelpers.CreateCollectionAsync( + _factory, + _organization.Id, + "Shared Collection with Access", + externalId: "shared-collection-with-access", + groups: + [ + new CollectionAccessSelection { Id = group.Id, ReadOnly = false, HidePasswords = false, Manage = true } + ], + users: + [ + new CollectionAccessSelection { Id = user.Id, ReadOnly = true, HidePasswords = true, Manage = false } + ]); + + // Act + var response = await _client.GetFromJsonAsync>("public/collections"); + + // Assert + Assert.NotNull(response); + + Assert.DoesNotContain(response.Data, c => c.Id == defaultCollection.Id); + + var collectionResponse = response.Data.First(c => c.Id == sharedCollection.Id); + Assert.NotNull(collectionResponse.Groups); + Assert.Single(collectionResponse.Groups); + + var groupResponse = collectionResponse.Groups.First(); + Assert.Equal(group.Id, groupResponse.Id); + Assert.False(groupResponse.ReadOnly); + Assert.False(groupResponse.HidePasswords); + Assert.True(groupResponse.Manage); + } } From b360d6a00a547e88e4c524aea63c7bfaa25274fb Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 23 Jan 2026 11:43:05 +0000 Subject: [PATCH 44/49] [deps]: Update github-action minor (#6868) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Daniel James Smith <2670567+djsmith85@users.noreply.github.com> --- .github/workflows/build.yml | 10 +++++----- .github/workflows/test-database.yml | 6 +++--- .github/workflows/test.yml | 4 ++-- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a7717be4e8..f3cc279a58 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -31,7 +31,7 @@ jobs: persist-credentials: false - name: Set up .NET - uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1 + uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0 - name: Verify format run: dotnet format --verify-no-changes @@ -119,10 +119,10 @@ jobs: fi - name: Set up .NET - uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1 + uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0 - name: Set up Node - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: "npm" cache-dependency-path: "**/package-lock.json" @@ -294,7 +294,7 @@ jobs: persist-credentials: false - name: Set up .NET - uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1 + uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0 - name: Log in to Azure uses: bitwarden/gh-actions/azure-login@main @@ -420,7 +420,7 @@ jobs: persist-credentials: false - name: Set up .NET - uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1 + uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0 - name: Print environment run: | diff --git a/.github/workflows/test-database.yml b/.github/workflows/test-database.yml index 4630c18e40..25ff9d0488 100644 --- a/.github/workflows/test-database.yml +++ b/.github/workflows/test-database.yml @@ -49,7 +49,7 @@ jobs: persist-credentials: false - name: Set up .NET - uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1 + uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0 - name: Restore tools run: dotnet tool restore @@ -156,7 +156,7 @@ jobs: run: 'docker logs "$(docker ps --quiet --filter "name=mssql")"' - name: Report test results - uses: dorny/test-reporter@fe45e9537387dac839af0d33ba56eed8e24189e8 # v2.3.0 + uses: dorny/test-reporter@b082adf0eced0765477756c2a610396589b8c637 # v2.5.0 if: ${{ github.event.pull_request.head.repo.full_name == github.repository && !cancelled() }} with: name: Test Results @@ -183,7 +183,7 @@ jobs: persist-credentials: false - name: Set up .NET - uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1 + uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0 - name: Print environment run: | diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a6d07bb650..12b5355c33 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -32,7 +32,7 @@ jobs: persist-credentials: false - name: Set up .NET - uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1 + uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0 - name: Install rust uses: dtolnay/rust-toolchain@f7ccc83f9ed1e5b9c81d8a67d7ad1a747e22a561 # stable @@ -59,7 +59,7 @@ jobs: run: dotnet test ./bitwarden_license/test --configuration Debug --logger "trx;LogFileName=bw-test-results.trx" /p:CoverletOutputFormatter="cobertura" --collect:"XPlat Code Coverage" - name: Report test results - uses: dorny/test-reporter@fe45e9537387dac839af0d33ba56eed8e24189e8 # v2.3.0 + uses: dorny/test-reporter@b082adf0eced0765477756c2a610396589b8c637 # v2.5.0 if: ${{ github.event.pull_request.head.repo.full_name == github.repository && !cancelled() }} with: name: Test Results From 867e61694bf31722992c4f779e9326cc48cc01e3 Mon Sep 17 00:00:00 2001 From: Robyn MacCallum Date: Fri, 23 Jan 2026 09:05:58 -0500 Subject: [PATCH 45/49] Add NotificationUndeterminedCipherScenarioLogic feature flag (#6884) * Add NotificationUndeterminedCipherScenarioLogic feature flag * Remove whitespace --- src/Core/Constants.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 9ffe199f1d..c3c5cb5e5b 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -174,6 +174,7 @@ public static class FeatureFlagKeys public const string MacOsNativeCredentialSync = "macos-native-credential-sync"; public const string WindowsDesktopAutotype = "windows-desktop-autotype"; public const string WindowsDesktopAutotypeGA = "windows-desktop-autotype-ga"; + public const string NotificationUndeterminedCipherScenarioLogic = "undetermined-cipher-scenario-logic"; /* Billing Team */ public const string TrialPayment = "PM-8163-trial-payment"; From b623e381b4c52b993c83db7f31c1b4cfd82431c0 Mon Sep 17 00:00:00 2001 From: Vijay Oommen Date: Fri, 23 Jan 2026 08:34:19 -0600 Subject: [PATCH 46/49] PM-30799 added validation for DomainName (#6856) --- .../Request/OrganizationDomainRequestModel.cs | 2 + src/Core/Utilities/DomainNameAttribute.cs | 64 ++++++++++++++ .../Utilities/DomainNameAttributeTests.cs | 84 +++++++++++++++++++ 3 files changed, 150 insertions(+) create mode 100644 src/Core/Utilities/DomainNameAttribute.cs create mode 100644 test/Core.Test/Utilities/DomainNameAttributeTests.cs diff --git a/src/Api/AdminConsole/Models/Request/OrganizationDomainRequestModel.cs b/src/Api/AdminConsole/Models/Request/OrganizationDomainRequestModel.cs index 46b253da31..3a2ada719f 100644 --- a/src/Api/AdminConsole/Models/Request/OrganizationDomainRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/OrganizationDomainRequestModel.cs @@ -2,11 +2,13 @@ #nullable disable using System.ComponentModel.DataAnnotations; +using Bit.Core.Utilities; namespace Bit.Api.AdminConsole.Models.Request; public class OrganizationDomainRequestModel { [Required] + [DomainNameValidator] public string DomainName { get; set; } } diff --git a/src/Core/Utilities/DomainNameAttribute.cs b/src/Core/Utilities/DomainNameAttribute.cs new file mode 100644 index 0000000000..9b571e96d7 --- /dev/null +++ b/src/Core/Utilities/DomainNameAttribute.cs @@ -0,0 +1,64 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.RegularExpressions; + +namespace Bit.Core.Utilities; + +/// +/// https://bitwarden.atlassian.net/browse/VULN-376 +/// Domain names are vulnerable to XSS attacks if not properly validated. +/// Domain names can contain letters, numbers, dots, and hyphens. +/// Domain names maybe internationalized (IDN) and contain unicode characters. +/// +public class DomainNameValidatorAttribute : ValidationAttribute +{ + // RFC 1123 compliant domain name regex + // - Allows alphanumeric characters and hyphens + // - Cannot start or end with a hyphen + // - Each label (part between dots) must be 1-63 characters + // - Total length should not exceed 253 characters + // - Supports internationalized domain names (IDN) - which is why this regex includes unicode ranges + private static readonly Regex _domainNameRegex = new( + @"^(?:[a-zA-Z0-9\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF](?:[a-zA-Z0-9\-\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]{0,61}[a-zA-Z0-9\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])?\.)*[a-zA-Z0-9\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF](?:[a-zA-Z0-9\-\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]{0,61}[a-zA-Z0-9\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])?$", + RegexOptions.Compiled | RegexOptions.IgnoreCase + ); + + public DomainNameValidatorAttribute() + : base("The {0} field is not a valid domain name.") + { } + + public override bool IsValid(object? value) + { + if (value == null) + { + return true; // Use [Required] for null checks + } + + var domainName = value.ToString(); + + if (string.IsNullOrWhiteSpace(domainName)) + { + return false; + } + + // Reject if contains any whitespace (including leading/trailing spaces, tabs, newlines) + if (domainName.Any(char.IsWhiteSpace)) + { + return false; + } + + // Check length constraints + if (domainName.Length > 253) + { + return false; + } + + // Check for control characters or other dangerous characters + if (domainName.Any(c => char.IsControl(c) || c == '<' || c == '>' || c == '"' || c == '\'' || c == '&')) + { + return false; + } + + // Validate against domain name regex + return _domainNameRegex.IsMatch(domainName); + } +} diff --git a/test/Core.Test/Utilities/DomainNameAttributeTests.cs b/test/Core.Test/Utilities/DomainNameAttributeTests.cs new file mode 100644 index 0000000000..3f3190c9a1 --- /dev/null +++ b/test/Core.Test/Utilities/DomainNameAttributeTests.cs @@ -0,0 +1,84 @@ +using Bit.Core.Utilities; +using Xunit; + +namespace Bit.Core.Test.Utilities; + +public class DomainNameValidatorAttributeTests +{ + [Theory] + [InlineData("example.com")] // basic domain + [InlineData("sub.example.com")] // subdomain + [InlineData("sub.sub2.example.com")] // multiple subdomains + [InlineData("example-dash.com")] // domain with dash + [InlineData("123example.com")] // domain starting with number + [InlineData("example123.com")] // domain with numbers + [InlineData("e.com")] // short domain + [InlineData("very-long-subdomain-name.example.com")] // long subdomain + [InlineData("wörldé.com")] // unicode domain (IDN) + public void IsValid_ReturnsTrueWhenValid(string domainName) + { + var sut = new DomainNameValidatorAttribute(); + + var actual = sut.IsValid(domainName); + + Assert.True(actual); + } + + [Theory] + [InlineData("")] // XSS attempt + [InlineData("example.com