diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 5b1d2a85dc..c3e95e724b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -97,9 +97,7 @@ src/Admin/Views/Tools @bitwarden/team-billing-dev .github/workflows/test-database.yml @bitwarden/team-platform-dev .github/workflows/test.yml @bitwarden/team-platform-dev **/*Platform* @bitwarden/team-platform-dev -**/.dockerignore @bitwarden/team-platform-dev -**/Dockerfile @bitwarden/team-platform-dev -**/entrypoint.sh @bitwarden/team-platform-dev + # The PushType enum is expected to be editted by anyone without need for Platform review src/Core/Platform/Push/PushType.cs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c207620bae..bf9778651a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -263,7 +263,7 @@ jobs: - name: Scan Docker image id: container-scan - uses: anchore/scan-action@62b74fb7bb810d2c45b1865f47a77655621862a5 # v7.2.3 + uses: anchore/scan-action@0d444ed77d83ee2ba7f5ced0d90d640a1281d762 # v7.3.0 with: image: ${{ steps.image-tags.outputs.primary_tag }} fail-build: false diff --git a/Directory.Build.props b/Directory.Build.props index c4c7f342fa..d223304ada 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -13,6 +13,10 @@ true + + false + false + diff --git a/bitwarden_license/src/Scim/Scim.csproj b/bitwarden_license/src/Scim/Scim.csproj index 7d1ea317b2..d3858e1225 100644 --- a/bitwarden_license/src/Scim/Scim.csproj +++ b/bitwarden_license/src/Scim/Scim.csproj @@ -1,4 +1,5 @@  + bitwarden-Scim diff --git a/bitwarden_license/src/Sso/Controllers/AccountController.cs b/bitwarden_license/src/Sso/Controllers/AccountController.cs index dde2ac7a46..3d998b6a75 100644 --- a/bitwarden_license/src/Sso/Controllers/AccountController.cs +++ b/bitwarden_license/src/Sso/Controllers/AccountController.cs @@ -2,7 +2,7 @@ using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; -using Bit.Core.AdminConsole.Repositories; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; @@ -45,7 +45,7 @@ public class AccountController : Controller private readonly ISsoConfigRepository _ssoConfigRepository; private readonly ISsoUserRepository _ssoUserRepository; private readonly IUserRepository _userRepository; - private readonly IPolicyRepository _policyRepository; + private readonly IPolicyQuery _policyQuery; private readonly IUserService _userService; private readonly II18nService _i18nService; private readonly UserManager _userManager; @@ -67,7 +67,7 @@ public class AccountController : Controller ISsoConfigRepository ssoConfigRepository, ISsoUserRepository ssoUserRepository, IUserRepository userRepository, - IPolicyRepository policyRepository, + IPolicyQuery policyQuery, IUserService userService, II18nService i18nService, UserManager userManager, @@ -88,7 +88,7 @@ public class AccountController : Controller _userRepository = userRepository; _ssoConfigRepository = ssoConfigRepository; _ssoUserRepository = ssoUserRepository; - _policyRepository = policyRepository; + _policyQuery = policyQuery; _userService = userService; _i18nService = i18nService; _userManager = userManager; @@ -687,9 +687,8 @@ public class AccountController : Controller await _registerUserCommand.RegisterSSOAutoProvisionedUserAsync(newUser, organization); // If the organization has 2fa policy enabled, make sure to default jit user 2fa to email - var twoFactorPolicy = - await _policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.TwoFactorAuthentication); - if (twoFactorPolicy != null && twoFactorPolicy.Enabled) + var twoFactorPolicy = await _policyQuery.RunAsync(organization.Id, PolicyType.TwoFactorAuthentication); + if (twoFactorPolicy.Enabled) { newUser.SetTwoFactorProviders(new Dictionary { diff --git a/bitwarden_license/src/Sso/Sso.csproj b/bitwarden_license/src/Sso/Sso.csproj index 2a1c14ae5a..709e8c2c4a 100644 --- a/bitwarden_license/src/Sso/Sso.csproj +++ b/bitwarden_license/src/Sso/Sso.csproj @@ -1,4 +1,5 @@  + bitwarden-Sso diff --git a/bitwarden_license/src/Sso/Startup.cs b/bitwarden_license/src/Sso/Startup.cs index a2f363d533..c4c676d51f 100644 --- a/bitwarden_license/src/Sso/Startup.cs +++ b/bitwarden_license/src/Sso/Startup.cs @@ -8,7 +8,6 @@ using Bit.Core.Utilities; using Bit.SharedWeb.Utilities; using Bit.Sso.Utilities; using Duende.IdentityServer.Services; -using Microsoft.IdentityModel.Logging; using Stripe; namespace Bit.Sso; @@ -91,20 +90,15 @@ public class Startup public void Configure( IApplicationBuilder app, - IWebHostEnvironment env, + IWebHostEnvironment environment, IHostApplicationLifetime appLifetime, GlobalSettings globalSettings, ILogger logger) { - if (env.IsDevelopment() || globalSettings.SelfHosted) - { - IdentityModelEventSource.ShowPII = true; - } - // Add general security headers app.UseMiddleware(); - if (!env.IsDevelopment()) + if (!environment.IsDevelopment()) { var uri = new Uri(globalSettings.BaseServiceUri.Sso); app.Use(async (ctx, next) => @@ -120,7 +114,7 @@ public class Startup app.UseForwardedHeaders(globalSettings); } - if (env.IsDevelopment()) + if (environment.IsDevelopment()) { app.UseDeveloperExceptionPage(); app.UseCookiePolicy(); diff --git a/dev/setup_secrets.ps1 b/dev/setup_secrets.ps1 index 5013ca8bac..a41890bc46 100755 --- a/dev/setup_secrets.ps1 +++ b/dev/setup_secrets.ps1 @@ -28,6 +28,7 @@ $projects = @{ Scim = "../bitwarden_license/src/Scim" IntegrationTests = "../test/Infrastructure.IntegrationTest" SeederApi = "../util/SeederApi" + SeederUtility = "../util/DbSeederUtility" } foreach ($key in $projects.keys) { diff --git a/global.json b/global.json index 4cbe3f083a..970250aec9 100644 --- a/global.json +++ b/global.json @@ -6,6 +6,6 @@ "msbuild-sdks": { "Microsoft.Build.Traversal": "4.1.0", "Microsoft.Build.Sql": "1.0.0", - "Bitwarden.Server.Sdk": "1.2.0" + "Bitwarden.Server.Sdk": "1.4.0" } } diff --git a/src/Admin/Admin.csproj b/src/Admin/Admin.csproj index b815ddea82..5733589466 100644 --- a/src/Admin/Admin.csproj +++ b/src/Admin/Admin.csproj @@ -1,4 +1,5 @@ + bitwarden-Admin diff --git a/src/Admin/Controllers/UsersController.cs b/src/Admin/Controllers/UsersController.cs index f42b22b098..eb7f1e8c04 100644 --- a/src/Admin/Controllers/UsersController.cs +++ b/src/Admin/Controllers/UsersController.cs @@ -86,7 +86,7 @@ public class UsersController : Controller return RedirectToAction("Index"); } - var ciphers = await _cipherRepository.GetManyByUserIdAsync(id); + var ciphers = await _cipherRepository.GetManyByUserIdAsync(id, withOrganizations: false); var isTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user); var verifiedDomain = await _userService.IsClaimedByAnyOrganizationAsync(user.Id); diff --git a/src/Admin/Program.cs b/src/Admin/Program.cs index 006a8223b2..80a1ae058c 100644 --- a/src/Admin/Program.cs +++ b/src/Admin/Program.cs @@ -8,7 +8,7 @@ public class Program { Host .CreateDefaultBuilder(args) - .ConfigureCustomAppConfiguration(args) + .UseBitwardenSdk() .ConfigureWebHostDefaults(webBuilder => { webBuilder.ConfigureKestrel(o => diff --git a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs index 024c54a48e..37b58bc252 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs @@ -57,7 +57,7 @@ public class OrganizationUsersController : BaseAdminConsoleController private readonly ICollectionRepository _collectionRepository; private readonly IGroupRepository _groupRepository; private readonly IUserService _userService; - private readonly IPolicyRepository _policyRepository; + private readonly IPolicyQuery _policyQuery; private readonly ICurrentContext _currentContext; private readonly ICountNewSmSeatsRequiredQuery _countNewSmSeatsRequiredQuery; private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand; @@ -90,7 +90,7 @@ public class OrganizationUsersController : BaseAdminConsoleController ICollectionRepository collectionRepository, IGroupRepository groupRepository, IUserService userService, - IPolicyRepository policyRepository, + IPolicyQuery policyQuery, ICurrentContext currentContext, ICountNewSmSeatsRequiredQuery countNewSmSeatsRequiredQuery, IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand, @@ -123,7 +123,7 @@ public class OrganizationUsersController : BaseAdminConsoleController _collectionRepository = collectionRepository; _groupRepository = groupRepository; _userService = userService; - _policyRepository = policyRepository; + _policyQuery = policyQuery; _currentContext = currentContext; _countNewSmSeatsRequiredQuery = countNewSmSeatsRequiredQuery; _updateSecretsManagerSubscriptionCommand = updateSecretsManagerSubscriptionCommand; @@ -350,10 +350,9 @@ public class OrganizationUsersController : BaseAdminConsoleController return false; } - var masterPasswordPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(orgId, PolicyType.ResetPassword); - var useMasterPasswordPolicy = masterPasswordPolicy != null && - masterPasswordPolicy.Enabled && - masterPasswordPolicy.GetDataModel().AutoEnrollEnabled; + var masterPasswordPolicy = await _policyQuery.RunAsync(orgId, PolicyType.ResetPassword); + var useMasterPasswordPolicy = masterPasswordPolicy.Enabled && + masterPasswordPolicy.GetDataModel().AutoEnrollEnabled; return useMasterPasswordPolicy; } diff --git a/src/Api/AdminConsole/Controllers/OrganizationsController.cs b/src/Api/AdminConsole/Controllers/OrganizationsController.cs index 100cd7caf6..a6de8c521f 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationsController.cs @@ -48,7 +48,7 @@ public class OrganizationsController : Controller { private readonly IOrganizationRepository _organizationRepository; private readonly IOrganizationUserRepository _organizationUserRepository; - private readonly IPolicyRepository _policyRepository; + private readonly IPolicyQuery _policyQuery; private readonly IOrganizationService _organizationService; private readonly IUserService _userService; private readonly ICurrentContext _currentContext; @@ -74,7 +74,7 @@ public class OrganizationsController : Controller public OrganizationsController( IOrganizationRepository organizationRepository, IOrganizationUserRepository organizationUserRepository, - IPolicyRepository policyRepository, + IPolicyQuery policyQuery, IOrganizationService organizationService, IUserService userService, ICurrentContext currentContext, @@ -99,7 +99,7 @@ public class OrganizationsController : Controller { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; - _policyRepository = policyRepository; + _policyQuery = policyQuery; _organizationService = organizationService; _userService = userService; _currentContext = currentContext; @@ -183,15 +183,14 @@ public class OrganizationsController : Controller return new OrganizationAutoEnrollStatusResponseModel(organization.Id, resetPasswordPolicyRequirement.AutoEnrollEnabled(organization.Id)); } - var resetPasswordPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword); - if (resetPasswordPolicy == null || !resetPasswordPolicy.Enabled || resetPasswordPolicy.Data == null) + var resetPasswordPolicy = await _policyQuery.RunAsync(organization.Id, PolicyType.ResetPassword); + if (!resetPasswordPolicy.Enabled || resetPasswordPolicy.Data == null) { return new OrganizationAutoEnrollStatusResponseModel(organization.Id, false); } var data = JsonSerializer.Deserialize(resetPasswordPolicy.Data, JsonHelpers.IgnoreCase); return new OrganizationAutoEnrollStatusResponseModel(organization.Id, data?.AutoEnrollEnabled ?? false); - } [HttpPost("")] diff --git a/src/Api/AdminConsole/Controllers/PoliciesController.cs b/src/Api/AdminConsole/Controllers/PoliciesController.cs index bce0332d67..fe3600c3dd 100644 --- a/src/Api/AdminConsole/Controllers/PoliciesController.cs +++ b/src/Api/AdminConsole/Controllers/PoliciesController.cs @@ -7,7 +7,6 @@ using Bit.Api.AdminConsole.Models.Request; using Bit.Api.AdminConsole.Models.Response.Helpers; using Bit.Api.AdminConsole.Models.Response.Organizations; using Bit.Api.Models.Response; -using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; @@ -43,6 +42,7 @@ public class PoliciesController : Controller private readonly IUserService _userService; private readonly ISavePolicyCommand _savePolicyCommand; private readonly IVNextSavePolicyCommand _vNextSavePolicyCommand; + private readonly IPolicyQuery _policyQuery; public PoliciesController(IPolicyRepository policyRepository, IOrganizationUserRepository organizationUserRepository, @@ -54,7 +54,8 @@ public class PoliciesController : Controller IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery, IOrganizationRepository organizationRepository, ISavePolicyCommand savePolicyCommand, - IVNextSavePolicyCommand vNextSavePolicyCommand) + IVNextSavePolicyCommand vNextSavePolicyCommand, + IPolicyQuery policyQuery) { _policyRepository = policyRepository; _organizationUserRepository = organizationUserRepository; @@ -68,27 +69,24 @@ public class PoliciesController : Controller _organizationHasVerifiedDomainsQuery = organizationHasVerifiedDomainsQuery; _savePolicyCommand = savePolicyCommand; _vNextSavePolicyCommand = vNextSavePolicyCommand; + _policyQuery = policyQuery; } [HttpGet("{type}")] - public async Task Get(Guid orgId, int type) + public async Task Get(Guid orgId, PolicyType type) { if (!await _currentContext.ManagePolicies(orgId)) { throw new NotFoundException(); } - var policy = await _policyRepository.GetByOrganizationIdTypeAsync(orgId, (PolicyType)type); - if (policy == null) - { - return new PolicyDetailResponseModel(new Policy { Type = (PolicyType)type }); - } + var policy = await _policyQuery.RunAsync(orgId, type); if (policy.Type is PolicyType.SingleOrg) { - return await policy.GetSingleOrgPolicyDetailResponseAsync(_organizationHasVerifiedDomainsQuery); + return await policy.GetSingleOrgPolicyStatusResponseAsync(_organizationHasVerifiedDomainsQuery); } - return new PolicyDetailResponseModel(policy); + return new PolicyStatusResponseModel(policy); } [HttpGet("")] diff --git a/src/Api/AdminConsole/Models/Response/Helpers/PolicyDetailResponses.cs b/src/Api/AdminConsole/Models/Response/Helpers/PolicyStatusResponses.cs similarity index 66% rename from src/Api/AdminConsole/Models/Response/Helpers/PolicyDetailResponses.cs rename to src/Api/AdminConsole/Models/Response/Helpers/PolicyStatusResponses.cs index dded6a4c89..da08cdef0f 100644 --- a/src/Api/AdminConsole/Models/Response/Helpers/PolicyDetailResponses.cs +++ b/src/Api/AdminConsole/Models/Response/Helpers/PolicyStatusResponses.cs @@ -1,19 +1,21 @@ using Bit.Api.AdminConsole.Models.Response.Organizations; -using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces; namespace Bit.Api.AdminConsole.Models.Response.Helpers; -public static class PolicyDetailResponses +public static class PolicyStatusResponses { - public static async Task GetSingleOrgPolicyDetailResponseAsync(this Policy policy, IOrganizationHasVerifiedDomainsQuery hasVerifiedDomainsQuery) + public static async Task GetSingleOrgPolicyStatusResponseAsync( + this PolicyStatus policy, IOrganizationHasVerifiedDomainsQuery hasVerifiedDomainsQuery) { if (policy.Type is not PolicyType.SingleOrg) { throw new ArgumentException($"'{nameof(policy)}' must be of type '{nameof(PolicyType.SingleOrg)}'.", nameof(policy)); } - return new PolicyDetailResponseModel(policy, await CanToggleState()); + + return new PolicyStatusResponseModel(policy, await CanToggleState()); async Task CanToggleState() { @@ -25,5 +27,4 @@ public static class PolicyDetailResponses return !policy.Enabled; } } - } diff --git a/src/Api/AdminConsole/Models/Response/Organizations/PolicyDetailResponseModel.cs b/src/Api/AdminConsole/Models/Response/Organizations/PolicyDetailResponseModel.cs deleted file mode 100644 index cb5560e689..0000000000 --- a/src/Api/AdminConsole/Models/Response/Organizations/PolicyDetailResponseModel.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Bit.Core.AdminConsole.Entities; - -namespace Bit.Api.AdminConsole.Models.Response.Organizations; - -public class PolicyDetailResponseModel : PolicyResponseModel -{ - public PolicyDetailResponseModel(Policy policy, string obj = "policy") : base(policy, obj) - { - } - - public PolicyDetailResponseModel(Policy policy, bool canToggleState) : base(policy) - { - CanToggleState = canToggleState; - } - - /// - /// Indicates whether the Policy can be enabled/disabled - /// - public bool CanToggleState { get; set; } = true; -} diff --git a/src/Api/AdminConsole/Models/Response/Organizations/PolicyStatusResponseModel.cs b/src/Api/AdminConsole/Models/Response/Organizations/PolicyStatusResponseModel.cs new file mode 100644 index 0000000000..8c93302a17 --- /dev/null +++ b/src/Api/AdminConsole/Models/Response/Organizations/PolicyStatusResponseModel.cs @@ -0,0 +1,33 @@ +using System.Text.Json; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.Models.Api; + +namespace Bit.Api.AdminConsole.Models.Response.Organizations; + +public class PolicyStatusResponseModel : ResponseModel +{ + public PolicyStatusResponseModel(PolicyStatus policy, bool canToggleState = true) : base("policy") + { + OrganizationId = policy.OrganizationId; + Type = policy.Type; + + if (!string.IsNullOrWhiteSpace(policy.Data)) + { + Data = JsonSerializer.Deserialize>(policy.Data) ?? new(); + } + + Enabled = policy.Enabled; + CanToggleState = canToggleState; + } + + public Guid OrganizationId { get; init; } + public PolicyType Type { get; init; } + public Dictionary Data { get; init; } = new(); + public bool Enabled { get; init; } + + /// + /// Indicates whether the Policy can be enabled/disabled + /// + public bool CanToggleState { get; init; } +} diff --git a/src/Api/Api.csproj b/src/Api/Api.csproj index d25b989d11..c4a286f33c 100644 --- a/src/Api/Api.csproj +++ b/src/Api/Api.csproj @@ -1,4 +1,6 @@  + + bitwarden-Api false diff --git a/src/Api/Billing/Controllers/OrganizationSponsorshipsController.cs b/src/Api/Billing/Controllers/OrganizationSponsorshipsController.cs index 7ca85d52a8..8a1467dfa2 100644 --- a/src/Api/Billing/Controllers/OrganizationSponsorshipsController.cs +++ b/src/Api/Billing/Controllers/OrganizationSponsorshipsController.cs @@ -6,7 +6,7 @@ using Bit.Api.Models.Response; using Bit.Api.Models.Response.Organizations; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationConnections.Interfaces; -using Bit.Core.AdminConsole.Repositories; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Exceptions; @@ -38,7 +38,7 @@ public class OrganizationSponsorshipsController : Controller private readonly ICloudSyncSponsorshipsCommand _syncSponsorshipsCommand; private readonly ICurrentContext _currentContext; private readonly IUserService _userService; - private readonly IPolicyRepository _policyRepository; + private readonly IPolicyQuery _policyQuery; private readonly IFeatureService _featureService; public OrganizationSponsorshipsController( @@ -55,7 +55,7 @@ public class OrganizationSponsorshipsController : Controller ICloudSyncSponsorshipsCommand syncSponsorshipsCommand, IUserService userService, ICurrentContext currentContext, - IPolicyRepository policyRepository, + IPolicyQuery policyQuery, IFeatureService featureService) { _organizationSponsorshipRepository = organizationSponsorshipRepository; @@ -71,7 +71,7 @@ public class OrganizationSponsorshipsController : Controller _syncSponsorshipsCommand = syncSponsorshipsCommand; _userService = userService; _currentContext = currentContext; - _policyRepository = policyRepository; + _policyQuery = policyQuery; _featureService = featureService; } @@ -81,10 +81,10 @@ public class OrganizationSponsorshipsController : Controller public async Task CreateSponsorship(Guid sponsoringOrgId, [FromBody] OrganizationSponsorshipCreateRequestModel model) { var sponsoringOrg = await _organizationRepository.GetByIdAsync(sponsoringOrgId); - var freeFamiliesSponsorshipPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(sponsoringOrgId, + var freeFamiliesSponsorshipPolicy = await _policyQuery.RunAsync(sponsoringOrgId, PolicyType.FreeFamiliesSponsorshipPolicy); - if (freeFamiliesSponsorshipPolicy?.Enabled == true) + if (freeFamiliesSponsorshipPolicy.Enabled) { throw new BadRequestException("Free Bitwarden Families sponsorship has been disabled by your organization administrator."); } @@ -108,10 +108,10 @@ public class OrganizationSponsorshipsController : Controller [SelfHosted(NotSelfHostedOnly = true)] public async Task ResendSponsorshipOffer(Guid sponsoringOrgId, [FromQuery] string sponsoredFriendlyName) { - var freeFamiliesSponsorshipPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(sponsoringOrgId, + var freeFamiliesSponsorshipPolicy = await _policyQuery.RunAsync(sponsoringOrgId, PolicyType.FreeFamiliesSponsorshipPolicy); - if (freeFamiliesSponsorshipPolicy?.Enabled == true) + if (freeFamiliesSponsorshipPolicy.Enabled) { throw new BadRequestException("Free Bitwarden Families sponsorship has been disabled by your organization administrator."); } @@ -138,9 +138,9 @@ public class OrganizationSponsorshipsController : Controller var (isValid, sponsorship) = await _validateRedemptionTokenCommand.ValidateRedemptionTokenAsync(sponsorshipToken, (await CurrentUser).Email); if (isValid && sponsorship.SponsoringOrganizationId.HasValue) { - var policy = await _policyRepository.GetByOrganizationIdTypeAsync(sponsorship.SponsoringOrganizationId.Value, + var policy = await _policyQuery.RunAsync(sponsorship.SponsoringOrganizationId.Value, PolicyType.FreeFamiliesSponsorshipPolicy); - isFreeFamilyPolicyEnabled = policy?.Enabled ?? false; + isFreeFamilyPolicyEnabled = policy.Enabled; } var response = PreValidateSponsorshipResponseModel.From(isValid, isFreeFamilyPolicyEnabled); @@ -165,10 +165,10 @@ public class OrganizationSponsorshipsController : Controller throw new BadRequestException("Can only redeem sponsorship for an organization you own."); } - var freeFamiliesSponsorshipPolicy = await _policyRepository.GetByOrganizationIdTypeAsync( + var freeFamiliesSponsorshipPolicy = await _policyQuery.RunAsync( model.SponsoredOrganizationId, PolicyType.FreeFamiliesSponsorshipPolicy); - if (freeFamiliesSponsorshipPolicy?.Enabled == true) + if (freeFamiliesSponsorshipPolicy.Enabled) { throw new BadRequestException("Free Bitwarden Families sponsorship has been disabled by your organization administrator."); } diff --git a/src/Api/Billing/Controllers/TaxController.cs b/src/Api/Billing/Controllers/PreviewInvoiceController.cs similarity index 62% rename from src/Api/Billing/Controllers/TaxController.cs rename to src/Api/Billing/Controllers/PreviewInvoiceController.cs index 4ead414589..c958454618 100644 --- a/src/Api/Billing/Controllers/TaxController.cs +++ b/src/Api/Billing/Controllers/PreviewInvoiceController.cs @@ -1,8 +1,9 @@ using Bit.Api.Billing.Attributes; -using Bit.Api.Billing.Models.Requests.Tax; +using Bit.Api.Billing.Models.Requests.PreviewInvoice; using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Organizations.Commands; using Bit.Core.Billing.Premium.Commands; +using Bit.Core.Entities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ModelBinding; @@ -10,10 +11,11 @@ using Microsoft.AspNetCore.Mvc.ModelBinding; namespace Bit.Api.Billing.Controllers; [Authorize("Application")] -[Route("billing/tax")] -public class TaxController( +[Route("billing/preview-invoice")] +public class PreviewInvoiceController( IPreviewOrganizationTaxCommand previewOrganizationTaxCommand, - IPreviewPremiumTaxCommand previewPremiumTaxCommand) : BaseBillingController + IPreviewPremiumTaxCommand previewPremiumTaxCommand, + IPreviewPremiumUpgradeProrationCommand previewPremiumUpgradeProrationCommand) : BaseBillingController { [HttpPost("organizations/subscriptions/purchase")] public async Task PreviewOrganizationSubscriptionPurchaseTaxAsync( @@ -21,11 +23,7 @@ public class TaxController( { var (purchase, billingAddress) = request.ToDomain(); var result = await previewOrganizationTaxCommand.Run(purchase, billingAddress); - return Handle(result.Map(pair => new - { - pair.Tax, - pair.Total - })); + return Handle(result.Map(pair => new { pair.Tax, pair.Total })); } [HttpPost("organizations/{organizationId:guid}/subscription/plan-change")] @@ -36,11 +34,7 @@ public class TaxController( { var (planChange, billingAddress) = request.ToDomain(); var result = await previewOrganizationTaxCommand.Run(organization, planChange, billingAddress); - return Handle(result.Map(pair => new - { - pair.Tax, - pair.Total - })); + return Handle(result.Map(pair => new { pair.Tax, pair.Total })); } [HttpPut("organizations/{organizationId:guid}/subscription/update")] @@ -51,11 +45,7 @@ public class TaxController( { var update = request.ToDomain(); var result = await previewOrganizationTaxCommand.Run(organization, update); - return Handle(result.Map(pair => new - { - pair.Tax, - pair.Total - })); + return Handle(result.Map(pair => new { pair.Tax, pair.Total })); } [HttpPost("premium/subscriptions/purchase")] @@ -64,10 +54,29 @@ public class TaxController( { var (purchase, billingAddress) = request.ToDomain(); var result = await previewPremiumTaxCommand.Run(purchase, billingAddress); - return Handle(result.Map(pair => new + return Handle(result.Map(pair => new { pair.Tax, pair.Total })); + } + + [HttpPost("premium/subscriptions/upgrade")] + [InjectUser] + public async Task PreviewPremiumUpgradeProrationAsync( + [BindNever] User user, + [FromBody] PreviewPremiumUpgradeProrationRequest request) + { + var (planType, billingAddress) = request.ToDomain(); + + var result = await previewPremiumUpgradeProrationCommand.Run( + user, + planType, + billingAddress); + + return Handle(result.Map(proration => new { - pair.Tax, - pair.Total + proration.NewPlanProratedAmount, + proration.Credit, + proration.Tax, + proration.Total, + proration.NewPlanProratedMonths })); } } diff --git a/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs b/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs index 6c56d6db3a..241e595333 100644 --- a/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs +++ b/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs @@ -132,8 +132,8 @@ public class AccountBillingVNextController( [BindNever] User user, [FromBody] UpgradePremiumToOrganizationRequest request) { - var (organizationName, key, planType) = request.ToDomain(); - var result = await upgradePremiumToOrganizationCommand.Run(user, organizationName, key, planType); + var (organizationName, key, planType, billingAddress) = request.ToDomain(); + var result = await upgradePremiumToOrganizationCommand.Run(user, organizationName, key, planType, billingAddress); return Handle(result); } } diff --git a/src/Api/Billing/Models/Requests/Premium/UpgradePremiumToOrganizationRequest.cs b/src/Api/Billing/Models/Requests/Premium/UpgradePremiumToOrganizationRequest.cs index 14375efc78..00b1da4bba 100644 --- a/src/Api/Billing/Models/Requests/Premium/UpgradePremiumToOrganizationRequest.cs +++ b/src/Api/Billing/Models/Requests/Premium/UpgradePremiumToOrganizationRequest.cs @@ -1,5 +1,6 @@ using System.ComponentModel.DataAnnotations; using System.Text.Json.Serialization; +using Bit.Api.Billing.Models.Requests.Payment; using Bit.Core.Billing.Enums; namespace Bit.Api.Billing.Models.Requests.Premium; @@ -14,24 +15,30 @@ public class UpgradePremiumToOrganizationRequest [Required] [JsonConverter(typeof(JsonStringEnumConverter))] - public ProductTierType Tier { get; set; } + public required ProductTierType TargetProductTierType { get; set; } [Required] - [JsonConverter(typeof(JsonStringEnumConverter))] - public PlanCadenceType Cadence { get; set; } + public required MinimalBillingAddressRequest BillingAddress { get; set; } - private PlanType PlanType => - Tier switch + private PlanType PlanType + { + get { - ProductTierType.Families => PlanType.FamiliesAnnually, - ProductTierType.Teams => Cadence == PlanCadenceType.Monthly - ? PlanType.TeamsMonthly - : PlanType.TeamsAnnually, - ProductTierType.Enterprise => Cadence == PlanCadenceType.Monthly - ? PlanType.EnterpriseMonthly - : PlanType.EnterpriseAnnually, - _ => throw new InvalidOperationException("Cannot upgrade to an Organization subscription that isn't Families, Teams or Enterprise.") - }; + if (TargetProductTierType is not (ProductTierType.Families or ProductTierType.Teams or ProductTierType.Enterprise)) + { + throw new InvalidOperationException($"Cannot upgrade Premium subscription to {TargetProductTierType} plan."); + } - public (string OrganizationName, string Key, PlanType PlanType) ToDomain() => (OrganizationName, Key, PlanType); + return TargetProductTierType switch + { + ProductTierType.Families => PlanType.FamiliesAnnually, + ProductTierType.Teams => PlanType.TeamsAnnually, + ProductTierType.Enterprise => PlanType.EnterpriseAnnually, + _ => throw new InvalidOperationException($"Unexpected ProductTierType: {TargetProductTierType}") + }; + } + } + + public (string OrganizationName, string Key, PlanType PlanType, Core.Billing.Payment.Models.BillingAddress BillingAddress) ToDomain() => + (OrganizationName, Key, PlanType, BillingAddress.ToDomain()); } diff --git a/src/Api/Billing/Models/Requests/Tax/PreviewOrganizationSubscriptionPlanChangeTaxRequest.cs b/src/Api/Billing/Models/Requests/PreviewInvoice/PreviewOrganizationSubscriptionPlanChangeTaxRequest.cs similarity index 91% rename from src/Api/Billing/Models/Requests/Tax/PreviewOrganizationSubscriptionPlanChangeTaxRequest.cs rename to src/Api/Billing/Models/Requests/PreviewInvoice/PreviewOrganizationSubscriptionPlanChangeTaxRequest.cs index 9233a53c85..ccb8f948af 100644 --- a/src/Api/Billing/Models/Requests/Tax/PreviewOrganizationSubscriptionPlanChangeTaxRequest.cs +++ b/src/Api/Billing/Models/Requests/PreviewInvoice/PreviewOrganizationSubscriptionPlanChangeTaxRequest.cs @@ -4,7 +4,7 @@ using Bit.Api.Billing.Models.Requests.Payment; using Bit.Core.Billing.Organizations.Models; using Bit.Core.Billing.Payment.Models; -namespace Bit.Api.Billing.Models.Requests.Tax; +namespace Bit.Api.Billing.Models.Requests.PreviewInvoice; public record PreviewOrganizationSubscriptionPlanChangeTaxRequest { diff --git a/src/Api/Billing/Models/Requests/Tax/PreviewOrganizationSubscriptionPurchaseTaxRequest.cs b/src/Api/Billing/Models/Requests/PreviewInvoice/PreviewOrganizationSubscriptionPurchaseTaxRequest.cs similarity index 91% rename from src/Api/Billing/Models/Requests/Tax/PreviewOrganizationSubscriptionPurchaseTaxRequest.cs rename to src/Api/Billing/Models/Requests/PreviewInvoice/PreviewOrganizationSubscriptionPurchaseTaxRequest.cs index dcc5911f3d..40bec9dec3 100644 --- a/src/Api/Billing/Models/Requests/Tax/PreviewOrganizationSubscriptionPurchaseTaxRequest.cs +++ b/src/Api/Billing/Models/Requests/PreviewInvoice/PreviewOrganizationSubscriptionPurchaseTaxRequest.cs @@ -4,7 +4,7 @@ using Bit.Api.Billing.Models.Requests.Payment; using Bit.Core.Billing.Organizations.Models; using Bit.Core.Billing.Payment.Models; -namespace Bit.Api.Billing.Models.Requests.Tax; +namespace Bit.Api.Billing.Models.Requests.PreviewInvoice; public record PreviewOrganizationSubscriptionPurchaseTaxRequest { diff --git a/src/Api/Billing/Models/Requests/Tax/PreviewOrganizationSubscriptionUpdateTaxRequest.cs b/src/Api/Billing/Models/Requests/PreviewInvoice/PreviewOrganizationSubscriptionUpdateTaxRequest.cs similarity index 84% rename from src/Api/Billing/Models/Requests/Tax/PreviewOrganizationSubscriptionUpdateTaxRequest.cs rename to src/Api/Billing/Models/Requests/PreviewInvoice/PreviewOrganizationSubscriptionUpdateTaxRequest.cs index ae96214ae3..4568fea972 100644 --- a/src/Api/Billing/Models/Requests/Tax/PreviewOrganizationSubscriptionUpdateTaxRequest.cs +++ b/src/Api/Billing/Models/Requests/PreviewInvoice/PreviewOrganizationSubscriptionUpdateTaxRequest.cs @@ -1,7 +1,7 @@ using Bit.Api.Billing.Models.Requests.Organizations; using Bit.Core.Billing.Organizations.Models; -namespace Bit.Api.Billing.Models.Requests.Tax; +namespace Bit.Api.Billing.Models.Requests.PreviewInvoice; public class PreviewOrganizationSubscriptionUpdateTaxRequest { diff --git a/src/Api/Billing/Models/Requests/Tax/PreviewPremiumSubscriptionPurchaseTaxRequest.cs b/src/Api/Billing/Models/Requests/PreviewInvoice/PreviewPremiumSubscriptionPurchaseTaxRequest.cs similarity index 90% rename from src/Api/Billing/Models/Requests/Tax/PreviewPremiumSubscriptionPurchaseTaxRequest.cs rename to src/Api/Billing/Models/Requests/PreviewInvoice/PreviewPremiumSubscriptionPurchaseTaxRequest.cs index 76b8a5a444..d1707cf6de 100644 --- a/src/Api/Billing/Models/Requests/Tax/PreviewPremiumSubscriptionPurchaseTaxRequest.cs +++ b/src/Api/Billing/Models/Requests/PreviewInvoice/PreviewPremiumSubscriptionPurchaseTaxRequest.cs @@ -2,7 +2,7 @@ using Bit.Api.Billing.Models.Requests.Payment; using Bit.Core.Billing.Payment.Models; -namespace Bit.Api.Billing.Models.Requests.Tax; +namespace Bit.Api.Billing.Models.Requests.PreviewInvoice; public record PreviewPremiumSubscriptionPurchaseTaxRequest { diff --git a/src/Api/Billing/Models/Requests/PreviewInvoice/PreviewPremiumUpgradeProrationRequest.cs b/src/Api/Billing/Models/Requests/PreviewInvoice/PreviewPremiumUpgradeProrationRequest.cs new file mode 100644 index 0000000000..68d7a8d002 --- /dev/null +++ b/src/Api/Billing/Models/Requests/PreviewInvoice/PreviewPremiumUpgradeProrationRequest.cs @@ -0,0 +1,39 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using Bit.Api.Billing.Models.Requests.Payment; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Payment.Models; + +namespace Bit.Api.Billing.Models.Requests.PreviewInvoice; + +public record PreviewPremiumUpgradeProrationRequest +{ + [Required] + [JsonConverter(typeof(JsonStringEnumConverter))] + public required ProductTierType TargetProductTierType { get; set; } + + [Required] + public required MinimalBillingAddressRequest BillingAddress { get; set; } + + private PlanType PlanType + { + get + { + if (TargetProductTierType is not (ProductTierType.Families or ProductTierType.Teams or ProductTierType.Enterprise)) + { + throw new InvalidOperationException($"Cannot upgrade Premium subscription to {TargetProductTierType} plan."); + } + + return TargetProductTierType switch + { + ProductTierType.Families => PlanType.FamiliesAnnually, + ProductTierType.Teams => PlanType.TeamsAnnually, + ProductTierType.Enterprise => PlanType.EnterpriseAnnually, + _ => throw new InvalidOperationException($"Unexpected ProductTierType: {TargetProductTierType}") + }; + } + } + + public (PlanType, BillingAddress) ToDomain() => + (PlanType, BillingAddress.ToDomain()); +} diff --git a/src/Api/Program.cs b/src/Api/Program.cs index bf924af47f..baeaab9fdb 100644 --- a/src/Api/Program.cs +++ b/src/Api/Program.cs @@ -8,7 +8,7 @@ public class Program { Host .CreateDefaultBuilder(args) - .ConfigureCustomAppConfiguration(args) + .UseBitwardenSdk() .ConfigureWebHostDefaults(webBuilder => { webBuilder.UseStartup(); diff --git a/src/Api/Startup.cs b/src/Api/Startup.cs index b201cef0f3..acbc4a68fa 100644 --- a/src/Api/Startup.cs +++ b/src/Api/Startup.cs @@ -14,7 +14,6 @@ using Bit.Api.Tools.Models.Request; using Bit.Api.Vault.Models.Request; using Bit.Core.Auth.Entities; using Bit.SharedWeb.Health; -using Microsoft.IdentityModel.Logging; using Microsoft.OpenApi.Models; using Bit.SharedWeb.Utilities; using Microsoft.AspNetCore.Diagnostics.HealthChecks; @@ -238,8 +237,6 @@ public class Startup GlobalSettings globalSettings, ILogger logger) { - IdentityModelEventSource.ShowPII = true; - // Add general security headers app.UseMiddleware(); diff --git a/src/Api/Tools/Controllers/ImportCiphersController.cs b/src/Api/Tools/Controllers/ImportCiphersController.cs index 8b3ec5e26c..bebf7cbf29 100644 --- a/src/Api/Tools/Controllers/ImportCiphersController.cs +++ b/src/Api/Tools/Controllers/ImportCiphersController.cs @@ -74,11 +74,6 @@ public class ImportCiphersController : Controller throw new BadRequestException("You cannot import this much data at once."); } - if (model.Ciphers.Any(c => c.ArchivedDate.HasValue)) - { - throw new BadRequestException("You cannot import archived items into an organization."); - } - var orgId = new Guid(organizationId); var collections = model.Collections.Select(c => c.ToCollection(orgId)).ToList(); diff --git a/src/Core/AdminConsole/Models/Data/Organizations/Policies/PolicyStatus.cs b/src/Core/AdminConsole/Models/Data/Organizations/Policies/PolicyStatus.cs new file mode 100644 index 0000000000..68c754f6ba --- /dev/null +++ b/src/Core/AdminConsole/Models/Data/Organizations/Policies/PolicyStatus.cs @@ -0,0 +1,26 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.Utilities; + +namespace Bit.Core.AdminConsole.Models.Data.Organizations.Policies; + +public class PolicyStatus +{ + public PolicyStatus(Guid organizationId, PolicyType policyType, Policy? policy = null) + { + OrganizationId = policy?.OrganizationId ?? organizationId; + Data = policy?.Data; + Type = policy?.Type ?? policyType; + Enabled = policy?.Enabled ?? false; + } + + public Guid OrganizationId { get; set; } + public PolicyType Type { get; set; } + public bool Enabled { get; set; } + public string? Data { get; set; } + + public T GetDataModel() where T : IPolicyDataModel, new() + { + return CoreHelpers.LoadClassFromJsonData(Data); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommand.cs index 5783301a0b..bd30112945 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommand.cs @@ -1,5 +1,5 @@ using Bit.Core.AdminConsole.Enums; -using Bit.Core.AdminConsole.Repositories; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -11,7 +11,7 @@ using Microsoft.AspNetCore.Identity; namespace Bit.Core.AdminConsole.OrganizationFeatures.AccountRecovery; public class AdminRecoverAccountCommand(IOrganizationRepository organizationRepository, - IPolicyRepository policyRepository, + IPolicyQuery policyQuery, IUserRepository userRepository, IMailService mailService, IEventService eventService, @@ -30,9 +30,8 @@ public class AdminRecoverAccountCommand(IOrganizationRepository organizationRepo } // Enterprise policy must be enabled - var resetPasswordPolicy = - await policyRepository.GetByOrganizationIdTypeAsync(orgId, PolicyType.ResetPassword); - if (resetPasswordPolicy == null || !resetPasswordPolicy.Enabled) + var resetPasswordPolicy = await policyQuery.RunAsync(orgId, PolicyType.ResetPassword); + if (!resetPasswordPolicy.Enabled) { throw new BadRequestException("Organization does not have the password reset policy enabled."); } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/AutomaticallyConfirmOrganizationUsersValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/AutomaticallyConfirmOrganizationUsersValidator.cs index 3375120516..f067f529ea 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/AutomaticallyConfirmOrganizationUsersValidator.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/AutomaticallyConfirmOrganizationUsersValidator.cs @@ -3,7 +3,6 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimed using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; -using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Utilities.v2; using Bit.Core.AdminConsole.Utilities.v2.Validation; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; @@ -20,7 +19,7 @@ public class AutomaticallyConfirmOrganizationUsersValidator( IPolicyRequirementQuery policyRequirementQuery, IAutomaticUserConfirmationPolicyEnforcementValidator automaticUserConfirmationPolicyEnforcementValidator, IUserService userService, - IPolicyRepository policyRepository) : IAutomaticallyConfirmOrganizationUsersValidator + IPolicyQuery policyQuery) : IAutomaticallyConfirmOrganizationUsersValidator { public async Task> ValidateAsync( AutomaticallyConfirmOrganizationUserValidationRequest request) @@ -74,7 +73,7 @@ public class AutomaticallyConfirmOrganizationUsersValidator( } private async Task OrganizationHasAutomaticallyConfirmUsersPolicyEnabledAsync(AutomaticallyConfirmOrganizationUserValidationRequest request) => - await policyRepository.GetByOrganizationIdTypeAsync(request.OrganizationId, PolicyType.AutomaticUserConfirmation) is { Enabled: true } + (await policyQuery.RunAsync(request.OrganizationId, PolicyType.AutomaticUserConfirmation)).Enabled && request.Organization is { UseAutomaticUserConfirmation: true }; private async Task OrganizationUserConformsToTwoFactorRequiredPolicyAsync(AutomaticallyConfirmOrganizationUserValidationRequest request) diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommand.cs index cd5066d11b..61f428414f 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommand.cs @@ -4,7 +4,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; -using Bit.Core.AdminConsole.Repositories; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.Auth.Models.Business; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Repositories; @@ -19,7 +19,7 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUse public class SendOrganizationInvitesCommand( IUserRepository userRepository, ISsoConfigRepository ssoConfigurationRepository, - IPolicyRepository policyRepository, + IPolicyQuery policyQuery, IOrgUserInviteTokenableFactory orgUserInviteTokenableFactory, IDataProtectorTokenFactory dataProtectorTokenFactory, IMailService mailService) : ISendOrganizationInvitesCommand @@ -58,7 +58,7 @@ public class SendOrganizationInvitesCommand( // need to check the policy if the org has SSO enabled. var orgSsoLoginRequiredPolicyEnabled = orgSsoEnabled && organization.UsePolicies && - (await policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.RequireSso))?.Enabled == true; + (await policyQuery.RunAsync(organization.Id, PolicyType.RequireSso)).Enabled; // Generate the list of org users and expiring tokens // create helper function to create expiring tokens diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/IPolicyQuery.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/IPolicyQuery.cs new file mode 100644 index 0000000000..02eeeaa847 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/IPolicyQuery.cs @@ -0,0 +1,17 @@ +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies; + +public interface IPolicyQuery +{ + /// + /// Retrieves a summary view of an organization's usage of a policy specified by the . + /// + /// + /// This query is the entrypoint for consumers interested in understanding how a particular + /// has been applied to an organization; the resultant is not indicative of explicit + /// policy configuration. + /// + Task RunAsync(Guid organizationId, PolicyType policyType); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/PolicyQuery.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/PolicyQuery.cs new file mode 100644 index 0000000000..0ee6f9ab06 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/PolicyQuery.cs @@ -0,0 +1,14 @@ +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.AdminConsole.Repositories; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.Implementations; + +public class PolicyQuery(IPolicyRepository policyRepository) : IPolicyQuery +{ + public async Task RunAsync(Guid organizationId, PolicyType policyType) + { + var dbPolicy = await policyRepository.GetByOrganizationIdTypeAsync(organizationId, policyType); + return new PolicyStatus(organizationId, policyType, dbPolicy); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs index f69935715d..6e0c3aa8d9 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs @@ -18,6 +18,7 @@ public static class PolicyServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddPolicyValidators(); diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index b51842398d..d87bc65042 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -48,7 +48,7 @@ public class OrganizationService : IOrganizationService private readonly IEventService _eventService; private readonly IApplicationCacheService _applicationCacheService; private readonly IStripePaymentService _paymentService; - private readonly IPolicyRepository _policyRepository; + private readonly IPolicyQuery _policyQuery; private readonly IPolicyService _policyService; private readonly ISsoUserRepository _ssoUserRepository; private readonly IGlobalSettings _globalSettings; @@ -75,7 +75,7 @@ public class OrganizationService : IOrganizationService IEventService eventService, IApplicationCacheService applicationCacheService, IStripePaymentService paymentService, - IPolicyRepository policyRepository, + IPolicyQuery policyQuery, IPolicyService policyService, ISsoUserRepository ssoUserRepository, IGlobalSettings globalSettings, @@ -102,7 +102,7 @@ public class OrganizationService : IOrganizationService _eventService = eventService; _applicationCacheService = applicationCacheService; _paymentService = paymentService; - _policyRepository = policyRepository; + _policyQuery = policyQuery; _policyService = policyService; _ssoUserRepository = ssoUserRepository; _globalSettings = globalSettings; @@ -835,9 +835,8 @@ public class OrganizationService : IOrganizationService } // Make sure the organization has the policy enabled - var resetPasswordPolicy = - await _policyRepository.GetByOrganizationIdTypeAsync(organizationId, PolicyType.ResetPassword); - if (resetPasswordPolicy == null || !resetPasswordPolicy.Enabled) + var resetPasswordPolicy = await _policyQuery.RunAsync(organizationId, PolicyType.ResetPassword); + if (!resetPasswordPolicy.Enabled) { throw new BadRequestException("Organization does not have the password reset policy enabled."); } diff --git a/src/Core/Auth/Identity/TokenProviders/WebAuthnTokenProvider.cs b/src/Core/Auth/Identity/TokenProviders/WebAuthnTokenProvider.cs index 60fb2c5635..a6b1a27713 100644 --- a/src/Core/Auth/Identity/TokenProviders/WebAuthnTokenProvider.cs +++ b/src/Core/Auth/Identity/TokenProviders/WebAuthnTokenProvider.cs @@ -147,16 +147,12 @@ public class WebAuthnTokenProvider : IUserTwoFactorTokenProvider return keys; } - // Support up to 5 keys - for (var i = 1; i <= 5; i++) + // Load all WebAuthn credentials stored in metadata. The number of allowed credentials + // is controlled by credential registration. + foreach (var kvp in provider.MetaData.Where(k => k.Key.StartsWith("Key"))) { - var keyName = $"Key{i}"; - if (provider.MetaData.TryGetValue(keyName, out var value)) - { - var key = new TwoFactorProvider.WebAuthnData((dynamic)value); - - keys.Add(new Tuple(keyName, key)); - } + var key = new TwoFactorProvider.WebAuthnData((dynamic)kvp.Value); + keys.Add(new Tuple(kvp.Key, key)); } return keys; diff --git a/src/Core/Auth/Services/Implementations/SsoConfigService.cs b/src/Core/Auth/Services/Implementations/SsoConfigService.cs index 0cb8b68042..3c4f1ef85d 100644 --- a/src/Core/Auth/Services/Implementations/SsoConfigService.cs +++ b/src/Core/Auth/Services/Implementations/SsoConfigService.cs @@ -5,9 +5,9 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; -using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Repositories; @@ -21,7 +21,7 @@ namespace Bit.Core.Auth.Services; public class SsoConfigService : ISsoConfigService { private readonly ISsoConfigRepository _ssoConfigRepository; - private readonly IPolicyRepository _policyRepository; + private readonly IPolicyQuery _policyQuery; private readonly IOrganizationRepository _organizationRepository; private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IEventService _eventService; @@ -29,14 +29,14 @@ public class SsoConfigService : ISsoConfigService public SsoConfigService( ISsoConfigRepository ssoConfigRepository, - IPolicyRepository policyRepository, + IPolicyQuery policyQuery, IOrganizationRepository organizationRepository, IOrganizationUserRepository organizationUserRepository, IEventService eventService, IVNextSavePolicyCommand vNextSavePolicyCommand) { _ssoConfigRepository = ssoConfigRepository; - _policyRepository = policyRepository; + _policyQuery = policyQuery; _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; _eventService = eventService; @@ -114,14 +114,14 @@ public class SsoConfigService : ISsoConfigService throw new BadRequestException("Organization cannot use Key Connector."); } - var singleOrgPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(config.OrganizationId, PolicyType.SingleOrg); - if (singleOrgPolicy is not { Enabled: true }) + var singleOrgPolicy = await _policyQuery.RunAsync(config.OrganizationId, PolicyType.SingleOrg); + if (!singleOrgPolicy.Enabled) { throw new BadRequestException("Key Connector requires the Single Organization policy to be enabled."); } - var ssoPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(config.OrganizationId, PolicyType.RequireSso); - if (ssoPolicy is not { Enabled: true }) + var ssoPolicy = await _policyQuery.RunAsync(config.OrganizationId, PolicyType.RequireSso); + if (!ssoPolicy.Enabled) { throw new BadRequestException("Key Connector requires the Single Sign-On Authentication policy to be enabled."); } diff --git a/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs b/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs index 4a0e9c2cf5..ba63afb54c 100644 --- a/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs +++ b/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs @@ -1,6 +1,6 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; -using Bit.Core.AdminConsole.Repositories; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; using Bit.Core.Auth.Models.Business.Tokenables; @@ -27,7 +27,7 @@ public class RegisterUserCommand : IRegisterUserCommand private readonly IGlobalSettings _globalSettings; private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IOrganizationRepository _organizationRepository; - private readonly IPolicyRepository _policyRepository; + private readonly IPolicyQuery _policyQuery; private readonly IOrganizationDomainRepository _organizationDomainRepository; private readonly IFeatureService _featureService; @@ -50,7 +50,7 @@ public class RegisterUserCommand : IRegisterUserCommand IGlobalSettings globalSettings, IOrganizationUserRepository organizationUserRepository, IOrganizationRepository organizationRepository, - IPolicyRepository policyRepository, + IPolicyQuery policyQuery, IOrganizationDomainRepository organizationDomainRepository, IFeatureService featureService, IDataProtectionProvider dataProtectionProvider, @@ -65,7 +65,7 @@ public class RegisterUserCommand : IRegisterUserCommand _globalSettings = globalSettings; _organizationUserRepository = organizationUserRepository; _organizationRepository = organizationRepository; - _policyRepository = policyRepository; + _policyQuery = policyQuery; _organizationDomainRepository = organizationDomainRepository; _featureService = featureService; @@ -246,9 +246,9 @@ public class RegisterUserCommand : IRegisterUserCommand var orgUser = await _organizationUserRepository.GetByIdAsync(orgUserId.Value); if (orgUser != null) { - var twoFactorPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(orgUser.OrganizationId, + var twoFactorPolicy = await _policyQuery.RunAsync(orgUser.OrganizationId, PolicyType.TwoFactorAuthentication); - if (twoFactorPolicy != null && twoFactorPolicy.Enabled) + if (twoFactorPolicy.Enabled) { user.SetTwoFactorProviders(new Dictionary { diff --git a/src/Core/Billing/Constants/StripeConstants.cs b/src/Core/Billing/Constants/StripeConstants.cs index e9c34d7e06..524a819b28 100644 --- a/src/Core/Billing/Constants/StripeConstants.cs +++ b/src/Core/Billing/Constants/StripeConstants.cs @@ -70,10 +70,6 @@ public static class StripeConstants 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"; - public const string PreviousPremiumUserId = "previous_premium_user_id"; public const string ProviderId = "providerId"; public const string Region = "region"; public const string RetiredBraintreeCustomerId = "btCustomerId_old"; diff --git a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs index 13a120a1f4..db77f3c412 100644 --- a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs +++ b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs @@ -61,6 +61,7 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddTransient(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); } diff --git a/src/Core/Billing/Premium/Commands/PreviewPremiumUpgradeProrationCommand.cs b/src/Core/Billing/Premium/Commands/PreviewPremiumUpgradeProrationCommand.cs new file mode 100644 index 0000000000..af2a8bdacb --- /dev/null +++ b/src/Core/Billing/Premium/Commands/PreviewPremiumUpgradeProrationCommand.cs @@ -0,0 +1,166 @@ +using Bit.Core.Billing.Commands; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Payment.Models; +using Bit.Core.Billing.Premium.Models; +using Bit.Core.Billing.Pricing; +using Bit.Core.Billing.Services; +using Bit.Core.Entities; +using Microsoft.Extensions.Logging; +using Stripe; + +namespace Bit.Core.Billing.Premium.Commands; + +/// +/// Previews the proration details for upgrading a Premium user subscription to an Organization +/// plan by using the Stripe API to create an invoice preview, prorated, for the upgrade. +/// +public interface IPreviewPremiumUpgradeProrationCommand +{ + /// + /// Calculates the tax, total cost, and proration credit for upgrading a Premium subscription to an Organization plan. + /// + /// The user with an active Premium subscription. + /// The target organization plan type. + /// The billing address for tax calculation. + /// The proration details for the upgrade including costs, credits, tax, and time remaining. + Task> Run( + User user, + PlanType targetPlanType, + BillingAddress billingAddress); +} + +public class PreviewPremiumUpgradeProrationCommand( + ILogger logger, + IPricingClient pricingClient, + IStripeAdapter stripeAdapter) + : BaseBillingCommand(logger), + IPreviewPremiumUpgradeProrationCommand +{ + public Task> Run( + User user, + PlanType targetPlanType, + BillingAddress billingAddress) => HandleAsync(async () => + { + if (user is not { Premium: true, GatewaySubscriptionId: not null and not "" }) + { + return new BadRequest("User does not have an active Premium subscription."); + } + + var currentSubscription = await stripeAdapter.GetSubscriptionAsync( + user.GatewaySubscriptionId, + new SubscriptionGetOptions { Expand = ["customer"] }); + var premiumPlans = await pricingClient.ListPremiumPlans(); + var passwordManagerItem = currentSubscription.Items.Data.FirstOrDefault(i => + premiumPlans.Any(p => p.Seat.StripePriceId == i.Price.Id)); + + if (passwordManagerItem == null) + { + return new BadRequest("Premium subscription password manager item not found."); + } + + var usersPremiumPlan = premiumPlans.First(p => p.Seat.StripePriceId == passwordManagerItem.Price.Id); + var targetPlan = await pricingClient.GetPlanOrThrow(targetPlanType); + var subscriptionItems = new List(); + var storageItem = currentSubscription.Items.Data.FirstOrDefault(i => + i.Price.Id == usersPremiumPlan.Storage.StripePriceId); + + // Delete the storage item if it exists for this user's plan + if (storageItem != null) + { + subscriptionItems.Add(new InvoiceSubscriptionDetailsItemOptions + { + Id = storageItem.Id, + Deleted = true + }); + } + + // Hardcode seats to 1 for upgrade flow + if (targetPlan.HasNonSeatBasedPasswordManagerPlan()) + { + subscriptionItems.Add(new InvoiceSubscriptionDetailsItemOptions + { + Id = passwordManagerItem.Id, + Price = targetPlan.PasswordManager.StripePlanId, + Quantity = 1 + }); + } + else + { + subscriptionItems.Add(new InvoiceSubscriptionDetailsItemOptions + { + Id = passwordManagerItem.Id, + Price = targetPlan.PasswordManager.StripeSeatPlanId, + Quantity = 1 + }); + } + + var options = new InvoiceCreatePreviewOptions + { + AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true }, + Customer = user.GatewayCustomerId, + Subscription = user.GatewaySubscriptionId, + CustomerDetails = new InvoiceCustomerDetailsOptions + { + Address = new AddressOptions + { + Country = billingAddress.Country, + PostalCode = billingAddress.PostalCode + } + }, + SubscriptionDetails = new InvoiceSubscriptionDetailsOptions + { + Items = subscriptionItems, + ProrationBehavior = StripeConstants.ProrationBehavior.AlwaysInvoice + } + }; + + var invoicePreview = await stripeAdapter.CreateInvoicePreviewAsync(options); + var proration = GetProration(invoicePreview, passwordManagerItem); + + return proration; + }); + + private static PremiumUpgradeProration GetProration(Invoice invoicePreview, SubscriptionItem passwordManagerItem) => new() + { + NewPlanProratedAmount = GetNewPlanProratedAmountFromInvoice(invoicePreview), + Credit = GetProrationCreditFromInvoice(invoicePreview), + Tax = Convert.ToDecimal(invoicePreview.TotalTaxes.Sum(invoiceTotalTax => invoiceTotalTax.Amount)) / 100, + Total = Convert.ToDecimal(invoicePreview.Total) / 100, + // Use invoice periodEnd here instead of UtcNow so that testing with Stripe time clocks works correctly. And if there is no test clock, + // (like in production), the previewInvoice's periodEnd is the same as UtcNow anyway because of the proration behavior (always_invoice) + NewPlanProratedMonths = CalculateNewPlanProratedMonths(invoicePreview.PeriodEnd, passwordManagerItem.CurrentPeriodEnd) + }; + + private static decimal GetProrationCreditFromInvoice(Invoice invoicePreview) + { + // Extract proration credit from negative line items (credits are negative in Stripe) + var prorationCredit = invoicePreview.Lines?.Data? + .Where(line => line.Amount < 0) + .Sum(line => Math.Abs(line.Amount)) ?? 0; // Return the credit as positive number + + return Convert.ToDecimal(prorationCredit) / 100; + } + + private static decimal GetNewPlanProratedAmountFromInvoice(Invoice invoicePreview) + { + // The target plan's prorated upgrade amount should be the only positive-valued line item + var proratedTotal = invoicePreview.Lines?.Data? + .Where(line => line.Amount > 0) + .Sum(line => line.Amount) ?? 0; + + return Convert.ToDecimal(proratedTotal) / 100; + } + + private static int CalculateNewPlanProratedMonths(DateTime invoicePeriodEnd, DateTime currentPeriodEnd) + { + var daysInProratedPeriod = (currentPeriodEnd - invoicePeriodEnd).TotalDays; + + // Round to nearest month (30-day periods) + // 1-14 days = 1 month, 15-44 days = 1 month, 45-74 days = 2 months, etc. + // Minimum is always 1 month (never returns 0) + // Use MidpointRounding.AwayFromZero to round 0.5 up to 1 + var months = (int)Math.Round(daysInProratedPeriod / 30, MidpointRounding.AwayFromZero); + return Math.Max(1, months); + } +} diff --git a/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs b/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs index 81bc5c9e2c..803674120a 100644 --- a/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs +++ b/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs @@ -2,7 +2,6 @@ using Bit.Core.Billing.Commands; 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.Entities; @@ -28,12 +27,14 @@ public interface IUpgradePremiumToOrganizationCommand /// The name for the new organization. /// The encrypted organization key for the owner. /// The target organization plan type to upgrade to. + /// The billing address for tax calculation. /// A billing command result indicating success or failure with appropriate error details. Task> Run( User user, string organizationName, string key, - PlanType targetPlanType); + PlanType targetPlanType, + Payment.Models.BillingAddress billingAddress); } public class UpgradePremiumToOrganizationCommand( @@ -51,7 +52,8 @@ public class UpgradePremiumToOrganizationCommand( User user, string organizationName, string key, - PlanType targetPlanType) => HandleAsync(async () => + PlanType targetPlanType, + Payment.Models.BillingAddress billingAddress) => HandleAsync(async () => { // Validate that the user has an active Premium subscription if (user is not { Premium: true, GatewaySubscriptionId: not null and not "" }) @@ -74,7 +76,7 @@ public class UpgradePremiumToOrganizationCommand( if (passwordManagerItem == null) { - return new BadRequest("Premium subscription item not found."); + return new BadRequest("Premium subscription password manager item not found."); } var usersPremiumPlan = premiumPlans.First(p => p.Seat.StripePriceId == passwordManagerItem.Price.Id); @@ -85,20 +87,10 @@ public class UpgradePremiumToOrganizationCommand( // Build the list of subscription item updates var subscriptionItemOptions = new List(); - // Delete the user's specific password manager item - subscriptionItemOptions.Add(new SubscriptionItemOptions - { - Id = passwordManagerItem.Id, - Deleted = true - }); - // Delete the storage item if it exists for this user's plan var storageItem = currentSubscription.Items.Data.FirstOrDefault(i => i.Price.Id == usersPremiumPlan.Storage.StripePriceId); - // Capture the previous additional storage quantity for potential revert - var previousAdditionalStorage = storageItem?.Quantity ?? 0; - if (storageItem != null) { subscriptionItemOptions.Add(new SubscriptionItemOptions @@ -113,6 +105,7 @@ public class UpgradePremiumToOrganizationCommand( { subscriptionItemOptions.Add(new SubscriptionItemOptions { + Id = passwordManagerItem.Id, Price = targetPlan.PasswordManager.StripePlanId, Quantity = 1 }); @@ -121,6 +114,7 @@ public class UpgradePremiumToOrganizationCommand( { subscriptionItemOptions.Add(new SubscriptionItemOptions { + Id = passwordManagerItem.Id, Price = targetPlan.PasswordManager.StripeSeatPlanId, Quantity = seats }); @@ -133,14 +127,12 @@ public class UpgradePremiumToOrganizationCommand( var subscriptionUpdateOptions = new SubscriptionUpdateOptions { Items = subscriptionItemOptions, - ProrationBehavior = StripeConstants.ProrationBehavior.None, + ProrationBehavior = StripeConstants.ProrationBehavior.AlwaysInvoice, + BillingCycleAnchor = SubscriptionBillingCycleAnchor.Unchanged, + AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }, Metadata = new Dictionary { [StripeConstants.MetadataKeys.OrganizationId] = organizationId.ToString(), - [StripeConstants.MetadataKeys.PreviousPremiumPriceId] = usersPremiumPlan.Seat.StripePriceId, - [StripeConstants.MetadataKeys.PreviousPeriodEndDate] = currentSubscription.GetCurrentPeriodEnd()?.ToString("O") ?? string.Empty, - [StripeConstants.MetadataKeys.PreviousAdditionalStorage] = previousAdditionalStorage.ToString(), - [StripeConstants.MetadataKeys.PreviousPremiumUserId] = user.Id.ToString(), [StripeConstants.MetadataKeys.UserId] = string.Empty // Remove userId to unlink subscription from User } }; @@ -152,7 +144,7 @@ public class UpgradePremiumToOrganizationCommand( Name = organizationName, BillingEmail = user.Email, PlanType = targetPlan.Type, - Seats = (short)seats, + Seats = seats, MaxCollections = targetPlan.PasswordManager.MaxCollections, MaxStorageGb = targetPlan.PasswordManager.BaseStorageGb, UsePolicies = targetPlan.HasPolicies, @@ -182,6 +174,16 @@ public class UpgradePremiumToOrganizationCommand( GatewaySubscriptionId = currentSubscription.Id }; + // Update customer billing address for tax calculation + await stripeAdapter.UpdateCustomerAsync(user.GatewayCustomerId, new CustomerUpdateOptions + { + Address = new AddressOptions + { + Country = billingAddress.Country, + PostalCode = billingAddress.PostalCode + } + }); + // Update the subscription in Stripe await stripeAdapter.UpdateSubscriptionAsync(currentSubscription.Id, subscriptionUpdateOptions); diff --git a/src/Core/Billing/Premium/Models/PremiumUpgradeProration.cs b/src/Core/Billing/Premium/Models/PremiumUpgradeProration.cs new file mode 100644 index 0000000000..d8acaa3170 --- /dev/null +++ b/src/Core/Billing/Premium/Models/PremiumUpgradeProration.cs @@ -0,0 +1,36 @@ +namespace Bit.Core.Billing.Premium.Models; + +/// +/// Represents the proration details for upgrading a Premium user subscription to an Organization plan. +/// +public class PremiumUpgradeProration +{ + /// + /// The prorated cost for the new organization plan, calculated from now until the end of the current billing period. + /// This represents what the user will pay for the upgraded plan for the remainder of the period. + /// + public decimal NewPlanProratedAmount { get; set; } + + /// + /// The credit amount for the unused portion of the current Premium subscription. + /// This credit is applied against the cost of the new organization plan. + /// + public decimal Credit { get; set; } + + /// + /// The tax amount calculated for the upgrade transaction. + /// + public decimal Tax { get; set; } + + /// + /// The total amount due for the upgrade after applying the credit and adding tax. + /// + public decimal Total { get; set; } + + /// + /// The number of months the user will be charged for the new organization plan in the prorated billing period. + /// Calculated by rounding the days remaining in the current billing cycle to the nearest month. + /// Minimum value is 1 month (never returns 0). + /// + public int NewPlanProratedMonths { get; set; } +} diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 58b50a6512..8fb213245a 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -141,7 +141,6 @@ public static class FeatureFlagKeys public const string AutomaticConfirmUsers = "pm-19934-auto-confirm-organization-users"; 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 DefaultUserCollectionRestore = "pm-30883-my-items-restored-users"; public const string PremiumAccessQuery = "pm-29495-refactor-premium-interface"; public const string RefactorMembersComponent = "pm-29503-refactor-members-inheritance"; @@ -161,7 +160,6 @@ public static class FeatureFlagKeys public const string Otp6Digits = "pm-18612-otp-6-digits"; public const string PM24579_PreventSsoOnExistingNonCompliantUsers = "pm-24579-prevent-sso-on-existing-non-compliant-users"; public const string DisableAlternateLoginMethods = "pm-22110-disable-alternate-login-methods"; - public const string MJMLBasedEmailTemplates = "mjml-based-email-templates"; public const string PM2035PasskeyUnlock = "pm-2035-passkey-unlock"; public const string MjmlWelcomeEmailTemplates = "pm-21741-mjml-welcome-email"; public const string OrganizationConfirmationEmail = "pm-28402-update-confirmed-to-org-email-template"; diff --git a/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmail.html.hbs b/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmail.html.hbs index 5bf1f24218..7d30fdcbe4 100644 --- a/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmail.html.hbs +++ b/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmail.html.hbs @@ -1,28 +1,691 @@ -{{#>FullHtmlLayout}} - - - - - - - - - - - - - -
- Verify your email to access this Bitwarden Send. -
-
- Your verification code is: {{Token}} -
-
- This code can only be used once and expires in 5 minutes. After that you'll need to verify your email again. -
-
-
- {{TheDate}} at {{TheTime}} {{TimeZone}} -
-{{/FullHtmlLayout}} \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+ + + + + + + +
+ + + + + + + + +
+ + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + + + +
+ + + + + + + +
+ + + +
+ +
+ +

+ Verify your email to access this Bitwarden Send +

+ +
+ +
+ + + +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ +
+ +
+ + +
+ +
+ + + + + +
+ + +
+ +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ + + + + + + +
+ + +
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +
+ +
Your verification code is:
+ +
+ +
{{Token}}
+ +
+ +
+ +
+ +
This code expires in {{Expiry}} minutes. After that, you'll need + to verify your email again.
+ +
+ +
+ +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ + +
+ + + + + + + +
+ + + + + + + + + +
+ +

+ Bitwarden Send transmits sensitive, temporary information to + others easily and securely. Learn more about + Bitwarden Send + or + sign up + to try it today. +

+ +
+ +
+ +
+ + +
+ +
+ + + +
+ +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ + + + + + + +
+ + +
+ + + + + + + + + +
+ +

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

+ +
+ +
+ + +
+ +
+ + + + + +
+ + + + \ No newline at end of file diff --git a/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmail.text.hbs b/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmail.text.hbs index f83008c30b..7c9c1db527 100644 --- a/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmail.text.hbs +++ b/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmail.text.hbs @@ -3,7 +3,7 @@ Verify your email to access this Bitwarden Send. Your verification code is: {{Token}} -This code can only be used once and expires in 5 minutes. After that you'll need to verify your email again. +This code can only be used once and expires in {{Expiry}} minutes. After that you'll need to verify your email again. -Date : {{TheDate}} at {{TheTime}} {{TimeZone}} -{{/BasicTextLayout}} \ No newline at end of file +Bitwarden Send transmits sensitive, temporary information to others easily and securely. Learn more about Bitwarden Send or sign up to try it today. +{{/BasicTextLayout}} diff --git a/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmailv2.html.hbs b/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmailv2.html.hbs deleted file mode 100644 index 7d30fdcbe4..0000000000 --- a/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmailv2.html.hbs +++ /dev/null @@ -1,691 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - - - - -
- - - - - - - -
- - - - - - - - -
- - - - - -
- - - - - - - -
- - -
- - - - - - - - - - - - - -
- - - - - - - -
- - - -
- -
- -

- Verify your email to access this Bitwarden Send -

- -
- -
- - - -
- - - - - - - - - -
- - - - - - - -
- - - -
- -
- -
- - -
- -
- - - - - -
- - -
- -
- - - - - - - - - -
- - - - - - - -
- - - -
- - - - - - - -
- - -
- - - - - - - -
- - - - - - - - - - - - - - - - - - - - - -
- -
Your verification code is:
- -
- -
{{Token}}
- -
- -
- -
- -
This code expires in {{Expiry}} minutes. After that, you'll need - to verify your email again.
- -
- -
- -
- - -
- -
- - - - - -
- - - - - - - -
- - -
- - - - - - - -
- - - - - - - - - -
- -

- Bitwarden Send transmits sensitive, temporary information to - others easily and securely. Learn more about - Bitwarden Send - or - sign up - to try it today. -

- -
- -
- -
- - -
- -
- - - -
- -
- - - - - - - - - -
- - - - - - - -
- - - -
- - - - - - - -
- - -
- - - - - - - - - -
- -

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

- -
- -
- - -
- -
- - - - - -
- - - - \ No newline at end of file diff --git a/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmailv2.text.hbs b/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmailv2.text.hbs deleted file mode 100644 index 7c9c1db527..0000000000 --- a/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmailv2.text.hbs +++ /dev/null @@ -1,9 +0,0 @@ -{{#>BasicTextLayout}} -Verify your email to access this Bitwarden Send. - -Your verification code is: {{Token}} - -This code can only be used once and expires in {{Expiry}} minutes. After that you'll need to verify your email again. - -Bitwarden Send transmits sensitive, temporary information to others easily and securely. Learn more about Bitwarden Send or sign up to try it today. -{{/BasicTextLayout}} diff --git a/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs b/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs index 4ad63bd8d7..9c06ce1709 100644 --- a/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs @@ -5,6 +5,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.OrganizationConnectionConfigs; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Repositories; @@ -30,6 +31,7 @@ public class UpgradeOrganizationPlanCommand : IUpgradeOrganizationPlanCommand private readonly IGroupRepository _groupRepository; private readonly IStripePaymentService _paymentService; private readonly IPolicyRepository _policyRepository; + private readonly IPolicyQuery _policyQuery; private readonly ISsoConfigRepository _ssoConfigRepository; private readonly IOrganizationConnectionRepository _organizationConnectionRepository; private readonly IServiceAccountRepository _serviceAccountRepository; @@ -45,6 +47,7 @@ public class UpgradeOrganizationPlanCommand : IUpgradeOrganizationPlanCommand IGroupRepository groupRepository, IStripePaymentService paymentService, IPolicyRepository policyRepository, + IPolicyQuery policyQuery, ISsoConfigRepository ssoConfigRepository, IOrganizationConnectionRepository organizationConnectionRepository, IServiceAccountRepository serviceAccountRepository, @@ -59,6 +62,7 @@ public class UpgradeOrganizationPlanCommand : IUpgradeOrganizationPlanCommand _groupRepository = groupRepository; _paymentService = paymentService; _policyRepository = policyRepository; + _policyQuery = policyQuery; _ssoConfigRepository = ssoConfigRepository; _organizationConnectionRepository = organizationConnectionRepository; _serviceAccountRepository = serviceAccountRepository; @@ -184,9 +188,8 @@ public class UpgradeOrganizationPlanCommand : IUpgradeOrganizationPlanCommand if (!newPlan.HasResetPassword && organization.UseResetPassword) { - var resetPasswordPolicy = - await _policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword); - if (resetPasswordPolicy != null && resetPasswordPolicy.Enabled) + var resetPasswordPolicy = await _policyQuery.RunAsync(organization.Id, PolicyType.ResetPassword); + if (resetPasswordPolicy.Enabled) { throw new BadRequestException("Your new plan does not allow the Password Reset feature. " + "Disable your Password Reset policy."); diff --git a/src/Core/Platform/Mail/HandlebarsMailService.cs b/src/Core/Platform/Mail/HandlebarsMailService.cs index e92fa34daa..910285244d 100644 --- a/src/Core/Platform/Mail/HandlebarsMailService.cs +++ b/src/Core/Platform/Mail/HandlebarsMailService.cs @@ -209,26 +209,6 @@ public class HandlebarsMailService : IMailService } public async Task SendSendEmailOtpEmailAsync(string email, string token, string subject) - { - var message = CreateDefaultMessage(subject, email); - var requestDateTime = DateTime.UtcNow; - var model = new DefaultEmailOtpViewModel - { - Token = token, - TheDate = requestDateTime.ToLongDateString(), - TheTime = requestDateTime.ToShortTimeString(), - TimeZone = _utcTimeZoneDisplay, - WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash, - SiteName = _globalSettings.SiteName, - }; - await AddMessageContentAsync(message, "Auth.SendAccessEmailOtpEmail", model); - message.MetaData.Add("SendGridBypassListManagement", true); - // TODO - PM-25380 change to string constant - message.Category = "SendEmailOtp"; - await _mailDeliveryService.SendEmailAsync(message); - } - - public async Task SendSendEmailOtpEmailv2Async(string email, string token, string subject) { var message = CreateDefaultMessage(subject, email); var requestDateTime = DateTime.UtcNow; @@ -242,7 +222,7 @@ public class HandlebarsMailService : IMailService WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash, SiteName = _globalSettings.SiteName, }; - await AddMessageContentAsync(message, "Auth.SendAccessEmailOtpEmailv2", model); + await AddMessageContentAsync(message, "Auth.SendAccessEmailOtpEmail", model); message.MetaData.Add("SendGridBypassListManagement", true); // TODO - PM-25380 change to string constant message.Category = "SendEmailOtp"; diff --git a/src/Core/Platform/Mail/IMailService.cs b/src/Core/Platform/Mail/IMailService.cs index e21e1a010f..e07e4bad29 100644 --- a/src/Core/Platform/Mail/IMailService.cs +++ b/src/Core/Platform/Mail/IMailService.cs @@ -51,17 +51,15 @@ public interface IMailService Task SendChangeEmailAlreadyExistsEmailAsync(string fromEmail, string toEmail); Task SendChangeEmailEmailAsync(string newEmailAddress, string token); Task SendTwoFactorEmailAsync(string email, string accountEmail, string token, string deviceIp, string deviceType, TwoFactorEmailPurpose purpose); - Task SendSendEmailOtpEmailAsync(string email, string token, string subject); /// - /// has a default expiry of 5 minutes so we set the expiry to that value int he view model. + /// has a default expiry of 5 minutes so we set the expiry to that value in the view model. /// Sends OTP code token to the specified email address. - /// will replace when MJML templates are fully accepted. /// /// Email address to send the OTP to /// Otp code token - /// subject line of the email + /// Subject line of the email /// Task - Task SendSendEmailOtpEmailv2Async(string email, string token, string subject); + Task SendSendEmailOtpEmailAsync(string email, string token, string subject); Task SendFailedTwoFactorAttemptEmailAsync(string email, TwoFactorProviderType type, DateTime utcNow, string ip); Task SendNoMasterPasswordHintEmailAsync(string email); Task SendMasterPasswordHintEmailAsync(string email, string hint); diff --git a/src/Core/Platform/Mail/NoopMailService.cs b/src/Core/Platform/Mail/NoopMailService.cs index 7de48e4619..0064058afb 100644 --- a/src/Core/Platform/Mail/NoopMailService.cs +++ b/src/Core/Platform/Mail/NoopMailService.cs @@ -99,11 +99,6 @@ public class NoopMailService : IMailService return Task.FromResult(0); } - public Task SendSendEmailOtpEmailv2Async(string email, string token, string subject) - { - return Task.FromResult(0); - } - public Task SendFailedTwoFactorAttemptEmailAsync(string email, TwoFactorProviderType failedType, DateTime utcNow, string ip) { return Task.FromResult(0); diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index 64caf1d462..5f87ee85d2 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -61,7 +61,7 @@ public class UserService : UserManager, IUserService private readonly IEventService _eventService; private readonly IApplicationCacheService _applicationCacheService; private readonly IStripePaymentService _paymentService; - private readonly IPolicyRepository _policyRepository; + private readonly IPolicyQuery _policyQuery; private readonly IPolicyService _policyService; private readonly IFido2 _fido2; private readonly ICurrentContext _currentContext; @@ -98,7 +98,7 @@ public class UserService : UserManager, IUserService IEventService eventService, IApplicationCacheService applicationCacheService, IStripePaymentService paymentService, - IPolicyRepository policyRepository, + IPolicyQuery policyQuery, IPolicyService policyService, IFido2 fido2, ICurrentContext currentContext, @@ -139,7 +139,7 @@ public class UserService : UserManager, IUserService _eventService = eventService; _applicationCacheService = applicationCacheService; _paymentService = paymentService; - _policyRepository = policyRepository; + _policyQuery = policyQuery; _policyService = policyService; _fido2 = fido2; _currentContext = currentContext; @@ -722,9 +722,8 @@ public class UserService : UserManager, IUserService } // Enterprise policy must be enabled - var resetPasswordPolicy = - await _policyRepository.GetByOrganizationIdTypeAsync(orgId, PolicyType.ResetPassword); - if (resetPasswordPolicy == null || !resetPasswordPolicy.Enabled) + var resetPasswordPolicy = await _policyQuery.RunAsync(orgId, PolicyType.ResetPassword); + if (!resetPasswordPolicy.Enabled) { throw new BadRequestException("Organization does not have the password reset policy enabled."); } diff --git a/src/Core/Tools/ImportFeatures/ImportCiphersCommand.cs b/src/Core/Tools/ImportFeatures/ImportCiphersCommand.cs index 9300e3c4bb..3f856e96fc 100644 --- a/src/Core/Tools/ImportFeatures/ImportCiphersCommand.cs +++ b/src/Core/Tools/ImportFeatures/ImportCiphersCommand.cs @@ -76,6 +76,12 @@ public class ImportCiphersCommand : IImportCiphersCommand { cipher.Favorites = $"{{\"{cipher.UserId.ToString().ToUpperInvariant()}\":true}}"; } + + if (cipher.UserId.HasValue && cipher.ArchivedDate.HasValue) + { + cipher.Archives = $"{{\"{cipher.UserId.Value.ToString().ToUpperInvariant()}\":\"" + + $"{cipher.ArchivedDate.Value:yyyy-MM-ddTHH:mm:ss.fffffffZ}\"}}"; + } } var userfoldersIds = (await _folderRepository.GetManyByUserIdAsync(importingUserId)).Select(f => f.Id).ToList(); @@ -135,10 +141,16 @@ public class ImportCiphersCommand : IImportCiphersCommand } } - // Init. ids for ciphers foreach (var cipher in ciphers) { + // Init. ids for ciphers cipher.SetNewId(); + + if (cipher.ArchivedDate.HasValue) + { + cipher.Archives = $"{{\"{importingUserId.ToString().ToUpperInvariant()}\":\"" + + $"{cipher.ArchivedDate.Value:yyyy-MM-ddTHH:mm:ss.fffffffZ}\"}}"; + } } var organizationCollectionsIds = (await _collectionRepository.GetManyByOrganizationIdAsync(org.Id)).Select(c => c.Id).ToList(); diff --git a/src/Core/Tools/Models/Data/SendAuthenticationTypes.cs b/src/Core/Tools/Models/Data/SendAuthenticationTypes.cs index c90dba43a8..769e9df713 100644 --- a/src/Core/Tools/Models/Data/SendAuthenticationTypes.cs +++ b/src/Core/Tools/Models/Data/SendAuthenticationTypes.cs @@ -44,7 +44,7 @@ public record ResourcePassword(string Hash) : SendAuthenticationMethod; /// /// Create a send claim by requesting a one time password (OTP) confirmation code. /// -/// +/// /// The list of email address **hashes** permitted access to the send. /// -public record EmailOtp(string[] Emails) : SendAuthenticationMethod; +public record EmailOtp(string[] EmailHashes) : SendAuthenticationMethod; diff --git a/src/Events/Events.csproj b/src/Events/Events.csproj index dcd66892ed..dc1df1d587 100644 --- a/src/Events/Events.csproj +++ b/src/Events/Events.csproj @@ -1,4 +1,5 @@  + bitwarden-Events diff --git a/src/Events/Program.cs b/src/Events/Program.cs index 1a00549005..78a3cfcdc0 100644 --- a/src/Events/Program.cs +++ b/src/Events/Program.cs @@ -8,7 +8,7 @@ public class Program { Host .CreateDefaultBuilder(args) - .ConfigureCustomAppConfiguration(args) + .UseBitwardenSdk() .ConfigureWebHostDefaults(webBuilder => { webBuilder.UseStartup(); diff --git a/src/EventsProcessor/EventsProcessor.csproj b/src/EventsProcessor/EventsProcessor.csproj index 2f1aeaef54..9c128aa606 100644 --- a/src/EventsProcessor/EventsProcessor.csproj +++ b/src/EventsProcessor/EventsProcessor.csproj @@ -1,4 +1,5 @@  + bitwarden-EventsProcessor diff --git a/src/EventsProcessor/Startup.cs b/src/EventsProcessor/Startup.cs index 239393a693..78f99058ad 100644 --- a/src/EventsProcessor/Startup.cs +++ b/src/EventsProcessor/Startup.cs @@ -1,7 +1,6 @@ using System.Globalization; using Bit.Core.Utilities; using Bit.SharedWeb.Utilities; -using Microsoft.IdentityModel.Logging; namespace Bit.EventsProcessor; @@ -40,7 +39,6 @@ public class Startup public void Configure(IApplicationBuilder app) { - IdentityModelEventSource.ShowPII = true; // Add general security headers app.UseMiddleware(); app.UseRouting(); diff --git a/src/Icons/Icons.csproj b/src/Icons/Icons.csproj index 9dc39eab1e..adc1f6d557 100644 --- a/src/Icons/Icons.csproj +++ b/src/Icons/Icons.csproj @@ -1,4 +1,5 @@  + bitwarden-Icons diff --git a/src/Icons/Program.cs b/src/Icons/Program.cs index 80c1b5728e..5ca5723a52 100644 --- a/src/Icons/Program.cs +++ b/src/Icons/Program.cs @@ -8,6 +8,7 @@ public class Program { Host .CreateDefaultBuilder(args) + .UseBitwardenSdk() .ConfigureWebHostDefaults(webBuilder => { webBuilder.UseStartup(); diff --git a/src/Identity/Identity.csproj b/src/Identity/Identity.csproj index db49f8c856..f31d8c005e 100644 --- a/src/Identity/Identity.csproj +++ b/src/Identity/Identity.csproj @@ -1,4 +1,5 @@  + bitwarden-Identity diff --git a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendEmailOtpRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendEmailOtpRequestValidator.cs index 34a7a6f6e7..bd2b4f91b4 100644 --- a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendEmailOtpRequestValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendEmailOtpRequestValidator.cs @@ -1,5 +1,6 @@ using System.Security.Claims; -using Bit.Core; +using System.Security.Cryptography; +using System.Text; using Bit.Core.Auth.Identity; using Bit.Core.Auth.Identity.TokenProviders; using Bit.Core.Services; @@ -11,7 +12,6 @@ using Duende.IdentityServer.Validation; namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess; public class SendEmailOtpRequestValidator( - IFeatureService featureService, IOtpTokenProvider otpTokenProvider, IMailService mailService) : ISendAuthenticationMethodValidator { @@ -40,8 +40,10 @@ public class SendEmailOtpRequestValidator( return BuildErrorResult(SendAccessConstants.EmailOtpValidatorResults.EmailRequired); } - // email must be in the list of emails in the EmailOtp array - if (!authMethod.Emails.Contains(email)) + // email hash must be in the list of email hashes in the EmailOtp array + byte[] hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(email)); + string hashEmailHex = Convert.ToHexString(hashBytes).ToUpperInvariant(); + if (!authMethod.EmailHashes.Contains(hashEmailHex)) { return BuildErrorResult(SendAccessConstants.EmailOtpValidatorResults.EmailInvalid); } @@ -62,20 +64,12 @@ public class SendEmailOtpRequestValidator( { return BuildErrorResult(SendAccessConstants.EmailOtpValidatorResults.OtpGenerationFailed); } - if (featureService.IsEnabled(FeatureFlagKeys.MJMLBasedEmailTemplates)) - { - await mailService.SendSendEmailOtpEmailv2Async( - email, - token, - string.Format(SendAccessConstants.OtpEmail.Subject, token)); - } - else - { - await mailService.SendSendEmailOtpEmailAsync( - email, - token, - string.Format(SendAccessConstants.OtpEmail.Subject, token)); - } + + await mailService.SendSendEmailOtpEmailAsync( + email, + token, + string.Format(SendAccessConstants.OtpEmail.Subject, token)); + return BuildErrorResult(SendAccessConstants.EmailOtpValidatorResults.EmailOtpSent); } diff --git a/src/Identity/Program.cs b/src/Identity/Program.cs index 238ad8ce3a..ae284c86f2 100644 --- a/src/Identity/Program.cs +++ b/src/Identity/Program.cs @@ -15,7 +15,7 @@ public class Program { return Host .CreateDefaultBuilder(args) - .ConfigureCustomAppConfiguration(args) + .UseBitwardenSdk() .ConfigureWebHostDefaults(webBuilder => { webBuilder.UseStartup(); diff --git a/src/Identity/Startup.cs b/src/Identity/Startup.cs index 9d5536fd10..c6d21b59ad 100644 --- a/src/Identity/Startup.cs +++ b/src/Identity/Startup.cs @@ -14,7 +14,6 @@ using Bit.SharedWeb.Swagger; using Bit.SharedWeb.Utilities; using Duende.IdentityServer.Services; using Microsoft.Extensions.DependencyInjection.Extensions; -using Microsoft.IdentityModel.Logging; using Microsoft.OpenApi.Models; namespace Bit.Identity; @@ -170,16 +169,14 @@ public class Startup public void Configure( IApplicationBuilder app, - IWebHostEnvironment env, + IWebHostEnvironment environment, GlobalSettings globalSettings, ILogger logger) { - IdentityModelEventSource.ShowPII = true; - // Add general security headers app.UseMiddleware(); - if (!env.IsDevelopment()) + if (!environment.IsDevelopment()) { var uri = new Uri(globalSettings.BaseServiceUri.Identity); app.Use(async (ctx, next) => @@ -196,7 +193,7 @@ public class Startup } // Default Middleware - app.UseDefaultMiddleware(env, globalSettings); + app.UseDefaultMiddleware(environment, globalSettings); if (!globalSettings.SelfHosted) { @@ -204,7 +201,7 @@ public class Startup app.UseMiddleware(); } - if (env.IsDevelopment()) + if (environment.IsDevelopment()) { app.UseSwagger(); app.UseDeveloperExceptionPage(); diff --git a/src/Infrastructure.Dapper/Vault/Repositories/CipherRepository.cs b/src/Infrastructure.Dapper/Vault/Repositories/CipherRepository.cs index 48232ef484..ecf6d8e4e7 100644 --- a/src/Infrastructure.Dapper/Vault/Repositories/CipherRepository.cs +++ b/src/Infrastructure.Dapper/Vault/Repositories/CipherRepository.cs @@ -248,7 +248,7 @@ public class CipherRepository : Repository, ICipherRepository new { Ids = ids.ToGuidIdArrayTVP(), UserId = userId }, commandType: CommandType.StoredProcedure); - return results; + return DateTime.SpecifyKind(results, DateTimeKind.Utc); } } @@ -595,7 +595,7 @@ public class CipherRepository : Repository, ICipherRepository new { Ids = ids.ToGuidIdArrayTVP(), UserId = userId }, commandType: CommandType.StoredProcedure); - return results; + return DateTime.SpecifyKind(results, DateTimeKind.Utc); } } @@ -608,7 +608,7 @@ public class CipherRepository : Repository, ICipherRepository new { Ids = ids.ToGuidIdArrayTVP(), UserId = userId }, commandType: CommandType.StoredProcedure); - return results; + return DateTime.SpecifyKind(results, DateTimeKind.Utc); } } @@ -621,7 +621,7 @@ public class CipherRepository : Repository, ICipherRepository new { Ids = ids.ToGuidIdArrayTVP(), OrganizationId = organizationId }, commandType: CommandType.StoredProcedure); - return results; + return DateTime.SpecifyKind(results, DateTimeKind.Utc); } } diff --git a/src/Notifications/Notifications.csproj b/src/Notifications/Notifications.csproj index 4d19f7faf9..76278fdea8 100644 --- a/src/Notifications/Notifications.csproj +++ b/src/Notifications/Notifications.csproj @@ -1,4 +1,5 @@  + bitwarden-Notifications diff --git a/src/Notifications/Program.cs b/src/Notifications/Program.cs index 2792391729..ec7ea67fda 100644 --- a/src/Notifications/Program.cs +++ b/src/Notifications/Program.cs @@ -8,7 +8,7 @@ public class Program { Host .CreateDefaultBuilder(args) - .ConfigureCustomAppConfiguration(args) + .UseBitwardenSdk() .ConfigureWebHostDefaults(webBuilder => { webBuilder.UseStartup(); diff --git a/src/Notifications/Startup.cs b/src/Notifications/Startup.cs index 65904ea698..3a4dc2d447 100644 --- a/src/Notifications/Startup.cs +++ b/src/Notifications/Startup.cs @@ -5,7 +5,6 @@ using Bit.Core.Utilities; using Bit.SharedWeb.Utilities; using Duende.IdentityModel; using Microsoft.AspNetCore.SignalR; -using Microsoft.IdentityModel.Logging; namespace Bit.Notifications; @@ -84,8 +83,6 @@ public class Startup IWebHostEnvironment env, GlobalSettings globalSettings) { - IdentityModelEventSource.ShowPII = true; - // Add general security headers app.UseMiddleware(); diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index 5234a257cf..2e0f2f96ca 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -472,11 +472,6 @@ public static class ServiceCollectionExtensions addAuthorization.Invoke(config); }); } - - if (environment.IsDevelopment()) - { - Microsoft.IdentityModel.Logging.IdentityModelEventSource.ShowPII = true; - } } public static void AddCustomDataProtectionServices( @@ -666,7 +661,6 @@ public static class ServiceCollectionExtensions Constants.BrowserExtensions.OperaId }; } - }); } diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/GroupsControllerPerformanceTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/GroupsControllerPerformanceTests.cs index 71c6bf104c..a70be7d557 100644 --- a/test/Api.IntegrationTest/AdminConsole/Controllers/GroupsControllerPerformanceTests.cs +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/GroupsControllerPerformanceTests.cs @@ -1,11 +1,14 @@ using System.Net; using System.Text; using System.Text.Json; +using AutoMapper; using Bit.Api.AdminConsole.Models.Request; using Bit.Api.IntegrationTest.Factories; using Bit.Api.IntegrationTest.Helpers; using Bit.Api.Models.Request; +using Bit.Core.Entities; using Bit.Seeder.Recipes; +using Microsoft.AspNetCore.Identity; using Xunit; using Xunit.Abstractions; @@ -26,7 +29,9 @@ public class GroupsControllerPerformanceTests(ITestOutputHelper testOutputHelper var client = factory.CreateClient(); var db = factory.GetDatabaseContext(); - var orgSeeder = new OrganizationWithUsersRecipe(db); + var mapper = factory.GetService(); + var passwordHasher = factory.GetService>(); + var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher); var collectionsSeeder = new CollectionsRecipe(db); var groupsSeeder = new GroupsRecipe(db); @@ -34,8 +39,8 @@ public class GroupsControllerPerformanceTests(ITestOutputHelper testOutputHelper var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: userCount); var orgUserIds = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).ToList(); - var collectionIds = collectionsSeeder.AddToOrganization(orgId, collectionCount, orgUserIds, 0); - var groupIds = groupsSeeder.AddToOrganization(orgId, 1, orgUserIds, 0); + var collectionIds = collectionsSeeder.Seed(orgId, collectionCount, orgUserIds, 0); + var groupIds = groupsSeeder.Seed(orgId, 1, orgUserIds, 0); var groupId = groupIds.First(); diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerPerformanceTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerPerformanceTests.cs index fc64930777..322fd62bd7 100644 --- a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerPerformanceTests.cs +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerPerformanceTests.cs @@ -1,13 +1,16 @@ using System.Net; using System.Text; using System.Text.Json; +using AutoMapper; using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.IntegrationTest.Factories; using Bit.Api.IntegrationTest.Helpers; using Bit.Api.Models.Request; +using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Data; using Bit.Seeder.Recipes; +using Microsoft.AspNetCore.Identity; using Xunit; using Xunit.Abstractions; @@ -28,7 +31,9 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO var client = factory.CreateClient(); var db = factory.GetDatabaseContext(); - var orgSeeder = new OrganizationWithUsersRecipe(db); + var mapper = factory.GetService(); + var passwordHasher = factory.GetService>(); + var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher); var collectionsSeeder = new CollectionsRecipe(db); var groupsSeeder = new GroupsRecipe(db); @@ -37,8 +42,8 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: seats); var orgUserIds = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).ToList(); - collectionsSeeder.AddToOrganization(orgId, 10, orgUserIds); - groupsSeeder.AddToOrganization(orgId, 5, orgUserIds); + collectionsSeeder.Seed(orgId, 10, orgUserIds); + groupsSeeder.Seed(orgId, 5, orgUserIds); await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}"); @@ -64,7 +69,9 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO var client = factory.CreateClient(); var db = factory.GetDatabaseContext(); - var orgSeeder = new OrganizationWithUsersRecipe(db); + var mapper = factory.GetService(); + var passwordHasher = factory.GetService>(); + var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher); var collectionsSeeder = new CollectionsRecipe(db); var groupsSeeder = new GroupsRecipe(db); @@ -72,8 +79,8 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: seats); var orgUserIds = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).ToList(); - collectionsSeeder.AddToOrganization(orgId, 10, orgUserIds); - groupsSeeder.AddToOrganization(orgId, 5, orgUserIds); + collectionsSeeder.Seed(orgId, 10, orgUserIds); + groupsSeeder.Seed(orgId, 5, orgUserIds); await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}"); @@ -98,14 +105,16 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO var client = factory.CreateClient(); var db = factory.GetDatabaseContext(); - var orgSeeder = new OrganizationWithUsersRecipe(db); + var mapper = factory.GetService(); + var passwordHasher = factory.GetService>(); + var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher); var groupsSeeder = new GroupsRecipe(db); var domain = OrganizationTestHelpers.GenerateRandomDomain(); var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: 1); var orgUserId = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).FirstOrDefault(); - groupsSeeder.AddToOrganization(orgId, 2, [orgUserId]); + groupsSeeder.Seed(orgId, 2, [orgUserId]); await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}"); @@ -130,7 +139,9 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO var client = factory.CreateClient(); var db = factory.GetDatabaseContext(); - var orgSeeder = new OrganizationWithUsersRecipe(db); + var mapper = factory.GetService(); + var passwordHasher = factory.GetService>(); + var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher); var domain = OrganizationTestHelpers.GenerateRandomDomain(); var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: 1); @@ -163,7 +174,9 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO var client = factory.CreateClient(); var db = factory.GetDatabaseContext(); - var orgSeeder = new OrganizationWithUsersRecipe(db); + var mapper = factory.GetService(); + var passwordHasher = factory.GetService>(); + var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher); var domain = OrganizationTestHelpers.GenerateRandomDomain(); var orgId = orgSeeder.Seed( @@ -211,7 +224,9 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO var client = factory.CreateClient(); var db = factory.GetDatabaseContext(); - var orgSeeder = new OrganizationWithUsersRecipe(db); + var mapper = factory.GetService(); + var passwordHasher = factory.GetService>(); + var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher); var domain = OrganizationTestHelpers.GenerateRandomDomain(); var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: userCount); @@ -251,7 +266,9 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO var client = factory.CreateClient(); var db = factory.GetDatabaseContext(); - var orgSeeder = new OrganizationWithUsersRecipe(db); + var mapper = factory.GetService(); + var passwordHasher = factory.GetService>(); + var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher); var domain = OrganizationTestHelpers.GenerateRandomDomain(); var orgId = orgSeeder.Seed( @@ -295,7 +312,9 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO var client = factory.CreateClient(); var db = factory.GetDatabaseContext(); - var orgSeeder = new OrganizationWithUsersRecipe(db); + var mapper = factory.GetService(); + var passwordHasher = factory.GetService>(); + var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher); var domain = OrganizationTestHelpers.GenerateRandomDomain(); var orgId = orgSeeder.Seed( @@ -339,7 +358,9 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO var client = factory.CreateClient(); var db = factory.GetDatabaseContext(); - var orgSeeder = new OrganizationWithUsersRecipe(db); + var mapper = factory.GetService(); + var passwordHasher = factory.GetService>(); + var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher); var domainSeeder = new OrganizationDomainRecipe(db); var domain = OrganizationTestHelpers.GenerateRandomDomain(); @@ -350,7 +371,7 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO users: userCount, usersStatus: OrganizationUserStatusType.Confirmed); - domainSeeder.AddVerifiedDomainToOrganization(orgId, domain); + domainSeeder.Seed(orgId, domain); await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}"); @@ -384,7 +405,9 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO var client = factory.CreateClient(); var db = factory.GetDatabaseContext(); - var orgSeeder = new OrganizationWithUsersRecipe(db); + var mapper = factory.GetService(); + var passwordHasher = factory.GetService>(); + var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher); var collectionsSeeder = new CollectionsRecipe(db); var groupsSeeder = new GroupsRecipe(db); @@ -392,8 +415,8 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: 1); var orgUserIds = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).ToList(); - var collectionIds = collectionsSeeder.AddToOrganization(orgId, 3, orgUserIds, 0); - var groupIds = groupsSeeder.AddToOrganization(orgId, 2, orgUserIds, 0); + var collectionIds = collectionsSeeder.Seed(orgId, 3, orgUserIds, 0); + var groupIds = groupsSeeder.Seed(orgId, 2, orgUserIds, 0); await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}"); @@ -434,7 +457,9 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO var client = factory.CreateClient(); var db = factory.GetDatabaseContext(); - var orgSeeder = new OrganizationWithUsersRecipe(db); + var mapper = factory.GetService(); + var passwordHasher = factory.GetService>(); + var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher); var domain = OrganizationTestHelpers.GenerateRandomDomain(); var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: userCount); @@ -471,7 +496,9 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO var client = factory.CreateClient(); var db = factory.GetDatabaseContext(); - var orgSeeder = new OrganizationWithUsersRecipe(db); + var mapper = factory.GetService(); + var passwordHasher = factory.GetService>(); + var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher); var domainSeeder = new OrganizationDomainRecipe(db); var domain = OrganizationTestHelpers.GenerateRandomDomain(); @@ -481,7 +508,7 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO users: 2, usersStatus: OrganizationUserStatusType.Confirmed); - domainSeeder.AddVerifiedDomainToOrganization(orgId, domain); + domainSeeder.Seed(orgId, domain); await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}"); @@ -512,14 +539,16 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO var client = factory.CreateClient(); var db = factory.GetDatabaseContext(); - var orgSeeder = new OrganizationWithUsersRecipe(db); + var mapper = factory.GetService(); + var passwordHasher = factory.GetService>(); + var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher); var collectionsSeeder = new CollectionsRecipe(db); var domain = OrganizationTestHelpers.GenerateRandomDomain(); var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: 1); var orgUserIds = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).ToList(); - var collectionIds = collectionsSeeder.AddToOrganization(orgId, 2, orgUserIds, 0); + var collectionIds = collectionsSeeder.Seed(orgId, 2, orgUserIds, 0); await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}"); @@ -560,7 +589,9 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO var client = factory.CreateClient(); var db = factory.GetDatabaseContext(); - var orgSeeder = new OrganizationWithUsersRecipe(db); + var mapper = factory.GetService(); + var passwordHasher = factory.GetService>(); + var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher); var domain = OrganizationTestHelpers.GenerateRandomDomain(); var orgId = orgSeeder.Seed( diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationsControllerPerformanceTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationsControllerPerformanceTests.cs index 238a9a5d53..025eacc432 100644 --- a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationsControllerPerformanceTests.cs +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationsControllerPerformanceTests.cs @@ -1,14 +1,17 @@ using System.Net; using System.Text; using System.Text.Json; +using AutoMapper; using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.Auth.Models.Request.Accounts; using Bit.Api.IntegrationTest.Factories; using Bit.Api.IntegrationTest.Helpers; using Bit.Core.AdminConsole.Models.Business.Tokenables; using Bit.Core.Billing.Enums; +using Bit.Core.Entities; using Bit.Core.Tokens; using Bit.Seeder.Recipes; +using Microsoft.AspNetCore.Identity; using Xunit; using Xunit.Abstractions; @@ -29,7 +32,9 @@ public class OrganizationsControllerPerformanceTests(ITestOutputHelper testOutpu var client = factory.CreateClient(); var db = factory.GetDatabaseContext(); - var orgSeeder = new OrganizationWithUsersRecipe(db); + var mapper = factory.GetService(); + var passwordHasher = factory.GetService>(); + var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher); var collectionsSeeder = new CollectionsRecipe(db); var groupsSeeder = new GroupsRecipe(db); @@ -37,8 +42,8 @@ public class OrganizationsControllerPerformanceTests(ITestOutputHelper testOutpu var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: userCount); var orgUserIds = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).ToList(); - collectionsSeeder.AddToOrganization(orgId, collectionCount, orgUserIds, 0); - groupsSeeder.AddToOrganization(orgId, groupCount, orgUserIds, 0); + collectionsSeeder.Seed(orgId, collectionCount, orgUserIds, 0); + groupsSeeder.Seed(orgId, groupCount, orgUserIds, 0); await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}"); @@ -77,7 +82,9 @@ public class OrganizationsControllerPerformanceTests(ITestOutputHelper testOutpu var client = factory.CreateClient(); var db = factory.GetDatabaseContext(); - var orgSeeder = new OrganizationWithUsersRecipe(db); + var mapper = factory.GetService(); + var passwordHasher = factory.GetService>(); + var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher); var collectionsSeeder = new CollectionsRecipe(db); var groupsSeeder = new GroupsRecipe(db); @@ -85,8 +92,8 @@ public class OrganizationsControllerPerformanceTests(ITestOutputHelper testOutpu var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: userCount); var orgUserIds = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).ToList(); - collectionsSeeder.AddToOrganization(orgId, collectionCount, orgUserIds, 0); - groupsSeeder.AddToOrganization(orgId, groupCount, orgUserIds, 0); + collectionsSeeder.Seed(orgId, collectionCount, orgUserIds, 0); + groupsSeeder.Seed(orgId, groupCount, orgUserIds, 0); await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}"); diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs index d97a1be793..68a63bf579 100644 --- a/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs @@ -14,7 +14,6 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; -using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Utilities.v2.Results; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Repositories; @@ -30,6 +29,7 @@ using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.Repositories; using Bit.Core.Services; +using Bit.Core.Test.AdminConsole.AutoFixture; using Bit.Core.Utilities; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; @@ -137,23 +137,20 @@ public class OrganizationUsersControllerTests [Theory] [BitAutoData] public async Task Accept_WhenOrganizationUsePoliciesIsEnabledAndResetPolicyIsEnabled_ShouldHandleResetPassword(Guid orgId, Guid orgUserId, - OrganizationUserAcceptRequestModel model, User user, SutProvider sutProvider) + OrganizationUserAcceptRequestModel model, User user, + [Policy(PolicyType.ResetPassword, true)] PolicyStatus policy, + SutProvider sutProvider) { // Arrange var applicationCacheService = sutProvider.GetDependency(); applicationCacheService.GetOrganizationAbilityAsync(orgId).Returns(new OrganizationAbility { UsePolicies = true }); - var policy = new Policy - { - Enabled = true, - Data = CoreHelpers.ClassToJsonData(new ResetPasswordDataModel { AutoEnrollEnabled = true, }), - }; + policy.Data = CoreHelpers.ClassToJsonData(new ResetPasswordDataModel { AutoEnrollEnabled = true, }); var userService = sutProvider.GetDependency(); userService.GetUserByPrincipalAsync(default).ReturnsForAnyArgs(user); - - var policyRepository = sutProvider.GetDependency(); - policyRepository.GetByOrganizationIdTypeAsync(orgId, + var policyQuery = sutProvider.GetDependency(); + policyQuery.RunAsync(orgId, PolicyType.ResetPassword).Returns(policy); // Act @@ -167,29 +164,27 @@ public class OrganizationUsersControllerTests await userService.Received(1).GetUserByPrincipalAsync(default); await applicationCacheService.Received(1).GetOrganizationAbilityAsync(orgId); - await policyRepository.Received(1).GetByOrganizationIdTypeAsync(orgId, PolicyType.ResetPassword); + await policyQuery.Received(1).RunAsync(orgId, PolicyType.ResetPassword); } [Theory] [BitAutoData] public async Task Accept_WhenOrganizationUsePoliciesIsDisabled_ShouldNotHandleResetPassword(Guid orgId, Guid orgUserId, - OrganizationUserAcceptRequestModel model, User user, SutProvider sutProvider) + OrganizationUserAcceptRequestModel model, User user, + [Policy(PolicyType.ResetPassword, true)] PolicyStatus policy, + SutProvider sutProvider) { // Arrange var applicationCacheService = sutProvider.GetDependency(); applicationCacheService.GetOrganizationAbilityAsync(orgId).Returns(new OrganizationAbility { UsePolicies = false }); - var policy = new Policy - { - Enabled = true, - Data = CoreHelpers.ClassToJsonData(new ResetPasswordDataModel { AutoEnrollEnabled = true, }), - }; + policy.Data = CoreHelpers.ClassToJsonData(new ResetPasswordDataModel { AutoEnrollEnabled = true, }); var userService = sutProvider.GetDependency(); userService.GetUserByPrincipalAsync(default).ReturnsForAnyArgs(user); - var policyRepository = sutProvider.GetDependency(); - policyRepository.GetByOrganizationIdTypeAsync(orgId, + var policyQuery = sutProvider.GetDependency(); + policyQuery.RunAsync(orgId, PolicyType.ResetPassword).Returns(policy); // Act @@ -202,7 +197,7 @@ public class OrganizationUsersControllerTests await sutProvider.GetDependency().Received(0) .UpdateUserResetPasswordEnrollmentAsync(orgId, user.Id, model.ResetPasswordKey, user.Id); - await policyRepository.Received(0).GetByOrganizationIdTypeAsync(orgId, PolicyType.ResetPassword); + await policyQuery.Received(0).RunAsync(orgId, PolicyType.ResetPassword); await applicationCacheService.Received(1).GetOrganizationAbilityAsync(orgId); } @@ -383,7 +378,7 @@ public class OrganizationUsersControllerTests var policyRequirementQuery = sutProvider.GetDependency(); - var policyRepository = sutProvider.GetDependency(); + var policyQuery = sutProvider.GetDependency(); var policyRequirement = new ResetPasswordPolicyRequirement { AutoEnrollOrganizations = [orgId] }; @@ -400,7 +395,7 @@ public class OrganizationUsersControllerTests await userService.Received(1).GetUserByPrincipalAsync(default); await applicationCacheService.Received(0).GetOrganizationAbilityAsync(orgId); - await policyRepository.Received(0).GetByOrganizationIdTypeAsync(orgId, PolicyType.ResetPassword); + await policyQuery.Received(0).RunAsync(orgId, PolicyType.ResetPassword); await policyRequirementQuery.Received(1).GetAsync(user.Id); Assert.True(policyRequirement.AutoEnrollEnabled(orgId)); } @@ -425,7 +420,7 @@ public class OrganizationUsersControllerTests var userService = sutProvider.GetDependency(); userService.GetUserByPrincipalAsync(default).ReturnsForAnyArgs(user); - var policyRepository = sutProvider.GetDependency(); + var policyQuery = sutProvider.GetDependency(); var policyRequirementQuery = sutProvider.GetDependency(); @@ -445,7 +440,7 @@ public class OrganizationUsersControllerTests await userService.Received(1).GetUserByPrincipalAsync(default); await applicationCacheService.Received(0).GetOrganizationAbilityAsync(orgId); - await policyRepository.Received(0).GetByOrganizationIdTypeAsync(orgId, PolicyType.ResetPassword); + await policyQuery.Received(0).RunAsync(orgId, PolicyType.ResetPassword); await policyRequirementQuery.Received(1).GetAsync(user.Id); Assert.Equal("Master Password reset is required, but not provided.", exception.Message); diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs index d87f035a13..cc09e9e0a0 100644 --- a/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs @@ -7,6 +7,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Models.Business; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; @@ -25,6 +26,7 @@ using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Core.Services; +using Bit.Core.Test.AdminConsole.AutoFixture; using Bit.Core.Test.Billing.Mocks; using Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider; using Bit.Test.Common.AutoFixture; @@ -200,28 +202,21 @@ public class OrganizationsControllerTests SutProvider sutProvider, User user, Organization organization, - OrganizationUser organizationUser) + OrganizationUser organizationUser, + [Policy(PolicyType.ResetPassword, data: "{\"AutoEnrollEnabled\": true}")] PolicyStatus policy) { - var policy = new Policy - { - Type = PolicyType.ResetPassword, - Enabled = true, - Data = "{\"AutoEnrollEnabled\": true}", - OrganizationId = organization.Id - }; - sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()).Returns(user); sutProvider.GetDependency().GetByIdentifierAsync(organization.Id.ToString()).Returns(organization); sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(false); sutProvider.GetDependency().GetByOrganizationAsync(organization.Id, user.Id).Returns(organizationUser); - sutProvider.GetDependency().GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword).Returns(policy); + sutProvider.GetDependency().RunAsync(organization.Id, PolicyType.ResetPassword).Returns(policy); var result = await sutProvider.Sut.GetAutoEnrollStatus(organization.Id.ToString()); await sutProvider.GetDependency().Received(1).GetUserByPrincipalAsync(Arg.Any()); await sutProvider.GetDependency().Received(1).GetByIdentifierAsync(organization.Id.ToString()); await sutProvider.GetDependency().Received(0).GetAsync(user.Id); - await sutProvider.GetDependency().Received(1).GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword); + await sutProvider.GetDependency().Received(1).RunAsync(organization.Id, PolicyType.ResetPassword); Assert.True(result.ResetPasswordEnabled); } diff --git a/test/Api.Test/AdminConsole/Models/Response/Helpers/PolicyDetailResponsesTests.cs b/test/Api.Test/AdminConsole/Models/Response/Helpers/PolicyStatusResponsesTests.cs similarity index 62% rename from test/Api.Test/AdminConsole/Models/Response/Helpers/PolicyDetailResponsesTests.cs rename to test/Api.Test/AdminConsole/Models/Response/Helpers/PolicyStatusResponsesTests.cs index 9b863091db..46c6d64bdd 100644 --- a/test/Api.Test/AdminConsole/Models/Response/Helpers/PolicyDetailResponsesTests.cs +++ b/test/Api.Test/AdminConsole/Models/Response/Helpers/PolicyStatusResponsesTests.cs @@ -1,14 +1,13 @@ -using AutoFixture; -using Bit.Api.AdminConsole.Models.Response.Helpers; -using Bit.Core.AdminConsole.Entities; +using Bit.Api.AdminConsole.Models.Response.Helpers; using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces; using NSubstitute; using Xunit; namespace Bit.Api.Test.AdminConsole.Models.Response.Helpers; -public class PolicyDetailResponsesTests +public class PolicyStatusResponsesTests { [Theory] [InlineData(true, false)] @@ -17,19 +16,13 @@ public class PolicyDetailResponsesTests bool policyEnabled, bool expectedCanToggle) { - var fixture = new Fixture(); - - var policy = fixture.Build() - .Without(p => p.Data) - .With(p => p.Type, PolicyType.SingleOrg) - .With(p => p.Enabled, policyEnabled) - .Create(); + var policy = new PolicyStatus(Guid.NewGuid(), PolicyType.SingleOrg) { Enabled = policyEnabled }; var querySub = Substitute.For(); querySub.HasVerifiedDomainsAsync(policy.OrganizationId) .Returns(true); - var result = await policy.GetSingleOrgPolicyDetailResponseAsync(querySub); + var result = await policy.GetSingleOrgPolicyStatusResponseAsync(querySub); Assert.Equal(expectedCanToggle, result.CanToggleState); } @@ -37,18 +30,13 @@ public class PolicyDetailResponsesTests [Fact] public async Task GetSingleOrgPolicyDetailResponseAsync_WhenIsNotSingleOrgType_ThenShouldThrowArgumentException() { - var fixture = new Fixture(); - - var policy = fixture.Build() - .Without(p => p.Data) - .With(p => p.Type, PolicyType.TwoFactorAuthentication) - .Create(); + var policy = new PolicyStatus(Guid.NewGuid(), PolicyType.TwoFactorAuthentication); var querySub = Substitute.For(); querySub.HasVerifiedDomainsAsync(policy.OrganizationId) .Returns(true); - var action = async () => await policy.GetSingleOrgPolicyDetailResponseAsync(querySub); + var action = async () => await policy.GetSingleOrgPolicyStatusResponseAsync(querySub); await Assert.ThrowsAsync("policy", action); } @@ -56,18 +44,13 @@ public class PolicyDetailResponsesTests [Fact] public async Task GetSingleOrgPolicyDetailResponseAsync_WhenIsSingleOrgTypeAndDoesNotHaveVerifiedDomains_ThenShouldBeAbleToToggle() { - var fixture = new Fixture(); - - var policy = fixture.Build() - .Without(p => p.Data) - .With(p => p.Type, PolicyType.SingleOrg) - .Create(); + var policy = new PolicyStatus(Guid.NewGuid(), PolicyType.SingleOrg); var querySub = Substitute.For(); querySub.HasVerifiedDomainsAsync(policy.OrganizationId) .Returns(false); - var result = await policy.GetSingleOrgPolicyDetailResponseAsync(querySub); + var result = await policy.GetSingleOrgPolicyStatusResponseAsync(querySub); Assert.True(result.CanToggleState); } diff --git a/test/Api.Test/Billing/Controllers/OrganizationSponsorshipsControllerTests.cs b/test/Api.Test/Billing/Controllers/OrganizationSponsorshipsControllerTests.cs index 87334dc085..a7eb4dda5e 100644 --- a/test/Api.Test/Billing/Controllers/OrganizationSponsorshipsControllerTests.cs +++ b/test/Api.Test/Billing/Controllers/OrganizationSponsorshipsControllerTests.cs @@ -1,6 +1,9 @@ using Bit.Api.Billing.Controllers; using Bit.Api.Models.Request.Organizations; using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.Billing.Enums; using Bit.Core.Context; using Bit.Core.Entities; @@ -10,6 +13,7 @@ using Bit.Core.Models.Data; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; using Bit.Core.Repositories; using Bit.Core.Services; +using Bit.Core.Test.AdminConsole.AutoFixture; using Bit.Core.Test.Billing.Mocks; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; @@ -82,7 +86,9 @@ public class OrganizationSponsorshipsControllerTests [BitAutoData] public async Task RedeemSponsorship_NotSponsoredOrgOwner_Success(string sponsorshipToken, User user, OrganizationSponsorship sponsorship, Organization sponsoringOrganization, - OrganizationSponsorshipRedeemRequestModel model, SutProvider sutProvider) + OrganizationSponsorshipRedeemRequestModel model, + [Policy(PolicyType.FreeFamiliesSponsorshipPolicy, false)] PolicyStatus policy, + SutProvider sutProvider) { sutProvider.GetDependency().UserId.Returns(user.Id); sutProvider.GetDependency().GetUserByIdAsync(user.Id) @@ -91,6 +97,9 @@ public class OrganizationSponsorshipsControllerTests user.Email).Returns((true, sponsorship)); sutProvider.GetDependency().OrganizationOwner(model.SponsoredOrganizationId).Returns(true); sutProvider.GetDependency().GetByIdAsync(model.SponsoredOrganizationId).Returns(sponsoringOrganization); + sutProvider.GetDependency() + .RunAsync(Arg.Any(), PolicyType.FreeFamiliesSponsorshipPolicy) + .Returns(policy); await sutProvider.Sut.RedeemSponsorship(sponsorshipToken, model); @@ -101,14 +110,18 @@ public class OrganizationSponsorshipsControllerTests [Theory] [BitAutoData] public async Task PreValidateSponsorshipToken_ValidatesToken_Success(string sponsorshipToken, User user, - OrganizationSponsorship sponsorship, SutProvider sutProvider) + OrganizationSponsorship sponsorship, + [Policy(PolicyType.FreeFamiliesSponsorshipPolicy, false)] PolicyStatus policy, + SutProvider sutProvider) { sutProvider.GetDependency().UserId.Returns(user.Id); sutProvider.GetDependency().GetUserByIdAsync(user.Id) .Returns(user); sutProvider.GetDependency() .ValidateRedemptionTokenAsync(sponsorshipToken, user.Email).Returns((true, sponsorship)); - + sutProvider.GetDependency() + .RunAsync(Arg.Any(), PolicyType.FreeFamiliesSponsorshipPolicy) + .Returns(policy); await sutProvider.Sut.PreValidateSponsorshipToken(sponsorshipToken); await sutProvider.GetDependency().Received(1) diff --git a/test/Api.Test/Billing/Models/Requests/PreviewPremiumUpgradeProrationRequestTests.cs b/test/Api.Test/Billing/Models/Requests/PreviewPremiumUpgradeProrationRequestTests.cs new file mode 100644 index 0000000000..5ed4182a5d --- /dev/null +++ b/test/Api.Test/Billing/Models/Requests/PreviewPremiumUpgradeProrationRequestTests.cs @@ -0,0 +1,56 @@ +using Bit.Api.Billing.Models.Requests.Payment; +using Bit.Api.Billing.Models.Requests.PreviewInvoice; +using Bit.Core.Billing.Enums; +using Xunit; + +namespace Bit.Api.Test.Billing.Models.Requests; + +public class PreviewPremiumUpgradeProrationRequestTests +{ + [Theory] + [InlineData(ProductTierType.Families, PlanType.FamiliesAnnually)] + [InlineData(ProductTierType.Teams, PlanType.TeamsAnnually)] + [InlineData(ProductTierType.Enterprise, PlanType.EnterpriseAnnually)] + public void ToDomain_ValidTierTypes_ReturnsPlanType(ProductTierType tierType, PlanType expectedPlanType) + { + // Arrange + var sut = new PreviewPremiumUpgradeProrationRequest + { + TargetProductTierType = tierType, + BillingAddress = new MinimalBillingAddressRequest + { + Country = "US", + PostalCode = "12345" + } + }; + + // Act + var (planType, billingAddress) = sut.ToDomain(); + + // Assert + Assert.Equal(expectedPlanType, planType); + Assert.Equal("US", billingAddress.Country); + Assert.Equal("12345", billingAddress.PostalCode); + } + + [Theory] + [InlineData(ProductTierType.Free)] + [InlineData(ProductTierType.TeamsStarter)] + public void ToDomain_InvalidTierTypes_ThrowsInvalidOperationException(ProductTierType tierType) + { + // Arrange + var sut = new PreviewPremiumUpgradeProrationRequest + { + TargetProductTierType = tierType, + BillingAddress = new MinimalBillingAddressRequest + { + Country = "US", + PostalCode = "12345" + } + }; + + // Act & Assert + var exception = Assert.Throws(() => sut.ToDomain()); + Assert.Contains($"Cannot upgrade Premium subscription to {tierType} plan", exception.Message); + } +} diff --git a/test/Api.Test/Billing/Models/Requests/UpgradePremiumToOrganizationRequestTests.cs b/test/Api.Test/Billing/Models/Requests/UpgradePremiumToOrganizationRequestTests.cs new file mode 100644 index 0000000000..2d3bdb7b14 --- /dev/null +++ b/test/Api.Test/Billing/Models/Requests/UpgradePremiumToOrganizationRequestTests.cs @@ -0,0 +1,62 @@ +using Bit.Api.Billing.Models.Requests.Payment; +using Bit.Api.Billing.Models.Requests.Premium; +using Bit.Core.Billing.Enums; +using Xunit; + +namespace Bit.Api.Test.Billing.Models.Requests; + +public class UpgradePremiumToOrganizationRequestTests +{ + [Theory] + [InlineData(ProductTierType.Families, PlanType.FamiliesAnnually)] + [InlineData(ProductTierType.Teams, PlanType.TeamsAnnually)] + [InlineData(ProductTierType.Enterprise, PlanType.EnterpriseAnnually)] + public void ToDomain_ValidTierTypes_ReturnsPlanType(ProductTierType tierType, PlanType expectedPlanType) + { + // Arrange + var sut = new UpgradePremiumToOrganizationRequest + { + OrganizationName = "Test Organization", + Key = "encrypted-key", + TargetProductTierType = tierType, + BillingAddress = new MinimalBillingAddressRequest + { + Country = "US", + PostalCode = "12345" + } + }; + + // Act + var (organizationName, key, planType, billingAddress) = sut.ToDomain(); + + // Assert + Assert.Equal("Test Organization", organizationName); + Assert.Equal("encrypted-key", key); + Assert.Equal(expectedPlanType, planType); + Assert.Equal("US", billingAddress.Country); + Assert.Equal("12345", billingAddress.PostalCode); + } + + [Theory] + [InlineData(ProductTierType.Free)] + [InlineData(ProductTierType.TeamsStarter)] + public void ToDomain_InvalidTierTypes_ThrowsInvalidOperationException(ProductTierType tierType) + { + // Arrange + var sut = new UpgradePremiumToOrganizationRequest + { + OrganizationName = "Test Organization", + Key = "encrypted-key", + TargetProductTierType = tierType, + BillingAddress = new MinimalBillingAddressRequest + { + Country = "US", + PostalCode = "12345" + } + }; + + // Act & Assert + var exception = Assert.Throws(() => sut.ToDomain()); + Assert.Contains($"Cannot upgrade Premium subscription to {tierType} plan", exception.Message); + } +} diff --git a/test/Api.Test/Controllers/PoliciesControllerTests.cs b/test/Api.Test/Controllers/PoliciesControllerTests.cs index efb9f7aaa9..03ab20ec28 100644 --- a/test/Api.Test/Controllers/PoliciesControllerTests.cs +++ b/test/Api.Test/Controllers/PoliciesControllerTests.cs @@ -49,7 +49,7 @@ public class PoliciesControllerTests sutProvider.GetDependency() .GetProperUserId(Arg.Any()) - .Returns((Guid?)userId); + .Returns(userId); sutProvider.GetDependency() .GetByOrganizationAsync(orgId, userId) @@ -95,7 +95,7 @@ public class PoliciesControllerTests // Arrange sutProvider.GetDependency() .GetProperUserId(Arg.Any()) - .Returns((Guid?)userId); + .Returns(userId); sutProvider.GetDependency() .GetByOrganizationAsync(orgId, userId) @@ -113,7 +113,7 @@ public class PoliciesControllerTests // Arrange sutProvider.GetDependency() .GetProperUserId(Arg.Any()) - .Returns((Guid?)userId); + .Returns(userId); sutProvider.GetDependency() .GetByOrganizationAsync(orgId, userId) @@ -135,7 +135,7 @@ public class PoliciesControllerTests // Arrange sutProvider.GetDependency() .GetProperUserId(Arg.Any()) - .Returns((Guid?)userId); + .Returns(userId); sutProvider.GetDependency() .GetByOrganizationAsync(orgId, userId) @@ -186,59 +186,35 @@ public class PoliciesControllerTests [Theory] [BitAutoData] public async Task Get_WhenUserCanManagePolicies_WithExistingType_ReturnsExistingPolicy( - SutProvider sutProvider, Guid orgId, Policy policy, int type) + SutProvider sutProvider, Guid orgId, PolicyStatus policy, PolicyType type) { // Arrange sutProvider.GetDependency() .ManagePolicies(orgId) .Returns(true); - policy.Type = (PolicyType)type; + policy.Type = type; policy.Enabled = true; policy.Data = null; - sutProvider.GetDependency() - .GetByOrganizationIdTypeAsync(orgId, (PolicyType)type) + sutProvider.GetDependency() + .RunAsync(orgId, type) .Returns(policy); // Act var result = await sutProvider.Sut.Get(orgId, type); // Assert - Assert.IsType(result); - Assert.Equal(policy.Id, result.Id); + Assert.IsType(result); Assert.Equal(policy.Type, result.Type); Assert.Equal(policy.Enabled, result.Enabled); Assert.Equal(policy.OrganizationId, result.OrganizationId); } - [Theory] - [BitAutoData] - public async Task Get_WhenUserCanManagePolicies_WithNonExistingType_ReturnsDefaultPolicy( - SutProvider sutProvider, Guid orgId, int type) - { - // Arrange - sutProvider.GetDependency() - .ManagePolicies(orgId) - .Returns(true); - - sutProvider.GetDependency() - .GetByOrganizationIdTypeAsync(orgId, (PolicyType)type) - .Returns((Policy)null); - - // Act - var result = await sutProvider.Sut.Get(orgId, type); - - // Assert - Assert.IsType(result); - Assert.Equal(result.Type, (PolicyType)type); - Assert.False(result.Enabled); - } - [Theory] [BitAutoData] public async Task Get_WhenUserCannotManagePolicies_ThrowsNotFoundException( - SutProvider sutProvider, Guid orgId, int type) + SutProvider sutProvider, Guid orgId, PolicyType type) { // Arrange sutProvider.GetDependency() diff --git a/test/Api.Test/Tools/Controllers/ImportCiphersControllerTests.cs b/test/Api.Test/Tools/Controllers/ImportCiphersControllerTests.cs index 9ca641a28e..a8465ed0f6 100644 --- a/test/Api.Test/Tools/Controllers/ImportCiphersControllerTests.cs +++ b/test/Api.Test/Tools/Controllers/ImportCiphersControllerTests.cs @@ -806,63 +806,6 @@ public class ImportCiphersControllerTests Arg.Any()); } - [Theory, BitAutoData] - public async Task PostImportOrganization_ThrowsException_WhenAnyCipherIsArchived( - SutProvider sutProvider, - IFixture fixture, - User user - ) - { - var orgId = Guid.NewGuid(); - - sutProvider.GetDependency() - .SelfHosted = false; - sutProvider.GetDependency() - .ImportCiphersLimitation = _organizationCiphersLimitations; - - SetupUserService(sutProvider, user); - - var ciphers = fixture.Build() - .With(_ => _.ArchivedDate, DateTime.UtcNow) - .CreateMany(2).ToArray(); - - var request = new ImportOrganizationCiphersRequestModel - { - Collections = new List().ToArray(), - Ciphers = ciphers, - CollectionRelationships = new List>().ToArray(), - }; - - sutProvider.GetDependency() - .AccessImportExport(Arg.Any()) - .Returns(false); - - sutProvider.GetDependency() - .AuthorizeAsync(Arg.Any(), - Arg.Any>(), - Arg.Is>(reqs => - reqs.Contains(BulkCollectionOperations.ImportCiphers))) - .Returns(AuthorizationResult.Failed()); - - sutProvider.GetDependency() - .AuthorizeAsync(Arg.Any(), - Arg.Any>(), - Arg.Is>(reqs => - reqs.Contains(BulkCollectionOperations.Create))) - .Returns(AuthorizationResult.Success()); - - sutProvider.GetDependency() - .GetManyByOrganizationIdAsync(orgId) - .Returns(new List()); - - var exception = await Assert.ThrowsAsync(async () => - { - await sutProvider.Sut.PostImportOrganization(orgId.ToString(), request); - }); - - Assert.Equal("You cannot import archived items into an organization.", exception.Message); - } - private static void SetupUserService(SutProvider sutProvider, User user) { // This is a workaround for the NSubstitute issue with ambiguous arguments diff --git a/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs b/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs index 52a1b2a0ed..f4da9f8066 100644 --- a/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs +++ b/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs @@ -819,7 +819,7 @@ public class SubscriptionUpdatedHandlerTests { Data = [ - new SubscriptionItem { Plan = new Plan { Id = "secrets-manager-teams-seat-annually" } }, + new SubscriptionItem { Plan = new Stripe.Plan { Id = "secrets-manager-teams-seat-annually" } }, ] } }) diff --git a/test/Common/Helpers/CryptographyHelper.cs b/test/Common/Helpers/CryptographyHelper.cs new file mode 100644 index 0000000000..30dfb1a679 --- /dev/null +++ b/test/Common/Helpers/CryptographyHelper.cs @@ -0,0 +1,17 @@ +using System.Security.Cryptography; +using System.Text; + +namespace Bit.Test.Common.Helpers; + +public class CryptographyHelper +{ + /// + /// Returns a hex-encoded, SHA256 hash for the given string + /// + public static string HashAndEncode(string text) + { + var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(text)); + var hashEncoded = Convert.ToHexString(hashBytes).ToUpperInvariant(); + return hashEncoded; + } +} diff --git a/test/Core.Test/AdminConsole/AutoFixture/PolicyFixtures.cs b/test/Core.Test/AdminConsole/AutoFixture/PolicyFixtures.cs index 09b112c43c..01ffb86a7d 100644 --- a/test/Core.Test/AdminConsole/AutoFixture/PolicyFixtures.cs +++ b/test/Core.Test/AdminConsole/AutoFixture/PolicyFixtures.cs @@ -3,6 +3,7 @@ using AutoFixture; using AutoFixture.Xunit2; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; namespace Bit.Core.Test.AdminConsole.AutoFixture; @@ -10,19 +11,30 @@ internal class PolicyCustomization : ICustomization { public PolicyType Type { get; set; } public bool Enabled { get; set; } + public string? Data { get; set; } - public PolicyCustomization(PolicyType type, bool enabled) + public PolicyCustomization(PolicyType type, bool enabled, string? data) { Type = type; Enabled = enabled; + Data = data; } public void Customize(IFixture fixture) { + var orgId = Guid.NewGuid(); + fixture.Customize(composer => composer - .With(o => o.OrganizationId, Guid.NewGuid()) + .With(o => o.OrganizationId, orgId) .With(o => o.Type, Type) - .With(o => o.Enabled, Enabled)); + .With(o => o.Enabled, Enabled) + .With(o => o.Data, Data)); + + fixture.Customize(composer => composer + .With(o => o.OrganizationId, orgId) + .With(o => o.Type, Type) + .With(o => o.Enabled, Enabled) + .With(o => o.Data, Data)); } } @@ -30,15 +42,17 @@ public class PolicyAttribute : CustomizeAttribute { private readonly PolicyType _type; private readonly bool _enabled; + private readonly string? _data; - public PolicyAttribute(PolicyType type, bool enabled = true) + public PolicyAttribute(PolicyType type, bool enabled = true, string? data = null) { _type = type; _enabled = enabled; + _data = data; } public override ICustomization GetCustomization(ParameterInfo parameter) { - return new PolicyCustomization(_type, _enabled); + return new PolicyCustomization(_type, _enabled, _data); } } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommandTests.cs index 88025301b6..3095907a22 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommandTests.cs @@ -1,14 +1,16 @@ using AutoFixture; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.AccountRecovery; -using Bit.Core.AdminConsole.Repositories; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; +using Bit.Core.Test.AdminConsole.AutoFixture; using Bit.Core.Test.AutoFixture.OrganizationUserFixtures; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; @@ -29,11 +31,12 @@ public class AdminRecoverAccountCommandTests Organization organization, OrganizationUser organizationUser, User user, + [Policy(PolicyType.ResetPassword, true)] PolicyStatus policy, SutProvider sutProvider) { // Arrange SetupValidOrganization(sutProvider, organization); - SetupValidPolicy(sutProvider, organization); + SetupValidPolicy(sutProvider, organization, policy); SetupValidOrganizationUser(organizationUser, organization.Id); SetupValidUser(sutProvider, user, organizationUser); SetupSuccessfulPasswordUpdate(sutProvider, user, newMasterPassword); @@ -87,25 +90,18 @@ public class AdminRecoverAccountCommandTests Assert.Equal("Organization does not allow password reset.", exception.Message); } - public static IEnumerable InvalidPolicies => new object[][] - { - [new Policy { Type = PolicyType.ResetPassword, Enabled = false }], [null] - }; - [Theory] - [BitMemberAutoData(nameof(InvalidPolicies))] + [BitAutoData] public async Task RecoverAccountAsync_InvalidPolicy_ThrowsBadRequest( - Policy resetPasswordPolicy, string newMasterPassword, string key, Organization organization, + [Policy(PolicyType.ResetPassword, false)] PolicyStatus policy, SutProvider sutProvider) { // Arrange SetupValidOrganization(sutProvider, organization); - sutProvider.GetDependency() - .GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword) - .Returns(resetPasswordPolicy); + SetupValidPolicy(sutProvider, organization, policy); // Act & Assert var exception = await Assert.ThrowsAsync(() => @@ -171,11 +167,12 @@ public class AdminRecoverAccountCommandTests Organization organization, string newMasterPassword, string key, + [Policy(PolicyType.ResetPassword, true)] PolicyStatus policy, SutProvider sutProvider) { // Arrange SetupValidOrganization(sutProvider, organization); - SetupValidPolicy(sutProvider, organization); + SetupValidPolicy(sutProvider, organization, policy); // Act & Assert var exception = await Assert.ThrowsAsync(() => @@ -190,11 +187,12 @@ public class AdminRecoverAccountCommandTests string key, Organization organization, OrganizationUser organizationUser, + [Policy(PolicyType.ResetPassword, true)] PolicyStatus policy, SutProvider sutProvider) { // Arrange SetupValidOrganization(sutProvider, organization); - SetupValidPolicy(sutProvider, organization); + SetupValidPolicy(sutProvider, organization, policy); SetupValidOrganizationUser(organizationUser, organization.Id); sutProvider.GetDependency() .GetUserByIdAsync(organizationUser.UserId!.Value) @@ -213,11 +211,12 @@ public class AdminRecoverAccountCommandTests Organization organization, OrganizationUser organizationUser, User user, + [Policy(PolicyType.ResetPassword, true)] PolicyStatus policy, SutProvider sutProvider) { // Arrange SetupValidOrganization(sutProvider, organization); - SetupValidPolicy(sutProvider, organization); + SetupValidPolicy(sutProvider, organization, policy); SetupValidOrganizationUser(organizationUser, organization.Id); user.UsesKeyConnector = true; sutProvider.GetDependency() @@ -238,11 +237,10 @@ public class AdminRecoverAccountCommandTests .Returns(organization); } - private static void SetupValidPolicy(SutProvider sutProvider, Organization organization) + private static void SetupValidPolicy(SutProvider sutProvider, Organization organization, PolicyStatus policy) { - var policy = new Policy { Type = PolicyType.ResetPassword, Enabled = true }; - sutProvider.GetDependency() - .GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword) + sutProvider.GetDependency() + .RunAsync(organization.Id, PolicyType.ResetPassword) .Returns(policy); } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUsers/AutomaticallyConfirmOrganizationUsersValidatorTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUsers/AutomaticallyConfirmOrganizationUsersValidatorTests.cs index c3fb52ecbe..50e40b9803 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUsers/AutomaticallyConfirmOrganizationUsersValidatorTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUsers/AutomaticallyConfirmOrganizationUsersValidatorTests.cs @@ -7,7 +7,6 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimed using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; -using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Billing.Enums; using Bit.Core.Entities; @@ -120,7 +119,7 @@ public class AutomaticallyConfirmOrganizationUsersValidatorTests [Organization(useAutomaticUserConfirmation: true, planType: PlanType.EnterpriseAnnually)] Organization organization, [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser organizationUser, User user, - [Policy(PolicyType.AutomaticUserConfirmation)] Policy autoConfirmPolicy) + [Policy(PolicyType.AutomaticUserConfirmation)] PolicyStatus autoConfirmPolicy) { // Arrange organizationUser.UserId = user.Id; @@ -137,8 +136,8 @@ public class AutomaticallyConfirmOrganizationUsersValidatorTests Key = "test-key" }; - sutProvider.GetDependency() - .GetByOrganizationIdTypeAsync(organization.Id, PolicyType.AutomaticUserConfirmation) + sutProvider.GetDependency() + .RunAsync(organization.Id, PolicyType.AutomaticUserConfirmation) .Returns(autoConfirmPolicy); sutProvider.GetDependency() @@ -280,7 +279,7 @@ public class AutomaticallyConfirmOrganizationUsersValidatorTests [Organization(useAutomaticUserConfirmation: true)] Organization organization, [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser organizationUser, Guid userId, - [Policy(PolicyType.AutomaticUserConfirmation)] Policy autoConfirmPolicy) + [Policy(PolicyType.AutomaticUserConfirmation)] PolicyStatus autoConfirmPolicy) { // Arrange organizationUser.UserId = userId; @@ -303,8 +302,8 @@ public class AutomaticallyConfirmOrganizationUsersValidatorTests PolicyType = PolicyType.TwoFactorAuthentication }; - sutProvider.GetDependency() - .GetByOrganizationIdTypeAsync(organization.Id, PolicyType.AutomaticUserConfirmation) + sutProvider.GetDependency() + .RunAsync(organization.Id, PolicyType.AutomaticUserConfirmation) .Returns(autoConfirmPolicy); sutProvider.GetDependency() @@ -334,7 +333,7 @@ public class AutomaticallyConfirmOrganizationUsersValidatorTests [Organization(useAutomaticUserConfirmation: true)] Organization organization, [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser organizationUser, User user, - [Policy(PolicyType.AutomaticUserConfirmation)] Policy autoConfirmPolicy) + [Policy(PolicyType.AutomaticUserConfirmation)] PolicyStatus autoConfirmPolicy) { // Arrange organizationUser.UserId = user.Id; @@ -351,8 +350,8 @@ public class AutomaticallyConfirmOrganizationUsersValidatorTests Key = "test-key" }; - sutProvider.GetDependency() - .GetByOrganizationIdTypeAsync(organization.Id, PolicyType.AutomaticUserConfirmation) + sutProvider.GetDependency() + .RunAsync(organization.Id, PolicyType.AutomaticUserConfirmation) .Returns(autoConfirmPolicy); sutProvider.GetDependency() @@ -389,7 +388,7 @@ public class AutomaticallyConfirmOrganizationUsersValidatorTests [Organization(useAutomaticUserConfirmation: true)] Organization organization, [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser organizationUser, User user, - [Policy(PolicyType.AutomaticUserConfirmation)] Policy autoConfirmPolicy) + [Policy(PolicyType.AutomaticUserConfirmation)] PolicyStatus autoConfirmPolicy) { // Arrange organizationUser.UserId = user.Id; @@ -406,8 +405,8 @@ public class AutomaticallyConfirmOrganizationUsersValidatorTests Key = "test-key" }; - sutProvider.GetDependency() - .GetByOrganizationIdTypeAsync(organization.Id, PolicyType.AutomaticUserConfirmation) + sutProvider.GetDependency() + .RunAsync(organization.Id, PolicyType.AutomaticUserConfirmation) .Returns(autoConfirmPolicy); sutProvider.GetDependency() @@ -448,7 +447,7 @@ public class AutomaticallyConfirmOrganizationUsersValidatorTests [Organization(useAutomaticUserConfirmation: true)] Organization organization, [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser organizationUser, User user, - [Policy(PolicyType.AutomaticUserConfirmation)] Policy autoConfirmPolicy) + [Policy(PolicyType.AutomaticUserConfirmation)] PolicyStatus autoConfirmPolicy) { // Arrange organizationUser.UserId = user.Id; @@ -465,8 +464,8 @@ public class AutomaticallyConfirmOrganizationUsersValidatorTests Key = "test-key" }; - sutProvider.GetDependency() - .GetByOrganizationIdTypeAsync(organization.Id, PolicyType.AutomaticUserConfirmation) + sutProvider.GetDependency() + .RunAsync(organization.Id, PolicyType.AutomaticUserConfirmation) .Returns(autoConfirmPolicy); sutProvider.GetDependency() @@ -501,7 +500,8 @@ public class AutomaticallyConfirmOrganizationUsersValidatorTests SutProvider sutProvider, Organization organization, [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser organizationUser, - Guid userId) + Guid userId, + [Policy(PolicyType.AutomaticUserConfirmation, false)] PolicyStatus policy) { // Arrange organizationUser.UserId = userId; @@ -518,9 +518,9 @@ public class AutomaticallyConfirmOrganizationUsersValidatorTests Key = "test-key" }; - sutProvider.GetDependency() - .GetByOrganizationIdTypeAsync(organization.Id, PolicyType.AutomaticUserConfirmation) - .Returns((Policy)null); + sutProvider.GetDependency() + .RunAsync(organization.Id, PolicyType.AutomaticUserConfirmation) + .Returns(policy); sutProvider.GetDependency() .TwoFactorIsEnabledAsync(Arg.Any>()) @@ -545,7 +545,7 @@ public class AutomaticallyConfirmOrganizationUsersValidatorTests [Organization(useAutomaticUserConfirmation: false)] Organization organization, [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser organizationUser, Guid userId, - [Policy(PolicyType.AutomaticUserConfirmation)] Policy autoConfirmPolicy) + [Policy(PolicyType.AutomaticUserConfirmation)] PolicyStatus autoConfirmPolicy) { // Arrange organizationUser.UserId = userId; @@ -562,8 +562,8 @@ public class AutomaticallyConfirmOrganizationUsersValidatorTests Key = "test-key" }; - sutProvider.GetDependency() - .GetByOrganizationIdTypeAsync(organization.Id, PolicyType.AutomaticUserConfirmation) + sutProvider.GetDependency() + .RunAsync(organization.Id, PolicyType.AutomaticUserConfirmation) .Returns(autoConfirmPolicy); sutProvider.GetDependency() @@ -589,7 +589,7 @@ public class AutomaticallyConfirmOrganizationUsersValidatorTests [Organization(useAutomaticUserConfirmation: true)] Organization organization, [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser organizationUser, User user, - [Policy(PolicyType.AutomaticUserConfirmation)] Policy autoConfirmPolicy) + [Policy(PolicyType.AutomaticUserConfirmation)] PolicyStatus autoConfirmPolicy) { // Arrange organizationUser.UserId = user.Id; @@ -606,8 +606,8 @@ public class AutomaticallyConfirmOrganizationUsersValidatorTests Key = "test-key" }; - sutProvider.GetDependency() - .GetByOrganizationIdTypeAsync(organization.Id, PolicyType.AutomaticUserConfirmation) + sutProvider.GetDependency() + .RunAsync(organization.Id, PolicyType.AutomaticUserConfirmation) .Returns(autoConfirmPolicy); sutProvider.GetDependency() diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommandTests.cs index 23c1a32c03..ddede2d191 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommandTests.cs @@ -1,7 +1,9 @@ using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; -using Bit.Core.AdminConsole.Repositories; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Repositories; @@ -9,6 +11,7 @@ using Bit.Core.Billing.Enums; using Bit.Core.Entities; using Bit.Core.Models.Mail; using Bit.Core.Services; +using Bit.Core.Test.AdminConsole.AutoFixture; using Bit.Core.Test.AutoFixture.OrganizationFixtures; using Bit.Core.Tokens; using Bit.Test.Common.AutoFixture; @@ -31,6 +34,7 @@ public class SendOrganizationInvitesCommandTests Organization organization, SsoConfig ssoConfig, OrganizationUser invite, + [Policy(PolicyType.RequireSso, false)] PolicyStatus policy, SutProvider sutProvider) { // Setup FakeDataProtectorTokenFactory for creating new tokens - this must come first in order to avoid resetting mocks @@ -45,7 +49,9 @@ public class SendOrganizationInvitesCommandTests sutProvider.GetDependency().GetByOrganizationIdAsync(organization.Id).Returns(ssoConfig); // Return null policy to mimic new org that's never turned on the require sso policy - sutProvider.GetDependency().GetManyByOrganizationIdAsync(organization.Id).ReturnsNull(); + sutProvider.GetDependency() + .RunAsync(organization.Id, PolicyType.RequireSso) + .Returns(policy); // Mock tokenable factory to return a token that expires in 5 days sutProvider.GetDependency() diff --git a/test/Core.Test/Auth/Services/SsoConfigServiceTests.cs b/test/Core.Test/Auth/Services/SsoConfigServiceTests.cs index 2f4d00a7fa..ca4378e6ec 100644 --- a/test/Core.Test/Auth/Services/SsoConfigServiceTests.cs +++ b/test/Core.Test/Auth/Services/SsoConfigServiceTests.cs @@ -2,9 +2,9 @@ using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; -using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models.Data; @@ -13,6 +13,7 @@ using Bit.Core.Auth.Services; using Bit.Core.Exceptions; using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Repositories; +using Bit.Core.Test.AdminConsole.AutoFixture; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; @@ -163,7 +164,8 @@ public class SsoConfigServiceTests [Theory, BitAutoData] public async Task SaveAsync_KeyConnector_SingleOrgNotEnabled_Throws(SutProvider sutProvider, - Organization organization) + Organization organization, + [Policy(PolicyType.SingleOrg, false)] PolicyStatus policy) { var utcNow = DateTime.UtcNow; @@ -180,6 +182,9 @@ public class SsoConfigServiceTests RevisionDate = utcNow.AddDays(-10), }; + sutProvider.GetDependency().RunAsync( + Arg.Any(), PolicyType.SingleOrg).Returns(policy); + var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.SaveAsync(ssoConfig, organization)); @@ -191,7 +196,9 @@ public class SsoConfigServiceTests [Theory, BitAutoData] public async Task SaveAsync_KeyConnector_SsoPolicyNotEnabled_Throws(SutProvider sutProvider, - Organization organization) + Organization organization, + [Policy(PolicyType.SingleOrg, true)] PolicyStatus singleOrgPolicy, + [Policy(PolicyType.RequireSso, false)] PolicyStatus requireSsoPolicy) { var utcNow = DateTime.UtcNow; @@ -208,11 +215,10 @@ public class SsoConfigServiceTests RevisionDate = utcNow.AddDays(-10), }; - sutProvider.GetDependency().GetByOrganizationIdTypeAsync( - Arg.Any(), PolicyType.SingleOrg).Returns(new Policy - { - Enabled = true - }); + sutProvider.GetDependency().RunAsync( + Arg.Any(), PolicyType.SingleOrg).Returns(singleOrgPolicy); + sutProvider.GetDependency().RunAsync( + Arg.Any(), PolicyType.RequireSso).Returns(requireSsoPolicy); var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.SaveAsync(ssoConfig, organization)); @@ -225,7 +231,8 @@ public class SsoConfigServiceTests [Theory, BitAutoData] public async Task SaveAsync_KeyConnector_SsoConfigNotEnabled_Throws(SutProvider sutProvider, - Organization organization) + Organization organization, + [Policy(PolicyType.SingleOrg, true)] PolicyStatus policy) { var utcNow = DateTime.UtcNow; @@ -242,11 +249,8 @@ public class SsoConfigServiceTests RevisionDate = utcNow.AddDays(-10), }; - sutProvider.GetDependency().GetByOrganizationIdTypeAsync( - Arg.Any(), Arg.Any()).Returns(new Policy - { - Enabled = true - }); + sutProvider.GetDependency().RunAsync( + Arg.Any(), Arg.Any()).Returns(policy); var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.SaveAsync(ssoConfig, organization)); @@ -259,7 +263,8 @@ public class SsoConfigServiceTests [Theory, BitAutoData] public async Task SaveAsync_KeyConnector_KeyConnectorAbilityNotEnabled_Throws(SutProvider sutProvider, - Organization organization) + Organization organization, + [Policy(PolicyType.SingleOrg, true)] PolicyStatus policy) { var utcNow = DateTime.UtcNow; @@ -277,11 +282,8 @@ public class SsoConfigServiceTests RevisionDate = utcNow.AddDays(-10), }; - sutProvider.GetDependency().GetByOrganizationIdTypeAsync( - Arg.Any(), Arg.Any()).Returns(new Policy - { - Enabled = true, - }); + sutProvider.GetDependency().RunAsync( + Arg.Any(), Arg.Any()).Returns(policy); var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.SaveAsync(ssoConfig, organization)); @@ -294,7 +296,8 @@ public class SsoConfigServiceTests [Theory, BitAutoData] public async Task SaveAsync_KeyConnector_Success(SutProvider sutProvider, - Organization organization) + Organization organization, + [Policy(PolicyType.SingleOrg, true)] PolicyStatus policy) { var utcNow = DateTime.UtcNow; @@ -312,11 +315,8 @@ public class SsoConfigServiceTests RevisionDate = utcNow.AddDays(-10), }; - sutProvider.GetDependency().GetByOrganizationIdTypeAsync( - Arg.Any(), Arg.Any()).Returns(new Policy - { - Enabled = true, - }); + sutProvider.GetDependency().RunAsync( + Arg.Any(), Arg.Any()).Returns(policy); await sutProvider.Sut.SaveAsync(ssoConfig, organization); diff --git a/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs b/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs index b67bfaa131..29193bacbc 100644 --- a/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs +++ b/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs @@ -1,7 +1,8 @@ using System.Text; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; -using Bit.Core.AdminConsole.Repositories; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; using Bit.Core.Auth.Models.Business.Tokenables; @@ -13,6 +14,7 @@ using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterpri using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; +using Bit.Core.Test.AdminConsole.AutoFixture; using Bit.Core.Tokens; using Bit.Core.Utilities; using Bit.Test.Common.AutoFixture; @@ -241,7 +243,8 @@ public class RegisterUserCommandTests [BitAutoData(true, "sampleInitiationPath")] [BitAutoData(true, "Secrets Manager trial")] public async Task RegisterUserViaOrganizationInviteToken_ComplexHappyPath_Succeeds(bool addUserReferenceData, string initiationPath, - SutProvider sutProvider, User user, string masterPasswordHash, OrganizationUser orgUser, string orgInviteToken, Guid orgUserId, Policy twoFactorPolicy) + SutProvider sutProvider, User user, string masterPasswordHash, OrganizationUser orgUser, string orgInviteToken, Guid orgUserId, + [Policy(PolicyType.TwoFactorAuthentication, true)] PolicyStatus policy) { // Arrange sutProvider.GetDependency() @@ -267,10 +270,9 @@ public class RegisterUserCommandTests .GetByIdAsync(orgUserId) .Returns(orgUser); - twoFactorPolicy.Enabled = true; - sutProvider.GetDependency() - .GetByOrganizationIdTypeAsync(orgUser.OrganizationId, PolicyType.TwoFactorAuthentication) - .Returns(twoFactorPolicy); + sutProvider.GetDependency() + .RunAsync(orgUser.OrganizationId, PolicyType.TwoFactorAuthentication) + .Returns(policy); sutProvider.GetDependency() .CreateUserAsync(user, masterPasswordHash) @@ -286,9 +288,9 @@ public class RegisterUserCommandTests .Received(1) .GetByIdAsync(orgUserId); - await sutProvider.GetDependency() + await sutProvider.GetDependency() .Received(1) - .GetByOrganizationIdTypeAsync(orgUser.OrganizationId, PolicyType.TwoFactorAuthentication); + .RunAsync(orgUser.OrganizationId, PolicyType.TwoFactorAuthentication); sutProvider.GetDependency() .Received(1) @@ -431,7 +433,8 @@ public class RegisterUserCommandTests [Theory] [BitAutoData] public async Task RegisterUserViaOrganizationInviteToken_BlockedDomainFromDifferentOrg_ThrowsBadRequestException( - SutProvider sutProvider, User user, string masterPasswordHash, OrganizationUser orgUser, string orgInviteToken, Guid orgUserId) + SutProvider sutProvider, User user, string masterPasswordHash, OrganizationUser orgUser, string orgInviteToken, Guid orgUserId, + [Policy(PolicyType.TwoFactorAuthentication, false)] PolicyStatus policy) { // Arrange user.Email = "user@blocked-domain.com"; @@ -463,6 +466,10 @@ public class RegisterUserCommandTests .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync("blocked-domain.com", orgUser.OrganizationId) .Returns(true); + sutProvider.GetDependency() + .RunAsync(Arg.Any(), PolicyType.TwoFactorAuthentication) + .Returns(policy); + // Act & Assert var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.RegisterUserViaOrganizationInviteToken(user, masterPasswordHash, orgInviteToken, orgUserId)); @@ -472,7 +479,8 @@ public class RegisterUserCommandTests [Theory] [BitAutoData] public async Task RegisterUserViaOrganizationInviteToken_BlockedDomainFromSameOrg_Succeeds( - SutProvider sutProvider, User user, string masterPasswordHash, OrganizationUser orgUser, string orgInviteToken, Guid orgUserId) + SutProvider sutProvider, User user, string masterPasswordHash, OrganizationUser orgUser, string orgInviteToken, Guid orgUserId, + [Policy(PolicyType.TwoFactorAuthentication, false)] PolicyStatus policy) { // Arrange user.Email = "user@company-domain.com"; @@ -509,6 +517,10 @@ public class RegisterUserCommandTests .CreateUserAsync(user, masterPasswordHash) .Returns(IdentityResult.Success); + sutProvider.GetDependency() + .RunAsync(Arg.Any(), PolicyType.TwoFactorAuthentication) + .Returns(policy); + // Act var result = await sutProvider.Sut.RegisterUserViaOrganizationInviteToken(user, masterPasswordHash, orgInviteToken, orgUserId); @@ -1245,6 +1257,7 @@ public class RegisterUserCommandTests OrganizationUser orgUser, string orgInviteToken, string masterPasswordHash, + [Policy(PolicyType.TwoFactorAuthentication, false)] PolicyStatus policy, SutProvider sutProvider) { // Arrange @@ -1259,9 +1272,9 @@ public class RegisterUserCommandTests .GetByIdAsync(orgUser.Id) .Returns(orgUser); - sutProvider.GetDependency() - .GetByOrganizationIdTypeAsync(Arg.Any(), PolicyType.TwoFactorAuthentication) - .Returns((Policy)null); + sutProvider.GetDependency() + .RunAsync(Arg.Any(), PolicyType.TwoFactorAuthentication) + .Returns(policy); sutProvider.GetDependency() .GetByIdAsync(orgUser.OrganizationId) @@ -1331,6 +1344,7 @@ public class RegisterUserCommandTests OrganizationUser orgUser, string masterPasswordHash, string orgInviteToken, + [Policy(PolicyType.TwoFactorAuthentication, false)] PolicyStatus policy, SutProvider sutProvider) { // Arrange @@ -1346,9 +1360,9 @@ public class RegisterUserCommandTests .GetByIdAsync(orgUser.Id) .Returns(orgUser); - sutProvider.GetDependency() - .GetByOrganizationIdTypeAsync(Arg.Any(), PolicyType.TwoFactorAuthentication) - .Returns((Policy)null); + sutProvider.GetDependency() + .RunAsync(Arg.Any(), PolicyType.TwoFactorAuthentication) + .Returns(policy); sutProvider.GetDependency() .GetByIdAsync(orgUser.OrganizationId) diff --git a/test/Core.Test/Billing/Premium/Commands/PreviewPremiumUpgradeProrationCommandTests.cs b/test/Core.Test/Billing/Premium/Commands/PreviewPremiumUpgradeProrationCommandTests.cs new file mode 100644 index 0000000000..c2af07f633 --- /dev/null +++ b/test/Core.Test/Billing/Premium/Commands/PreviewPremiumUpgradeProrationCommandTests.cs @@ -0,0 +1,777 @@ +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Payment.Models; +using Bit.Core.Billing.Premium.Commands; +using Bit.Core.Billing.Pricing; +using Bit.Core.Billing.Services; +using Bit.Core.Entities; +using Bit.Core.Test.Billing.Mocks.Plans; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Stripe; +using Xunit; +using PremiumPlan = Bit.Core.Billing.Pricing.Premium.Plan; + +namespace Bit.Core.Test.Billing.Premium.Commands; + +public class PreviewPremiumUpgradeProrationCommandTests +{ + private readonly ILogger _logger = Substitute.For>(); + private readonly IPricingClient _pricingClient = Substitute.For(); + private readonly IStripeAdapter _stripeAdapter = Substitute.For(); + private readonly PreviewPremiumUpgradeProrationCommand _command; + + public PreviewPremiumUpgradeProrationCommandTests() + { + _command = new PreviewPremiumUpgradeProrationCommand( + _logger, + _pricingClient, + _stripeAdapter); + } + + [Theory, BitAutoData] + public async Task Run_UserWithoutPremium_ReturnsBadRequest(User user, BillingAddress billingAddress) + { + // Arrange + user.Premium = false; + + // Act + var result = await _command.Run(user, PlanType.TeamsAnnually, billingAddress); + + // Assert + Assert.True(result.IsT1); + var badRequest = result.AsT1; + Assert.Equal("User does not have an active Premium subscription.", badRequest.Response); + } + + [Theory, BitAutoData] + public async Task Run_UserWithoutGatewaySubscriptionId_ReturnsBadRequest(User user, BillingAddress billingAddress) + { + // Arrange + user.Premium = true; + user.GatewaySubscriptionId = null; + + // Act + var result = await _command.Run(user, PlanType.TeamsAnnually, billingAddress); + + // Assert + Assert.True(result.IsT1); + var badRequest = result.AsT1; + Assert.Equal("User does not have an active Premium subscription.", badRequest.Response); + } + + [Theory, BitAutoData] + public async Task Run_ValidUpgrade_ReturnsProrationAmounts(User user, BillingAddress billingAddress) + { + // Arrange - Setup valid Premium user + user.Premium = true; + user.GatewaySubscriptionId = "sub_123"; + user.GatewayCustomerId = "cus_123"; + + // Setup Premium plans + var premiumPlan = new PremiumPlan + { + Name = "Premium", + Available = true, + LegacyYear = null, + Seat = new Bit.Core.Billing.Pricing.Premium.Purchasable + { + StripePriceId = "premium-annually", + Price = 10m, + Provided = 1 + }, + Storage = new Bit.Core.Billing.Pricing.Premium.Purchasable + { + StripePriceId = "storage-gb-annually", + Price = 4m, + Provided = 1 + } + }; + + var premiumPlans = new List { premiumPlan }; + + // Setup current Stripe subscription + var now = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var currentPeriodEnd = now.AddMonths(6); + var currentSubscription = new Subscription + { + Id = "sub_123", + Customer = new Customer + { + Id = "cus_123", + Discount = null + }, + Items = new StripeList + { + Data = new List + { + new() + { + Id = "si_premium", + Price = new Price { Id = "premium-annually" }, + CurrentPeriodEnd = currentPeriodEnd + } + } + } + }; + + // Setup target organization plan + var targetPlan = new TeamsPlan(isAnnual: true); + + // Setup invoice preview response + var invoice = new Invoice + { + Total = 5000, // $50.00 + TotalTaxes = new List + { + new() { Amount = 500 } // $5.00 + }, + Lines = new StripeList + { + Data = new List + { + new() { Amount = 5000 } // $50.00 for new plan + } + }, + PeriodEnd = now + }; + + // Configure mocks + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(targetPlan); + _stripeAdapter.GetSubscriptionAsync( + "sub_123", + Arg.Any()) + .Returns(currentSubscription); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(invoice); + + // Act + var result = await _command.Run(user, PlanType.TeamsAnnually, billingAddress); + + // Assert + Assert.True(result.IsT0); + var proration = result.AsT0; + Assert.Equal(50.00m, proration.NewPlanProratedAmount); + Assert.Equal(0m, proration.Credit); + Assert.Equal(5.00m, proration.Tax); + Assert.Equal(50.00m, proration.Total); + Assert.Equal(6, proration.NewPlanProratedMonths); // 6 months remaining + } + + [Theory, BitAutoData] + public async Task Run_ValidUpgrade_ExtractsProrationCredit(User user, BillingAddress billingAddress) + { + // Arrange + user.Premium = true; + user.GatewaySubscriptionId = "sub_123"; + user.GatewayCustomerId = "cus_123"; + + var premiumPlan = new PremiumPlan + { + Name = "Premium", + Available = true, + LegacyYear = null, + Seat = new Bit.Core.Billing.Pricing.Premium.Purchasable + { + StripePriceId = "premium-annually", + Price = 10m, + Provided = 1 + }, + Storage = new Bit.Core.Billing.Pricing.Premium.Purchasable + { + StripePriceId = "storage-gb-annually", + Price = 4m, + Provided = 1 + } + }; + var premiumPlans = new List { premiumPlan }; + + // Use fixed time to avoid DateTime.UtcNow differences + var now = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var currentPeriodEnd = now.AddDays(45); // 1.5 months ~ 2 months rounded + var currentSubscription = new Subscription + { + Id = "sub_123", + Customer = new Customer { Id = "cus_123", Discount = null }, + Items = new StripeList + { + Data = new List + { + new() { Id = "si_premium", Price = new Price { Id = "premium-annually" }, CurrentPeriodEnd = currentPeriodEnd } + } + } + }; + + var targetPlan = new TeamsPlan(isAnnual: true); + + // Invoice with negative line item (proration credit) + var invoice = new Invoice + { + Total = 4000, // $40.00 + TotalTaxes = new List { new() { Amount = 400 } }, // $4.00 + Lines = new StripeList + { + Data = new List + { + new() { Amount = -1000 }, // -$10.00 credit from unused Premium + new() { Amount = 5000 } // $50.00 for new plan + } + }, + PeriodEnd = now + }; + + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(targetPlan); + _stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any()) + .Returns(currentSubscription); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(invoice); + + // Act + var result = await _command.Run(user, PlanType.TeamsAnnually, billingAddress); + + // Assert + Assert.True(result.IsT0); + var proration = result.AsT0; + Assert.Equal(50.00m, proration.NewPlanProratedAmount); + Assert.Equal(10.00m, proration.Credit); // Proration credit + Assert.Equal(4.00m, proration.Tax); + Assert.Equal(40.00m, proration.Total); + Assert.Equal(2, proration.NewPlanProratedMonths); // 45 days rounds to 2 months + } + + [Theory, BitAutoData] + public async Task Run_ValidUpgrade_AlwaysUsesOneSeat(User user, BillingAddress billingAddress) + { + // Arrange + user.Premium = true; + user.GatewaySubscriptionId = "sub_123"; + user.GatewayCustomerId = "cus_123"; + + var premiumPlan = new PremiumPlan + { + Name = "Premium", + Available = true, + LegacyYear = null, + Seat = new Bit.Core.Billing.Pricing.Premium.Purchasable + { + StripePriceId = "premium-annually", + Price = 10m, + Provided = 1 + }, + Storage = new Bit.Core.Billing.Pricing.Premium.Purchasable + { + StripePriceId = "storage-gb-annually", + Price = 4m, + Provided = 1 + } + }; + var premiumPlans = new List { premiumPlan }; + + var currentSubscription = new Subscription + { + Id = "sub_123", + Customer = new Customer { Id = "cus_123", Discount = null }, + Items = new StripeList + { + Data = new List + { + new() { Id = "si_premium", Price = new Price { Id = "premium-annually" }, CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1) } + } + } + }; + + var targetPlan = new TeamsPlan(isAnnual: true); + + var invoice = new Invoice + { + Total = 5000, + TotalTaxes = new List { new() { Amount = 500 } }, + Lines = new StripeList { Data = new List { new() { Amount = 5000 } } }, + PeriodEnd = DateTime.UtcNow + }; + + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(targetPlan); + _stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any()) + .Returns(currentSubscription); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(invoice); + + // Act + await _command.Run(user, PlanType.TeamsAnnually, billingAddress); + + // Assert - Verify that the subscription item quantity is always 1 and has Id + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync( + Arg.Is(options => + options.SubscriptionDetails.Items.Any(item => + item.Id == "si_premium" && + item.Price == targetPlan.PasswordManager.StripeSeatPlanId && + item.Quantity == 1))); + } + + [Theory, BitAutoData] + public async Task Run_ValidUpgrade_DeletesPremiumSubscriptionItems(User user, BillingAddress billingAddress) + { + // Arrange + user.Premium = true; + user.GatewaySubscriptionId = "sub_123"; + user.GatewayCustomerId = "cus_123"; + + var premiumPlan = new PremiumPlan + { + Name = "Premium", + Available = true, + LegacyYear = null, + Seat = new Bit.Core.Billing.Pricing.Premium.Purchasable + { + StripePriceId = "premium-annually", + Price = 10m, + Provided = 1 + }, + Storage = new Bit.Core.Billing.Pricing.Premium.Purchasable + { + StripePriceId = "storage-gb-annually", + Price = 4m, + Provided = 1 + } + }; + var premiumPlans = new List { premiumPlan }; + + var currentSubscription = new Subscription + { + Id = "sub_123", + Customer = new Customer { Id = "cus_123", Discount = null }, + Items = new StripeList + { + Data = new List + { + new() { Id = "si_password_manager", Price = new Price { Id = "premium-annually" }, CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1) }, + new() { Id = "si_storage", Price = new Price { Id = "storage-gb-annually" }, CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1) } + } + } + }; + + var targetPlan = new TeamsPlan(isAnnual: true); + + var invoice = new Invoice + { + Total = 5000, + TotalTaxes = new List { new() { Amount = 500 } }, + Lines = new StripeList { Data = new List { new() { Amount = 5000 } } }, + PeriodEnd = DateTime.UtcNow + }; + + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(targetPlan); + _stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any()) + .Returns(currentSubscription); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(invoice); + + // Act + await _command.Run(user, PlanType.TeamsAnnually, billingAddress); + + // Assert - Verify password manager item is modified and storage item is deleted + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync( + Arg.Is(options => + // Password manager item should be modified to new plan price, not deleted + options.SubscriptionDetails.Items.Any(item => + item.Id == "si_password_manager" && + item.Price == targetPlan.PasswordManager.StripeSeatPlanId && + item.Deleted != true) && + // Storage item should be deleted + options.SubscriptionDetails.Items.Any(item => + item.Id == "si_storage" && item.Deleted == true))); + } + + [Theory, BitAutoData] + public async Task Run_NonSeatBasedPlan_UsesStripePlanId(User user, BillingAddress billingAddress) + { + // Arrange + user.Premium = true; + user.GatewaySubscriptionId = "sub_123"; + user.GatewayCustomerId = "cus_123"; + + var premiumPlan = new PremiumPlan + { + Name = "Premium", + Available = true, + LegacyYear = null, + Seat = new Bit.Core.Billing.Pricing.Premium.Purchasable + { + StripePriceId = "premium-annually", + Price = 10m, + Provided = 1 + }, + Storage = new Bit.Core.Billing.Pricing.Premium.Purchasable + { + StripePriceId = "storage-gb-annually", + Price = 4m, + Provided = 1 + } + }; + var premiumPlans = new List { premiumPlan }; + + var currentSubscription = new Subscription + { + Id = "sub_123", + Customer = new Customer { Id = "cus_123", Discount = null }, + Items = new StripeList + { + Data = new List + { + new() { Id = "si_premium", Price = new Price { Id = "premium-annually" }, CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1) } + } + } + }; + + var targetPlan = new FamiliesPlan(); // families is non seat based + + var invoice = new Invoice + { + Total = 5000, + TotalTaxes = new List { new() { Amount = 500 } }, + Lines = new StripeList { Data = new List { new() { Amount = 5000 } } }, + PeriodEnd = DateTime.UtcNow + }; + + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(targetPlan); + _stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any()) + .Returns(currentSubscription); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(invoice); + + // Act + await _command.Run(user, PlanType.FamiliesAnnually, billingAddress); + + // Assert - Verify non-seat-based plan uses StripePlanId with quantity 1 and modifies existing item + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync( + Arg.Is(options => + options.SubscriptionDetails.Items.Any(item => + item.Id == "si_premium" && + item.Price == targetPlan.PasswordManager.StripePlanId && + item.Quantity == 1))); + } + + [Theory, BitAutoData] + public async Task Run_ValidUpgrade_CreatesCorrectInvoicePreviewOptions(User user, BillingAddress billingAddress) + { + // Arrange + user.Premium = true; + user.GatewaySubscriptionId = "sub_123"; + user.GatewayCustomerId = "cus_123"; + billingAddress.Country = "US"; + billingAddress.PostalCode = "12345"; + + var premiumPlan = new PremiumPlan + { + Name = "Premium", + Available = true, + LegacyYear = null, + Seat = new Bit.Core.Billing.Pricing.Premium.Purchasable + { + StripePriceId = "premium-annually", + Price = 10m, + Provided = 1 + }, + Storage = new Bit.Core.Billing.Pricing.Premium.Purchasable + { + StripePriceId = "storage-gb-annually", + Price = 4m, + Provided = 1 + } + }; + var premiumPlans = new List { premiumPlan }; + + var currentSubscription = new Subscription + { + Id = "sub_123", + Customer = new Customer { Id = "cus_123", Discount = null }, + Items = new StripeList + { + Data = new List + { + new() { Id = "si_premium", Price = new Price { Id = "premium-annually" }, CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1) } + } + } + }; + + var targetPlan = new TeamsPlan(isAnnual: true); + + var invoice = new Invoice + { + Total = 5000, + TotalTaxes = new List { new() { Amount = 500 } }, + Lines = new StripeList { Data = new List { new() { Amount = 5000 } } }, + PeriodEnd = DateTime.UtcNow + }; + + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(targetPlan); + _stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any()) + .Returns(currentSubscription); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(invoice); + + // Act + await _command.Run(user, PlanType.TeamsAnnually, billingAddress); + + // Assert - Verify all invoice preview options are correct + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync( + Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Customer == "cus_123" && + options.Subscription == "sub_123" && + options.CustomerDetails.Address.Country == "US" && + options.CustomerDetails.Address.PostalCode == "12345" && + options.SubscriptionDetails.ProrationBehavior == "always_invoice")); + } + + [Theory, BitAutoData] + public async Task Run_SeatBasedPlan_UsesStripeSeatPlanId(User user, BillingAddress billingAddress) + { + // Arrange + user.Premium = true; + user.GatewaySubscriptionId = "sub_123"; + user.GatewayCustomerId = "cus_123"; + + var premiumPlan = new PremiumPlan + { + Name = "Premium", + Available = true, + LegacyYear = null, + Seat = new Bit.Core.Billing.Pricing.Premium.Purchasable + { + StripePriceId = "premium-annually", + Price = 10m, + Provided = 1 + }, + Storage = new Bit.Core.Billing.Pricing.Premium.Purchasable + { + StripePriceId = "storage-gb-annually", + Price = 4m, + Provided = 1 + } + }; + var premiumPlans = new List { premiumPlan }; + + var currentSubscription = new Subscription + { + Id = "sub_123", + Customer = new Customer { Id = "cus_123", Discount = null }, + Items = new StripeList + { + Data = new List + { + new() { Id = "si_premium", Price = new Price { Id = "premium-annually" }, CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1) } + } + } + }; + + // Use Teams which is seat-based + var targetPlan = new TeamsPlan(isAnnual: true); + + var invoice = new Invoice + { + Total = 5000, + TotalTaxes = new List { new() { Amount = 500 } }, + Lines = new StripeList { Data = new List { new() { Amount = 5000 } } }, + PeriodEnd = DateTime.UtcNow + }; + + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(targetPlan); + _stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any()) + .Returns(currentSubscription); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(invoice); + + // Act + await _command.Run(user, PlanType.TeamsAnnually, billingAddress); + + // Assert - Verify seat-based plan uses StripeSeatPlanId with quantity 1 and modifies existing item + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync( + Arg.Is(options => + options.SubscriptionDetails.Items.Any(item => + item.Id == "si_premium" && + item.Price == targetPlan.PasswordManager.StripeSeatPlanId && + item.Quantity == 1))); + } + + [Theory] + [InlineData(0, 1)] // Less than 15 days, minimum 1 month + [InlineData(1, 1)] // 1 day = 1 month minimum + [InlineData(14, 1)] // 14 days = 1 month minimum + [InlineData(15, 1)] // 15 days rounds to 1 month + [InlineData(30, 1)] // 30 days = 1 month + [InlineData(44, 1)] // 44 days rounds to 1 month + [InlineData(45, 2)] // 45 days rounds to 2 months + [InlineData(60, 2)] // 60 days = 2 months + [InlineData(90, 3)] // 90 days = 3 months + [InlineData(180, 6)] // 180 days = 6 months + [InlineData(365, 12)] // 365 days rounds to 12 months + public async Task Run_ValidUpgrade_CalculatesNewPlanProratedMonthsCorrectly(int daysRemaining, int expectedMonths) + { + // Arrange + var user = new User + { + Premium = true, + GatewaySubscriptionId = "sub_123", + GatewayCustomerId = "cus_123" + }; + var billingAddress = new Core.Billing.Payment.Models.BillingAddress + { + Country = "US", + PostalCode = "12345" + }; + + var premiumPlan = new PremiumPlan + { + Name = "Premium", + Available = true, + LegacyYear = null, + Seat = new Bit.Core.Billing.Pricing.Premium.Purchasable + { + StripePriceId = "premium-annually", + Price = 10m, + Provided = 1 + }, + Storage = new Bit.Core.Billing.Pricing.Premium.Purchasable + { + StripePriceId = "storage-gb-annually", + Price = 4m, + Provided = 1 + } + }; + var premiumPlans = new List { premiumPlan }; + + // Use fixed time to avoid DateTime.UtcNow differences + var now = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var currentPeriodEnd = now.AddDays(daysRemaining); + var currentSubscription = new Subscription + { + Id = "sub_123", + Customer = new Customer { Id = "cus_123", Discount = null }, + Items = new StripeList + { + Data = new List + { + new() { Id = "si_premium", Price = new Price { Id = "premium-annually" }, CurrentPeriodEnd = currentPeriodEnd } + } + } + }; + + var targetPlan = new TeamsPlan(isAnnual: true); + + var invoice = new Invoice + { + Total = 5000, + TotalTaxes = new List { new() { Amount = 500 } }, + Lines = new StripeList + { + Data = new List { new() { Amount = 5000 } } + }, + PeriodEnd = now + }; + + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(targetPlan); + _stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any()) + .Returns(currentSubscription); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(invoice); + + // Act + var result = await _command.Run(user, PlanType.TeamsAnnually, billingAddress); + + // Assert + Assert.True(result.IsT0); + var proration = result.AsT0; + Assert.Equal(expectedMonths, proration.NewPlanProratedMonths); + } + + [Theory, BitAutoData] + public async Task Run_ValidUpgrade_ReturnsNewPlanProratedAmountCorrectly(User user, BillingAddress billingAddress) + { + // Arrange + user.Premium = true; + user.GatewaySubscriptionId = "sub_123"; + user.GatewayCustomerId = "cus_123"; + + var premiumPlan = new PremiumPlan + { + Name = "Premium", + Available = true, + LegacyYear = null, + Seat = new Bit.Core.Billing.Pricing.Premium.Purchasable + { + StripePriceId = "premium-annually", + Price = 10m, + Provided = 1 + }, + Storage = new Bit.Core.Billing.Pricing.Premium.Purchasable + { + StripePriceId = "storage-gb-annually", + Price = 4m, + Provided = 1 + } + }; + var premiumPlans = new List { premiumPlan }; + + var now = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var currentPeriodEnd = now.AddMonths(3); + var currentSubscription = new Subscription + { + Id = "sub_123", + Customer = new Customer { Id = "cus_123", Discount = null }, + Items = new StripeList + { + Data = new List + { + new() { Id = "si_premium", Price = new Price { Id = "premium-annually" }, CurrentPeriodEnd = currentPeriodEnd } + } + } + }; + + var targetPlan = new TeamsPlan(isAnnual: true); + + // Invoice showing new plan cost, credit, and net + var invoice = new Invoice + { + Total = 4500, // $45.00 net after $5 credit + TotalTaxes = new List { new() { Amount = 450 } }, // $4.50 + Lines = new StripeList + { + Data = new List + { + new() { Amount = -500 }, // -$5.00 credit + new() { Amount = 5000 } // $50.00 for new plan + } + }, + PeriodEnd = now + }; + + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(targetPlan); + _stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any()) + .Returns(currentSubscription); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(invoice); + + // Act + var result = await _command.Run(user, PlanType.TeamsAnnually, billingAddress); + + // Assert + Assert.True(result.IsT0); + var proration = result.AsT0; + + Assert.Equal(50.00m, proration.NewPlanProratedAmount); + Assert.Equal(5.00m, proration.Credit); + Assert.Equal(4.50m, proration.Tax); + Assert.Equal(45.00m, proration.Total); + } +} + diff --git a/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs b/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs index e686d04009..b4fd0e2d21 100644 --- a/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs +++ b/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs @@ -37,7 +37,6 @@ public class UpgradePremiumToOrganizationCommandTests NameLocalizationKey = ""; DescriptionLocalizationKey = ""; CanBeUsedByBusiness = true; - TrialPeriodDays = null; HasSelfHost = false; HasPolicies = false; HasGroups = false; @@ -86,10 +85,8 @@ public class UpgradePremiumToOrganizationCommandTests string? stripePlanId = null, string? stripeSeatPlanId = null, string? stripePremiumAccessPlanId = null, - string? stripeStoragePlanId = null) - { - return new TestPlan(planType, stripePlanId, stripeSeatPlanId, stripePremiumAccessPlanId, stripeStoragePlanId); - } + string? stripeStoragePlanId = null) => + new TestPlan(planType, stripePlanId, stripeSeatPlanId, stripePremiumAccessPlanId, stripeStoragePlanId); private static PremiumPlan CreateTestPremiumPlan( string seatPriceId = "premium-annually", @@ -151,6 +148,9 @@ public class UpgradePremiumToOrganizationCommandTests _applicationCacheService); } + private static Core.Billing.Payment.Models.BillingAddress CreateTestBillingAddress() => + new() { Country = "US", PostalCode = "12345" }; + [Theory, BitAutoData] public async Task Run_UserNotPremium_ReturnsBadRequest(User user) { @@ -158,7 +158,7 @@ public class UpgradePremiumToOrganizationCommandTests user.Premium = false; // Act - var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually); + var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually, CreateTestBillingAddress()); // Assert Assert.True(result.IsT1); @@ -174,7 +174,7 @@ public class UpgradePremiumToOrganizationCommandTests user.GatewaySubscriptionId = null; // Act - var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually); + var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually, CreateTestBillingAddress()); // Assert Assert.True(result.IsT1); @@ -190,7 +190,7 @@ public class UpgradePremiumToOrganizationCommandTests user.GatewaySubscriptionId = ""; // Act - var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually); + var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually, CreateTestBillingAddress()); // Assert Assert.True(result.IsT1); @@ -245,7 +245,7 @@ public class UpgradePremiumToOrganizationCommandTests _userService.SaveUserAsync(user).Returns(Task.CompletedTask); // Act - var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually); + var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually, CreateTestBillingAddress()); // Assert Assert.True(result.IsT0); @@ -253,9 +253,8 @@ public class UpgradePremiumToOrganizationCommandTests await _stripeAdapter.Received(1).UpdateSubscriptionAsync( "sub_123", Arg.Is(opts => - opts.Items.Count == 2 && // 1 deleted + 1 seat (no storage) - opts.Items.Any(i => i.Deleted == true) && - opts.Items.Any(i => i.Price == "teams-seat-annually" && i.Quantity == 1))); + opts.Items.Count == 1 && // Only 1 item: modify existing password manager item (no storage to delete) + opts.Items.Any(i => i.Id == "si_premium" && i.Price == "teams-seat-annually" && i.Quantity == 1 && i.Deleted != true))); await _organizationRepository.Received(1).CreateAsync(Arg.Is(o => o.Name == "My Organization" && @@ -320,7 +319,7 @@ public class UpgradePremiumToOrganizationCommandTests _userService.SaveUserAsync(user).Returns(Task.CompletedTask); // Act - var result = await _command.Run(user, "My Families Org", "encrypted-key", PlanType.FamiliesAnnually); + var result = await _command.Run(user, "My Families Org", "encrypted-key", PlanType.FamiliesAnnually, CreateTestBillingAddress()); // Assert Assert.True(result.IsT0); @@ -328,9 +327,8 @@ public class UpgradePremiumToOrganizationCommandTests await _stripeAdapter.Received(1).UpdateSubscriptionAsync( "sub_123", Arg.Is(opts => - opts.Items.Count == 2 && // 1 deleted + 1 plan - opts.Items.Any(i => i.Deleted == true) && - opts.Items.Any(i => i.Price == "families-plan-annually" && i.Quantity == 1))); + opts.Items.Count == 1 && // Only 1 item: modify existing password manager item (no storage to delete) + opts.Items.Any(i => i.Id == "si_premium" && i.Price == "families-plan-annually" && i.Quantity == 1 && i.Deleted != true))); await _organizationRepository.Received(1).CreateAsync(Arg.Is(o => o.Name == "My Families Org")); @@ -383,7 +381,7 @@ public class UpgradePremiumToOrganizationCommandTests _userService.SaveUserAsync(user).Returns(Task.CompletedTask); // Act - var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually); + var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually, CreateTestBillingAddress()); // Assert Assert.True(result.IsT0); @@ -392,11 +390,6 @@ public class UpgradePremiumToOrganizationCommandTests "sub_123", Arg.Is(opts => opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.OrganizationId) && - opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.PreviousPremiumPriceId) && - opts.Metadata[StripeConstants.MetadataKeys.PreviousPremiumPriceId] == "premium-annually" && - opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.PreviousPeriodEndDate) && - opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.PreviousAdditionalStorage) && - opts.Metadata[StripeConstants.MetadataKeys.PreviousAdditionalStorage] == "0" && opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.UserId) && opts.Metadata[StripeConstants.MetadataKeys.UserId] == string.Empty)); // Removes userId to unlink from User } @@ -453,19 +446,18 @@ public class UpgradePremiumToOrganizationCommandTests _userService.SaveUserAsync(user).Returns(Task.CompletedTask); // Act - var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually); + var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually, CreateTestBillingAddress()); // Assert Assert.True(result.IsT0); - // Verify that BOTH legacy items (password manager + storage) are deleted by ID + // Verify that legacy password manager item is modified and legacy storage is deleted await _stripeAdapter.Received(1).UpdateSubscriptionAsync( "sub_123", Arg.Is(opts => - opts.Items.Count == 3 && // 2 deleted (legacy PM + legacy storage) + 1 new seat - opts.Items.Count(i => i.Deleted == true && i.Id == "si_premium_legacy") == 1 && // Legacy PM deleted - opts.Items.Count(i => i.Deleted == true && i.Id == "si_storage_legacy") == 1 && // Legacy storage deleted - opts.Items.Any(i => i.Price == "teams-seat-annually" && i.Quantity == 1))); + opts.Items.Count == 2 && // 1 modified (legacy PM to new price) + 1 deleted (legacy storage) + opts.Items.Count(i => i.Id == "si_premium_legacy" && i.Price == "teams-seat-annually" && i.Quantity == 1 && i.Deleted != true) == 1 && // Legacy PM modified + opts.Items.Count(i => i.Deleted == true && i.Id == "si_storage_legacy") == 1)); // Legacy storage deleted } [Theory, BitAutoData] @@ -520,20 +512,19 @@ public class UpgradePremiumToOrganizationCommandTests _userService.SaveUserAsync(user).Returns(Task.CompletedTask); // Act - var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually); + var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually, CreateTestBillingAddress()); // Assert Assert.True(result.IsT0); - // Verify that ONLY the premium password manager item is deleted (not other products) - // Note: We delete the specific premium item by ID, so other products are untouched + // Verify that ONLY the premium password manager item is modified (not other products) + // Note: We modify the specific premium item by ID, so other products are untouched await _stripeAdapter.Received(1).UpdateSubscriptionAsync( "sub_123", Arg.Is(opts => - opts.Items.Count == 2 && // 1 deleted (premium password manager) + 1 new seat - opts.Items.Count(i => i.Deleted == true && i.Id == "si_premium") == 1 && // Premium item deleted by ID - opts.Items.Count(i => i.Id == "si_other_product") == 0 && // Other product NOT in update (untouched) - opts.Items.Any(i => i.Price == "teams-seat-annually" && i.Quantity == 1))); + opts.Items.Count == 1 && // Only modify premium password manager item + opts.Items.Count(i => i.Id == "si_premium" && i.Price == "teams-seat-annually" && i.Quantity == 1 && i.Deleted != true) == 1 && // Premium item modified + opts.Items.Count(i => i.Id == "si_other_product") == 0)); // Other product NOT in update (untouched) } [Theory, BitAutoData] @@ -589,7 +580,7 @@ public class UpgradePremiumToOrganizationCommandTests _userService.SaveUserAsync(user).Returns(Task.CompletedTask); // Act - var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually); + var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually, CreateTestBillingAddress()); // Assert Assert.True(result.IsT0); @@ -598,10 +589,8 @@ public class UpgradePremiumToOrganizationCommandTests await _stripeAdapter.Received(1).UpdateSubscriptionAsync( "sub_123", Arg.Is(opts => - opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.PreviousAdditionalStorage) && - opts.Metadata[StripeConstants.MetadataKeys.PreviousAdditionalStorage] == "5" && - opts.Items.Count == 3 && // 2 deleted (premium + storage) + 1 new seat - opts.Items.Count(i => i.Deleted == true) == 2)); + opts.Items.Count == 2 && // 1 modified (premium to new price) + 1 deleted (storage) + opts.Items.Count(i => i.Deleted == true) == 1)); } [Theory, BitAutoData] @@ -636,11 +625,385 @@ public class UpgradePremiumToOrganizationCommandTests _pricingClient.ListPremiumPlans().Returns(mockPremiumPlans); // Act - var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually); + var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually, CreateTestBillingAddress()); // Assert Assert.True(result.IsT1); var badRequest = result.AsT1; - Assert.Equal("Premium subscription item not found.", badRequest.Response); + Assert.Equal("Premium subscription password manager item not found.", badRequest.Response); + } + + [Theory, BitAutoData] + public async Task Run_UpdatesCustomerBillingAddress(User user) + { + // Arrange + user.Premium = true; + user.GatewaySubscriptionId = "sub_123"; + user.GatewayCustomerId = "cus_123"; + + var mockSubscription = new Subscription + { + Id = "sub_123", + Items = new StripeList + { + Data = new List + { + new SubscriptionItem + { + Id = "si_premium", + Price = new Price { Id = "premium-annually" } + } + } + }, + Metadata = new Dictionary() + }; + + var mockPremiumPlans = CreateTestPremiumPlansList(); + var mockPlan = CreateTestPlan(PlanType.TeamsAnnually, stripeSeatPlanId: "teams-seat-annually"); + + _stripeAdapter.GetSubscriptionAsync("sub_123").Returns(mockSubscription); + _pricingClient.ListPremiumPlans().Returns(mockPremiumPlans); + _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(mockPlan); + _stripeAdapter.UpdateSubscriptionAsync(Arg.Any(), Arg.Any()).Returns(mockSubscription); + _stripeAdapter.UpdateCustomerAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(new Customer())); + _organizationRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _organizationApiKeyRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _organizationUserRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _applicationCacheService.UpsertOrganizationAbilityAsync(Arg.Any()).Returns(Task.CompletedTask); + _userService.SaveUserAsync(user).Returns(Task.CompletedTask); + + var billingAddress = new Core.Billing.Payment.Models.BillingAddress { Country = "US", PostalCode = "12345" }; + + // Act + var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually, billingAddress); + + // Assert + Assert.True(result.IsT0); + + await _stripeAdapter.Received(1).UpdateCustomerAsync( + "cus_123", + Arg.Is(opts => + opts.Address.Country == "US" && + opts.Address.PostalCode == "12345")); + } + + [Theory, BitAutoData] + public async Task Run_EnablesAutomaticTaxOnSubscription(User user) + { + // Arrange + user.Premium = true; + user.GatewaySubscriptionId = "sub_123"; + user.GatewayCustomerId = "cus_123"; + + var mockSubscription = new Subscription + { + Id = "sub_123", + Items = new StripeList + { + Data = new List + { + new SubscriptionItem + { + Id = "si_premium", + Price = new Price { Id = "premium-annually" } + } + } + }, + Metadata = new Dictionary() + }; + + var mockPremiumPlans = CreateTestPremiumPlansList(); + var mockPlan = CreateTestPlan(PlanType.TeamsAnnually, stripeSeatPlanId: "teams-seat-annually"); + + _stripeAdapter.GetSubscriptionAsync("sub_123").Returns(mockSubscription); + _pricingClient.ListPremiumPlans().Returns(mockPremiumPlans); + _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(mockPlan); + _stripeAdapter.UpdateSubscriptionAsync(Arg.Any(), Arg.Any()).Returns(mockSubscription); + _stripeAdapter.UpdateCustomerAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(new Customer())); + _organizationRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _organizationApiKeyRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _organizationUserRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _applicationCacheService.UpsertOrganizationAbilityAsync(Arg.Any()).Returns(Task.CompletedTask); + _userService.SaveUserAsync(user).Returns(Task.CompletedTask); + + // Act + var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually, CreateTestBillingAddress()); + + // Assert + Assert.True(result.IsT0); + + await _stripeAdapter.Received(1).UpdateSubscriptionAsync( + "sub_123", + Arg.Is(opts => + opts.AutomaticTax != null && + opts.AutomaticTax.Enabled == true)); + } + + [Theory, BitAutoData] + public async Task Run_UsesAlwaysInvoiceProrationBehavior(User user) + { + // Arrange + user.Premium = true; + user.GatewaySubscriptionId = "sub_123"; + user.GatewayCustomerId = "cus_123"; + + var mockSubscription = new Subscription + { + Id = "sub_123", + Items = new StripeList + { + Data = new List + { + new SubscriptionItem + { + Id = "si_premium", + Price = new Price { Id = "premium-annually" } + } + } + }, + Metadata = new Dictionary() + }; + + var mockPremiumPlans = CreateTestPremiumPlansList(); + var mockPlan = CreateTestPlan(PlanType.TeamsAnnually, stripeSeatPlanId: "teams-seat-annually"); + + _stripeAdapter.GetSubscriptionAsync("sub_123").Returns(mockSubscription); + _pricingClient.ListPremiumPlans().Returns(mockPremiumPlans); + _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(mockPlan); + _stripeAdapter.UpdateSubscriptionAsync(Arg.Any(), Arg.Any()).Returns(mockSubscription); + _stripeAdapter.UpdateCustomerAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(new Customer())); + _organizationRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _organizationApiKeyRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _organizationUserRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _applicationCacheService.UpsertOrganizationAbilityAsync(Arg.Any()).Returns(Task.CompletedTask); + _userService.SaveUserAsync(user).Returns(Task.CompletedTask); + + // Act + var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually, CreateTestBillingAddress()); + + // Assert + Assert.True(result.IsT0); + + await _stripeAdapter.Received(1).UpdateSubscriptionAsync( + "sub_123", + Arg.Is(opts => + opts.ProrationBehavior == "always_invoice")); + } + + [Theory, BitAutoData] + public async Task Run_ModifiesExistingSubscriptionItem_NotDeleteAndRecreate(User user) + { + // Arrange + user.Premium = true; + user.GatewaySubscriptionId = "sub_123"; + user.GatewayCustomerId = "cus_123"; + + var mockSubscription = new Subscription + { + Id = "sub_123", + Items = new StripeList + { + Data = new List + { + new SubscriptionItem + { + Id = "si_premium", + Price = new Price { Id = "premium-annually" } + } + } + }, + Metadata = new Dictionary() + }; + + var mockPremiumPlans = CreateTestPremiumPlansList(); + var mockPlan = CreateTestPlan(PlanType.TeamsAnnually, stripeSeatPlanId: "teams-seat-annually"); + + _stripeAdapter.GetSubscriptionAsync("sub_123").Returns(mockSubscription); + _pricingClient.ListPremiumPlans().Returns(mockPremiumPlans); + _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(mockPlan); + _stripeAdapter.UpdateSubscriptionAsync(Arg.Any(), Arg.Any()).Returns(mockSubscription); + _stripeAdapter.UpdateCustomerAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(new Customer())); + _organizationRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _organizationApiKeyRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _organizationUserRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _applicationCacheService.UpsertOrganizationAbilityAsync(Arg.Any()).Returns(Task.CompletedTask); + _userService.SaveUserAsync(user).Returns(Task.CompletedTask); + + // Act + var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually, CreateTestBillingAddress()); + + // Assert + Assert.True(result.IsT0); + + // Verify that the subscription item was modified, not deleted + await _stripeAdapter.Received(1).UpdateSubscriptionAsync( + "sub_123", + Arg.Is(opts => + // Should have an item with the original ID being modified + opts.Items.Any(item => + item.Id == "si_premium" && + item.Price == "teams-seat-annually" && + item.Quantity == 1 && + item.Deleted != true))); + } + + [Theory, BitAutoData] + public async Task Run_CreatesOrganizationWithCorrectSettings(User user) + { + // Arrange + user.Premium = true; + user.GatewaySubscriptionId = "sub_123"; + user.GatewayCustomerId = "cus_123"; + + var mockSubscription = new Subscription + { + Id = "sub_123", + Items = new StripeList + { + Data = new List + { + new SubscriptionItem + { + Id = "si_premium", + Price = new Price { Id = "premium-annually" } + } + } + }, + Metadata = new Dictionary() + }; + + var mockPremiumPlans = CreateTestPremiumPlansList(); + var mockPlan = CreateTestPlan(PlanType.TeamsAnnually, stripeSeatPlanId: "teams-seat-annually"); + + _stripeAdapter.GetSubscriptionAsync("sub_123").Returns(mockSubscription); + _pricingClient.ListPremiumPlans().Returns(mockPremiumPlans); + _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(mockPlan); + _stripeAdapter.UpdateSubscriptionAsync(Arg.Any(), Arg.Any()).Returns(mockSubscription); + _stripeAdapter.UpdateCustomerAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(new Customer())); + _organizationRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _organizationApiKeyRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _organizationUserRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _applicationCacheService.UpsertOrganizationAbilityAsync(Arg.Any()).Returns(Task.CompletedTask); + _userService.SaveUserAsync(user).Returns(Task.CompletedTask); + + // Act + var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually, CreateTestBillingAddress()); + + // Assert + Assert.True(result.IsT0); + + await _organizationRepository.Received(1).CreateAsync( + Arg.Is(org => + org.Name == "My Organization" && + org.BillingEmail == user.Email && + org.PlanType == PlanType.TeamsAnnually && + org.Seats == 1 && + org.Gateway == GatewayType.Stripe && + org.GatewayCustomerId == "cus_123" && + org.GatewaySubscriptionId == "sub_123" && + org.Enabled == true)); + } + + [Theory, BitAutoData] + public async Task Run_CreatesOrganizationApiKeyWithCorrectType(User user) + { + // Arrange + user.Premium = true; + user.GatewaySubscriptionId = "sub_123"; + user.GatewayCustomerId = "cus_123"; + + var mockSubscription = new Subscription + { + Id = "sub_123", + Items = new StripeList + { + Data = new List + { + new SubscriptionItem + { + Id = "si_premium", + Price = new Price { Id = "premium-annually" } + } + } + }, + Metadata = new Dictionary() + }; + + var mockPremiumPlans = CreateTestPremiumPlansList(); + var mockPlan = CreateTestPlan(PlanType.TeamsAnnually, stripeSeatPlanId: "teams-seat-annually"); + + _stripeAdapter.GetSubscriptionAsync("sub_123").Returns(mockSubscription); + _pricingClient.ListPremiumPlans().Returns(mockPremiumPlans); + _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(mockPlan); + _stripeAdapter.UpdateSubscriptionAsync(Arg.Any(), Arg.Any()).Returns(mockSubscription); + _stripeAdapter.UpdateCustomerAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(new Customer())); + _organizationRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _organizationApiKeyRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _organizationUserRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _applicationCacheService.UpsertOrganizationAbilityAsync(Arg.Any()).Returns(Task.CompletedTask); + _userService.SaveUserAsync(user).Returns(Task.CompletedTask); + + // Act + var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually, CreateTestBillingAddress()); + + // Assert + Assert.True(result.IsT0); + + await _organizationApiKeyRepository.Received(1).CreateAsync( + Arg.Is(apiKey => + apiKey.Type == OrganizationApiKeyType.Default && + !string.IsNullOrEmpty(apiKey.ApiKey))); + } + + [Theory, BitAutoData] + public async Task Run_CreatesOrganizationUserAsOwnerWithAllPermissions(User user) + { + // Arrange + user.Premium = true; + user.GatewaySubscriptionId = "sub_123"; + user.GatewayCustomerId = "cus_123"; + + var mockSubscription = new Subscription + { + Id = "sub_123", + Items = new StripeList + { + Data = new List + { + new SubscriptionItem + { + Id = "si_premium", + Price = new Price { Id = "premium-annually" } + } + } + }, + Metadata = new Dictionary() + }; + + var mockPremiumPlans = CreateTestPremiumPlansList(); + var mockPlan = CreateTestPlan(PlanType.TeamsAnnually, stripeSeatPlanId: "teams-seat-annually"); + + _stripeAdapter.GetSubscriptionAsync("sub_123").Returns(mockSubscription); + _pricingClient.ListPremiumPlans().Returns(mockPremiumPlans); + _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(mockPlan); + _stripeAdapter.UpdateSubscriptionAsync(Arg.Any(), Arg.Any()).Returns(mockSubscription); + _stripeAdapter.UpdateCustomerAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(new Customer())); + _organizationRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _organizationApiKeyRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _organizationUserRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _applicationCacheService.UpsertOrganizationAbilityAsync(Arg.Any()).Returns(Task.CompletedTask); + _userService.SaveUserAsync(user).Returns(Task.CompletedTask); + + // Act + var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually, CreateTestBillingAddress()); + + // Assert + Assert.True(result.IsT0); + + await _organizationUserRepository.Received(1).CreateAsync( + Arg.Is(orgUser => + orgUser.UserId == user.Id && + orgUser.Type == OrganizationUserType.Owner && + orgUser.Status == OrganizationUserStatusType.Confirmed)); } } diff --git a/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpgradeOrganizationPlanCommandTests.cs b/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpgradeOrganizationPlanCommandTests.cs index 223047ee07..b4f1fe2d98 100644 --- a/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpgradeOrganizationPlanCommandTests.cs +++ b/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpgradeOrganizationPlanCommandTests.cs @@ -1,4 +1,7 @@ -using Bit.Core.Billing.Enums; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.Billing.Enums; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Exceptions; @@ -9,6 +12,7 @@ using Bit.Core.OrganizationFeatures.OrganizationSubscriptions; using Bit.Core.Repositories; using Bit.Core.SecretsManager.Repositories; using Bit.Core.Services; +using Bit.Core.Test.AdminConsole.AutoFixture; using Bit.Core.Test.AutoFixture.OrganizationFixtures; using Bit.Core.Test.Billing.Mocks; using Bit.Test.Common.AutoFixture; @@ -72,8 +76,12 @@ public class UpgradeOrganizationPlanCommandTests [Theory] [FreeOrganizationUpgradeCustomize, BitAutoData] public async Task UpgradePlan_Passes(Organization organization, OrganizationUpgrade upgrade, + [Policy(PolicyType.ResetPassword, false)] PolicyStatus policy, SutProvider sutProvider) { + sutProvider.GetDependency() + .RunAsync(Arg.Any(), Arg.Any()) + .Returns(policy); sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); sutProvider.GetDependency().GetPlanOrThrow(organization.PlanType).Returns(MockPlans.Get(organization.PlanType)); upgrade.AdditionalSmSeats = 10; @@ -100,6 +108,7 @@ public class UpgradeOrganizationPlanCommandTests PlanType planType, Organization organization, OrganizationUpgrade organizationUpgrade, + [Policy(PolicyType.ResetPassword, false)] PolicyStatus policy, SutProvider sutProvider) { sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); @@ -116,6 +125,9 @@ public class UpgradeOrganizationPlanCommandTests organizationUpgrade.Plan = planType; sutProvider.GetDependency().GetPlanOrThrow(organizationUpgrade.Plan).Returns(MockPlans.Get(organizationUpgrade.Plan)); + sutProvider.GetDependency() + .RunAsync(Arg.Any(), Arg.Any()) + .Returns(policy); sutProvider.GetDependency() .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts { @@ -141,15 +153,20 @@ public class UpgradeOrganizationPlanCommandTests [BitAutoData(PlanType.TeamsAnnually)] [BitAutoData(PlanType.TeamsStarter)] public async Task UpgradePlan_SM_Passes(PlanType planType, Organization organization, OrganizationUpgrade upgrade, + [Policy(PolicyType.ResetPassword, false)] PolicyStatus policy, SutProvider sutProvider) { - sutProvider.GetDependency().GetPlanOrThrow(organization.PlanType).Returns(MockPlans.Get(organization.PlanType)); upgrade.Plan = planType; sutProvider.GetDependency().GetPlanOrThrow(upgrade.Plan).Returns(MockPlans.Get(upgrade.Plan)); var plan = MockPlans.Get(upgrade.Plan); + sutProvider.GetDependency() + .RunAsync(Arg.Any(), Arg.Any()) + .Returns(policy); + + sutProvider.GetDependency().GetPlanOrThrow(organization.PlanType).Returns(MockPlans.Get(organization.PlanType)); sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); upgrade.AdditionalSeats = 15; @@ -180,6 +197,7 @@ public class UpgradeOrganizationPlanCommandTests [BitAutoData(PlanType.TeamsAnnually)] [BitAutoData(PlanType.TeamsStarter)] public async Task UpgradePlan_SM_NotEnoughSmSeats_Throws(PlanType planType, Organization organization, OrganizationUpgrade upgrade, + [Policy(PolicyType.ResetPassword, false)] PolicyStatus policy, SutProvider sutProvider) { upgrade.Plan = planType; @@ -191,6 +209,10 @@ public class UpgradeOrganizationPlanCommandTests organization.SmSeats = 2; sutProvider.GetDependency().GetPlanOrThrow(organization.PlanType).Returns(MockPlans.Get(organization.PlanType)); + sutProvider.GetDependency() + .RunAsync(Arg.Any(), Arg.Any()) + .Returns(policy); + sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); sutProvider.GetDependency() .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts @@ -214,7 +236,9 @@ public class UpgradeOrganizationPlanCommandTests [BitAutoData(PlanType.TeamsAnnually, 51)] [BitAutoData(PlanType.TeamsStarter, 51)] public async Task UpgradePlan_SM_NotEnoughServiceAccounts_Throws(PlanType planType, int currentServiceAccounts, - Organization organization, OrganizationUpgrade upgrade, SutProvider sutProvider) + Organization organization, OrganizationUpgrade upgrade, + [Policy(PolicyType.ResetPassword, false)] PolicyStatus policy, + SutProvider sutProvider) { upgrade.Plan = planType; upgrade.AdditionalSeats = 15; @@ -226,6 +250,10 @@ public class UpgradeOrganizationPlanCommandTests organization.SmServiceAccounts = currentServiceAccounts; sutProvider.GetDependency().GetPlanOrThrow(organization.PlanType).Returns(MockPlans.Get(organization.PlanType)); + sutProvider.GetDependency() + .RunAsync(Arg.Any(), Arg.Any()) + .Returns(policy); + sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); sutProvider.GetDependency() .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts @@ -251,6 +279,7 @@ public class UpgradeOrganizationPlanCommandTests OrganizationUpgrade upgrade, string newPublicKey, string newPrivateKey, + [Policy(PolicyType.ResetPassword, false)] PolicyStatus policy, SutProvider sutProvider) { organization.PublicKey = null; @@ -262,6 +291,9 @@ public class UpgradeOrganizationPlanCommandTests publicKey: newPublicKey); upgrade.AdditionalSeats = 10; + sutProvider.GetDependency() + .RunAsync(Arg.Any(), Arg.Any()) + .Returns(policy); sutProvider.GetDependency() .GetByIdAsync(organization.Id) .Returns(organization); @@ -291,6 +323,7 @@ public class UpgradeOrganizationPlanCommandTests public async Task UpgradePlan_WhenOrganizationAlreadyHasPublicAndPrivateKeys_DoesNotOverwriteWithNull( Organization organization, OrganizationUpgrade upgrade, + [Policy(PolicyType.ResetPassword, false)] PolicyStatus policy, SutProvider sutProvider) { // Arrange @@ -304,6 +337,9 @@ public class UpgradeOrganizationPlanCommandTests upgrade.Keys = null; upgrade.AdditionalSeats = 10; + sutProvider.GetDependency() + .RunAsync(Arg.Any(), Arg.Any()) + .Returns(policy); sutProvider.GetDependency() .GetByIdAsync(organization.Id) .Returns(organization); @@ -333,6 +369,7 @@ public class UpgradeOrganizationPlanCommandTests public async Task UpgradePlan_WhenOrganizationAlreadyHasPublicAndPrivateKeys_DoesNotBackfillWithNewKeys( Organization organization, OrganizationUpgrade upgrade, + [Policy(PolicyType.ResetPassword, false)] PolicyStatus policy, SutProvider sutProvider) { // Arrange @@ -343,6 +380,9 @@ public class UpgradeOrganizationPlanCommandTests organization.PublicKey = existingPublicKey; organization.PrivateKey = existingPrivateKey; + sutProvider.GetDependency() + .RunAsync(Arg.Any(), Arg.Any()) + .Returns(policy); upgrade.Plan = PlanType.TeamsAnnually; upgrade.Keys = new PublicKeyEncryptionKeyPairData( diff --git a/test/Core.Test/OrganizationFeatures/Policies/PolicyQueryTests.cs b/test/Core.Test/OrganizationFeatures/Policies/PolicyQueryTests.cs new file mode 100644 index 0000000000..ac33a5e5a6 --- /dev/null +++ b/test/Core.Test/OrganizationFeatures/Policies/PolicyQueryTests.cs @@ -0,0 +1,55 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Implementations; +using Bit.Core.AdminConsole.Repositories; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using NSubstitute.ReturnsExtensions; +using Xunit; + +namespace Bit.Core.Test.OrganizationFeatures.Policies; + +[SutProviderCustomize] +public class PolicyQueryTests +{ + [Theory, BitAutoData] + public async Task RunAsync_WithExistingPolicy_ReturnsPolicy(SutProvider sutProvider, + Policy policy) + { + // Arrange + sutProvider.GetDependency() + .GetByOrganizationIdTypeAsync(policy.OrganizationId, policy.Type) + .Returns(policy); + + // Act + var policyData = await sutProvider.Sut.RunAsync(policy.OrganizationId, policy.Type); + + // Assert + Assert.Equal(policy.Data, policyData.Data); + Assert.Equal(policy.Type, policyData.Type); + Assert.Equal(policy.Enabled, policyData.Enabled); + Assert.Equal(policy.OrganizationId, policyData.OrganizationId); + } + + [Theory, BitAutoData] + public async Task RunAsync_WithNonExistentPolicy_ReturnsDefaultDisabledPolicy( + SutProvider sutProvider, + Guid organizationId, + PolicyType policyType) + { + // Arrange + sutProvider.GetDependency() + .GetByOrganizationIdTypeAsync(organizationId, policyType) + .ReturnsNull(); + + // Act + var policyData = await sutProvider.Sut.RunAsync(organizationId, policyType); + + // Assert + Assert.Equal(organizationId, policyData.OrganizationId); + Assert.Equal(policyType, policyData.Type); + Assert.False(policyData.Enabled); + Assert.Null(policyData.Data); + } +} diff --git a/test/Core.Test/Services/HandlebarsMailServiceTests.cs b/test/Core.Test/Services/HandlebarsMailServiceTests.cs index b98c4580f5..4ff0868c7e 100644 --- a/test/Core.Test/Services/HandlebarsMailServiceTests.cs +++ b/test/Core.Test/Services/HandlebarsMailServiceTests.cs @@ -254,21 +254,6 @@ public class HandlebarsMailServiceTests } } - [Fact] - public async Task SendSendEmailOtpEmailAsync_SendsEmail() - { - // Arrange - var email = "test@example.com"; - var token = "aToken"; - var subject = string.Format("Your Bitwarden Send verification code is {0}", token); - - // Act - await _sut.SendSendEmailOtpEmailAsync(email, token, subject); - - // Assert - await _mailDeliveryService.Received(1).SendEmailAsync(Arg.Any()); - } - [Fact] public async Task SendIndividualUserWelcomeEmailAsync_SendsCorrectEmail() { diff --git a/test/Core.Test/Tools/ImportFeatures/ImportCiphersAsyncCommandTests.cs b/test/Core.Test/Tools/ImportFeatures/ImportCiphersAsyncCommandTests.cs index aea06f39a8..f6b1bd200a 100644 --- a/test/Core.Test/Tools/ImportFeatures/ImportCiphersAsyncCommandTests.cs +++ b/test/Core.Test/Tools/ImportFeatures/ImportCiphersAsyncCommandTests.cs @@ -326,4 +326,101 @@ public class ImportCiphersAsyncCommandTests await sutProvider.GetDependency().Received(1).PushSyncVaultAsync(importingUserId); } + + [Theory, BitAutoData] + public async Task ImportIntoIndividualVaultAsync_WithArchivedCiphers_PreservesArchiveStatus( + Guid importingUserId, + List ciphers, + SutProvider sutProvider) + { + var archivedDate = DateTime.UtcNow.AddDays(-1); + ciphers[0].UserId = importingUserId; + ciphers[0].ArchivedDate = archivedDate; + + sutProvider.GetDependency() + .AnyPoliciesApplicableToUserAsync(importingUserId, PolicyType.OrganizationDataOwnership) + .Returns(false); + + sutProvider.GetDependency() + .GetManyByUserIdAsync(importingUserId) + .Returns(new List()); + + var folders = new List(); + var folderRelationships = new List>(); + + await sutProvider.Sut.ImportIntoIndividualVaultAsync(folders, ciphers, folderRelationships, importingUserId); + + await sutProvider.GetDependency() + .Received(1) + .CreateAsync(importingUserId, + Arg.Is>(c => + c[0].Archives != null && + c[0].Archives.Contains(importingUserId.ToString().ToUpperInvariant()) && + c[0].Archives.Contains(archivedDate.ToString("yyyy-MM-ddTHH:mm:ss.fffffffZ"))), + Arg.Any>()); + } + + /* + * Archive functionality is a per-user function. When importing archived ciphers into an organization vault, + * the Archives field should be set for the importing user only. This allows the importing user to see + * items as archived, while other organization members will not see them as archived. + */ + [Theory, BitAutoData] + public async Task ImportIntoOrganizationalVaultAsync_WithArchivedCiphers_SetsArchivesForImportingUserOnly( + Organization organization, + Guid importingUserId, + OrganizationUser importingOrganizationUser, + List collections, + List ciphers, + SutProvider sutProvider) + { + var archivedDate = DateTime.UtcNow.AddDays(-1); + organization.MaxCollections = null; + importingOrganizationUser.OrganizationId = organization.Id; + + foreach (var collection in collections) + { + collection.OrganizationId = organization.Id; + } + + foreach (var cipher in ciphers) + { + cipher.OrganizationId = organization.Id; + } + + ciphers[0].ArchivedDate = archivedDate; + ciphers[0].Archives = null; + + KeyValuePair[] collectionRelationships = { + new(0, 0), + new(1, 1), + new(2, 2) + }; + + sutProvider.GetDependency() + .GetByIdAsync(organization.Id) + .Returns(organization); + + sutProvider.GetDependency() + .GetByOrganizationAsync(organization.Id, importingUserId) + .Returns(importingOrganizationUser); + + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(organization.Id) + .Returns(new List()); + + await sutProvider.Sut.ImportIntoOrganizationalVaultAsync(collections, ciphers, collectionRelationships, importingUserId); + + await sutProvider.GetDependency() + .Received(1) + .CreateAsync( + Arg.Is>(c => + c[0].ArchivedDate == archivedDate && + c[0].Archives != null && + c[0].Archives.Contains(importingUserId.ToString().ToUpperInvariant()) && + c[0].Archives.Contains(archivedDate.ToString("yyyy-MM-ddTHH:mm:ss.fffffffZ"))), + Arg.Any>(), + Arg.Any>(), + Arg.Any>()); + } } diff --git a/test/Core.Test/Tools/Services/SendAuthenticationQueryTests.cs b/test/Core.Test/Tools/Services/SendAuthenticationQueryTests.cs index 56b0f306cb..b4b1ecbc79 100644 --- a/test/Core.Test/Tools/Services/SendAuthenticationQueryTests.cs +++ b/test/Core.Test/Tools/Services/SendAuthenticationQueryTests.cs @@ -56,7 +56,7 @@ public class SendAuthenticationQueryTests // Assert var emailOtp = Assert.IsType(result); - Assert.Equal(expectedEmailHashes, emailOtp.Emails); + Assert.Equal(expectedEmailHashes, emailOtp.EmailHashes); } [Fact] diff --git a/test/Identity.IntegrationTest/RequestValidation/SendAccess/SendEmailOtpReqestValidatorIntegrationTests.cs b/test/Identity.IntegrationTest/RequestValidation/SendAccess/SendEmailOtpReqestValidatorIntegrationTests.cs index 3c4657653b..1c740cd448 100644 --- a/test/Identity.IntegrationTest/RequestValidation/SendAccess/SendEmailOtpReqestValidatorIntegrationTests.cs +++ b/test/Identity.IntegrationTest/RequestValidation/SendAccess/SendEmailOtpReqestValidatorIntegrationTests.cs @@ -3,6 +3,7 @@ using Bit.Core.Services; using Bit.Core.Tools.Models.Data; using Bit.Core.Tools.SendFeatures.Queries.Interfaces; using Bit.IntegrationTestCommon.Factories; +using Bit.Test.Common.Helpers; using Duende.IdentityModel; using NSubstitute; using Xunit; @@ -60,7 +61,7 @@ public class SendEmailOtpRequestValidatorIntegrationTests(IdentityApplicationFac var sendAuthQuery = Substitute.For(); sendAuthQuery.GetAuthenticationMethod(sendId) - .Returns(new EmailOtp([email])); + .Returns(new EmailOtp([CryptographyHelper.HashAndEncode(email)])); services.AddSingleton(sendAuthQuery); // Mock OTP token provider @@ -75,6 +76,7 @@ public class SendEmailOtpRequestValidatorIntegrationTests(IdentityApplicationFac }); }).CreateClient(); + var requestBody = SendAccessTestUtilities.CreateTokenRequestBody(sendId, email: email); // Email but no OTP // Act @@ -104,7 +106,7 @@ public class SendEmailOtpRequestValidatorIntegrationTests(IdentityApplicationFac var sendAuthQuery = Substitute.For(); sendAuthQuery.GetAuthenticationMethod(sendId) - .Returns(new EmailOtp(new[] { email })); + .Returns(new EmailOtp(new[] { CryptographyHelper.HashAndEncode(email) })); services.AddSingleton(sendAuthQuery); // Mock OTP token provider to validate successfully @@ -148,7 +150,7 @@ public class SendEmailOtpRequestValidatorIntegrationTests(IdentityApplicationFac var sendAuthQuery = Substitute.For(); sendAuthQuery.GetAuthenticationMethod(sendId) - .Returns(new EmailOtp(new[] { email })); + .Returns(new EmailOtp(new[] { CryptographyHelper.HashAndEncode(email) })); services.AddSingleton(sendAuthQuery); // Mock OTP token provider to validate as false @@ -190,7 +192,7 @@ public class SendEmailOtpRequestValidatorIntegrationTests(IdentityApplicationFac var sendAuthQuery = Substitute.For(); sendAuthQuery.GetAuthenticationMethod(sendId) - .Returns(new EmailOtp(new[] { email })); + .Returns(new EmailOtp(new[] { CryptographyHelper.HashAndEncode(email) })); services.AddSingleton(sendAuthQuery); // Mock OTP token provider to fail generation diff --git a/test/Identity.Test/IdentityServer/SendAccess/SendEmailOtpRequestValidatorTests.cs b/test/Identity.Test/IdentityServer/SendAccess/SendEmailOtpRequestValidatorTests.cs index 7fdfacf428..d2c7051f69 100644 --- a/test/Identity.Test/IdentityServer/SendAccess/SendEmailOtpRequestValidatorTests.cs +++ b/test/Identity.Test/IdentityServer/SendAccess/SendEmailOtpRequestValidatorTests.cs @@ -5,6 +5,7 @@ using Bit.Core.Tools.Models.Data; using Bit.Identity.IdentityServer.RequestValidators.SendAccess; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; +using Bit.Test.Common.Helpers; using Duende.IdentityModel; using Duende.IdentityServer.Validation; using NSubstitute; @@ -105,7 +106,8 @@ public class SendEmailOtpRequestValidatorTests expectedUniqueId) .Returns(generatedToken); - emailOtp = emailOtp with { Emails = [email] }; + var emailHash = CryptographyHelper.HashAndEncode(email); + emailOtp = emailOtp with { EmailHashes = [emailHash] }; // Act var result = await sutProvider.Sut.ValidateRequestAsync(context, emailOtp, sendId); @@ -144,7 +146,8 @@ public class SendEmailOtpRequestValidatorTests Request = tokenRequest }; - emailOtp = emailOtp with { Emails = [email] }; + var emailHash = CryptographyHelper.HashAndEncode(email); + emailOtp = emailOtp with { EmailHashes = [emailHash] }; sutProvider.GetDependency>() .GenerateTokenAsync(Arg.Any(), Arg.Any(), Arg.Any()) @@ -179,7 +182,8 @@ public class SendEmailOtpRequestValidatorTests Request = tokenRequest }; - emailOtp = emailOtp with { Emails = [email] }; + var emailHash = CryptographyHelper.HashAndEncode(email); + emailOtp = emailOtp with { EmailHashes = [emailHash] }; var expectedUniqueId = string.Format(SendAccessConstants.OtpToken.TokenUniqueIdentifier, sendId, email); @@ -231,7 +235,8 @@ public class SendEmailOtpRequestValidatorTests Request = tokenRequest }; - emailOtp = emailOtp with { Emails = [email] }; + var emailHash = CryptographyHelper.HashAndEncode(email); + emailOtp = emailOtp with { EmailHashes = [emailHash] }; var expectedUniqueId = string.Format(SendAccessConstants.OtpToken.TokenUniqueIdentifier, sendId, email); @@ -265,10 +270,8 @@ public class SendEmailOtpRequestValidatorTests // Arrange var otpTokenProvider = Substitute.For>(); var mailService = Substitute.For(); - var featureService = Substitute.For(); - // Act - var validator = new SendEmailOtpRequestValidator(featureService, otpTokenProvider, mailService); + var validator = new SendEmailOtpRequestValidator(otpTokenProvider, mailService); // Assert Assert.NotNull(validator); diff --git a/test/Infrastructure.IntegrationTest/Infrastructure.IntegrationTest.csproj b/test/Infrastructure.IntegrationTest/Infrastructure.IntegrationTest.csproj index 4822df4c77..262a0fe856 100644 --- a/test/Infrastructure.IntegrationTest/Infrastructure.IntegrationTest.csproj +++ b/test/Infrastructure.IntegrationTest/Infrastructure.IntegrationTest.csproj @@ -9,9 +9,7 @@ - - - + diff --git a/test/IntegrationTestCommon/IntegrationTestCommon.csproj b/test/IntegrationTestCommon/IntegrationTestCommon.csproj index 6fd6369f49..9a2e159b22 100644 --- a/test/IntegrationTestCommon/IntegrationTestCommon.csproj +++ b/test/IntegrationTestCommon/IntegrationTestCommon.csproj @@ -8,7 +8,6 @@ - diff --git a/test/SeederApi.IntegrationTest/RustSdkCipherTests.cs b/test/SeederApi.IntegrationTest/RustSdkCipherTests.cs new file mode 100644 index 0000000000..7ca7a0b913 --- /dev/null +++ b/test/SeederApi.IntegrationTest/RustSdkCipherTests.cs @@ -0,0 +1,226 @@ +using System.Text.Json; +using Bit.Core.Vault.Models.Data; +using Bit.RustSDK; +using Bit.Seeder.Factories; +using Bit.Seeder.Models; +using Xunit; + +namespace Bit.SeederApi.IntegrationTest; + +public class RustSdkCipherTests +{ + private static readonly JsonSerializerOptions SdkJsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false + }; + + [Fact] + public void EncryptDecrypt_LoginCipher_RoundtripPreservesPlaintext() + { + var orgKeys = RustSdkService.GenerateOrganizationKeys(); + + var originalCipher = CreateTestLoginCipher(); + var originalJson = JsonSerializer.Serialize(originalCipher, SdkJsonOptions); + + var encryptedJson = RustSdkService.EncryptCipher(originalJson, orgKeys.Key); + + Assert.DoesNotContain("\"error\"", encryptedJson); + Assert.Contains("\"name\":\"2.", encryptedJson); + + var decryptedJson = RustSdkService.DecryptCipher(encryptedJson, orgKeys.Key); + + Assert.DoesNotContain("\"error\"", decryptedJson); + + var decryptedCipher = JsonSerializer.Deserialize(decryptedJson, SdkJsonOptions); + + Assert.NotNull(decryptedCipher); + Assert.Equal(originalCipher.Name, decryptedCipher.Name); + Assert.Equal(originalCipher.Notes, decryptedCipher.Notes); + Assert.Equal(originalCipher.Login?.Username, decryptedCipher.Login?.Username); + Assert.Equal(originalCipher.Login?.Password, decryptedCipher.Login?.Password); + } + + [Fact] + public void EncryptCipher_WithUri_EncryptsAllFields() + { + var orgKeys = RustSdkService.GenerateOrganizationKeys(); + + var cipher = new CipherViewDto + { + Name = "Amazon Shopping", + Notes = "Prime member since 2020", + Type = CipherTypes.Login, + Login = new LoginViewDto + { + Username = "shopper@example.com", + Password = "MySecretPassword123!", + Uris = + [ + new LoginUriViewDto { Uri = "https://amazon.com/login" }, + new LoginUriViewDto { Uri = "https://www.amazon.com" } + ] + } + }; + + var cipherJson = JsonSerializer.Serialize(cipher, SdkJsonOptions); + var encryptedJson = RustSdkService.EncryptCipher(cipherJson, orgKeys.Key); + + Assert.DoesNotContain("\"error\"", encryptedJson); + Assert.DoesNotContain("Amazon Shopping", encryptedJson); + Assert.DoesNotContain("shopper@example.com", encryptedJson); + Assert.DoesNotContain("MySecretPassword123!", encryptedJson); + } + + [Fact] + public void DecryptCipher_WithWrongKey_FailsOrProducesGarbage() + { + var encryptionKey = RustSdkService.GenerateOrganizationKeys(); + var differentKey = RustSdkService.GenerateOrganizationKeys(); + + var originalCipher = CreateTestLoginCipher(); + var cipherJson = JsonSerializer.Serialize(originalCipher, SdkJsonOptions); + + var encryptedJson = RustSdkService.EncryptCipher(cipherJson, encryptionKey.Key); + Assert.DoesNotContain("\"error\"", encryptedJson); + + var decryptedJson = RustSdkService.DecryptCipher(encryptedJson, differentKey.Key); + + var decryptionFailedWithError = decryptedJson.Contains("\"error\""); + if (!decryptionFailedWithError) + { + var decrypted = JsonSerializer.Deserialize(decryptedJson, SdkJsonOptions); + Assert.NotEqual(originalCipher.Name, decrypted?.Name); + } + } + + [Fact] + public void EncryptCipher_WithFields_EncryptsCustomFields() + { + var orgKeys = RustSdkService.GenerateOrganizationKeys(); + + var cipher = new CipherViewDto + { + Name = "Service Account", + Type = CipherTypes.Login, + Login = new LoginViewDto + { + Username = "service-account", + Password = "svc-password" + }, + Fields = + [ + new FieldViewDto { Name = "API Key", Value = "sk-secret-api-key-12345", Type = 1 }, + new FieldViewDto { Name = "Client ID", Value = "client-id-xyz", Type = 0 } + ] + }; + + var cipherJson = JsonSerializer.Serialize(cipher, SdkJsonOptions); + var encryptedJson = RustSdkService.EncryptCipher(cipherJson, orgKeys.Key); + + Assert.DoesNotContain("\"error\"", encryptedJson); + Assert.DoesNotContain("sk-secret-api-key-12345", encryptedJson); + Assert.DoesNotContain("client-id-xyz", encryptedJson); + + var decryptedJson = RustSdkService.DecryptCipher(encryptedJson, orgKeys.Key); + var decrypted = JsonSerializer.Deserialize(decryptedJson, SdkJsonOptions); + + Assert.NotNull(decrypted?.Fields); + Assert.Equal(2, decrypted.Fields.Count); + Assert.Equal("API Key", decrypted.Fields[0].Name); + Assert.Equal("sk-secret-api-key-12345", decrypted.Fields[0].Value); + } + + [Fact] + public void CipherSeeder_ProducesServerCompatibleFormat() + { + var orgKeys = RustSdkService.GenerateOrganizationKeys(); + var orgId = Guid.NewGuid(); + + // Create cipher using the seeder + var cipher = CipherSeeder.CreateOrganizationLoginCipher( + orgId, + orgKeys.Key, + name: "GitHub Account", + username: "developer@example.com", + password: "SecureP@ss123!", + uri: "https://github.com", + notes: "My development account"); + + Assert.Equal(orgId, cipher.OrganizationId); + Assert.Null(cipher.UserId); + Assert.Equal(Core.Vault.Enums.CipherType.Login, cipher.Type); + Assert.NotNull(cipher.Data); + + var loginData = JsonSerializer.Deserialize(cipher.Data); + Assert.NotNull(loginData); + + var encStringPrefix = "2."; + Assert.StartsWith(encStringPrefix, loginData.Name); + Assert.StartsWith(encStringPrefix, loginData.Username); + Assert.StartsWith(encStringPrefix, loginData.Password); + Assert.StartsWith(encStringPrefix, loginData.Notes); + + Assert.NotNull(loginData.Uris); + var uriData = loginData.Uris.First(); + Assert.StartsWith(encStringPrefix, uriData.Uri); + + Assert.DoesNotContain("GitHub Account", cipher.Data); + Assert.DoesNotContain("developer@example.com", cipher.Data); + Assert.DoesNotContain("SecureP@ss123!", cipher.Data); + } + + [Fact] + public void CipherSeeder_WithFields_ProducesCorrectServerFormat() + { + var orgKeys = RustSdkService.GenerateOrganizationKeys(); + + var cipher = CipherSeeder.CreateOrganizationLoginCipherWithFields( + Guid.NewGuid(), + orgKeys.Key, + name: "API Service", + username: "service@example.com", + password: "SvcP@ss!", + uri: "https://api.example.com", + fields: [ + ("API Key", "sk-live-abc123", 1), // Hidden field + ("Environment", "production", 0) // Text field + ]); + + var loginData = JsonSerializer.Deserialize(cipher.Data); + Assert.NotNull(loginData); + Assert.NotNull(loginData.Fields); + + var fields = loginData.Fields.ToList(); + Assert.Equal(2, fields.Count); + + var encStringPrefix = "2."; + Assert.StartsWith(encStringPrefix, fields[0].Name); + Assert.StartsWith(encStringPrefix, fields[0].Value); + Assert.StartsWith(encStringPrefix, fields[1].Name); + Assert.StartsWith(encStringPrefix, fields[1].Value); + + Assert.Equal(Core.Vault.Enums.FieldType.Hidden, fields[0].Type); + Assert.Equal(Core.Vault.Enums.FieldType.Text, fields[1].Type); + + Assert.DoesNotContain("API Key", cipher.Data); + Assert.DoesNotContain("sk-live-abc123", cipher.Data); + } + + private static CipherViewDto CreateTestLoginCipher() + { + return new CipherViewDto + { + Name = "Test Login", + Notes = "Secret notes about this login", + Type = CipherTypes.Login, + Login = new LoginViewDto + { + Username = "testuser@example.com", + Password = "SuperSecretP@ssw0rd!", + Uris = [new LoginUriViewDto { Uri = "https://example.com" }] + } + }; + } + +} diff --git a/util/DbSeederUtility/Program.cs b/util/DbSeederUtility/Program.cs index 0b41c1a692..379f60ea1a 100644 --- a/util/DbSeederUtility/Program.cs +++ b/util/DbSeederUtility/Program.cs @@ -1,6 +1,9 @@ -using Bit.Infrastructure.EntityFramework.Repositories; +using AutoMapper; +using Bit.Core.Entities; +using Bit.Infrastructure.EntityFramework.Repositories; using Bit.Seeder.Recipes; using CommandDotNet; +using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.DependencyInjection; namespace Bit.DbSeederUtility; @@ -33,7 +36,29 @@ public class Program var scopedServices = scope.ServiceProvider; var db = scopedServices.GetRequiredService(); - var recipe = new OrganizationWithUsersRecipe(db); + var mapper = scopedServices.GetRequiredService(); + var passwordHasher = scopedServices.GetRequiredService>(); + var recipe = new OrganizationWithUsersRecipe(db, mapper, passwordHasher); recipe.Seed(name: name, domain: domain, users: users); } + + [Command("vault-organization", Description = "Seed an organization with users and encrypted vault data (ciphers, collections, groups)")] + public void VaultOrganization(VaultOrganizationArgs args) + { + args.Validate(); + + var services = new ServiceCollection(); + ServiceCollectionExtension.ConfigureServices(services); + var serviceProvider = services.BuildServiceProvider(); + + using var scope = serviceProvider.CreateScope(); + var scopedServices = scope.ServiceProvider; + + var recipe = new OrganizationWithVaultRecipe( + scopedServices.GetRequiredService(), + scopedServices.GetRequiredService(), + scopedServices.GetRequiredService>()); + + recipe.Seed(args.ToOptions()); + } } diff --git a/util/DbSeederUtility/README.md b/util/DbSeederUtility/README.md index 0eb21ae6c5..4bd3c389d6 100644 --- a/util/DbSeederUtility/README.md +++ b/util/DbSeederUtility/README.md @@ -28,13 +28,23 @@ DbSeeder.exe [options] ```bash # Generate an organization called "seeded" with 10000 users using the @large.test email domain. -# Login using "admin@large.test" with password "asdfasdfasdf" +# Login using "owner@large.test" with password "asdfasdfasdf" DbSeeder.exe organization -n seeded -u 10000 -d large.test + +# Generate an organization with 5 users and 100 encrypted ciphers +DbSeeder.exe vault-organization -n TestOrg -u 5 -d test.com -c 100 + +# Generate with Spotify-style collections (tribes, chapters, guilds) +DbSeeder.exe vault-organization -n TestOrg -u 10 -d test.com -c 50 -o Spotify + +# Generate a small test organization with ciphers for manual testing +DbSeeder.exe vault-organization -n DevOrg -u 2 -d dev.local -c 10 ``` ## Dependencies This utility depends on: + - The Seeder class library - CommandDotNet for command-line parsing - .NET 8.0 runtime diff --git a/util/DbSeederUtility/ServiceCollectionExtension.cs b/util/DbSeederUtility/ServiceCollectionExtension.cs index 0653bb1801..ca454c50f3 100644 --- a/util/DbSeederUtility/ServiceCollectionExtension.cs +++ b/util/DbSeederUtility/ServiceCollectionExtension.cs @@ -1,5 +1,7 @@ -using Bit.SharedWeb.Utilities; +using Bit.Core.Entities; +using Bit.SharedWeb.Utilities; using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -13,8 +15,14 @@ public static class ServiceCollectionExtension var globalSettings = GlobalSettingsFactory.GlobalSettings; // Register services - services.AddLogging(builder => builder.AddConsole()); + services.AddLogging(builder => + { + builder.AddConsole(); + builder.SetMinimumLevel(LogLevel.Warning); + builder.AddFilter("Microsoft.EntityFrameworkCore.Model.Validation", LogLevel.Error); + }); services.AddSingleton(globalSettings); + services.AddSingleton, PasswordHasher>(); // Add Data Protection services services.AddDataProtection() diff --git a/util/DbSeederUtility/VaultOrganizationArgs.cs b/util/DbSeederUtility/VaultOrganizationArgs.cs new file mode 100644 index 0000000000..8ec7762073 --- /dev/null +++ b/util/DbSeederUtility/VaultOrganizationArgs.cs @@ -0,0 +1,112 @@ +using Bit.Seeder.Data.Enums; +using Bit.Seeder.Options; +using CommandDotNet; + +namespace Bit.DbSeederUtility; + +/// +/// CLI argument model for the vault-organization command. +/// Maps to for the Seeder library. +/// +public class VaultOrganizationArgs : IArgumentModel +{ + [Option('n', "name", Description = "Name of organization")] + public string Name { get; set; } = null!; + + [Option('u', "users", Description = "Number of users to generate (minimum 1)")] + public int Users { get; set; } + + [Option('d', "domain", Description = "Email domain for users")] + public string Domain { get; set; } = null!; + + [Option('c', "ciphers", Description = "Number of login ciphers to create (minimum 1)")] + public int Ciphers { get; set; } + + [Option('g', "groups", Description = "Number of groups to create (minimum 1)")] + public int Groups { get; set; } + + [Option('m', "mix-user-statuses", Description = "Use realistic status mix (85% confirmed, 5% each invited/accepted/revoked). Requires >= 10 users.")] + public bool MixStatuses { get; set; } = true; + + [Option('o', "org-structure", Description = "Org structure for collections: Traditional, Spotify, or Modern")] + public string? Structure { get; set; } + + [Option('r', "region", Description = "Geographic region for names: NorthAmerica, Europe, AsiaPacific, LatinAmerica, MiddleEast, Africa, or Global")] + public string? Region { get; set; } + + public void Validate() + { + if (Users < 1) + { + throw new ArgumentException("Users must be at least 1. Use another command for orgs without users."); + } + + if (Ciphers < 1) + { + throw new ArgumentException("Ciphers must be at least 1. Use another command for orgs without vault data."); + } + + if (Groups < 1) + { + throw new ArgumentException("Groups must be at least 1. Use another command for orgs without groups."); + } + + if (!string.IsNullOrEmpty(Structure)) + { + ParseOrgStructure(Structure); + } + + if (!string.IsNullOrEmpty(Region)) + { + ParseGeographicRegion(Region); + } + } + + public OrganizationVaultOptions ToOptions() => new() + { + Name = Name, + Domain = Domain, + Users = Users, + Ciphers = Ciphers, + Groups = Groups, + RealisticStatusMix = MixStatuses, + StructureModel = ParseOrgStructure(Structure), + Region = ParseGeographicRegion(Region) + }; + + private static OrgStructureModel? ParseOrgStructure(string? structure) + { + if (string.IsNullOrEmpty(structure)) + { + return null; + } + + return structure.ToLowerInvariant() switch + { + "traditional" => OrgStructureModel.Traditional, + "spotify" => OrgStructureModel.Spotify, + "modern" => OrgStructureModel.Modern, + _ => throw new ArgumentException($"Unknown structure '{structure}'. Use: Traditional, Spotify, or Modern") + }; + } + + private static GeographicRegion? ParseGeographicRegion(string? region) + { + if (string.IsNullOrEmpty(region)) + { + return null; + } + + return region.ToLowerInvariant() switch + { + "northamerica" => GeographicRegion.NorthAmerica, + "europe" => GeographicRegion.Europe, + "asiapacific" => GeographicRegion.AsiaPacific, + "latinamerica" => GeographicRegion.LatinAmerica, + "middleeast" => GeographicRegion.MiddleEast, + "africa" => GeographicRegion.Africa, + "global" => GeographicRegion.Global, + _ => throw new ArgumentException($"Unknown region '{region}'. Use: NorthAmerica, Europe, AsiaPacific, LatinAmerica, MiddleEast, Africa, or Global") + }; + } +} diff --git a/util/RustSdk/RustSdkService.cs b/util/RustSdk/RustSdkService.cs index ee01d56fee..b6ada76df7 100644 --- a/util/RustSdk/RustSdkService.cs +++ b/util/RustSdk/RustSdkService.cs @@ -37,7 +37,7 @@ public class RustSdkService PropertyNameCaseInsensitive = true }; - public unsafe UserKeys GenerateUserKeys(string email, string password) + public static unsafe UserKeys GenerateUserKeys(string email, string password) { var emailBytes = StringToRustString(email); var passwordBytes = StringToRustString(password); @@ -47,22 +47,22 @@ public class RustSdkService { var resultPtr = NativeMethods.generate_user_keys(emailPtr, passwordPtr); - var result = TakeAndDestroyRustString(resultPtr); + var result = ParseResponse(resultPtr); return JsonSerializer.Deserialize(result, CaseInsensitiveOptions)!; } } - public unsafe OrganizationKeys GenerateOrganizationKeys() + public static unsafe OrganizationKeys GenerateOrganizationKeys() { var resultPtr = NativeMethods.generate_organization_keys(); - var result = TakeAndDestroyRustString(resultPtr); + var result = ParseResponse(resultPtr); return JsonSerializer.Deserialize(result, CaseInsensitiveOptions)!; } - public unsafe string GenerateUserOrganizationKey(string userKey, string orgKey) + public static unsafe string GenerateUserOrganizationKey(string userKey, string orgKey) { var userKeyBytes = StringToRustString(userKey); var orgKeyBytes = StringToRustString(orgKey); @@ -72,19 +72,70 @@ public class RustSdkService { var resultPtr = NativeMethods.generate_user_organization_key(userKeyPtr, orgKeyPtr); - var result = TakeAndDestroyRustString(resultPtr); + var result = ParseResponse(resultPtr); return result; } } + public static unsafe string EncryptCipher(string cipherViewJson, string symmetricKeyBase64) + { + var cipherViewBytes = StringToRustString(cipherViewJson); + var keyBytes = StringToRustString(symmetricKeyBase64); + + fixed (byte* cipherViewPtr = cipherViewBytes) + fixed (byte* keyPtr = keyBytes) + { + var resultPtr = NativeMethods.encrypt_cipher(cipherViewPtr, keyPtr); + + return ParseResponse(resultPtr); + } + } + + public static unsafe string DecryptCipher(string cipherJson, string symmetricKeyBase64) + { + var cipherBytes = StringToRustString(cipherJson); + var keyBytes = StringToRustString(symmetricKeyBase64); + + fixed (byte* cipherPtr = cipherBytes) + fixed (byte* keyPtr = keyBytes) + { + var resultPtr = NativeMethods.decrypt_cipher(cipherPtr, keyPtr); + + return ParseResponse(resultPtr); + } + } + + /// + /// Encrypts a plaintext string using the provided symmetric key. + /// Returns an EncString in format "2.{iv}|{data}|{mac}". + /// + public static unsafe string EncryptString(string plaintext, string symmetricKeyBase64) + { + var plaintextBytes = StringToRustString(plaintext); + var keyBytes = StringToRustString(symmetricKeyBase64); + + fixed (byte* plaintextPtr = plaintextBytes) + fixed (byte* keyPtr = keyBytes) + { + var resultPtr = NativeMethods.encrypt_string(plaintextPtr, keyPtr); + + return ParseResponse(resultPtr); + } + } private static byte[] StringToRustString(string str) { return Encoding.UTF8.GetBytes(str + '\0'); } - private static unsafe string TakeAndDestroyRustString(byte* ptr) + /// + /// Parses a response from Rust FFI, checks for errors, and frees the native string. + /// + /// Pointer to the C string returned from Rust + /// The parsed response string + /// Thrown if the pointer is null, conversion fails, or the response contains an error + private static unsafe string ParseResponse(byte* ptr) { if (ptr == null) { @@ -99,6 +150,28 @@ public class RustSdkService throw new RustSdkException("Failed to convert native result to string"); } + // Check if response is an error from Rust + // Rust error responses follow the format: {"error": "message"} + if (result.TrimStart().StartsWith('{') && result.Contains("\"error\"", StringComparison.Ordinal)) + { + try + { + using var doc = JsonDocument.Parse(result); + if (doc.RootElement.TryGetProperty("error", out var errorElement)) + { + var errorMessage = errorElement.GetString(); + if (!string.IsNullOrEmpty(errorMessage)) + { + throw new RustSdkException($"Rust SDK error: {errorMessage}"); + } + } + } + catch (JsonException) + { + // If we can't parse it as an error, it's likely valid data that happens to contain "error" + } + } + return result; } } diff --git a/util/RustSdk/rust/Cargo.lock b/util/RustSdk/rust/Cargo.lock index aff61935e4..1170795133 100644 --- a/util/RustSdk/rust/Cargo.lock +++ b/util/RustSdk/rust/Cargo.lock @@ -126,6 +126,21 @@ version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + [[package]] name = "bitflags" version = "2.9.1" @@ -162,6 +177,22 @@ dependencies = [ "uuid", ] +[[package]] +name = "bitwarden-collections" +version = "1.0.0" +source = "git+https://github.com/bitwarden/sdk-internal.git?rev=7080159154a42b59028ccb9f5af62bf087e565f9#7080159154a42b59028ccb9f5af62bf087e565f9" +dependencies = [ + "bitwarden-api-api", + "bitwarden-core", + "bitwarden-crypto", + "bitwarden-error", + "bitwarden-uuid", + "serde", + "serde_repr", + "thiserror 2.0.12", + "uuid", +] + [[package]] name = "bitwarden-core" version = "1.0.0" @@ -188,9 +219,10 @@ dependencies = [ "serde_json", "serde_qs", "serde_repr", - "thiserror 1.0.69", + "thiserror 2.0.12", "uuid", "zeroize", + "zxcvbn", ] [[package]] @@ -224,7 +256,7 @@ dependencies = [ "sha1", "sha2", "subtle", - "thiserror 1.0.69", + "thiserror 2.0.12", "typenum", "uuid", "zeroize", @@ -239,7 +271,7 @@ dependencies = [ "data-encoding", "data-encoding-macro", "serde", - "thiserror 1.0.69", + "thiserror 2.0.12", ] [[package]] @@ -274,7 +306,7 @@ dependencies = [ "rusqlite", "serde", "serde_json", - "thiserror 1.0.69", + "thiserror 2.0.12", "tokio", "tsify", ] @@ -287,7 +319,7 @@ dependencies = [ "bitwarden-error", "log", "serde", - "thiserror 1.0.69", + "thiserror 2.0.12", "tokio", "tokio-util", ] @@ -309,6 +341,36 @@ dependencies = [ "syn", ] +[[package]] +name = "bitwarden-vault" +version = "1.0.0" +source = "git+https://github.com/bitwarden/sdk-internal.git?rev=7080159154a42b59028ccb9f5af62bf087e565f9#7080159154a42b59028ccb9f5af62bf087e565f9" +dependencies = [ + "bitwarden-api-api", + "bitwarden-collections", + "bitwarden-core", + "bitwarden-crypto", + "bitwarden-encoding", + "bitwarden-error", + "bitwarden-state", + "bitwarden-uuid", + "chrono", + "data-encoding", + "futures", + "hmac", + "percent-encoding", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "sha1", + "sha2", + "subtle", + "thiserror 2.0.12", + "uuid", + "zxcvbn", +] + [[package]] name = "blake2" version = "0.11.0-rc.3" @@ -431,8 +493,10 @@ checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" dependencies = [ "android-tzdata", "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link", ] @@ -695,6 +759,37 @@ dependencies = [ "serde", ] +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn", +] + [[package]] name = "digest" version = "0.10.7" @@ -784,6 +879,17 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fancy-regex" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "531e46835a22af56d1e3b66f04844bed63158bc094a628bec1d321d9b4c44bf2" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fiat-crypto" version = "0.2.9" @@ -811,6 +917,21 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.31" @@ -818,6 +939,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -826,6 +948,23 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + [[package]] name = "futures-macro" version = "0.3.31" @@ -855,9 +994,13 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ + "futures-channel", "futures-core", + "futures-io", "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "pin-utils", "slab", @@ -1292,6 +1435,15 @@ dependencies = [ "serde", ] +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.15" @@ -2099,6 +2251,7 @@ dependencies = [ "base64", "bitwarden-core", "bitwarden-crypto", + "bitwarden-vault", "csbindgen", "serde", "serde_json", @@ -3189,3 +3342,20 @@ dependencies = [ "quote", "syn", ] + +[[package]] +name = "zxcvbn" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad76e35b00ad53688d6b90c431cabe3cbf51f7a4a154739e04b63004ab1c736c" +dependencies = [ + "chrono", + "derive_builder", + "fancy-regex", + "itertools", + "lazy_static", + "regex", + "time", + "wasm-bindgen", + "web-sys", +] diff --git a/util/RustSdk/rust/Cargo.toml b/util/RustSdk/rust/Cargo.toml index 65b0d42e5f..767cbf47e6 100644 --- a/util/RustSdk/rust/Cargo.toml +++ b/util/RustSdk/rust/Cargo.toml @@ -13,8 +13,9 @@ crate-type = ["cdylib"] [dependencies] base64 = "0.22.1" -bitwarden-core = { git = "https://github.com/bitwarden/sdk-internal.git", rev = "7080159154a42b59028ccb9f5af62bf087e565f9" } +bitwarden-core = { git = "https://github.com/bitwarden/sdk-internal.git", rev = "7080159154a42b59028ccb9f5af62bf087e565f9", features = ["internal"] } bitwarden-crypto = { git = "https://github.com/bitwarden/sdk-internal.git", rev = "7080159154a42b59028ccb9f5af62bf087e565f9" } +bitwarden-vault = { git = "https://github.com/bitwarden/sdk-internal.git", rev = "7080159154a42b59028ccb9f5af62bf087e565f9" } serde = "=1.0.219" serde_json = "=1.0.141" diff --git a/util/RustSdk/rust/build.rs b/util/RustSdk/rust/build.rs index 0905afc22d..2eeedbbebd 100644 --- a/util/RustSdk/rust/build.rs +++ b/util/RustSdk/rust/build.rs @@ -1,6 +1,7 @@ fn main() { csbindgen::Builder::default() .input_extern_file("src/lib.rs") + .input_extern_file("src/cipher.rs") .csharp_dll_name("libsdk") .csharp_namespace("Bit.RustSDK") .csharp_class_accessibility("public") diff --git a/util/RustSdk/rust/src/cipher.rs b/util/RustSdk/rust/src/cipher.rs new file mode 100644 index 0000000000..208aa65193 --- /dev/null +++ b/util/RustSdk/rust/src/cipher.rs @@ -0,0 +1,403 @@ +//! Cipher encryption and decryption functions for the Seeder. +//! +//! This module provides FFI functions for encrypting and decrypting Bitwarden ciphers +//! using the Rust SDK's cryptographic primitives. + +use std::ffi::{c_char, CStr, CString}; + +use base64::{engine::general_purpose::STANDARD, Engine}; + +use bitwarden_core::key_management::KeyIds; +use bitwarden_crypto::{ + BitwardenLegacyKeyBytes, CompositeEncryptable, Decryptable, KeyEncryptable, KeyStore, + SymmetricCryptoKey, +}; +use bitwarden_vault::{Cipher, CipherView}; + +/// Create an error JSON response and return it as a C string pointer. +fn error_response(message: &str) -> *const c_char { + let error_json = serde_json::json!({ "error": message }).to_string(); + CString::new(error_json).unwrap().into_raw() +} + +/// Encrypt a CipherView with a symmetric key, returning an encrypted Cipher as JSON. +/// +/// # Arguments +/// * `cipher_view_json` - JSON string representing a CipherView (camelCase format) +/// * `symmetric_key_b64` - Base64-encoded symmetric key (64 bytes for AES-256-CBC-HMAC-SHA256) +/// +/// # Returns +/// JSON string representing the encrypted Cipher +/// +/// # Safety +/// Both pointers must be valid null-terminated strings. +#[no_mangle] +pub unsafe extern "C" fn encrypt_cipher( + cipher_view_json: *const c_char, + symmetric_key_b64: *const c_char, +) -> *const c_char { + let Ok(cipher_view_json) = CStr::from_ptr(cipher_view_json).to_str() else { + return error_response("Invalid UTF-8 in cipher_view_json"); + }; + + let Ok(key_b64) = CStr::from_ptr(symmetric_key_b64).to_str() else { + return error_response("Invalid UTF-8 in symmetric_key_b64"); + }; + + let Ok(cipher_view): Result = serde_json::from_str(cipher_view_json) else { + return error_response("Failed to parse CipherView JSON"); + }; + + let Ok(key_bytes) = STANDARD.decode(key_b64) else { + return error_response("Failed to decode base64 key"); + }; + + let Ok(key) = SymmetricCryptoKey::try_from(&BitwardenLegacyKeyBytes::from(key_bytes.as_slice())) else { + return error_response("Failed to create symmetric key: invalid key format or length"); + }; + + let store: KeyStore = KeyStore::default(); + let mut ctx = store.context_mut(); + let key_id = ctx.add_local_symmetric_key(key); + + let Ok(cipher) = cipher_view.encrypt_composite(&mut ctx, key_id) else { + return error_response("Failed to encrypt cipher: encryption operation failed"); + }; + + match serde_json::to_string(&cipher) { + Ok(json) => CString::new(json).unwrap().into_raw(), + Err(_) => error_response("Failed to serialize encrypted cipher"), + } +} + +/// Decrypt an encrypted Cipher with a symmetric key, returning a CipherView as JSON. +/// +/// # Arguments +/// * `cipher_json` - JSON string representing an encrypted Cipher +/// * `symmetric_key_b64` - Base64-encoded symmetric key (64 bytes for AES-256-CBC-HMAC-SHA256) +/// +/// # Returns +/// JSON string representing the decrypted CipherView +/// +/// # Safety +/// Both pointers must be valid null-terminated strings. +#[no_mangle] +pub unsafe extern "C" fn decrypt_cipher( + cipher_json: *const c_char, + symmetric_key_b64: *const c_char, +) -> *const c_char { + let Ok(cipher_json) = CStr::from_ptr(cipher_json).to_str() else { + return error_response("Invalid UTF-8 in cipher_json"); + }; + + let Ok(key_b64) = CStr::from_ptr(symmetric_key_b64).to_str() else { + return error_response("Invalid UTF-8 in symmetric_key_b64"); + }; + + let Ok(cipher): Result = serde_json::from_str(cipher_json) else { + return error_response("Failed to parse Cipher JSON"); + }; + + let Ok(key_bytes) = STANDARD.decode(key_b64) else { + return error_response("Failed to decode base64 key"); + }; + + let Ok(key) = SymmetricCryptoKey::try_from(&BitwardenLegacyKeyBytes::from(key_bytes.as_slice())) else { + return error_response("Failed to create symmetric key: invalid key format or length"); + }; + + let store: KeyStore = KeyStore::default(); + let mut ctx = store.context_mut(); + let key_id = ctx.add_local_symmetric_key(key); + + let Ok(cipher_view): Result = cipher.decrypt(&mut ctx, key_id) else { + return error_response("Failed to decrypt cipher: decryption operation failed"); + }; + + match serde_json::to_string(&cipher_view) { + Ok(json) => CString::new(json).unwrap().into_raw(), + Err(_) => error_response("Failed to serialize decrypted cipher"), + } +} + +/// Encrypt a plaintext string with a symmetric key, returning an EncString. +/// +/// # Arguments +/// * `plaintext` - The plaintext string to encrypt +/// * `symmetric_key_b64` - Base64-encoded symmetric key (64 bytes for AES-256-CBC-HMAC-SHA256) +/// +/// # Returns +/// EncString in format "2.{iv}|{data}|{mac}" +/// +/// # Safety +/// Both pointers must be valid null-terminated strings. +#[no_mangle] +pub unsafe extern "C" fn encrypt_string( + plaintext: *const c_char, + symmetric_key_b64: *const c_char, +) -> *const c_char { + let Ok(plaintext) = CStr::from_ptr(plaintext).to_str() else { + return error_response("Invalid UTF-8 in plaintext"); + }; + + let Ok(key_b64) = CStr::from_ptr(symmetric_key_b64).to_str() else { + return error_response("Invalid UTF-8 in symmetric_key_b64"); + }; + + let Ok(key_bytes) = STANDARD.decode(key_b64) else { + return error_response("Failed to decode base64 key"); + }; + + let Ok(key) = SymmetricCryptoKey::try_from(&BitwardenLegacyKeyBytes::from(key_bytes.as_slice())) else { + return error_response("Failed to create symmetric key: invalid key format or length"); + }; + + let Ok(encrypted) = plaintext.to_string().encrypt_with_key(&key) else { + return error_response("Failed to encrypt string"); + }; + + CString::new(encrypted.to_string()).unwrap().into_raw() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{free_c_string, generate_organization_keys}; + use bitwarden_vault::{CipherType, LoginView}; + + fn create_test_cipher_view() -> CipherView { + CipherView { + id: None, + organization_id: None, + folder_id: None, + collection_ids: vec![], + key: None, + name: "Test Login".to_string(), + notes: Some("Secret notes".to_string()), + r#type: CipherType::Login, + login: Some(LoginView { + username: Some("testuser@example.com".to_string()), + password: Some("SuperSecretP@ssw0rd!".to_string()), + password_revision_date: None, + uris: None, + totp: None, + autofill_on_page_load: None, + fido2_credentials: None, + }), + identity: None, + card: None, + secure_note: None, + ssh_key: None, + favorite: false, + reprompt: bitwarden_vault::CipherRepromptType::None, + organization_use_totp: false, + edit: true, + permissions: None, + view_password: true, + local_data: None, + attachments: None, + fields: None, + password_history: None, + creation_date: "2025-01-01T00:00:00Z".parse().unwrap(), + deleted_date: None, + revision_date: "2025-01-01T00:00:00Z".parse().unwrap(), + archived_date: None, + } + } + + fn call_encrypt_cipher(cipher_json: &str, key_b64: &str) -> String { + let cipher_cstr = CString::new(cipher_json).unwrap(); + let key_cstr = CString::new(key_b64).unwrap(); + + let result_ptr = unsafe { encrypt_cipher(cipher_cstr.as_ptr(), key_cstr.as_ptr()) }; + let result_cstr = unsafe { CStr::from_ptr(result_ptr) }; + let result = result_cstr.to_str().unwrap().to_owned(); + unsafe { free_c_string(result_ptr as *mut c_char) }; + + result + } + + fn make_test_key_b64() -> String { + SymmetricCryptoKey::make_aes256_cbc_hmac_key() + .to_base64() + .into() + } + + #[test] + fn encrypt_cipher_produces_encrypted_fields() { + let key_b64 = make_test_key_b64(); + let cipher_view = create_test_cipher_view(); + let cipher_json = serde_json::to_string(&cipher_view).unwrap(); + + let encrypted_json = call_encrypt_cipher(&cipher_json, &key_b64); + + assert!( + !encrypted_json.contains("\"error\""), + "Got error: {}", + encrypted_json + ); + + let encrypted_cipher: Cipher = + serde_json::from_str(&encrypted_json).expect("Failed to parse encrypted cipher JSON"); + + let encrypted_name = encrypted_cipher.name.to_string(); + assert!( + encrypted_name.starts_with("2."), + "Name should be encrypted: {}", + encrypted_name + ); + + let login = encrypted_cipher.login.expect("Login should be present"); + if let Some(username) = &login.username { + assert!( + username.to_string().starts_with("2."), + "Username should be encrypted" + ); + } + if let Some(password) = &login.password { + assert!( + password.to_string().starts_with("2."), + "Password should be encrypted" + ); + } + } + + #[test] + fn encrypt_cipher_works_with_generated_org_key() { + let org_keys_ptr = unsafe { generate_organization_keys() }; + let org_keys_cstr = unsafe { CStr::from_ptr(org_keys_ptr) }; + let org_keys_json = org_keys_cstr.to_str().unwrap().to_owned(); + unsafe { free_c_string(org_keys_ptr as *mut c_char) }; + + let org_keys: serde_json::Value = serde_json::from_str(&org_keys_json).unwrap(); + let org_key_b64 = org_keys["key"].as_str().unwrap(); + + let cipher_view = create_test_cipher_view(); + let cipher_json = serde_json::to_string(&cipher_view).unwrap(); + + let encrypted_json = call_encrypt_cipher(&cipher_json, org_key_b64); + + assert!( + !encrypted_json.contains("\"error\""), + "Got error: {}", + encrypted_json + ); + + let encrypted_cipher: Cipher = serde_json::from_str(&encrypted_json).unwrap(); + assert!(encrypted_cipher.name.to_string().starts_with("2.")); + } + + #[test] + fn encrypt_cipher_rejects_invalid_json() { + let key_b64 = make_test_key_b64(); + + let error_json = call_encrypt_cipher("{ this is not valid json }", &key_b64); + + assert!( + error_json.contains("\"error\""), + "Should return error for invalid JSON" + ); + assert!(error_json.contains("Failed to parse CipherView JSON")); + } + + #[test] + fn encrypt_cipher_rejects_invalid_base64_key() { + let cipher_view = create_test_cipher_view(); + let cipher_json = serde_json::to_string(&cipher_view).unwrap(); + + let error_json = call_encrypt_cipher(&cipher_json, "not-valid-base64!!!"); + + assert!( + error_json.contains("\"error\""), + "Should return error for invalid base64" + ); + assert!(error_json.contains("Failed to decode base64 key")); + } + + #[test] + fn encrypt_cipher_rejects_wrong_key_length() { + let cipher_view = create_test_cipher_view(); + let cipher_json = serde_json::to_string(&cipher_view).unwrap(); + let short_key_b64 = STANDARD.encode(b"too short"); + + let error_json = call_encrypt_cipher(&cipher_json, &short_key_b64); + + assert!( + error_json.contains("\"error\""), + "Should return error for wrong key length" + ); + assert!(error_json.contains("invalid key format or length")); + } + + fn call_decrypt_cipher(cipher_json: &str, key_b64: &str) -> String { + let cipher_cstr = CString::new(cipher_json).unwrap(); + let key_cstr = CString::new(key_b64).unwrap(); + + let result_ptr = unsafe { decrypt_cipher(cipher_cstr.as_ptr(), key_cstr.as_ptr()) }; + let result_cstr = unsafe { CStr::from_ptr(result_ptr) }; + let result = result_cstr.to_str().unwrap().to_owned(); + unsafe { free_c_string(result_ptr as *mut c_char) }; + + result + } + + #[test] + fn encrypt_decrypt_roundtrip_preserves_plaintext() { + let key_b64 = make_test_key_b64(); + let original_view = create_test_cipher_view(); + let original_json = serde_json::to_string(&original_view).unwrap(); + + let encrypted_json = call_encrypt_cipher(&original_json, &key_b64); + assert!( + !encrypted_json.contains("\"error\""), + "Encryption failed: {}", + encrypted_json + ); + + let decrypted_json = call_decrypt_cipher(&encrypted_json, &key_b64); + assert!( + !decrypted_json.contains("\"error\""), + "Decryption failed: {}", + decrypted_json + ); + + let decrypted_view: CipherView = serde_json::from_str(&decrypted_json) + .expect("Failed to parse decrypted CipherView"); + + assert_eq!(decrypted_view.name, original_view.name); + assert_eq!(decrypted_view.notes, original_view.notes); + + let original_login = original_view.login.expect("Original should have login"); + let decrypted_login = decrypted_view.login.expect("Decrypted should have login"); + + assert_eq!(decrypted_login.username, original_login.username); + assert_eq!(decrypted_login.password, original_login.password); + } + + #[test] + fn decrypt_cipher_rejects_wrong_key() { + let encrypt_key = make_test_key_b64(); + let wrong_key = make_test_key_b64(); + + let original_view = create_test_cipher_view(); + let original_json = serde_json::to_string(&original_view).unwrap(); + + let encrypted_json = call_encrypt_cipher(&original_json, &encrypt_key); + assert!(!encrypted_json.contains("\"error\"")); + + let decrypted_json = call_decrypt_cipher(&encrypted_json, &wrong_key); + + // Decryption with wrong key should fail or produce garbage + // The SDK may return an error or the MAC validation will fail + let result: Result = serde_json::from_str(&decrypted_json); + if !decrypted_json.contains("\"error\"") { + // If no error, the decrypted data should not match original + if let Ok(view) = result { + assert_ne!( + view.name, original_view.name, + "Decryption with wrong key should not produce original plaintext" + ); + } + } + } +} diff --git a/util/RustSdk/rust/src/lib.rs b/util/RustSdk/rust/src/lib.rs index 10f8d8dca4..65b9d4f116 100644 --- a/util/RustSdk/rust/src/lib.rs +++ b/util/RustSdk/rust/src/lib.rs @@ -1,4 +1,7 @@ #![allow(clippy::missing_safety_doc)] + +mod cipher; + use std::{ ffi::{c_char, CStr, CString}, num::NonZeroU32, @@ -20,9 +23,6 @@ pub unsafe extern "C" fn generate_user_keys( let email = CStr::from_ptr(email).to_str().unwrap(); let password = CStr::from_ptr(password).to_str().unwrap(); - println!("Generating keys for {email}"); - println!("Password: {password}"); - let kdf = Kdf::PBKDF2 { iterations: NonZeroU32::new(5_000).unwrap(), }; @@ -32,8 +32,6 @@ pub unsafe extern "C" fn generate_user_keys( let master_password_hash = master_key.derive_master_key_hash(password.as_bytes(), HashPurpose::ServerAuthorization); - println!("Master password hash: {}", master_password_hash); - let (user_key, encrypted_user_key) = master_key.make_user_key().unwrap(); let keypair = keypair(&user_key.0); diff --git a/util/Seeder/CLAUDE.md b/util/Seeder/CLAUDE.md new file mode 100644 index 0000000000..a5a4105f03 --- /dev/null +++ b/util/Seeder/CLAUDE.md @@ -0,0 +1,215 @@ +# Seeder - Claude Code Context + +## Ubiquitous Language + +The Seeder follows six core patterns: + +1. **Factories** - Create ONE entity with encryption. Named `{Entity}Seeder` with `Create{Type}{Entity}()` methods. Do not interact with database. + +2. **Recipes** - Orchestrate MANY entities. Named `{DomainConcept}Recipe`. **MUST have `Seed()` method** as primary interface, not `AddToOrganization()` or similar. Use parameters for variations, not separate methods. Compose Factories internally. + +3. **Models** - DTOs bridging SDK ↔ Server format. Named `{Entity}ViewDto` (plaintext), `Encrypted{Entity}Dto` (SDK format). Pure data, no logic. + +4. **Scenes** - Complete test scenarios with ID mangling. Implement `IScene`. Async, returns `SceneResult` with MangleMap and result property populated with `TResult`. Named `{Scenario}Scene`. + +5. **Queries** - Read-only data retrieval. Implement `IQuery`. Synchronous, no DB modifications. Named `{DataToRetrieve}Query`. + +6. **Data** - Static, filterable test data collections (Companies, Passwords, Names, OrgStructures). Deterministic, composable. Enums provide public API. + +## The Recipe Contract + +Recipes follow strict rules (like a cooking recipe that you follow completely): + +1. A Recipe SHALL have exactly one public method named `Seed()` +2. A Recipe MUST produce one cohesive result (like baking one complete cake) +3. A Recipe MAY have overloaded `Seed()` methods with different parameters +4. A Recipe SHALL use private helper methods for internal steps +5. A Recipe SHALL use BulkCopy for performance when creating multiple entities +6. A Recipe SHALL compose Factories for individual entity creation +7. A Recipe SHALL NOT expose implementation details as public methods + +**Current violations** (to be refactored): + +- `CiphersRecipe` - Uses `AddLoginCiphersToOrganization()` instead of `Seed()` +- `CollectionsRecipe` - Uses `AddFromStructure()` and `AddToOrganization()` instead of `Seed()` +- `GroupsRecipe` - Uses `AddToOrganization()` instead of `Seed()` +- `OrganizationDomainRecipe` - Uses `AddVerifiedDomainToOrganization()` instead of `Seed()` + +## Pattern Decision Tree + +``` +Need to create test data? +├─ ONE entity with encryption? → Factory +├─ MANY entities as cohesive operation? → Recipe +├─ Complete test scenario with ID mangling to be used by the Seeder API? → Scene +├─ READ existing seeded data? → Query +└─ Data transformation SDK ↔ Server? → Model +``` + +## When to Use the Seeder + +✅ Use for: + +- Local development database setup +- Integration test data creation +- Performance testing with realistic encrypted data + +❌ Do NOT use for: + +- Production data +- Copying real user vaults (use backup/restore instead) + +## Zero-Knowledge Architecture + +**Critical Principle:** Unencrypted vault data never leaves the client. The server never sees plaintext. + +### Why Seeder Uses the Rust SDK + +The Seeder must behave exactly like any other Bitwarden client. Since the server: + +- Never receives plaintext +- Cannot perform encryption (doesn't have keys) +- Only stores/retrieves encrypted blobs + +...the Seeder cannot simply write plaintext to the database. It must: + +1. Generate encryption keys (like a client does during account setup) +2. Encrypt vault data client-side (using the same SDK the real clients use) +3. Store only the encrypted result + +This is why we use the Rust SDK via FFI - it's the same cryptographic implementation used by the official clients. + +## Cipher Encryption Architecture + +### The Two-State Pattern + +Bitwarden uses a clean separation between encrypted and decrypted data: + +| State | SDK Type | Description | Stored in DB? | +| --------- | ------------ | ------------------------- | ------------- | +| Plaintext | `CipherView` | Decrypted, human-readable | Never | +| Encrypted | `Cipher` | EncString values | Yes | + +**Encryption flow:** + +``` +CipherView (plaintext) → encrypt_composite() → Cipher (encrypted) +``` + +**Decryption flow:** + +``` +Cipher (encrypted) → decrypt() → CipherView (plaintext) +``` + +### SDK vs Server Format Difference + +**Critical:** The SDK and server use different JSON structures. + +**SDK Cipher (nested):** + +```json +{ + "name": "2.abc...", + "login": { + "username": "2.def...", + "password": "2.ghi..." + } +} +``` + +**Server Cipher.Data (flat CipherLoginData):** + +```json +{ + "Name": "2.abc...", + "Username": "2.def...", + "Password": "2.ghi..." +} +``` + +### Data Flow in Seeder + +``` +┌─────────────────┐ ┌──────────────────┐ ┌─────────────────────┐ +│ CipherViewDto │────▶│ Rust SDK │────▶│ EncryptedCipherDto │ +│ (plaintext) │ │ encrypt_cipher │ │ (SDK Cipher) │ +└─────────────────┘ └──────────────────┘ └─────────────────────┘ + │ + ▼ + ┌───────────────────────┐ + │ TransformToServer │ + │ (flatten nested → │ + │ flat structure) │ + └───────────────────────┘ + │ + ▼ +┌─────────────────┐ ┌──────────────────┐ ┌─────────────────────┐ +│ Server Cipher │◀────│ CipherLoginData │◀────│ Flattened JSON │ +│ Entity │ │ (serialized) │ │ │ +└─────────────────┘ └──────────────────┘ └─────────────────────┘ +``` + +### Key Hierarchy + +Bitwarden uses a two-level encryption hierarchy: + +1. **User/Organization Key** - Encrypts the cipher's individual key +2. **Cipher Key** (optional) - Encrypts the actual cipher data + +For seeding, we use the organization's symmetric key directly (no per-cipher key). + +## Rust SDK FFI + +### Error Handling + +SDK functions return JSON with an `"error"` field on failure: + +```json +{ "error": "Failed to parse CipherView JSON" } +``` + +Always check for `"error"` in the response before parsing. + +## Testing + +Integration tests in `test/SeederApi.IntegrationTest` verify: + +1. **Roundtrip encryption** - Encrypt then decrypt preserves plaintext +2. **Server format compatibility** - Output matches CipherLoginData structure +3. **Field encryption** - Custom fields are properly encrypted +4. **Security** - Plaintext never appears in encrypted output + +## Common Patterns + +### Creating a Cipher + +```csharp +var sdk = new RustSdkService(); +var seeder = new CipherSeeder(sdk); + +var cipher = seeder.CreateOrganizationLoginCipher( + organizationId, + orgKey, // Base64-encoded symmetric key + name: "My Login", + username: "user@example.com", + password: "secret123"); +``` + +### Bulk Cipher Creation + +```csharp +var recipe = new CiphersRecipe(dbContext, sdkService); + +var cipherIds = recipe.AddLoginCiphersToOrganization( + organizationId, + orgKey, + collectionIds, + count: 100); +``` + +## Security Reminders + +- Generated test passwords are intentionally weak (`asdfasdfasdf`) +- Never commit database dumps containing seeded data to version control +- Seeded keys are for testing only - regenerate for each test run diff --git a/util/Seeder/Data/BogusNameProvider.cs b/util/Seeder/Data/BogusNameProvider.cs new file mode 100644 index 0000000000..4a41b6b120 --- /dev/null +++ b/util/Seeder/Data/BogusNameProvider.cs @@ -0,0 +1,78 @@ +using Bit.Seeder.Data.Enums; +using Bogus; +using Bogus.DataSets; + +namespace Bit.Seeder.Data; + +/// +/// Provides locale-aware name generation using the Bogus library. +/// Maps GeographicRegion to appropriate Bogus locales for culturally-appropriate names. +/// +internal sealed class BogusNameProvider +{ + private readonly Faker _faker; + + public BogusNameProvider(GeographicRegion region, int? seed = null) + { + var locale = MapRegionToLocale(region, seed); + _faker = seed.HasValue + ? new Faker(locale) { Random = new Randomizer(seed.Value) } + : new Faker(locale); + } + + public string FirstName() => _faker.Name.FirstName(); + + public string FirstName(Name.Gender gender) => _faker.Name.FirstName(gender); + + public string LastName() => _faker.Name.LastName(); + + private static string MapRegionToLocale(GeographicRegion region, int? seed) => region switch + { + GeographicRegion.NorthAmerica => "en_US", + GeographicRegion.Europe => GetRandomEuropeanLocale(seed), + GeographicRegion.AsiaPacific => GetRandomAsianLocale(seed), + GeographicRegion.LatinAmerica => GetRandomLatinAmericanLocale(seed), + GeographicRegion.MiddleEast => GetRandomMiddleEastLocale(seed), + GeographicRegion.Africa => GetRandomAfricanLocale(seed), + GeographicRegion.Global => "en", + _ => "en" + }; + + private static string GetRandomEuropeanLocale(int? seed) + { + var locales = new[] { "en_GB", "de", "fr", "es", "it", "nl", "pl", "pt_PT", "sv" }; + return PickLocale(locales, seed); + } + + private static string GetRandomAsianLocale(int? seed) + { + var locales = new[] { "ja", "ko", "zh_CN", "zh_TW", "vi" }; + return PickLocale(locales, seed); + } + + private static string GetRandomLatinAmericanLocale(int? seed) + { + var locales = new[] { "es_MX", "pt_BR", "es" }; + return PickLocale(locales, seed); + } + + private static string GetRandomMiddleEastLocale(int? seed) + { + // Bogus has limited Middle East support; use available Arabic/Turkish locales + var locales = new[] { "ar", "tr", "fa" }; + return PickLocale(locales, seed); + } + + private static string GetRandomAfricanLocale(int? seed) + { + // Bogus has limited African support; use South African English and French (West Africa) + var locales = new[] { "en_ZA", "fr" }; + return PickLocale(locales, seed); + } + + private static string PickLocale(string[] locales, int? seed) + { + var random = seed.HasValue ? new Random(seed.Value) : Random.Shared; + return locales[random.Next(locales.Length)]; + } +} diff --git a/util/Seeder/Data/CipherUsernameGenerator.cs b/util/Seeder/Data/CipherUsernameGenerator.cs new file mode 100644 index 0000000000..21a726a8ff --- /dev/null +++ b/util/Seeder/Data/CipherUsernameGenerator.cs @@ -0,0 +1,67 @@ +using Bit.Seeder.Data.Enums; + +namespace Bit.Seeder.Data; + +/// +/// Generates deterministic usernames for companies using configurable patterns. +/// Uses Bogus library for locale-aware name generation while maintaining determinism +/// through pre-generated arrays indexed by a seed. +/// +internal sealed class CipherUsernameGenerator +{ + private const int _namePoolSize = 1500; + + private readonly Random _random; + + private readonly UsernamePattern _pattern; + + private readonly string[] _firstNames; + + private readonly string[] _lastNames; + + public CipherUsernameGenerator( + int seed, + UsernamePatternType patternType = UsernamePatternType.FirstDotLast, + GeographicRegion? region = null) + { + _random = new Random(seed); + _pattern = UsernamePatterns.GetPattern(patternType); + + // Pre-generate arrays from Bogus for deterministic index-based access + var provider = new BogusNameProvider(region ?? GeographicRegion.Global, seed); + _firstNames = Enumerable.Range(0, _namePoolSize).Select(_ => provider.FirstName()).ToArray(); + _lastNames = Enumerable.Range(0, _namePoolSize).Select(_ => provider.LastName()).ToArray(); + } + + public string Generate(Company company) + { + var firstName = _firstNames[_random.Next(_firstNames.Length)]; + var lastName = _lastNames[_random.Next(_lastNames.Length)]; + return _pattern.Generate(firstName, lastName, company.Domain); + } + + /// + /// Generates username using index for deterministic selection across cipher iterations. + /// + public string GenerateByIndex(Company company, int index) + { + var firstName = _firstNames[index % _firstNames.Length]; + var lastName = _lastNames[(index * 7) % _lastNames.Length]; // Prime multiplier for variety + return _pattern.Generate(firstName, lastName, company.Domain); + } + + /// + /// Combines deterministic index with random offset for controlled variety. + /// + public string GenerateVaried(Company company, int index) + { + var offset = _random.Next(10); + var firstName = _firstNames[(index + offset) % _firstNames.Length]; + var lastName = _lastNames[(index * 7 + offset) % _lastNames.Length]; + return _pattern.Generate(firstName, lastName, company.Domain); + } + + public string GetFirstName(int index) => _firstNames[index % _firstNames.Length]; + + public string GetLastName(int index) => _lastNames[(index * 7) % _lastNames.Length]; +} diff --git a/util/Seeder/Data/Companies.cs b/util/Seeder/Data/Companies.cs new file mode 100644 index 0000000000..d37c2f810a --- /dev/null +++ b/util/Seeder/Data/Companies.cs @@ -0,0 +1,123 @@ +using Bit.Seeder.Data.Enums; + +namespace Bit.Seeder.Data; + +internal sealed record Company( + string Domain, + string Name, + CompanyCategory Category, + CompanyType Type, + GeographicRegion Region); + +/// +/// Sample company data organized by region. Add new regions by creating arrays and including them in All. +/// +internal static class Companies +{ + public static readonly Company[] NorthAmerica = + [ + // CRM & Sales + new("salesforce.com", "Salesforce", CompanyCategory.CRM, CompanyType.Enterprise, GeographicRegion.NorthAmerica), + new("hubspot.com", "HubSpot", CompanyCategory.CRM, CompanyType.Enterprise, GeographicRegion.NorthAmerica), + // Security + new("crowdstrike.com", "CrowdStrike", CompanyCategory.Security, CompanyType.Enterprise, GeographicRegion.NorthAmerica), + new("okta.com", "Okta", CompanyCategory.Security, CompanyType.Enterprise, GeographicRegion.NorthAmerica), + // Observability & DevOps + new("datadog.com", "Datadog", CompanyCategory.DevOps, CompanyType.Enterprise, GeographicRegion.NorthAmerica), + new("splunk.com", "Splunk", CompanyCategory.Analytics, CompanyType.Enterprise, GeographicRegion.NorthAmerica), + new("pagerduty.com", "PagerDuty", CompanyCategory.ITServiceManagement, CompanyType.Enterprise, GeographicRegion.NorthAmerica), + // Cloud & Infrastructure + new("snowflake.com", "Snowflake", CompanyCategory.CloudInfrastructure, CompanyType.Enterprise, GeographicRegion.NorthAmerica), + // HR & Workforce + new("workday.com", "Workday", CompanyCategory.HRTalent, CompanyType.Enterprise, GeographicRegion.NorthAmerica), + new("servicenow.com", "ServiceNow", CompanyCategory.ITServiceManagement, CompanyType.Enterprise, GeographicRegion.NorthAmerica), + // Consumer Tech Giants + new("google.com", "Google", CompanyCategory.Productivity, CompanyType.Hybrid, GeographicRegion.NorthAmerica), + new("meta.com", "Meta", CompanyCategory.SocialMedia, CompanyType.Consumer, GeographicRegion.NorthAmerica), + new("amazon.com", "Amazon", CompanyCategory.ECommerce, CompanyType.Hybrid, GeographicRegion.NorthAmerica), + new("netflix.com", "Netflix", CompanyCategory.Streaming, CompanyType.Consumer, GeographicRegion.NorthAmerica), + // Developer Tools + new("github.com", "GitHub", CompanyCategory.Developer, CompanyType.Hybrid, GeographicRegion.NorthAmerica), + new("stripe.com", "Stripe", CompanyCategory.Financial, CompanyType.Enterprise, GeographicRegion.NorthAmerica), + // Collaboration + new("slack.com", "Slack", CompanyCategory.Collaboration, CompanyType.Enterprise, GeographicRegion.NorthAmerica), + new("zoom.us", "Zoom", CompanyCategory.Collaboration, CompanyType.Hybrid, GeographicRegion.NorthAmerica), + new("dropbox.com", "Dropbox", CompanyCategory.Productivity, CompanyType.Hybrid, GeographicRegion.NorthAmerica), + // Streaming + new("spotify.com", "Spotify", CompanyCategory.Streaming, CompanyType.Consumer, GeographicRegion.NorthAmerica) + ]; + + public static readonly Company[] Europe = + [ + // Enterprise Software + new("sap.com", "SAP", CompanyCategory.FinanceERP, CompanyType.Enterprise, GeographicRegion.Europe), + new("elastic.co", "Elastic", CompanyCategory.Analytics, CompanyType.Enterprise, GeographicRegion.Europe), + new("atlassian.com", "Atlassian", CompanyCategory.ProjectManagement, CompanyType.Enterprise, GeographicRegion.Europe), + // Fintech + new("wise.com", "Wise", CompanyCategory.Financial, CompanyType.Consumer, GeographicRegion.Europe), + new("revolut.com", "Revolut", CompanyCategory.Financial, CompanyType.Consumer, GeographicRegion.Europe), + new("klarna.com", "Klarna", CompanyCategory.Financial, CompanyType.Consumer, GeographicRegion.Europe), + new("n26.com", "N26", CompanyCategory.Financial, CompanyType.Consumer, GeographicRegion.Europe), + // Developer Tools + new("gitlab.com", "GitLab", CompanyCategory.DevOps, CompanyType.Enterprise, GeographicRegion.Europe), + new("contentful.com", "Contentful", CompanyCategory.Developer, CompanyType.Enterprise, GeographicRegion.Europe), + // Consumer Services + new("deliveroo.com", "Deliveroo", CompanyCategory.ECommerce, CompanyType.Consumer, GeographicRegion.Europe), + new("booking.com", "Booking.com", CompanyCategory.ECommerce, CompanyType.Consumer, GeographicRegion.Europe), + // Collaboration + new("miro.com", "Miro", CompanyCategory.Collaboration, CompanyType.Enterprise, GeographicRegion.Europe), + new("intercom.io", "Intercom", CompanyCategory.CRM, CompanyType.Enterprise, GeographicRegion.Europe), + // Business Software + new("sage.com", "Sage", CompanyCategory.FinanceERP, CompanyType.Enterprise, GeographicRegion.Europe), + new("adyen.com", "Adyen", CompanyCategory.Financial, CompanyType.Enterprise, GeographicRegion.Europe) + ]; + + public static readonly Company[] AsiaPacific = + [ + // Chinese Tech Giants + new("alibaba.com", "Alibaba", CompanyCategory.ECommerce, CompanyType.Hybrid, GeographicRegion.AsiaPacific), + new("tencent.com", "Tencent", CompanyCategory.SocialMedia, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("bytedance.com", "ByteDance", CompanyCategory.SocialMedia, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("wechat.com", "WeChat", CompanyCategory.SocialMedia, CompanyType.Consumer, GeographicRegion.AsiaPacific), + // Japanese Companies + new("rakuten.com", "Rakuten", CompanyCategory.ECommerce, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("line.me", "Line", CompanyCategory.SocialMedia, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("sony.com", "Sony", CompanyCategory.Streaming, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("paypay.ne.jp", "PayPay", CompanyCategory.Financial, CompanyType.Consumer, GeographicRegion.AsiaPacific), + // Korean Companies + new("samsung.com", "Samsung", CompanyCategory.Productivity, CompanyType.Consumer, GeographicRegion.AsiaPacific), + // Southeast Asian Companies + new("grab.com", "Grab", CompanyCategory.ECommerce, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("sea.com", "Sea Limited", CompanyCategory.ECommerce, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("shopee.com", "Shopee", CompanyCategory.ECommerce, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("lazada.com", "Lazada", CompanyCategory.ECommerce, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("gojek.com", "Gojek", CompanyCategory.ECommerce, CompanyType.Consumer, GeographicRegion.AsiaPacific), + // Indian Companies + new("flipkart.com", "Flipkart", CompanyCategory.ECommerce, CompanyType.Consumer, GeographicRegion.AsiaPacific) + ]; + + public static readonly Company[] All = [.. NorthAmerica, .. Europe, .. AsiaPacific]; + + public static Company[] Filter( + CompanyType? type = null, + GeographicRegion? region = null, + CompanyCategory? category = null) + { + IEnumerable result = All; + + if (type.HasValue) + { + result = result.Where(c => c.Type == type.Value); + } + if (region.HasValue) + { + result = result.Where(c => c.Region == region.Value); + } + if (category.HasValue) + { + result = result.Where(c => c.Category == category.Value); + } + + return [.. result]; + } +} diff --git a/util/Seeder/Data/Enums/CompanyCategory.cs b/util/Seeder/Data/Enums/CompanyCategory.cs new file mode 100644 index 0000000000..cee7e0c583 --- /dev/null +++ b/util/Seeder/Data/Enums/CompanyCategory.cs @@ -0,0 +1,11 @@ +namespace Bit.Seeder.Data.Enums; + +/// +/// Business category for company classification. +/// +public enum CompanyCategory +{ + SocialMedia, Streaming, ECommerce, CRM, Security, CloudInfrastructure, + DevOps, Collaboration, HRTalent, FinanceERP, Analytics, ProjectManagement, + Marketing, ITServiceManagement, Productivity, Developer, Financial +} diff --git a/util/Seeder/Data/Enums/CompanyType.cs b/util/Seeder/Data/Enums/CompanyType.cs new file mode 100644 index 0000000000..a09e060589 --- /dev/null +++ b/util/Seeder/Data/Enums/CompanyType.cs @@ -0,0 +1,6 @@ +namespace Bit.Seeder.Data.Enums; + +/// +/// Target market type for companies. +/// +public enum CompanyType { Consumer, Enterprise, Hybrid } diff --git a/util/Seeder/Data/Enums/GeographicRegion.cs b/util/Seeder/Data/Enums/GeographicRegion.cs new file mode 100644 index 0000000000..55180e7f04 --- /dev/null +++ b/util/Seeder/Data/Enums/GeographicRegion.cs @@ -0,0 +1,9 @@ +namespace Bit.Seeder.Data.Enums; + +/// +/// Geographic region for company headquarters. +/// +public enum GeographicRegion +{ + NorthAmerica, Europe, AsiaPacific, LatinAmerica, MiddleEast, Africa, Global +} diff --git a/util/Seeder/Data/Enums/OrgStructureModel.cs b/util/Seeder/Data/Enums/OrgStructureModel.cs new file mode 100644 index 0000000000..675d0e758f --- /dev/null +++ b/util/Seeder/Data/Enums/OrgStructureModel.cs @@ -0,0 +1,6 @@ +namespace Bit.Seeder.Data.Enums; + +/// +/// Organizational structure model types. +/// +public enum OrgStructureModel { Traditional, Spotify, Modern } diff --git a/util/Seeder/Data/Enums/PasswordStrength.cs b/util/Seeder/Data/Enums/PasswordStrength.cs new file mode 100644 index 0000000000..bd7f72e2b6 --- /dev/null +++ b/util/Seeder/Data/Enums/PasswordStrength.cs @@ -0,0 +1,25 @@ +namespace Bit.Seeder.Data.Enums; + +/// +/// Password strength levels aligned with zxcvbn scoring (0-4). +/// +public enum PasswordStrength +{ + /// Score 0: Too guessable (< 10³ guesses) + VeryWeak = 0, + + /// Score 1: Very guessable (< 10⁶ guesses) + Weak = 1, + + /// Score 2: Somewhat guessable (< 10⁸ guesses) + Fair = 2, + + /// Score 3: Safely unguessable (< 10¹⁰ guesses) + Strong = 3, + + /// Score 4: Very unguessable (≥ 10¹⁰ guesses) + VeryStrong = 4, + + /// Realistic distribution based on breach data statistics. + Realistic = 99 +} diff --git a/util/Seeder/Data/Enums/UsernamePatternType.cs b/util/Seeder/Data/Enums/UsernamePatternType.cs new file mode 100644 index 0000000000..2c8083ca9d --- /dev/null +++ b/util/Seeder/Data/Enums/UsernamePatternType.cs @@ -0,0 +1,20 @@ +namespace Bit.Seeder.Data.Enums; + +/// +/// Username/email format patterns used by organizations. +/// +public enum UsernamePatternType +{ + /// first.last@domain.com + FirstDotLast, + /// f.last@domain.com + FDotLast, + /// flast@domain.com + FLast, + /// last.first@domain.com + LastDotFirst, + /// first_last@domain.com + First_Last, + /// lastf@domain.com + LastFirst +} diff --git a/util/Seeder/Data/FolderNameGenerator.cs b/util/Seeder/Data/FolderNameGenerator.cs new file mode 100644 index 0000000000..173fae3116 --- /dev/null +++ b/util/Seeder/Data/FolderNameGenerator.cs @@ -0,0 +1,31 @@ +using Bogus; + +namespace Bit.Seeder.Data; + +/// +/// Generates deterministic folder names using Bogus Commerce.Department(). +/// Pre-generates a pool of business-themed names for consistent index-based access. +/// +internal sealed class FolderNameGenerator +{ + private const int _namePoolSize = 50; + + private readonly string[] _folderNames; + + public FolderNameGenerator(int seed) + { + var faker = new Faker { Random = new Randomizer(seed) }; + + // Pre-generate business department names for determinism + // Examples: "Automotive", "Home & Garden", "Sports", "Electronics", "Beauty" + _folderNames = Enumerable.Range(0, _namePoolSize) + .Select(_ => faker.Commerce.Department()) + .Distinct() + .ToArray(); + } + + /// + /// Gets a folder name by index, wrapping around if index exceeds pool size. + /// + public string GetFolderName(int index) => _folderNames[index % _folderNames.Length]; +} diff --git a/util/Seeder/Data/OrgStructures.cs b/util/Seeder/Data/OrgStructures.cs new file mode 100644 index 0000000000..668653cd37 --- /dev/null +++ b/util/Seeder/Data/OrgStructures.cs @@ -0,0 +1,84 @@ +using Bit.Seeder.Data.Enums; + +namespace Bit.Seeder.Data; + +internal sealed record OrgUnit(string Name, string[]? SubUnits = null); + +internal sealed record OrgStructure(OrgStructureModel Model, OrgUnit[] Units); + +/// +/// Pre-defined organizational structures for different company models. +/// +internal static class OrgStructures +{ + public static readonly OrgStructure Traditional = new(OrgStructureModel.Traditional, + [ + new("Executive", ["CEO Office", "Strategy", "Board Relations"]), + new("Finance", ["Accounting", "FP&A", "Treasury", "Tax", "Audit"]), + new("Human Resources", ["Recruiting", "Benefits", "Training", "Employee Relations", "Compensation"]), + new("Information Technology", ["Infrastructure", "Security", "Support", "Enterprise Apps", "Network"]), + new("Marketing", ["Brand", "Digital Marketing", "Content", "Events", "PR"]), + new("Sales", ["Enterprise Sales", "SMB Sales", "Sales Operations", "Account Management", "Inside Sales"]), + new("Operations", ["Facilities", "Procurement", "Supply Chain", "Quality", "Business Operations"]), + new("Research & Development", ["Product Development", "Innovation", "Research", "Prototyping"]), + new("Legal", ["Corporate Legal", "Compliance", "Contracts", "IP", "Privacy"]), + new("Customer Success", ["Onboarding", "Support", "Customer Education", "Renewals"]), + new("Engineering", ["Backend", "Frontend", "Mobile", "QA", "DevOps", "Platform"]), + new("Product", ["Product Management", "UX Design", "User Research", "Product Analytics"]) + ]); + + public static readonly OrgStructure Spotify = new(OrgStructureModel.Spotify, + [ + // Tribes + new("Payments Tribe", ["Checkout Squad", "Fraud Prevention Squad", "Billing Squad", "Payment Methods Squad"]), + new("Growth Tribe", ["Acquisition Squad", "Activation Squad", "Retention Squad", "Monetization Squad"]), + new("Platform Tribe", ["API Squad", "Infrastructure Squad", "Data Platform Squad", "Developer Tools Squad"]), + new("Experience Tribe", ["Web App Squad", "Mobile Squad", "Desktop Squad", "Accessibility Squad"]), + // Chapters + new("Backend Chapter", ["Java Developers", "Go Developers", "Python Developers", "Database Specialists"]), + new("Frontend Chapter", ["React Developers", "TypeScript Specialists", "Performance Engineers", "UI Engineers"]), + new("QA Chapter", ["Test Automation", "Manual Testing", "Performance Testing", "Security Testing"]), + new("Design Chapter", ["Product Designers", "UX Researchers", "Visual Designers", "Design Systems"]), + new("Data Science Chapter", ["ML Engineers", "Data Analysts", "Data Engineers", "AI Researchers"]), + // Guilds + new("Security Guild"), + new("Innovation Guild"), + new("Architecture Guild"), + new("Accessibility Guild"), + new("Developer Experience Guild") + ]); + + public static readonly OrgStructure Modern = new(OrgStructureModel.Modern, + [ + // Feature Teams + new("Auth Team", ["Identity", "SSO", "MFA", "Passwordless"]), + new("Search Team", ["Indexing", "Ranking", "Query Processing", "Search UX"]), + new("Notifications Team", ["Email", "Push", "In-App", "Preferences"]), + new("Analytics Team", ["Tracking", "Dashboards", "Reporting", "Data Pipeline"]), + new("Integrations Team", ["API Gateway", "Webhooks", "Third-Party Apps", "Marketplace"]), + // Platform Teams + new("Developer Experience", ["SDK", "Documentation", "Developer Portal", "API Design"]), + new("Data Platform", ["Data Lake", "ETL", "Data Governance", "Real-Time Processing"]), + new("ML Platform", ["Model Training", "Model Serving", "Feature Store", "MLOps"]), + new("Security Platform", ["AppSec", "Infrastructure Security", "Security Tooling", "Compliance"]), + new("Infrastructure Platform", ["Cloud", "Kubernetes", "Observability", "CI/CD"]), + // Pods + new("AI Assistant Pod", ["LLM Integration", "Prompt Engineering", "AI UX", "AI Safety"]), + new("Performance Pod", ["Frontend Performance", "Backend Performance", "Database Optimization"]), + new("Compliance Pod", ["SOC 2", "GDPR", "HIPAA", "Audit"]), + new("Migration Pod", ["Legacy Systems", "Data Migration", "Cutover Planning"]), + // Enablers + new("Architecture", ["Technical Strategy", "System Design", "Tech Debt"]), + new("Quality", ["Testing Strategy", "Release Quality", "Production Health"]) + ]); + + public static readonly OrgStructure[] All = [Traditional, Spotify, Modern]; + + public static OrgStructure GetStructure(OrgStructureModel model) => model switch + { + OrgStructureModel.Traditional => Traditional, + OrgStructureModel.Spotify => Spotify, + OrgStructureModel.Modern => Modern, + _ => Traditional + }; +} diff --git a/util/Seeder/Data/Passwords.cs b/util/Seeder/Data/Passwords.cs new file mode 100644 index 0000000000..1717c2b408 --- /dev/null +++ b/util/Seeder/Data/Passwords.cs @@ -0,0 +1,148 @@ +using Bit.Seeder.Data.Enums; + +namespace Bit.Seeder.Data; + +/// +/// Password collections by zxcvbn strength level (0-4) for realistic test data. +/// +internal static class Passwords +{ + /// + /// Score 0 - Too guessable: keyboard walks, simple sequences, single words. + /// + public static readonly string[] VeryWeak = + [ + "password", "123456", "qwerty", "abc123", "letmein", + "admin", "welcome", "monkey", "dragon", "master", + "111111", "baseball", "iloveyou", "trustno1", "sunshine", + "princess", "football", "shadow", "superman", "michael", + "password1", "123456789", "12345678", "1234567", "12345", + "qwerty123", "1q2w3e4r", "123123", "000000", "654321" + ]; + + /// + /// Score 1 - Very guessable: common patterns with minor complexity. + /// + public static readonly string[] Weak = + [ + "Password1", "Qwerty123", "Welcome1", "Admin123", "Letmein1", + "Dragon123", "Master123", "Shadow123", "Michael1", "Jennifer1", + "abc123!", "pass123!", "test1234", "hello123", "love1234", + "money123", "secret1", "access1", "login123", "super123", + "changeme", "temp1234", "guest123", "user1234", "pass1234", + "default1", "sample12", "demo1234", "trial123", "secure1" + ]; + + /// + /// Score 2 - Somewhat guessable: meets basic complexity but predictable patterns. + /// + public static readonly string[] Fair = + [ + "Summer2024!", "Winter2023#", "Spring2024@", "Autumn2023$", "January2024!", + "Welcome123!", "Company2024#", "Secure123!", "Access2024@", "Login2024!", + "Michael123!", "Jennifer2024@", "Robert456#", "Sarah789!", "David2024!", + "Password123!", "Security2024@", "Admin2024!", "User2024#", "Guest123!", + "Football123!", "Baseball2024@", "Soccer456#", "Hockey789!", "Tennis2024!", + "NewYork2024!", "Chicago123@", "Boston2024#", "Seattle789!", "Denver2024$" + ]; + + /// + /// Score 3 - Safely unguessable: good entropy, mixed character types. + /// + public static readonly string[] Strong = + [ + "k#9Lm$vQ2@xR7nP!", "Yx8&mK3$pL5#wQ9@", "Nv4%jH7!bT2@sF6#", + "Rm9#cX5$gW1@zK8!", "Qp3@hY6#nL9$tB2!", "Wz7!mF4@kS8#xC1$", + "Jd2#pR9!vN5@bG7$", "Ht6@wL3#yK8!mQ4$", "Bf8$cM2@zT5#rX9!", + "Lg1!nV7@sH4#pY6$", "Xk5#tW8@jR2$mN9!", "Cv3@yB6#pF1$qL4!", + "correct-horse-battery", "purple-monkey-dishwasher", "quantum-bicycle-elephant", + "velvet-thunder-crystal", "neon-wizard-cosmic", "amber-phoenix-digital", + "Brave.Tiger.Runs.42", "Blue.Ocean.Deep.17", "Swift.Eagle.Soars.93", + "maple#stream#winter", "ember@cloud@silent", "frost$dawn$valley" + ]; + + /// + /// Score 4 - Very unguessable: high entropy, long passphrases, random strings. + /// + public static readonly string[] VeryStrong = + [ + "Kx9#mL4$pQ7@wR2!vN5hT8", "Yz3@hT8#bF1$cS6!nM9wK4", "Wv5!rK2@jG9#tX4$mL7nB3", + "Qn7$sB3@yH6#pC1!zF8kW2", "Tm2@xD5#kW9$vL4!rJ7gN1", "Pf4!nC8@bR3#yL6$hS9mV2", + "correct-horse-battery-staple", "purple-monkey-dishwasher-lamp", "quantum-bicycle-elephant-storm", + "velvet-thunder-crystal-forge", "neon-wizard-cosmic-river", "amber-phoenix-digital-maze", + "silver-falcon-ancient-code", "lunar-garden-frozen-spark", "echo-prism-wandering-light", + "Brave.Tiger.Runs.Fast.42!", "Blue.Ocean.Deep.Wave.17@", "Swift.Eagle.Soars.High.93#", + "maple#stream#winter#glow#dawn", "ember@cloud@silent@peak@mist", "frost$dawn$valley$mist$glow", + "7hK$mN2@pL9#xR4!wQ8vB5&jF", "3yT@nC7#bS1$kW6!mH9rL2%xD", "9pF!vK4@jR8#tN3$yB7mL1&wS" + ]; + + /// All passwords combined for mixed/random selection. + public static readonly string[] All = [.. VeryWeak, .. Weak, .. Fair, .. Strong, .. VeryStrong]; + + /// + /// Realistic distribution based on breach data and security research. + /// Sources: NordPass annual reports, Have I Been Pwned analysis, academic studies. + /// Distribution: 25% VeryWeak, 30% Weak, 25% Fair, 15% Strong, 5% VeryStrong + /// + private static readonly (PasswordStrength Strength, int CumulativePercent)[] RealisticDistribution = + [ + (PasswordStrength.VeryWeak, 25), // 25% - most common breached passwords + (PasswordStrength.Weak, 55), // 30% - simple patterns with numbers + (PasswordStrength.Fair, 80), // 25% - meets basic requirements + (PasswordStrength.Strong, 95), // 15% - good passwords + (PasswordStrength.VeryStrong, 100) // 5% - password manager users + ]; + + public static string[] GetByStrength(PasswordStrength strength) => strength switch + { + PasswordStrength.VeryWeak => VeryWeak, + PasswordStrength.Weak => Weak, + PasswordStrength.Fair => Fair, + PasswordStrength.Strong => Strong, + PasswordStrength.VeryStrong => VeryStrong, + PasswordStrength.Realistic => All, // For direct array access, use All + _ => Strong + }; + + /// + /// Gets a password with realistic strength distribution. + /// Uses deterministic selection based on index for reproducible test data. + /// + public static string GetRealisticPassword(int index) + { + var strength = GetRealisticStrength(index); + var passwords = GetByStrength(strength); + return passwords[index % passwords.Length]; + } + + /// + /// Gets a password strength following realistic distribution. + /// Deterministic based on index for reproducible results. + /// + public static PasswordStrength GetRealisticStrength(int index) + { + // Use modulo 100 for percentage-based bucket selection + var bucket = index % 100; + + foreach (var (strength, cumulativePercent) in RealisticDistribution) + { + if (bucket < cumulativePercent) + { + return strength; + } + } + + return PasswordStrength.Strong; // Fallback + } + + public static string GetPassword(PasswordStrength strength, int index) + { + if (strength == PasswordStrength.Realistic) + { + return GetRealisticPassword(index); + } + + var passwords = GetByStrength(strength); + return passwords[index % passwords.Length]; + } +} diff --git a/util/Seeder/Data/README.md b/util/Seeder/Data/README.md new file mode 100644 index 0000000000..7c16242a0c --- /dev/null +++ b/util/Seeder/Data/README.md @@ -0,0 +1,144 @@ +# Seeder Data System + +Structured data generation for realistic vault seeding. Designed for extensibility and spec-driven generation. + +## Architecture + +Foundation layer for all cipher generation—data and patterns that future cipher types build upon. + +- **Enums are the API.** Configure via `CompanyType`, `PasswordStrength`, etc. Everything else is internal. +- **Composable by region.** Arrays aggregate with `[.. UsNames, .. EuropeanNames]`. New region = new array + one line change. +- **Deterministic.** Seeded randomness means same org ID → same test data → reproducible debugging. +- **Filterable.** `Companies.Filter(type, region, category)` for targeted data selection. + +--- + +## Current Capabilities + +### Login Ciphers + +- 50 real companies across 3 regions with metadata (category, type, domain) +- 200 first names + 200 last names (US, European) +- 6 username patterns (corporate email conventions) +- 3 password strength levels (95 total passwords) + +### Organizational Structures + +- Traditional (departments + sub-units) +- Spotify Model (tribes, squads, chapters, guilds) +- Modern/AI-First (feature teams, platform teams, pods) + +--- + +## Roadmap + +### Phase 1: Additional Cipher Types + +| Cipher Type | Data Needed | Status | +| ----------- | ---------------------------------------------------- | ----------- | +| Login | Companies, Names, Passwords, Patterns | ✅ Complete | +| Card | Card networks, bank names, realistic numbers | ⬜ Planned | +| Identity | Full identity profiles (name, address, SSN patterns) | ⬜ Planned | +| SecureNote | Note templates, categories, content generators | ⬜ Planned | + +### Phase 2: Spec-Driven Generation + +Import a specification file and generate a complete vault to match: + +```yaml +# Example: organization-spec.yaml +organization: + name: "Acme Corp" + users: 500 + +collections: + structure: spotify # Use Spotify org model + +ciphers: + logins: + count: 2000 + companies: + type: enterprise + region: north_america + passwords: mixed # Realistic distribution + username_pattern: first_dot_last + + cards: + count: 100 + networks: [visa, mastercard, amex] + + identities: + count: 200 + regions: [us, europe] + + secure_notes: + count: 300 + categories: [api_keys, licenses, documentation] +``` + +**Spec Engine Components (Future)** + +- `SpecParser` - YAML/JSON spec file parsing +- `SpecValidator` - Schema validation +- `SpecExecutor` - Orchestrates generation from spec +- `ProgressReporter` - Real-time generation progress + +### Phase 3: Data Enhancements + +| Enhancement | Description | +| ----------------------- | ---------------------------------------------------- | +| **Additional Regions** | LatinAmerica, MiddleEast, Africa companies and names | +| **Industry Verticals** | Healthcare, Finance, Government-specific companies | +| **Localized Passwords** | Region-specific common passwords | +| **Custom Fields** | Field templates per cipher type | +| **TOTP Seeds** | Realistic 2FA seed generation | +| **Attachments** | File attachment simulation | +| **Password History** | Historical password entries | + +### Phase 4: Advanced Features + +- **Relationship Graphs** - Ciphers that reference each other (SSO relationships) +- **Temporal Data** - Realistic created/modified timestamps over time +- **Access Patterns** - Simulate realistic collection/group membership distributions +- **Breach Simulation** - Mark specific passwords as "exposed" for security testing + +--- + +## Adding New Data + +### New Region (e.g., Swedish Names) + +```csharp +// In Names.cs - add array +public static readonly string[] SwedishFirstNames = ["Erik", "Lars", "Anna", ...]; +public static readonly string[] SwedishLastNames = ["Andersson", "Johansson", ...]; + +// Update aggregates +public static readonly string[] AllFirstNames = [.. UsFirstNames, .. EuropeanFirstNames, .. SwedishFirstNames]; +public static readonly string[] AllLastNames = [.. UsLastNames, .. EuropeanLastNames, .. SwedishLastNames]; +``` + +### New Company Category + +```csharp +// In Enums/CompanyCategory.cs +public enum CompanyCategory +{ + // ... existing ... + Healthcare, // Add new category + Government +} + +// In Companies.cs - add companies with new category +new("epic.com", "Epic Systems", CompanyCategory.Healthcare, CompanyType.Enterprise, GeographicRegion.NorthAmerica), +``` + +### New Password Pattern + +```csharp +// In Passwords.cs - add to appropriate strength array +// Strong array - add new passphrase style +"correct-horse-battery-staple", // Diceware +"Brave.Tiger.Runs.Fast.42", // Mixed case with numbers +"maple#stream#winter#glow", // Symbol-separated (new) +``` diff --git a/util/Seeder/Data/UsernamePatterns.cs b/util/Seeder/Data/UsernamePatterns.cs new file mode 100644 index 0000000000..c435cacd93 --- /dev/null +++ b/util/Seeder/Data/UsernamePatterns.cs @@ -0,0 +1,57 @@ +using Bit.Seeder.Data.Enums; + +namespace Bit.Seeder.Data; + +internal sealed record UsernamePattern( + UsernamePatternType Type, + string FormatDescription, + Func Generate); + +/// +/// Username pattern implementations for different email conventions. +/// +internal static class UsernamePatterns +{ + public static readonly UsernamePattern FirstDotLast = new( + UsernamePatternType.FirstDotLast, + "first.last@domain", + (first, last, domain) => $"{first.ToLowerInvariant()}.{last.ToLowerInvariant()}@{domain}"); + + public static readonly UsernamePattern FDotLast = new( + UsernamePatternType.FDotLast, + "f.last@domain", + (first, last, domain) => $"{char.ToLowerInvariant(first[0])}.{last.ToLowerInvariant()}@{domain}"); + + public static readonly UsernamePattern FLast = new( + UsernamePatternType.FLast, + "flast@domain", + (first, last, domain) => $"{char.ToLowerInvariant(first[0])}{last.ToLowerInvariant()}@{domain}"); + + public static readonly UsernamePattern LastDotFirst = new( + UsernamePatternType.LastDotFirst, + "last.first@domain", + (first, last, domain) => $"{last.ToLowerInvariant()}.{first.ToLowerInvariant()}@{domain}"); + + public static readonly UsernamePattern First_Last = new( + UsernamePatternType.First_Last, + "first_last@domain", + (first, last, domain) => $"{first.ToLowerInvariant()}_{last.ToLowerInvariant()}@{domain}"); + + public static readonly UsernamePattern LastFirst = new( + UsernamePatternType.LastFirst, + "lastf@domain", + (first, last, domain) => $"{last.ToLowerInvariant()}{char.ToLowerInvariant(first[0])}@{domain}"); + + public static readonly UsernamePattern[] All = [FirstDotLast, FDotLast, FLast, LastDotFirst, First_Last, LastFirst]; + + public static UsernamePattern GetPattern(UsernamePatternType type) => type switch + { + UsernamePatternType.FirstDotLast => FirstDotLast, + UsernamePatternType.FDotLast => FDotLast, + UsernamePatternType.FLast => FLast, + UsernamePatternType.LastDotFirst => LastDotFirst, + UsernamePatternType.First_Last => First_Last, + UsernamePatternType.LastFirst => LastFirst, + _ => FirstDotLast + }; +} diff --git a/util/Seeder/Factories/CipherSeeder.cs b/util/Seeder/Factories/CipherSeeder.cs new file mode 100644 index 0000000000..9d4c039b2c --- /dev/null +++ b/util/Seeder/Factories/CipherSeeder.cs @@ -0,0 +1,146 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Bit.Core.Enums; +using Bit.Core.Utilities; +using Bit.Core.Vault.Entities; +using Bit.Core.Vault.Enums; +using Bit.Core.Vault.Models.Data; +using Bit.RustSDK; +using Bit.Seeder.Models; + +namespace Bit.Seeder.Factories; + +/// +/// Creates encrypted ciphers for seeding vaults via the Rust SDK. +/// +/// +/// Supported cipher types: +/// +/// Login - +/// +/// Future: Card, Identity, SecureNote will follow the same pattern—public Create method + private Transform method. +/// +public class CipherSeeder +{ + private static readonly JsonSerializerOptions SdkJsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + private static readonly JsonSerializerOptions ServerJsonOptions = new() + { + PropertyNamingPolicy = null, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + public static Cipher CreateOrganizationLoginCipher( + Guid organizationId, + string orgKeyBase64, + string name, + string? username = null, + string? password = null, + string? uri = null, + string? notes = null) + { + var cipherView = new CipherViewDto + { + OrganizationId = organizationId, + Name = name, + Notes = notes, + Type = CipherTypes.Login, + Login = new LoginViewDto + { + Username = username, + Password = password, + Uris = string.IsNullOrEmpty(uri) ? null : [new LoginUriViewDto { Uri = uri }] + } + }; + + return EncryptAndTransform(cipherView, orgKeyBase64, organizationId); + } + + public static Cipher CreateOrganizationLoginCipherWithFields( + Guid organizationId, + string orgKeyBase64, + string name, + string? username, + string? password, + string? uri, + IEnumerable<(string name, string value, int type)> fields) + { + var cipherView = new CipherViewDto + { + OrganizationId = organizationId, + Name = name, + Type = CipherTypes.Login, + Login = new LoginViewDto + { + Username = username, + Password = password, + Uris = string.IsNullOrEmpty(uri) ? null : [new LoginUriViewDto { Uri = uri }] + }, + Fields = fields.Select(f => new FieldViewDto + { + Name = f.name, + Value = f.value, + Type = f.type + }).ToList() + }; + + return EncryptAndTransform(cipherView, orgKeyBase64, organizationId); + } + + private static Cipher EncryptAndTransform(CipherViewDto cipherView, string keyBase64, Guid organizationId) + { + var viewJson = JsonSerializer.Serialize(cipherView, SdkJsonOptions); + var encryptedJson = RustSdkService.EncryptCipher(viewJson, keyBase64); + + var encryptedDto = JsonSerializer.Deserialize(encryptedJson, SdkJsonOptions) + ?? throw new InvalidOperationException("Failed to parse encrypted cipher"); + + return TransformLoginToServerCipher(encryptedDto, organizationId); + } + + private static Cipher TransformLoginToServerCipher(EncryptedCipherDto encrypted, Guid organizationId) + { + var loginData = new CipherLoginData + { + Name = encrypted.Name, + Notes = encrypted.Notes, + Username = encrypted.Login?.Username, + Password = encrypted.Login?.Password, + Totp = encrypted.Login?.Totp, + PasswordRevisionDate = encrypted.Login?.PasswordRevisionDate, + Uris = encrypted.Login?.Uris?.Select(u => new CipherLoginData.CipherLoginUriData + { + Uri = u.Uri, + UriChecksum = u.UriChecksum, + Match = u.Match.HasValue ? (UriMatchType?)u.Match : null + }), + Fields = encrypted.Fields?.Select(f => new CipherFieldData + { + Name = f.Name, + Value = f.Value, + Type = (FieldType)f.Type, + LinkedId = f.LinkedId + }) + }; + + var dataJson = JsonSerializer.Serialize(loginData, ServerJsonOptions); + + return new Cipher + { + Id = CoreHelpers.GenerateComb(), + OrganizationId = organizationId, + UserId = null, + Type = CipherType.Login, + Data = dataJson, + Key = encrypted.Key, + Reprompt = (CipherRepromptType?)encrypted.Reprompt, + CreationDate = DateTime.UtcNow, + RevisionDate = DateTime.UtcNow + }; + } +} + diff --git a/util/Seeder/Factories/CollectionSeeder.cs b/util/Seeder/Factories/CollectionSeeder.cs new file mode 100644 index 0000000000..231fe86b43 --- /dev/null +++ b/util/Seeder/Factories/CollectionSeeder.cs @@ -0,0 +1,36 @@ +using Bit.Core.Entities; +using Bit.RustSDK; + +namespace Bit.Seeder.Factories; + +public class CollectionSeeder +{ + public static Collection CreateCollection(Guid organizationId, string orgKey, string name) + { + return new Collection + { + Id = Guid.NewGuid(), + OrganizationId = organizationId, + Name = RustSdkService.EncryptString(name, orgKey), + CreationDate = DateTime.UtcNow, + RevisionDate = DateTime.UtcNow + }; + } + + public static CollectionUser CreateCollectionUser( + Guid collectionId, + Guid organizationUserId, + bool readOnly = false, + bool hidePasswords = false, + bool manage = false) + { + return new CollectionUser + { + CollectionId = collectionId, + OrganizationUserId = organizationUserId, + ReadOnly = readOnly, + HidePasswords = hidePasswords, + Manage = manage + }; + } +} diff --git a/util/Seeder/Factories/FolderSeeder.cs b/util/Seeder/Factories/FolderSeeder.cs new file mode 100644 index 0000000000..8cf7413bbc --- /dev/null +++ b/util/Seeder/Factories/FolderSeeder.cs @@ -0,0 +1,28 @@ +using Bit.Core.Utilities; +using Bit.Core.Vault.Entities; +using Bit.RustSDK; + +namespace Bit.Seeder.Factories; + +/// +/// Factory for creating Folder entities with encrypted names. +/// Folders are per-user constructs encrypted with the user's symmetric key. +/// +internal sealed class FolderSeeder +{ + /// + /// Creates a folder with an encrypted name. + /// + /// The user who owns this folder. + /// The user's symmetric key (not org key). + /// The plaintext folder name to encrypt. + public static Folder CreateFolder(Guid userId, string userKeyBase64, string name) + { + return new Folder + { + Id = CoreHelpers.GenerateComb(), + UserId = userId, + Name = RustSdkService.EncryptString(name, userKeyBase64) + }; + } +} diff --git a/util/Seeder/Factories/GroupSeeder.cs b/util/Seeder/Factories/GroupSeeder.cs new file mode 100644 index 0000000000..7ee7df9484 --- /dev/null +++ b/util/Seeder/Factories/GroupSeeder.cs @@ -0,0 +1,41 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Utilities; + +namespace Bit.Seeder.Factories; + +/// +/// Creates groups and group-user relationships for seeding. +/// +public static class GroupSeeder +{ + /// + /// Creates a group entity for an organization. + /// + /// The organization ID. + /// The group name. + /// A new Group entity (not persisted). + public static Group CreateGroup(Guid organizationId, string name) + { + return new Group + { + Id = CoreHelpers.GenerateComb(), + OrganizationId = organizationId, + Name = name + }; + } + + /// + /// Creates a group-user relationship entity. + /// + /// The group ID. + /// The organization user ID. + /// A new GroupUser entity (not persisted). + public static GroupUser CreateGroupUser(Guid groupId, Guid organizationUserId) + { + return new GroupUser + { + GroupId = groupId, + OrganizationUserId = organizationUserId + }; + } +} diff --git a/util/Seeder/Factories/OrganizationDomainSeeder.cs b/util/Seeder/Factories/OrganizationDomainSeeder.cs new file mode 100644 index 0000000000..2bc41f8514 --- /dev/null +++ b/util/Seeder/Factories/OrganizationDomainSeeder.cs @@ -0,0 +1,32 @@ +using Bit.Infrastructure.EntityFramework.Models; + +namespace Bit.Seeder.Factories; + +/// +/// Creates organization domain entities for seeding. +/// +public static class OrganizationDomainSeeder +{ + /// + /// Creates a verified organization domain entity. + /// + /// The organization ID. + /// The domain name (e.g., "example.com"). + /// A new verified OrganizationDomain entity (not persisted). + public static OrganizationDomain CreateVerifiedDomain(Guid organizationId, string domainName) + { + var domain = new OrganizationDomain + { + Id = Guid.NewGuid(), + OrganizationId = organizationId, + DomainName = domainName, + Txt = Guid.NewGuid().ToString("N"), + CreationDate = DateTime.UtcNow, + }; + + domain.SetVerifiedDate(); + domain.SetLastCheckedDate(); + + return domain; + } +} diff --git a/util/Seeder/Factories/OrganizationSeeder.cs b/util/Seeder/Factories/OrganizationSeeder.cs index 3aac87d400..30b790c343 100644 --- a/util/Seeder/Factories/OrganizationSeeder.cs +++ b/util/Seeder/Factories/OrganizationSeeder.cs @@ -1,13 +1,13 @@ -using Bit.Core.Billing.Enums; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Enums; using Bit.Core.Entities; using Bit.Core.Enums; -using Bit.Infrastructure.EntityFramework.AdminConsole.Models; namespace Bit.Seeder.Factories; public class OrganizationSeeder { - public static Organization CreateEnterprise(string name, string domain, int seats) + public static Organization CreateEnterprise(string name, string domain, int seats, string? publicKey = null, string? privateKey = null) { return new Organization { @@ -39,52 +39,38 @@ public class OrganizationSeeder UseAdminSponsoredFamilies = true, SyncSeats = true, Status = OrganizationStatusType.Created, - //GatewayCustomerId = "example-customer-id", - //GatewaySubscriptionId = "example-subscription-id", MaxStorageGb = 10, - // Currently hardcoded to the values from https://github.com/bitwarden/sdk-internal/blob/main/crates/bitwarden-core/src/client/test_accounts.rs. - // TODO: These should be dynamically generated by the SDK. - PublicKey = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAmIJbGMk6eZqVE7UxhZ46Weu2jKciqOiOkSVYtGvs61rfe9AXxtLaaZEKN4d4DmkZcF6dna2eXNxZmb7U4pwlttye8ksqISe6IUAZQox7auBpjopdCEPhKRg3BD/u8ks9UxSxgWe+fpebjt6gd5hsl1/5HOObn7SeU6EEU04cp3/eH7a4OTdXxB8oN62HGV9kM/ubM1goILgjoSJDbihMK0eb7b8hPHwcA/YOgKKiu/N3FighccdSMD5Pk+HfjacsFNZQa2EsqW09IvvSZ+iL6HQeZ1vwc/6TO1J7EOfJZFQcjoEL9LVI693efYoMZSmrPEWziZ4PvwpOOGo6OObyMQIDAQAB", - PrivateKey = "2.6FggyKVyaKQsfohi5yqgbg==|UU2JeafOB41L5UscGmf4kq15JGDf3Bkf67KECiehTODzbWctVLTgyDk0Qco8/6CMN6nZGXjxR2A4r5ExhmwRNsNxd77G+MprkmiJz+7w33ROZ1ouQO5XjD3wbQ3ssqNiTKId6yAUPBvuAZRixVApauTuADc8QWGixqCQcqZzmU7YSBBIPf652/AEYr4Tk64YihoE39pHiK8MRbTLdRt3EF4LSMugPAPM24vCgUv3w1TD3Fj6sDg/6oi3flOV9SJZX4vCiUXbDNEuD/p2aQrEXVbaxweFOHjTe7F4iawjXw3nG3SO8rUBHcxbhDDVx5rjYactbW5QvHWiyla6uLb6o8WHBneg2EjTEwAHOZE/rBjcqmAJb2sVp1E0Kwq8ycGmL69vmqJPC1GqVTohAQvmEkaxIPpfq24Yb9ZPrADA7iEXBKuAQ1FphFUVgJBJGJbd60sOV1Rz1T+gUwS4wCNQ4l3LG1S22+wzUVlEku5DXFnT932tatqTyWEthqPqLCt6dL1+qa94XLpeHagXAx2VGe8n8IlcADtxqS+l8xQ4heT12WO9kC316vqvg1mnsI56faup9hb3eT9ZpKyxSBGYOphlTWfV1Y/v64f5PYvTo4aL0IYHyLY/9Qi72vFmOpPeHBYgD5t3j+H2CsiU1PkYsBggOmD7xW8FDuT6HWVvwhEJqeibVPK0Lhyj6tgvlSIAvFUaSMFPlmwFNmwfj/AHUhr9KuTfsBFTZ10yy9TZVgf+EofwnrxHBaWUgdD40aHoY1VjfG33iEuajb6buxG3pYFyPNhJNzeLZisUKIDRMQpUHrsE22EyrFFran3tZGdtcyIEK4Q1F0ULYzJ6T9iY25/ZgPy3pEAAMZCtqo3s+GjX295fWIHfMcnjMgNUHPjExjWBHa+ggK9iQXkFpBVyYB1ga/+0eiIhiek3PlgtvpDrqF7TsLK+ROiBw2GJ7uaO3EEXOj2GpNBuEJ5CdodhZkwzhwMcSatgDHkUuNVu0iVbF6/MxVdOxWXKO+jCYM6PZk/vAhLYqpPzu2T2Uyz4nkDs2Tiq61ez6FoCrzdHIiyIxVTzUQH8G9FgSmtaZ7GCbqlhnurYgcMciwPzxg0hpAQT+NZw1tVEii9vFSpJJbGJqNhORKfKh/Mu1P/9LOQq7Y0P2FIR3x/eUVEQ7CGv2jVtO5ryGSmKeq/P9Fr54wTPaNiqN2K+leACUznCdUWw8kZo/AsBcrOe4OkRX6k8LC3oeJXy06DEToatxEvPYemUauhxiXRw8nfNMqc4LyJq2bbT0zCgJHoqpozPdNg6AYWcoIobgAGu7ZQGq+oE1MT3GZxotMPe/NUJiAc5YE9Thb5Yf3gyno71pyqPTVl/6IQuh4SUz7rkgwF/aVHEnr4aUYNoc0PEzd2Me0jElsA3GAneq1I/wngutOWgTViTK4Nptr5uIzMVQs9H1rOMJNorP8b02t1NDu010rSsib9GaaJJq4r4iy46laQOxWoU0ex26arYnk+jw4833WSCTVBIprTgizZ+fKjoY0xwXvI2oOvGNEUCtGFvKFORTaQrlaXZIg1toa2BBVNicyONbwnI3KIu3MgGJ2SlCVXJn8oHFppVHFCdwgN1uDzGiKAhjvr0sZTUtXin2f2CszPTbbo=|fUhbVKrr8CSKE7TZJneXpDGraj5YhRrq9ESo206S+BY=", + PublicKey = publicKey, + PrivateKey = privateKey }; } } -public static class OrgnaizationExtensions +public static class OrganizationExtensions { /// - /// Creates an OrganizationUser with fields populated based on status. - /// For Invited status, only user.Email is used. For other statuses, user.Id is used. + /// Creates an OrganizationUser with a dynamically provided encrypted org key. + /// The encryptedOrgKey should be generated using sdkService.GenerateUserOrganizationKey(). /// - public static OrganizationUser CreateOrganizationUser( - this Organization organization, User user, OrganizationUserType type, OrganizationUserStatusType status) + public static OrganizationUser CreateOrganizationUserWithKey( + this Organization organization, + User user, + OrganizationUserType type, + OrganizationUserStatusType status, + string? encryptedOrgKey) { - var isInvited = status == OrganizationUserStatusType.Invited; - var isConfirmed = status == OrganizationUserStatusType.Confirmed || status == OrganizationUserStatusType.Revoked; + var shouldLinkUserId = status != OrganizationUserStatusType.Invited; + var shouldIncludeKey = status == OrganizationUserStatusType.Confirmed || status == OrganizationUserStatusType.Revoked; return new OrganizationUser { Id = Guid.NewGuid(), OrganizationId = organization.Id, - UserId = isInvited ? null : user.Id, - Email = isInvited ? user.Email : null, - Key = isConfirmed ? "4.rY01mZFXHOsBAg5Fq4gyXuklWfm6mQASm42DJpx05a+e2mmp+P5W6r54WU2hlREX0uoTxyP91bKKwickSPdCQQ58J45LXHdr9t2uzOYyjVzpzebFcdMw1eElR9W2DW8wEk9+mvtWvKwu7yTebzND+46y1nRMoFydi5zPVLSlJEf81qZZ4Uh1UUMLwXz+NRWfixnGXgq2wRq1bH0n3mqDhayiG4LJKgGdDjWXC8W8MMXDYx24SIJrJu9KiNEMprJE+XVF9nQVNijNAjlWBqkDpsfaWTUfeVLRLctfAqW1blsmIv4RQ91PupYJZDNc8nO9ZTF3TEVM+2KHoxzDJrLs2Q==" : null, + UserId = shouldLinkUserId ? user.Id : null, + Email = shouldLinkUserId ? null : user.Email, + Key = shouldIncludeKey ? encryptedOrgKey : null, Type = type, Status = status }; } - - public static OrganizationUser CreateSdkOrganizationUser(this Organization organization, User user) - { - return new OrganizationUser - { - Id = Guid.NewGuid(), - OrganizationId = organization.Id, - UserId = user.Id, - - Key = "4.rY01mZFXHOsBAg5Fq4gyXuklWfm6mQASm42DJpx05a+e2mmp+P5W6r54WU2hlREX0uoTxyP91bKKwickSPdCQQ58J45LXHdr9t2uzOYyjVzpzebFcdMw1eElR9W2DW8wEk9+mvtWvKwu7yTebzND+46y1nRMoFydi5zPVLSlJEf81qZZ4Uh1UUMLwXz+NRWfixnGXgq2wRq1bH0n3mqDhayiG4LJKgGdDjWXC8W8MMXDYx24SIJrJu9KiNEMprJE+XVF9nQVNijNAjlWBqkDpsfaWTUfeVLRLctfAqW1blsmIv4RQ91PupYJZDNc8nO9ZTF3TEVM+2KHoxzDJrLs2Q==", - Type = OrganizationUserType.Admin, - Status = OrganizationUserStatusType.Confirmed - }; - } } diff --git a/util/Seeder/Factories/UserSeeder.cs b/util/Seeder/Factories/UserSeeder.cs index 9b80dbef3c..a860506e29 100644 --- a/util/Seeder/Factories/UserSeeder.cs +++ b/util/Seeder/Factories/UserSeeder.cs @@ -11,7 +11,7 @@ public struct UserData public string Email; } -public class UserSeeder(RustSdkService sdkService, IPasswordHasher passwordHasher, MangleId mangleId) +public class UserSeeder(IPasswordHasher passwordHasher, MangleId mangleId) { private string MangleEmail(string email) { @@ -21,7 +21,7 @@ public class UserSeeder(RustSdkService sdkService, IPasswordHasher + /// Default test password used for all seeded users. + /// + public const string DefaultPassword = "asdfasdfasdf"; + + /// + /// Creates a user with hardcoded keys (no email mangling, no SDK calls). + /// Used by OrganizationWithUsersRecipe for fast user creation without encryption needs. + /// public static User CreateUserNoMangle(string email) { return new User @@ -57,12 +65,54 @@ public class UserSeeder(RustSdkService sdkService, IPasswordHasher + /// Creates a user with SDK-generated cryptographic keys (no email mangling). + /// The user can log in with email and password = "asdfasdfasdf". + /// + public static User CreateUserWithSdkKeys( + string email, + IPasswordHasher passwordHasher) + { + var keys = RustSdkService.GenerateUserKeys(email, DefaultPassword); + return CreateUserFromKeys(email, keys, passwordHasher); + } + + /// + /// Creates a user from pre-generated keys (no email mangling). + /// Use this when you need to retain the user's symmetric key for subsequent operations + /// (e.g., encrypting folders with the user's key). + /// + public static User CreateUserFromKeys( + string email, + UserKeys keys, + IPasswordHasher passwordHasher) + { + var user = new User + { + Id = CoreHelpers.GenerateComb(), + Email = email, + EmailVerified = true, + MasterPassword = null, + SecurityStamp = Guid.NewGuid().ToString(), + Key = keys.EncryptedUserKey, + PublicKey = keys.PublicKey, + PrivateKey = keys.PrivateKey, + Premium = false, + ApiKey = Guid.NewGuid().ToString("N")[..30], + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = 5_000, + }; + + user.MasterPassword = passwordHasher.HashPassword(user, keys.MasterPasswordHash); + + return user; + } + public Dictionary GetMangleMap(User user, UserData expectedUserData) { var mangleMap = new Dictionary diff --git a/util/Seeder/Models/CipherViewDto.cs b/util/Seeder/Models/CipherViewDto.cs new file mode 100644 index 0000000000..bd6ccfd6bf --- /dev/null +++ b/util/Seeder/Models/CipherViewDto.cs @@ -0,0 +1,153 @@ +using System.Text.Json.Serialization; + +namespace Bit.Seeder.Models; + +public class CipherViewDto +{ + [JsonPropertyName("id")] + public Guid? Id { get; set; } + + [JsonPropertyName("organizationId")] + public Guid? OrganizationId { get; set; } + + [JsonPropertyName("folderId")] + public Guid? FolderId { get; set; } + + [JsonPropertyName("collectionIds")] + public List CollectionIds { get; set; } = []; + + [JsonPropertyName("key")] + public string? Key { get; set; } + + [JsonPropertyName("name")] + public required string Name { get; set; } + + [JsonPropertyName("notes")] + public string? Notes { get; set; } + + [JsonPropertyName("type")] + public int Type { get; set; } + + [JsonPropertyName("login")] + public LoginViewDto? Login { get; set; } + + [JsonPropertyName("identity")] + public object? Identity { get; set; } + + [JsonPropertyName("card")] + public object? Card { get; set; } + + [JsonPropertyName("secureNote")] + public object? SecureNote { get; set; } + + [JsonPropertyName("sshKey")] + public object? SshKey { get; set; } + + [JsonPropertyName("favorite")] + public bool Favorite { get; set; } + + [JsonPropertyName("reprompt")] + public int Reprompt { get; set; } + + [JsonPropertyName("organizationUseTotp")] + public bool OrganizationUseTotp { get; set; } + + [JsonPropertyName("edit")] + public bool Edit { get; set; } = true; + + [JsonPropertyName("permissions")] + public object? Permissions { get; set; } + + [JsonPropertyName("viewPassword")] + public bool ViewPassword { get; set; } = true; + + [JsonPropertyName("localData")] + public object? LocalData { get; set; } + + [JsonPropertyName("attachments")] + public object? Attachments { get; set; } + + [JsonPropertyName("fields")] + public List? Fields { get; set; } + + [JsonPropertyName("passwordHistory")] + public object? PasswordHistory { get; set; } + + [JsonPropertyName("creationDate")] + public DateTime CreationDate { get; set; } = DateTime.UtcNow; + + [JsonPropertyName("deletedDate")] + public DateTime? DeletedDate { get; set; } + + [JsonPropertyName("revisionDate")] + public DateTime RevisionDate { get; set; } = DateTime.UtcNow; + + [JsonPropertyName("archivedDate")] + public DateTime? ArchivedDate { get; set; } +} + +public class LoginViewDto +{ + [JsonPropertyName("username")] + public string? Username { get; set; } + + [JsonPropertyName("password")] + public string? Password { get; set; } + + [JsonPropertyName("passwordRevisionDate")] + public DateTime? PasswordRevisionDate { get; set; } + + [JsonPropertyName("uris")] + public List? Uris { get; set; } + + [JsonPropertyName("totp")] + public string? Totp { get; set; } + + [JsonPropertyName("autofillOnPageLoad")] + public bool? AutofillOnPageLoad { get; set; } + + [JsonPropertyName("fido2Credentials")] + public object? Fido2Credentials { get; set; } +} + +public class LoginUriViewDto +{ + [JsonPropertyName("uri")] + public string? Uri { get; set; } + + [JsonPropertyName("match")] + public int? Match { get; set; } + + [JsonPropertyName("uriChecksum")] + public string? UriChecksum { get; set; } +} + +public class FieldViewDto +{ + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("value")] + public string? Value { get; set; } + + [JsonPropertyName("type")] + public int Type { get; set; } + + [JsonPropertyName("linkedId")] + public int? LinkedId { get; set; } +} + +public static class CipherTypes +{ + public const int Login = 1; + public const int SecureNote = 2; + public const int Card = 3; + public const int Identity = 4; + public const int SshKey = 5; +} + +public static class RepromptTypes +{ + public const int None = 0; + public const int Password = 1; +} diff --git a/util/Seeder/Models/EncryptedCipherDto.cs b/util/Seeder/Models/EncryptedCipherDto.cs new file mode 100644 index 0000000000..5b5b6aa56c --- /dev/null +++ b/util/Seeder/Models/EncryptedCipherDto.cs @@ -0,0 +1,96 @@ +using System.Text.Json.Serialization; + +namespace Bit.Seeder.Models; + +public class EncryptedCipherDto +{ + [JsonPropertyName("id")] + public Guid? Id { get; set; } + + [JsonPropertyName("organizationId")] + public Guid? OrganizationId { get; set; } + + [JsonPropertyName("folderId")] + public Guid? FolderId { get; set; } + + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("notes")] + public string? Notes { get; set; } + + [JsonPropertyName("type")] + public int Type { get; set; } + + [JsonPropertyName("login")] + public EncryptedLoginDto? Login { get; set; } + + [JsonPropertyName("fields")] + public List? Fields { get; set; } + + [JsonPropertyName("favorite")] + public bool Favorite { get; set; } + + [JsonPropertyName("reprompt")] + public int Reprompt { get; set; } + + [JsonPropertyName("key")] + public string? Key { get; set; } + + [JsonPropertyName("creationDate")] + public DateTime CreationDate { get; set; } + + [JsonPropertyName("revisionDate")] + public DateTime RevisionDate { get; set; } + + [JsonPropertyName("deletedDate")] + public DateTime? DeletedDate { get; set; } +} + +public class EncryptedLoginDto +{ + [JsonPropertyName("username")] + public string? Username { get; set; } + + [JsonPropertyName("password")] + public string? Password { get; set; } + + [JsonPropertyName("totp")] + public string? Totp { get; set; } + + [JsonPropertyName("uris")] + public List? Uris { get; set; } + + [JsonPropertyName("passwordRevisionDate")] + public DateTime? PasswordRevisionDate { get; set; } + + [JsonPropertyName("fido2Credentials")] + public object? Fido2Credentials { get; set; } +} + +public class EncryptedLoginUriDto +{ + [JsonPropertyName("uri")] + public string? Uri { get; set; } + + [JsonPropertyName("match")] + public int? Match { get; set; } + + [JsonPropertyName("uriChecksum")] + public string? UriChecksum { get; set; } +} + +public class EncryptedFieldDto +{ + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("value")] + public string? Value { get; set; } + + [JsonPropertyName("type")] + public int Type { get; set; } + + [JsonPropertyName("linkedId")] + public int? LinkedId { get; set; } +} diff --git a/util/Seeder/Options/OrganizationVaultOptions.cs b/util/Seeder/Options/OrganizationVaultOptions.cs new file mode 100644 index 0000000000..ff1be02f7c --- /dev/null +++ b/util/Seeder/Options/OrganizationVaultOptions.cs @@ -0,0 +1,63 @@ +using Bit.Seeder.Data.Enums; + +namespace Bit.Seeder.Options; + +/// +/// Options for seeding an organization with vault data. +/// +public class OrganizationVaultOptions +{ + /// + /// Organization name. + /// + public required string Name { get; init; } + + /// + /// Domain for user emails (e.g., "example.com"). + /// + public required string Domain { get; init; } + + /// + /// Number of member users to create. + /// + public required int Users { get; init; } + + /// + /// Number of login ciphers to create. + /// + public int Ciphers { get; init; } = 0; + + /// + /// Number of groups to create. + /// + public int Groups { get; init; } = 0; + + /// + /// When true and Users >= 10, creates a realistic mix of user statuses: + /// 85% Confirmed, 5% Invited, 5% Accepted, 5% Revoked. + /// When false or Users < 10, all users are Confirmed. + /// + public bool RealisticStatusMix { get; init; } = false; + + /// + /// Org structure for realistic collection names. + /// + public OrgStructureModel? StructureModel { get; init; } + + /// + /// Username pattern for cipher logins. + /// + public UsernamePatternType UsernamePattern { get; init; } = UsernamePatternType.FirstDotLast; + + /// + /// Password strength for cipher logins. Defaults to Realistic distribution + /// (25% VeryWeak, 30% Weak, 25% Fair, 15% Strong, 5% VeryStrong). + /// + public PasswordStrength PasswordStrength { get; init; } = PasswordStrength.Realistic; + + /// + /// Geographic region for culturally-appropriate name generation in cipher usernames. + /// Defaults to Global (mixed locales from all regions). + /// + public GeographicRegion? Region { get; init; } +} diff --git a/util/Seeder/README.md b/util/Seeder/README.md index 8597ad6e39..3b38c3d731 100644 --- a/util/Seeder/README.md +++ b/util/Seeder/README.md @@ -1,18 +1,155 @@ # Bitwarden Database Seeder -A class library for generating and inserting test data. +A class library for generating and inserting properly encrypted test data into Bitwarden databases. -## Project Structure +## Domain Taxonomy -The project is organized into these main components: +### Cipher Encryption States -### Factories +| Term | Description | Stored in DB? | +| -------------- | ---------------------------------------------------- | ------------- | +| **CipherView** | Plaintext/decrypted form. Human-readable data. | Never | +| **Cipher** | Encrypted form. All sensitive fields are EncStrings. | Yes | -Factories are helper classes for creating domain entities and populating them with realistic data. This assist in -decreasing the amount of boilerplate code needed to create test data in recipes. +The "View" suffix always denotes plaintext. No suffix means encrypted. -### Recipes +### Data Structure Differences -Recipes are pre-defined data sets which can be run to generate and load data into the database. They often allow a allow -for a few arguments to customize the data slightly. Recipes should be kept simple and focused on a single task. Default -to creating more recipes rather than adding complexity to existing ones. +**SDK Structure (nested):** + +```json +{ "name": "2.x...", "login": { "username": "2.y...", "password": "2.z..." } } +``` + +**Server Structure (flat, stored in Cipher.Data):** + +```json +{ "Name": "2.x...", "Username": "2.y...", "Password": "2.z..." } +``` + +The seeder transforms SDK output to server format before database insertion. + +### Project Structure + +The Seeder is organized around six core patterns, each with a specific responsibility: + +#### Factories + +**Purpose:** Create individual domain entities with cryptographically correct encrypted data. + +**Metaphor:** Skilled craftspeople who create one perfect item per call. + +**When to use:** Need to create ONE entity (user, cipher, collection) with proper encryption. + +**Key characteristics:** + +- Create ONE entity per method call +- Handle encryption/transformation internally +- Stateless (except for SDK service dependency) +- Do NOT interact with database directly + +**Naming:** `{Entity}Seeder` class with `Create{Type}{Entity}()` methods + +--- + +#### Recipes + +**Purpose:** Orchestrate cohesive bulk operations using BulkCopy for performance. + +**Metaphor:** Cooking recipes that produce one complete result through coordinated steps. Like baking a three-layer cake - you don't grab three separate recipes and stack them; you follow one comprehensive recipe that orchestrates all the steps. + +**When to use:** Need to create MANY related entities as one cohesive operation (e.g., organization + users + collections + ciphers). + +**Key characteristics:** + +- Orchestrate multiple entity creations as a cohesive operation +- Use BulkCopy for performance optimization +- Interact with database directly +- Compose Factories for individual entity creation +- **SHALL have a `Seed()` method** that executes the complete recipe +- Use method parameters (with defaults) for variations, not separate methods + +**Naming:** `{DomainConcept}Recipe` class with primary `Seed()` method + +**Note:** Some existing recipes violate the `Seed()` method convention and will be refactored in the future. + +--- + +#### Models + +**Purpose:** DTOs that bridge the gap between SDK encryption format and server storage format. + +**Metaphor:** Translators between two different languages (SDK format vs. Server format). + +**When to use:** Need data transformation during the encryption pipeline (SDK → Server format). + +**Key characteristics:** + +- Pure data structures (DTOs) +- No business logic +- Handle serialization/deserialization +- Bridge SDK ↔ Server format differences + +#### Scenes + +**Purpose:** Create complete, isolated test scenarios for integration tests. + +**Metaphor:** Theater scenes with multiple actors and props arranged to tell a complete story. + +**When to use:** Need a complete test scenario with proper ID mangling for test isolation. + +**Key characteristics:** + +- Implement `IScene` or `IScene` +- Create complete, realistic test scenarios +- Handle uniqueness constraint mangling for test isolation +- Return `SceneResult` with mangle map and optional additional operation result data for test assertions +- Async operations +- CAN modify database state + +**Naming:** `{Scenario}Scene` class with `SeedAsync(Request)` method (defined by interface) + +#### Queries + +**Purpose:** Read-only data retrieval for test assertions and verification. + +**Metaphor:** Information desks that answer questions without changing anything. + +**When to use:** Need to READ existing seeded data for verification or follow-up operations. + +** Example:** Inviting a user to an organization produces a magic link to accept the invite, a query should be used to retrieve that link because it is easier than interfacing with an external smtp catcher. + +**Key characteristics:** + +- Implement `IQuery` +- Read-only (no database modifications) +- Return typed data for test assertions +- Can be used to retrieve side effects due to tested flows + +**Naming:** `{DataToRetrieve}Query` class with `Execute(Request)` method (defined by interface) + +#### Data + +**Purpose:** Reusable, realistic test data collections that provide the foundation for cipher generation. + +**Metaphor:** A well-stocked ingredient pantry that all recipes draw from. + +**When to use:** Need realistic, filterable data for cipher content (company names, passwords, usernames). + +**Key characteristics:** + +- Static readonly arrays and classes +- Filterable by region, type, category +- Deterministic (seeded randomness for reproducibility) +- Composable across regions +- Enums provide the public API (CompanyType, PasswordStrength, etc.) + +## Rust SDK Integration + +The seeder uses FFI calls to the Rust SDK for cryptographically correct encryption: + +``` +CipherViewDto → RustSdkService.EncryptCipher() → EncryptedCipherDto → Server Format +``` + +This ensures seeded data can be decrypted and displayed in the actual Bitwarden clients. diff --git a/util/Seeder/Recipes/CollectionsRecipe.cs b/util/Seeder/Recipes/CollectionsRecipe.cs index e0f9057418..d587dafbb4 100644 --- a/util/Seeder/Recipes/CollectionsRecipe.cs +++ b/util/Seeder/Recipes/CollectionsRecipe.cs @@ -14,7 +14,7 @@ public class CollectionsRecipe(DatabaseContext db) /// The number of collections to add. /// The IDs of the users to create relationships with. /// The maximum number of users to create relationships with. - public List AddToOrganization(Guid organizationId, int collections, List organizationUserIds, int maxUsersWithRelationships = 1000) + public List Seed(Guid organizationId, int collections, List organizationUserIds, int maxUsersWithRelationships = 1000) { var collectionList = CreateAndSaveCollections(organizationId, collections); diff --git a/util/Seeder/Recipes/GroupsRecipe.cs b/util/Seeder/Recipes/GroupsRecipe.cs index 3c8156d921..d72730def0 100644 --- a/util/Seeder/Recipes/GroupsRecipe.cs +++ b/util/Seeder/Recipes/GroupsRecipe.cs @@ -13,7 +13,7 @@ public class GroupsRecipe(DatabaseContext db) /// The number of groups to add. /// The IDs of the users to create relationships with. /// The maximum number of users to create relationships with. - public List AddToOrganization(Guid organizationId, int groups, List organizationUserIds, int maxUsersWithRelationships = 1000) + public List Seed(Guid organizationId, int groups, List organizationUserIds, int maxUsersWithRelationships = 1000) { var groupList = CreateAndSaveGroups(organizationId, groups); diff --git a/util/Seeder/Recipes/OrganizationDomainRecipe.cs b/util/Seeder/Recipes/OrganizationDomainRecipe.cs index b62dd5115e..97b52adf21 100644 --- a/util/Seeder/Recipes/OrganizationDomainRecipe.cs +++ b/util/Seeder/Recipes/OrganizationDomainRecipe.cs @@ -5,7 +5,7 @@ namespace Bit.Seeder.Recipes; public class OrganizationDomainRecipe(DatabaseContext db) { - public void AddVerifiedDomainToOrganization(Guid organizationId, string domainName) + public void Seed(Guid organizationId, string domainName) { var domain = new OrganizationDomain { diff --git a/util/Seeder/Recipes/OrganizationWithUsersRecipe.cs b/util/Seeder/Recipes/OrganizationWithUsersRecipe.cs index 87fcc1967b..f6a21ab4ac 100644 --- a/util/Seeder/Recipes/OrganizationWithUsersRecipe.cs +++ b/util/Seeder/Recipes/OrganizationWithUsersRecipe.cs @@ -1,39 +1,66 @@ -using Bit.Core.Entities; +using AutoMapper; +using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Infrastructure.EntityFramework.Repositories; +using Bit.RustSDK; using Bit.Seeder.Factories; using LinqToDB.EntityFrameworkCore; +using Microsoft.AspNetCore.Identity; +using EfOrganization = Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization; +using EfOrganizationUser = Bit.Infrastructure.EntityFramework.Models.OrganizationUser; +using EfUser = Bit.Infrastructure.EntityFramework.Models.User; namespace Bit.Seeder.Recipes; -public class OrganizationWithUsersRecipe(DatabaseContext db) +public class OrganizationWithUsersRecipe(DatabaseContext db, IMapper mapper, IPasswordHasher passwordHasher) { public Guid Seed(string name, string domain, int users, OrganizationUserStatusType usersStatus = OrganizationUserStatusType.Confirmed) { var seats = Math.Max(users + 1, 1000); - var organization = OrganizationSeeder.CreateEnterprise(name, domain, seats); - var ownerUser = UserSeeder.CreateUserNoMangle($"owner@{domain}"); - var ownerOrgUser = organization.CreateOrganizationUser(ownerUser, OrganizationUserType.Owner, OrganizationUserStatusType.Confirmed); + + // Generate organization keys + var orgKeys = RustSdkService.GenerateOrganizationKeys(); + var organization = OrganizationSeeder.CreateEnterprise( + name, domain, seats, orgKeys.PublicKey, orgKeys.PrivateKey); + + // Create owner with SDK-generated keys + var ownerUser = UserSeeder.CreateUserWithSdkKeys($"owner@{domain}", passwordHasher); + var ownerOrgKey = RustSdkService.GenerateUserOrganizationKey(ownerUser.PublicKey!, orgKeys.Key); + var ownerOrgUser = organization.CreateOrganizationUserWithKey( + ownerUser, OrganizationUserType.Owner, OrganizationUserStatusType.Confirmed, ownerOrgKey); var additionalUsers = new List(); var additionalOrgUsers = new List(); for (var i = 0; i < users; i++) { - var additionalUser = UserSeeder.CreateUserNoMangle($"user{i}@{domain}"); + var additionalUser = UserSeeder.CreateUserWithSdkKeys($"user{i}@{domain}", passwordHasher); additionalUsers.Add(additionalUser); - additionalOrgUsers.Add(organization.CreateOrganizationUser(additionalUser, OrganizationUserType.User, usersStatus)); + + // Generate org key for confirmed/revoked users + var shouldHaveKey = usersStatus == OrganizationUserStatusType.Confirmed + || usersStatus == OrganizationUserStatusType.Revoked; + var userOrgKey = shouldHaveKey + ? RustSdkService.GenerateUserOrganizationKey(additionalUser.PublicKey!, orgKeys.Key) + : null; + + additionalOrgUsers.Add(organization.CreateOrganizationUserWithKey( + additionalUser, OrganizationUserType.User, usersStatus, userOrgKey)); } - db.Add(organization); - db.Add(ownerUser); - db.Add(ownerOrgUser); + // Map Core entities to EF entities before adding to DbContext + db.Add(mapper.Map(organization)); + db.Add(mapper.Map(ownerUser)); + db.Add(mapper.Map(ownerOrgUser)); + + // Map and BulkCopy additional users + var efAdditionalUsers = additionalUsers.Select(u => mapper.Map(u)).ToList(); + var efAdditionalOrgUsers = additionalOrgUsers.Select(ou => mapper.Map(ou)).ToList(); + + db.BulkCopy(efAdditionalUsers); + db.BulkCopy(efAdditionalOrgUsers); db.SaveChanges(); - // Use LinqToDB's BulkCopy for significant better performance - db.BulkCopy(additionalUsers); - db.BulkCopy(additionalOrgUsers); - return organization.Id; } } diff --git a/util/Seeder/Recipes/OrganizationWithVaultRecipe.cs b/util/Seeder/Recipes/OrganizationWithVaultRecipe.cs new file mode 100644 index 0000000000..6b729273f1 --- /dev/null +++ b/util/Seeder/Recipes/OrganizationWithVaultRecipe.cs @@ -0,0 +1,326 @@ +using AutoMapper; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Infrastructure.EntityFramework.Repositories; +using Bit.RustSDK; +using Bit.Seeder.Data; +using Bit.Seeder.Data.Enums; +using Bit.Seeder.Factories; +using Bit.Seeder.Options; +using LinqToDB.EntityFrameworkCore; +using Microsoft.AspNetCore.Identity; +using EfFolder = Bit.Infrastructure.EntityFramework.Vault.Models.Folder; +using EfOrganization = Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization; +using EfOrganizationUser = Bit.Infrastructure.EntityFramework.Models.OrganizationUser; +using EfUser = Bit.Infrastructure.EntityFramework.Models.User; + +namespace Bit.Seeder.Recipes; + +/// +/// Seeds an organization with users, collections, groups, and encrypted ciphers. +/// +/// +/// This recipe creates a complete organization with vault data in a single operation. +/// All entity creation is delegated to factories. Users can log in with their email +/// and password "asdfasdfasdf". Organization and user keys are generated dynamically. +/// +public class OrganizationWithVaultRecipe( + DatabaseContext db, + IMapper mapper, + IPasswordHasher passwordHasher) +{ + + /// + /// Tracks a user with their symmetric key for folder encryption. + /// + private record UserWithKey(User User, string SymmetricKey); + + /// + /// Seeds an organization with users, collections, groups, and encrypted ciphers. + /// + /// Options specifying what to seed. + /// The organization ID. + public Guid Seed(OrganizationVaultOptions options) + { + var seats = Math.Max(options.Users + 1, 1000); + var orgKeys = RustSdkService.GenerateOrganizationKeys(); + + // Create organization via factory + var organization = OrganizationSeeder.CreateEnterprise( + options.Name, options.Domain, seats, orgKeys.PublicKey, orgKeys.PrivateKey); + + // Create owner user via factory + var ownerUser = UserSeeder.CreateUserWithSdkKeys($"owner@{options.Domain}", passwordHasher); + var ownerOrgKey = RustSdkService.GenerateUserOrganizationKey(ownerUser.PublicKey!, orgKeys.Key); + var ownerOrgUser = organization.CreateOrganizationUserWithKey( + ownerUser, OrganizationUserType.Owner, OrganizationUserStatusType.Confirmed, ownerOrgKey); + + // Create member users via factory, retaining keys for folder encryption + var memberUsersWithKeys = new List(); + var memberOrgUsers = new List(); + var useRealisticMix = options.RealisticStatusMix && options.Users >= 10; + + for (var i = 0; i < options.Users; i++) + { + var email = $"user{i}@{options.Domain}"; + var userKeys = RustSdkService.GenerateUserKeys(email, UserSeeder.DefaultPassword); + var memberUser = UserSeeder.CreateUserFromKeys(email, userKeys, passwordHasher); + memberUsersWithKeys.Add(new UserWithKey(memberUser, userKeys.Key)); + + var status = useRealisticMix + ? GetRealisticStatus(i, options.Users) + : OrganizationUserStatusType.Confirmed; + + var memberOrgKey = (status == OrganizationUserStatusType.Confirmed || + status == OrganizationUserStatusType.Revoked) + ? RustSdkService.GenerateUserOrganizationKey(memberUser.PublicKey!, orgKeys.Key) + : null; + + memberOrgUsers.Add(organization.CreateOrganizationUserWithKey( + memberUser, OrganizationUserType.User, status, memberOrgKey)); + } + + var memberUsers = memberUsersWithKeys.Select(uwk => uwk.User).ToList(); + + // Persist organization and users + db.Add(mapper.Map(organization)); + db.Add(mapper.Map(ownerUser)); + db.Add(mapper.Map(ownerOrgUser)); + + var efMemberUsers = memberUsers.Select(u => mapper.Map(u)).ToList(); + var efMemberOrgUsers = memberOrgUsers.Select(ou => mapper.Map(ou)).ToList(); + db.BulkCopy(efMemberUsers); + db.BulkCopy(efMemberOrgUsers); + db.SaveChanges(); + + // Get confirmed org user IDs for collection/group relationships + var confirmedOrgUserIds = memberOrgUsers + .Where(ou => ou.Status == OrganizationUserStatusType.Confirmed) + .Select(ou => ou.Id) + .Prepend(ownerOrgUser.Id) + .ToList(); + + var collectionIds = CreateCollections(organization.Id, orgKeys.Key, options.StructureModel, confirmedOrgUserIds); + CreateGroups(organization.Id, options.Groups, confirmedOrgUserIds); + CreateCiphers(organization.Id, orgKeys.Key, collectionIds, options.Ciphers, options.UsernamePattern, options.PasswordStrength, options.Region); + CreateFolders(memberUsersWithKeys); + + return organization.Id; + } + + private List CreateCollections( + Guid organizationId, + string orgKeyBase64, + OrgStructureModel? structureModel, + List orgUserIds) + { + List collections; + + if (structureModel.HasValue) + { + var structure = OrgStructures.GetStructure(structureModel.Value); + collections = structure.Units + .Select(unit => CollectionSeeder.CreateCollection(organizationId, orgKeyBase64, unit.Name)) + .ToList(); + } + else + { + collections = [CollectionSeeder.CreateCollection(organizationId, orgKeyBase64, "Default Collection")]; + } + + db.BulkCopy(collections); + + // Create collection-user relationships + if (collections.Count > 0 && orgUserIds.Count > 0) + { + var collectionUsers = orgUserIds + .SelectMany((orgUserId, userIndex) => + { + var maxAssignments = Math.Min((userIndex % 3) + 1, collections.Count); + return Enumerable.Range(0, maxAssignments) + .Select(j => CollectionSeeder.CreateCollectionUser( + collections[(userIndex + j) % collections.Count].Id, + orgUserId, + readOnly: j > 0, + manage: j == 0)); + }) + .ToList(); + db.BulkCopy(collectionUsers); + } + + return collections.Select(c => c.Id).ToList(); + } + + private void CreateGroups(Guid organizationId, int groupCount, List orgUserIds) + { + var groupList = Enumerable.Range(0, groupCount) + .Select(i => GroupSeeder.CreateGroup(organizationId, $"Group {i + 1}")) + .ToList(); + + db.BulkCopy(groupList); + + // Create group-user relationships (round-robin assignment) + if (groupList.Count > 0 && orgUserIds.Count > 0) + { + var groupUsers = orgUserIds + .Select((orgUserId, i) => GroupSeeder.CreateGroupUser( + groupList[i % groupList.Count].Id, + orgUserId)) + .ToList(); + db.BulkCopy(groupUsers); + } + } + + private void CreateCiphers( + Guid organizationId, + string orgKeyBase64, + List collectionIds, + int cipherCount, + UsernamePatternType usernamePattern, + PasswordStrength passwordStrength, + GeographicRegion? region) + { + var companies = Companies.All; + var usernameGenerator = new CipherUsernameGenerator(organizationId.GetHashCode(), usernamePattern, region); + + var cipherList = Enumerable.Range(0, cipherCount) + .Select(i => + { + var company = companies[i % companies.Length]; + return CipherSeeder.CreateOrganizationLoginCipher( + organizationId, + orgKeyBase64, + name: $"{company.Name} ({company.Category})", + username: usernameGenerator.GenerateVaried(company, i), + password: Passwords.GetPassword(passwordStrength, i), + uri: $"https://{company.Domain}"); + }) + .ToList(); + + db.BulkCopy(cipherList); + + // Create cipher-collection relationships + if (cipherList.Count > 0 && collectionIds.Count > 0) + { + var collectionCiphers = cipherList.SelectMany((cipher, i) => + { + var primary = new CollectionCipher + { + CipherId = cipher.Id, + CollectionId = collectionIds[i % collectionIds.Count] + }; + + // Every 3rd cipher gets assigned to an additional collection + if (i % 3 == 0 && collectionIds.Count > 1) + { + return new[] + { + primary, + new CollectionCipher + { + CipherId = cipher.Id, + CollectionId = collectionIds[(i + 1) % collectionIds.Count] + } + }; + } + + return new[] { primary }; + }).ToList(); + + db.BulkCopy(collectionCiphers); + } + } + + /// + /// Returns a realistic user status based on index position. + /// Distribution: 85% Confirmed, 5% Invited, 5% Accepted, 5% Revoked. + /// + private static OrganizationUserStatusType GetRealisticStatus(int index, int totalUsers) + { + // Calculate bucket boundaries + var confirmedCount = (int)(totalUsers * 0.85); + var invitedCount = (int)(totalUsers * 0.05); + var acceptedCount = (int)(totalUsers * 0.05); + // Revoked gets the remainder + + if (index < confirmedCount) + { + return OrganizationUserStatusType.Confirmed; + } + + if (index < confirmedCount + invitedCount) + { + return OrganizationUserStatusType.Invited; + } + + if (index < confirmedCount + invitedCount + acceptedCount) + { + return OrganizationUserStatusType.Accepted; + } + + return OrganizationUserStatusType.Revoked; + } + + /// + /// Creates personal vault folders for users with realistic distribution. + /// Folders are encrypted with each user's individual symmetric key. + /// + private void CreateFolders(List usersWithKeys) + { + if (usersWithKeys.Count == 0) + { + return; + } + + var seed = usersWithKeys[0].User.Id.GetHashCode(); + var random = new Random(seed); + var folderNameGenerator = new FolderNameGenerator(seed); + + var allFolders = usersWithKeys + .SelectMany((uwk, userIndex) => + { + var folderCount = GetFolderCountForUser(userIndex, usersWithKeys.Count, random); + return Enumerable.Range(0, folderCount) + .Select(folderIndex => FolderSeeder.CreateFolder( + uwk.User.Id, + uwk.SymmetricKey, + folderNameGenerator.GetFolderName(userIndex * 15 + folderIndex))); + }) + .ToList(); + + if (allFolders.Count > 0) + { + var efFolders = allFolders.Select(f => mapper.Map(f)).ToList(); + db.BulkCopy(efFolders); + } + } + + /// + /// Returns folder count based on user index position in the distribution. + /// Distribution: 35% Zero, 35% Few (1-3), 20% Some (4-7), 10% TooMany (10-15) + /// + private static int GetFolderCountForUser(int userIndex, int totalUsers, Random random) + { + var zeroCount = (int)(totalUsers * 0.35); + var fewCount = (int)(totalUsers * 0.35); + var someCount = (int)(totalUsers * 0.20); + // TooMany gets the remainder + + if (userIndex < zeroCount) + { + return 0; // Zero folders + } + + if (userIndex < zeroCount + fewCount) + { + return random.Next(1, 4); // Few: 1-3 folders + } + + if (userIndex < zeroCount + fewCount + someCount) + { + return random.Next(4, 8); // Some: 4-7 folders + } + + return random.Next(10, 16); // TooMany: 10-15 folders + } +} diff --git a/util/Seeder/Seeder.csproj b/util/Seeder/Seeder.csproj index fd6e26c1ee..b38c2cf1e1 100644 --- a/util/Seeder/Seeder.csproj +++ b/util/Seeder/Seeder.csproj @@ -19,6 +19,10 @@ + + + + diff --git a/util/SeederApi/Startup.cs b/util/SeederApi/Startup.cs index 420078f509..5caf0208e3 100644 --- a/util/SeederApi/Startup.cs +++ b/util/SeederApi/Startup.cs @@ -37,7 +37,6 @@ public class Startup services.AddScoped, PasswordHasher>(); - services.AddSingleton(); services.AddScoped(); services.AddSeederApiServices();