From 7855c4ee6ef702d94a155ecbdcc17cbfb08eefe9 Mon Sep 17 00:00:00 2001 From: Brandon Treston Date: Thu, 29 Jan 2026 10:10:22 -0500 Subject: [PATCH 1/6] [PM-28414] remove feature flag (#6914) * remove feature flagged logic * remove feature flag --- src/Core/Constants.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 58b50a6512..499254bc31 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"; From 0544ec41d50f93fc858ce9d460ceb5cc71e8badc Mon Sep 17 00:00:00 2001 From: Alex Dragovich <46065570+itsadrago@users.noreply.github.com> Date: Thu, 29 Jan 2026 11:48:12 -0800 Subject: [PATCH 2/6] [PM-31394] use email address hash for send access email verification (#6921) * [PM-31394] use email address hash for send access email verification * [PM-31394] fixing identity server tests for send access * [PM-31394] fixing more identity server tests for send access --- .../Models/Data/SendAuthenticationTypes.cs | 4 ++-- .../SendAccess/SendEmailOtpRequestValidator.cs | 8 ++++++-- test/Common/Helpers/CryptographyHelper.cs | 17 +++++++++++++++++ .../Services/SendAuthenticationQueryTests.cs | 2 +- ...ndEmailOtpReqestValidatorIntegrationTests.cs | 10 ++++++---- .../SendEmailOtpRequestValidatorTests.cs | 13 +++++++++---- 6 files changed, 41 insertions(+), 13 deletions(-) create mode 100644 test/Common/Helpers/CryptographyHelper.cs 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/Identity/IdentityServer/RequestValidators/SendAccess/SendEmailOtpRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendEmailOtpRequestValidator.cs index 34a7a6f6e7..f20fdb6f07 100644 --- a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendEmailOtpRequestValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendEmailOtpRequestValidator.cs @@ -1,4 +1,6 @@ using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; using Bit.Core; using Bit.Core.Auth.Identity; using Bit.Core.Auth.Identity.TokenProviders; @@ -40,8 +42,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); } 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/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..1815b9207d 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); From 93a28eed408a9438cc92f5067b1065ff47e387f5 Mon Sep 17 00:00:00 2001 From: sven-bitwarden Date: Thu, 29 Jan 2026 14:11:20 -0600 Subject: [PATCH 3/6] [PM-29246] Simplify Usage of Organization Policies (#6837) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial implementation of new policy query * Remove unused using * Adjusts method name to better match repository method * Correct namespace * Initial refactor of policy loading * Add xml doc, incorporate shim data model * Updates usages to reflect new shim model * Prune extranneous data from policy detail response model, format code * Fix broken test, delete inapplicable test * Adds test cases covering query * Adjust codebase to use new PolicyQueryçˆ * Format code * Fix incorrect mock on test * Fix formatting * Adjust method name * More naming adjustments * Add PolicyData constructor, update test usages * Rename PolicyData -> PolicyStatus * Remove unused using --- .../src/Sso/Controllers/AccountController.cs | 13 ++--- .../OrganizationUsersController.cs | 13 ++--- .../Controllers/OrganizationsController.cs | 11 ++-- .../Controllers/PoliciesController.cs | 18 +++--- ...lResponses.cs => PolicyStatusResponses.cs} | 11 ++-- .../PolicyDetailResponseModel.cs | 20 ------- .../PolicyStatusResponseModel.cs | 33 +++++++++++ .../OrganizationSponsorshipsController.cs | 24 ++++---- .../Organizations/Policies/PolicyStatus.cs | 26 +++++++++ .../AdminRecoverAccountCommand.cs | 9 ++- ...icallyConfirmOrganizationUsersValidator.cs | 5 +- .../SendOrganizationInvitesCommand.cs | 6 +- .../Policies/IPolicyQuery.cs | 17 ++++++ .../Policies/Implementations/PolicyQuery.cs | 14 +++++ .../PolicyServiceCollectionExtensions.cs | 1 + .../Implementations/OrganizationService.cs | 11 ++-- .../Implementations/SsoConfigService.cs | 16 +++--- .../Implementations/RegisterUserCommand.cs | 12 ++-- .../UpgradeOrganizationPlanCommand.cs | 9 ++- .../Services/Implementations/UserService.cs | 11 ++-- .../OrganizationUsersControllerTests.cs | 43 +++++++-------- .../OrganizationsControllerTests.cs | 17 ++---- ...Tests.cs => PolicyStatusResponsesTests.cs} | 35 +++--------- ...OrganizationSponsorshipsControllerTests.cs | 19 ++++++- .../Controllers/PoliciesControllerTests.cs | 44 ++++----------- .../AutoFixture/PolicyFixtures.cs | 24 ++++++-- .../AdminRecoverAccountCommandTests.cs | 36 ++++++------ ...yConfirmOrganizationUsersValidatorTests.cs | 52 +++++++++--------- .../SendOrganizationInvitesCommandTests.cs | 10 +++- .../Auth/Services/SsoConfigServiceTests.cs | 52 +++++++++--------- .../Registration/RegisterUserCommandTests.cs | 46 ++++++++++------ .../UpgradeOrganizationPlanCommandTests.cs | 46 +++++++++++++++- .../Policies/PolicyQueryTests.cs | 55 +++++++++++++++++++ 33 files changed, 457 insertions(+), 302 deletions(-) rename src/Api/AdminConsole/Models/Response/Helpers/{PolicyDetailResponses.cs => PolicyStatusResponses.cs} (66%) delete mode 100644 src/Api/AdminConsole/Models/Response/Organizations/PolicyDetailResponseModel.cs create mode 100644 src/Api/AdminConsole/Models/Response/Organizations/PolicyStatusResponseModel.cs create mode 100644 src/Core/AdminConsole/Models/Data/Organizations/Policies/PolicyStatus.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/Policies/IPolicyQuery.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/PolicyQuery.cs rename test/Api.Test/AdminConsole/Models/Response/Helpers/{PolicyDetailResponsesTests.cs => PolicyStatusResponsesTests.cs} (62%) create mode 100644 test/Core.Test/OrganizationFeatures/Policies/PolicyQueryTests.cs 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/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/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/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/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/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/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/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/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/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/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); + } +} From bfc645e1c1d60380244cb4da2e25aed4587eb651 Mon Sep 17 00:00:00 2001 From: Mick Letofsky Date: Fri, 30 Jan 2026 13:53:24 +0100 Subject: [PATCH 4/6] Add cipher seeding with Rust SDK encryption to enable cryptographically correct test data generation (#6896) --- dev/setup_secrets.ps1 | 1 + .../RustSdkCipherTests.cs | 234 ++++++++++ util/DbSeederUtility/Program.cs | 27 +- util/DbSeederUtility/README.md | 12 +- .../ServiceCollectionExtension.cs | 14 +- util/DbSeederUtility/VaultOrganizationArgs.cs | 112 +++++ util/RustSdk/RustSdkService.cs | 81 +++- util/RustSdk/rust/Cargo.lock | 180 +++++++- util/RustSdk/rust/Cargo.toml | 3 +- util/RustSdk/rust/build.rs | 1 + util/RustSdk/rust/src/cipher.rs | 403 ++++++++++++++++++ util/RustSdk/rust/src/lib.rs | 8 +- util/Seeder/CLAUDE.md | 215 ++++++++++ util/Seeder/Data/BogusNameProvider.cs | 78 ++++ util/Seeder/Data/CipherUsernameGenerator.cs | 67 +++ util/Seeder/Data/Companies.cs | 123 ++++++ util/Seeder/Data/Enums/CompanyCategory.cs | 11 + util/Seeder/Data/Enums/CompanyType.cs | 6 + util/Seeder/Data/Enums/GeographicRegion.cs | 9 + util/Seeder/Data/Enums/OrgStructureModel.cs | 6 + util/Seeder/Data/Enums/PasswordStrength.cs | 25 ++ util/Seeder/Data/Enums/UsernamePatternType.cs | 20 + util/Seeder/Data/FolderNameGenerator.cs | 31 ++ util/Seeder/Data/OrgStructures.cs | 84 ++++ util/Seeder/Data/Passwords.cs | 148 +++++++ util/Seeder/Data/README.md | 144 +++++++ util/Seeder/Data/UsernamePatterns.cs | 57 +++ util/Seeder/Factories/CipherSeeder.cs | 153 +++++++ util/Seeder/Factories/CollectionSeeder.cs | 36 ++ util/Seeder/Factories/FolderSeeder.cs | 28 ++ util/Seeder/Factories/GroupSeeder.cs | 41 ++ .../Factories/OrganizationDomainSeeder.cs | 32 ++ util/Seeder/Factories/OrganizationSeeder.cs | 43 +- util/Seeder/Factories/UserSeeder.cs | 57 ++- util/Seeder/Models/CipherViewDto.cs | 153 +++++++ util/Seeder/Models/EncryptedCipherDto.cs | 96 +++++ .../Options/OrganizationVaultOptions.cs | 63 +++ util/Seeder/README.md | 157 ++++++- .../Recipes/OrganizationWithVaultRecipe.cs | 330 ++++++++++++++ util/Seeder/Seeder.csproj | 4 + 40 files changed, 3245 insertions(+), 48 deletions(-) create mode 100644 test/SeederApi.IntegrationTest/RustSdkCipherTests.cs create mode 100644 util/DbSeederUtility/VaultOrganizationArgs.cs create mode 100644 util/RustSdk/rust/src/cipher.rs create mode 100644 util/Seeder/CLAUDE.md create mode 100644 util/Seeder/Data/BogusNameProvider.cs create mode 100644 util/Seeder/Data/CipherUsernameGenerator.cs create mode 100644 util/Seeder/Data/Companies.cs create mode 100644 util/Seeder/Data/Enums/CompanyCategory.cs create mode 100644 util/Seeder/Data/Enums/CompanyType.cs create mode 100644 util/Seeder/Data/Enums/GeographicRegion.cs create mode 100644 util/Seeder/Data/Enums/OrgStructureModel.cs create mode 100644 util/Seeder/Data/Enums/PasswordStrength.cs create mode 100644 util/Seeder/Data/Enums/UsernamePatternType.cs create mode 100644 util/Seeder/Data/FolderNameGenerator.cs create mode 100644 util/Seeder/Data/OrgStructures.cs create mode 100644 util/Seeder/Data/Passwords.cs create mode 100644 util/Seeder/Data/README.md create mode 100644 util/Seeder/Data/UsernamePatterns.cs create mode 100644 util/Seeder/Factories/CipherSeeder.cs create mode 100644 util/Seeder/Factories/CollectionSeeder.cs create mode 100644 util/Seeder/Factories/FolderSeeder.cs create mode 100644 util/Seeder/Factories/GroupSeeder.cs create mode 100644 util/Seeder/Factories/OrganizationDomainSeeder.cs create mode 100644 util/Seeder/Models/CipherViewDto.cs create mode 100644 util/Seeder/Models/EncryptedCipherDto.cs create mode 100644 util/Seeder/Options/OrganizationVaultOptions.cs create mode 100644 util/Seeder/Recipes/OrganizationWithVaultRecipe.cs 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/test/SeederApi.IntegrationTest/RustSdkCipherTests.cs b/test/SeederApi.IntegrationTest/RustSdkCipherTests.cs new file mode 100644 index 0000000000..3c831c4893 --- /dev/null +++ b/test/SeederApi.IntegrationTest/RustSdkCipherTests.cs @@ -0,0 +1,234 @@ +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 sdk = new RustSdkService(); + var orgKeys = sdk.GenerateOrganizationKeys(); + + var originalCipher = CreateTestLoginCipher(); + var originalJson = JsonSerializer.Serialize(originalCipher, SdkJsonOptions); + + var encryptedJson = sdk.EncryptCipher(originalJson, orgKeys.Key); + + Assert.DoesNotContain("\"error\"", encryptedJson); + Assert.Contains("\"name\":\"2.", encryptedJson); + + var decryptedJson = sdk.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 sdk = new RustSdkService(); + var orgKeys = sdk.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 = sdk.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 sdk = new RustSdkService(); + var encryptionKey = sdk.GenerateOrganizationKeys(); + var differentKey = sdk.GenerateOrganizationKeys(); + + var originalCipher = CreateTestLoginCipher(); + var cipherJson = JsonSerializer.Serialize(originalCipher, SdkJsonOptions); + + var encryptedJson = sdk.EncryptCipher(cipherJson, encryptionKey.Key); + Assert.DoesNotContain("\"error\"", encryptedJson); + + var decryptedJson = sdk.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 sdk = new RustSdkService(); + var orgKeys = sdk.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 = sdk.EncryptCipher(cipherJson, orgKeys.Key); + + Assert.DoesNotContain("\"error\"", encryptedJson); + Assert.DoesNotContain("sk-secret-api-key-12345", encryptedJson); + Assert.DoesNotContain("client-id-xyz", encryptedJson); + + var decryptedJson = sdk.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 sdk = new RustSdkService(); + var orgKeys = sdk.GenerateOrganizationKeys(); + var seeder = new CipherSeeder(sdk); + var orgId = Guid.NewGuid(); + + // Create cipher using the seeder + var cipher = seeder.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 sdk = new RustSdkService(); + var orgKeys = sdk.GenerateOrganizationKeys(); + var seeder = new CipherSeeder(sdk); + + var cipher = seeder.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..1336268de1 100644 --- a/util/DbSeederUtility/Program.cs +++ b/util/DbSeederUtility/Program.cs @@ -1,6 +1,10 @@ -using Bit.Infrastructure.EntityFramework.Repositories; +using AutoMapper; +using Bit.Core.Entities; +using Bit.Infrastructure.EntityFramework.Repositories; +using Bit.RustSDK; using Bit.Seeder.Recipes; using CommandDotNet; +using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.DependencyInjection; namespace Bit.DbSeederUtility; @@ -36,4 +40,25 @@ public class Program var recipe = new OrganizationWithUsersRecipe(db); 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(), + 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..f21c0b89cf 100644 --- a/util/DbSeederUtility/ServiceCollectionExtension.cs +++ b/util/DbSeederUtility/ServiceCollectionExtension.cs @@ -1,5 +1,8 @@ -using Bit.SharedWeb.Utilities; +using Bit.Core.Entities; +using Bit.RustSDK; +using Bit.SharedWeb.Utilities; using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -13,8 +16,15 @@ 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(); + 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..ec3712274f 100644 --- a/util/RustSdk/RustSdkService.cs +++ b/util/RustSdk/RustSdkService.cs @@ -47,7 +47,7 @@ public class RustSdkService { var resultPtr = NativeMethods.generate_user_keys(emailPtr, passwordPtr); - var result = TakeAndDestroyRustString(resultPtr); + var result = ParseResponse(resultPtr); return JsonSerializer.Deserialize(result, CaseInsensitiveOptions)!; } @@ -57,7 +57,7 @@ public class RustSdkService { var resultPtr = NativeMethods.generate_organization_keys(); - var result = TakeAndDestroyRustString(resultPtr); + var result = ParseResponse(resultPtr); return JsonSerializer.Deserialize(result, CaseInsensitiveOptions)!; } @@ -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 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 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 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..c751d83399 --- /dev/null +++ b/util/Seeder/Factories/CipherSeeder.cs @@ -0,0 +1,153 @@ +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 readonly RustSdkService _sdkService; + + private static readonly JsonSerializerOptions SdkJsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + private static readonly JsonSerializerOptions ServerJsonOptions = new() + { + PropertyNamingPolicy = null, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + public CipherSeeder(RustSdkService sdkService) + { + _sdkService = sdkService; + } + + public 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 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 Cipher EncryptAndTransform(CipherViewDto cipherView, string keyBase64, Guid organizationId) + { + var viewJson = JsonSerializer.Serialize(cipherView, SdkJsonOptions); + var encryptedJson = _sdkService.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..8d86335911 --- /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(RustSdkService sdkService) +{ + public Collection CreateCollection(Guid organizationId, string orgKey, string name) + { + return new Collection + { + Id = Guid.NewGuid(), + OrganizationId = organizationId, + Name = sdkService.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..d8674552bd --- /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(RustSdkService sdkService) +{ + /// + /// 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 Folder CreateFolder(Guid userId, string userKeyBase64, string name) + { + return new Folder + { + Id = CoreHelpers.GenerateComb(), + UserId = userId, + Name = sdkService.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..0646fdd9ee 100644 --- a/util/Seeder/Factories/OrganizationSeeder.cs +++ b/util/Seeder/Factories/OrganizationSeeder.cs @@ -1,13 +1,16 @@ -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) + private static readonly string _defaultPublicKey = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAmIJbGMk6eZqVE7UxhZ46Weu2jKciqOiOkSVYtGvs61rfe9AXxtLaaZEKN4d4DmkZcF6dna2eXNxZmb7U4pwlttye8ksqISe6IUAZQox7auBpjopdCEPhKRg3BD/u8ks9UxSxgWe+fpebjt6gd5hsl1/5HOObn7SeU6EEU04cp3/eH7a4OTdXxB8oN62HGV9kM/ubM1goILgjoSJDbihMK0eb7b8hPHwcA/YOgKKiu/N3FighccdSMD5Pk+HfjacsFNZQa2EsqW09IvvSZ+iL6HQeZ1vwc/6TO1J7EOfJZFQcjoEL9LVI693efYoMZSmrPEWziZ4PvwpOOGo6OObyMQIDAQAB"; + private static readonly string _defaultPrivateKey = "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="; + + public static Organization CreateEnterprise(string name, string domain, int seats, string? publicKey = null, string? privateKey = null) { return new Organization { @@ -39,18 +42,14 @@ 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 ?? _defaultPublicKey, + PrivateKey = privateKey ?? _defaultPrivateKey, }; } } -public static class OrgnaizationExtensions +public static class OrganizationExtensions { /// /// Creates an OrganizationUser with fields populated based on status. @@ -74,17 +73,29 @@ public static class OrgnaizationExtensions }; } - public static OrganizationUser CreateSdkOrganizationUser(this Organization organization, User user) + /// + /// Creates an OrganizationUser with a dynamically provided encrypted org key. + /// The encryptedOrgKey should be generated using sdkService.GenerateUserOrganizationKey(). + /// + public static OrganizationUser CreateOrganizationUserWithKey( + this Organization organization, + User user, + OrganizationUserType type, + OrganizationUserStatusType status, + string? encryptedOrgKey) { + var shouldLinkUserId = status != OrganizationUserStatusType.Invited; + var shouldIncludeKey = status == OrganizationUserStatusType.Confirmed || status == OrganizationUserStatusType.Revoked; + 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 + UserId = shouldLinkUserId ? user.Id : null, + Email = shouldLinkUserId ? null : user.Email, + Key = shouldIncludeKey ? encryptedOrgKey : null, + Type = type, + Status = status }; } } diff --git a/util/Seeder/Factories/UserSeeder.cs b/util/Seeder/Factories/UserSeeder.cs index 9b80dbef3c..f355bde705 100644 --- a/util/Seeder/Factories/UserSeeder.cs +++ b/util/Seeder/Factories/UserSeeder.cs @@ -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,55 @@ 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, + RustSdkService sdkService, + IPasswordHasher passwordHasher) + { + var keys = sdkService.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/OrganizationWithVaultRecipe.cs b/util/Seeder/Recipes/OrganizationWithVaultRecipe.cs new file mode 100644 index 0000000000..44c86f49f0 --- /dev/null +++ b/util/Seeder/Recipes/OrganizationWithVaultRecipe.cs @@ -0,0 +1,330 @@ +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, + RustSdkService sdkService, + IPasswordHasher passwordHasher) +{ + private readonly CollectionSeeder _collectionSeeder = new(sdkService); + private readonly CipherSeeder _cipherSeeder = new(sdkService); + private readonly FolderSeeder _folderSeeder = new(sdkService); + + /// + /// 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 = sdkService.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}", sdkService, passwordHasher); + var ownerOrgKey = sdkService.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 = sdkService.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) + ? sdkService.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 @@ + + + + From 5941e830d2f1186ddc5c8ffb179096df203856bd Mon Sep 17 00:00:00 2001 From: Mick Letofsky Date: Fri, 30 Jan 2026 16:03:56 +0100 Subject: [PATCH 5/6] Refactor to correctly implement statics and remove hardcoded organization keys (#6924) --- .../GroupsControllerPerformanceTests.cs | 11 ++- ...nizationUsersControllerPerformanceTests.cs | 79 +++++++++++++------ ...OrganizationsControllerPerformanceTests.cs | 19 +++-- .../RustSdkCipherTests.cs | 40 ++++------ util/DbSeederUtility/Program.cs | 6 +- .../ServiceCollectionExtension.cs | 2 - util/RustSdk/RustSdkService.cs | 12 +-- util/Seeder/Factories/CipherSeeder.cs | 15 +--- util/Seeder/Factories/CollectionSeeder.cs | 6 +- util/Seeder/Factories/FolderSeeder.cs | 6 +- util/Seeder/Factories/OrganizationSeeder.cs | 29 +------ util/Seeder/Factories/UserSeeder.cs | 7 +- util/Seeder/Recipes/CollectionsRecipe.cs | 2 +- util/Seeder/Recipes/GroupsRecipe.cs | 2 +- .../Recipes/OrganizationDomainRecipe.cs | 2 +- .../Recipes/OrganizationWithUsersRecipe.cs | 55 +++++++++---- .../Recipes/OrganizationWithVaultRecipe.cs | 22 +++--- util/SeederApi/Startup.cs | 1 - 18 files changed, 169 insertions(+), 147 deletions(-) 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/SeederApi.IntegrationTest/RustSdkCipherTests.cs b/test/SeederApi.IntegrationTest/RustSdkCipherTests.cs index 3c831c4893..7ca7a0b913 100644 --- a/test/SeederApi.IntegrationTest/RustSdkCipherTests.cs +++ b/test/SeederApi.IntegrationTest/RustSdkCipherTests.cs @@ -18,18 +18,17 @@ public class RustSdkCipherTests [Fact] public void EncryptDecrypt_LoginCipher_RoundtripPreservesPlaintext() { - var sdk = new RustSdkService(); - var orgKeys = sdk.GenerateOrganizationKeys(); + var orgKeys = RustSdkService.GenerateOrganizationKeys(); var originalCipher = CreateTestLoginCipher(); var originalJson = JsonSerializer.Serialize(originalCipher, SdkJsonOptions); - var encryptedJson = sdk.EncryptCipher(originalJson, orgKeys.Key); + var encryptedJson = RustSdkService.EncryptCipher(originalJson, orgKeys.Key); Assert.DoesNotContain("\"error\"", encryptedJson); Assert.Contains("\"name\":\"2.", encryptedJson); - var decryptedJson = sdk.DecryptCipher(encryptedJson, orgKeys.Key); + var decryptedJson = RustSdkService.DecryptCipher(encryptedJson, orgKeys.Key); Assert.DoesNotContain("\"error\"", decryptedJson); @@ -45,8 +44,7 @@ public class RustSdkCipherTests [Fact] public void EncryptCipher_WithUri_EncryptsAllFields() { - var sdk = new RustSdkService(); - var orgKeys = sdk.GenerateOrganizationKeys(); + var orgKeys = RustSdkService.GenerateOrganizationKeys(); var cipher = new CipherViewDto { @@ -66,7 +64,7 @@ public class RustSdkCipherTests }; var cipherJson = JsonSerializer.Serialize(cipher, SdkJsonOptions); - var encryptedJson = sdk.EncryptCipher(cipherJson, orgKeys.Key); + var encryptedJson = RustSdkService.EncryptCipher(cipherJson, orgKeys.Key); Assert.DoesNotContain("\"error\"", encryptedJson); Assert.DoesNotContain("Amazon Shopping", encryptedJson); @@ -77,17 +75,16 @@ public class RustSdkCipherTests [Fact] public void DecryptCipher_WithWrongKey_FailsOrProducesGarbage() { - var sdk = new RustSdkService(); - var encryptionKey = sdk.GenerateOrganizationKeys(); - var differentKey = sdk.GenerateOrganizationKeys(); + var encryptionKey = RustSdkService.GenerateOrganizationKeys(); + var differentKey = RustSdkService.GenerateOrganizationKeys(); var originalCipher = CreateTestLoginCipher(); var cipherJson = JsonSerializer.Serialize(originalCipher, SdkJsonOptions); - var encryptedJson = sdk.EncryptCipher(cipherJson, encryptionKey.Key); + var encryptedJson = RustSdkService.EncryptCipher(cipherJson, encryptionKey.Key); Assert.DoesNotContain("\"error\"", encryptedJson); - var decryptedJson = sdk.DecryptCipher(encryptedJson, differentKey.Key); + var decryptedJson = RustSdkService.DecryptCipher(encryptedJson, differentKey.Key); var decryptionFailedWithError = decryptedJson.Contains("\"error\""); if (!decryptionFailedWithError) @@ -100,8 +97,7 @@ public class RustSdkCipherTests [Fact] public void EncryptCipher_WithFields_EncryptsCustomFields() { - var sdk = new RustSdkService(); - var orgKeys = sdk.GenerateOrganizationKeys(); + var orgKeys = RustSdkService.GenerateOrganizationKeys(); var cipher = new CipherViewDto { @@ -120,13 +116,13 @@ public class RustSdkCipherTests }; var cipherJson = JsonSerializer.Serialize(cipher, SdkJsonOptions); - var encryptedJson = sdk.EncryptCipher(cipherJson, orgKeys.Key); + 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 = sdk.DecryptCipher(encryptedJson, orgKeys.Key); + var decryptedJson = RustSdkService.DecryptCipher(encryptedJson, orgKeys.Key); var decrypted = JsonSerializer.Deserialize(decryptedJson, SdkJsonOptions); Assert.NotNull(decrypted?.Fields); @@ -138,13 +134,11 @@ public class RustSdkCipherTests [Fact] public void CipherSeeder_ProducesServerCompatibleFormat() { - var sdk = new RustSdkService(); - var orgKeys = sdk.GenerateOrganizationKeys(); - var seeder = new CipherSeeder(sdk); + var orgKeys = RustSdkService.GenerateOrganizationKeys(); var orgId = Guid.NewGuid(); // Create cipher using the seeder - var cipher = seeder.CreateOrganizationLoginCipher( + var cipher = CipherSeeder.CreateOrganizationLoginCipher( orgId, orgKeys.Key, name: "GitHub Account", @@ -179,11 +173,9 @@ public class RustSdkCipherTests [Fact] public void CipherSeeder_WithFields_ProducesCorrectServerFormat() { - var sdk = new RustSdkService(); - var orgKeys = sdk.GenerateOrganizationKeys(); - var seeder = new CipherSeeder(sdk); + var orgKeys = RustSdkService.GenerateOrganizationKeys(); - var cipher = seeder.CreateOrganizationLoginCipherWithFields( + var cipher = CipherSeeder.CreateOrganizationLoginCipherWithFields( Guid.NewGuid(), orgKeys.Key, name: "API Service", diff --git a/util/DbSeederUtility/Program.cs b/util/DbSeederUtility/Program.cs index 1336268de1..379f60ea1a 100644 --- a/util/DbSeederUtility/Program.cs +++ b/util/DbSeederUtility/Program.cs @@ -1,7 +1,6 @@ using AutoMapper; using Bit.Core.Entities; using Bit.Infrastructure.EntityFramework.Repositories; -using Bit.RustSDK; using Bit.Seeder.Recipes; using CommandDotNet; using Microsoft.AspNetCore.Identity; @@ -37,7 +36,9 @@ 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); } @@ -56,7 +57,6 @@ public class Program var recipe = new OrganizationWithVaultRecipe( scopedServices.GetRequiredService(), scopedServices.GetRequiredService(), - scopedServices.GetRequiredService(), scopedServices.GetRequiredService>()); recipe.Seed(args.ToOptions()); diff --git a/util/DbSeederUtility/ServiceCollectionExtension.cs b/util/DbSeederUtility/ServiceCollectionExtension.cs index f21c0b89cf..ca454c50f3 100644 --- a/util/DbSeederUtility/ServiceCollectionExtension.cs +++ b/util/DbSeederUtility/ServiceCollectionExtension.cs @@ -1,5 +1,4 @@ using Bit.Core.Entities; -using Bit.RustSDK; using Bit.SharedWeb.Utilities; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Identity; @@ -23,7 +22,6 @@ public static class ServiceCollectionExtension builder.AddFilter("Microsoft.EntityFrameworkCore.Model.Validation", LogLevel.Error); }); services.AddSingleton(globalSettings); - services.AddSingleton(); services.AddSingleton, PasswordHasher>(); // Add Data Protection services diff --git a/util/RustSdk/RustSdkService.cs b/util/RustSdk/RustSdkService.cs index ec3712274f..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); @@ -53,7 +53,7 @@ public class RustSdkService } } - public unsafe OrganizationKeys GenerateOrganizationKeys() + public static unsafe OrganizationKeys GenerateOrganizationKeys() { var resultPtr = NativeMethods.generate_organization_keys(); @@ -62,7 +62,7 @@ public class RustSdkService 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); @@ -78,7 +78,7 @@ public class RustSdkService } } - public unsafe string EncryptCipher(string cipherViewJson, string symmetricKeyBase64) + public static unsafe string EncryptCipher(string cipherViewJson, string symmetricKeyBase64) { var cipherViewBytes = StringToRustString(cipherViewJson); var keyBytes = StringToRustString(symmetricKeyBase64); @@ -92,7 +92,7 @@ public class RustSdkService } } - public unsafe string DecryptCipher(string cipherJson, string symmetricKeyBase64) + public static unsafe string DecryptCipher(string cipherJson, string symmetricKeyBase64) { var cipherBytes = StringToRustString(cipherJson); var keyBytes = StringToRustString(symmetricKeyBase64); @@ -110,7 +110,7 @@ public class RustSdkService /// Encrypts a plaintext string using the provided symmetric key. /// Returns an EncString in format "2.{iv}|{data}|{mac}". /// - public unsafe string EncryptString(string plaintext, string symmetricKeyBase64) + public static unsafe string EncryptString(string plaintext, string symmetricKeyBase64) { var plaintextBytes = StringToRustString(plaintext); var keyBytes = StringToRustString(symmetricKeyBase64); diff --git a/util/Seeder/Factories/CipherSeeder.cs b/util/Seeder/Factories/CipherSeeder.cs index c751d83399..9d4c039b2c 100644 --- a/util/Seeder/Factories/CipherSeeder.cs +++ b/util/Seeder/Factories/CipherSeeder.cs @@ -22,8 +22,6 @@ namespace Bit.Seeder.Factories; /// public class CipherSeeder { - private readonly RustSdkService _sdkService; - private static readonly JsonSerializerOptions SdkJsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, @@ -36,12 +34,7 @@ public class CipherSeeder DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; - public CipherSeeder(RustSdkService sdkService) - { - _sdkService = sdkService; - } - - public Cipher CreateOrganizationLoginCipher( + public static Cipher CreateOrganizationLoginCipher( Guid organizationId, string orgKeyBase64, string name, @@ -67,7 +60,7 @@ public class CipherSeeder return EncryptAndTransform(cipherView, orgKeyBase64, organizationId); } - public Cipher CreateOrganizationLoginCipherWithFields( + public static Cipher CreateOrganizationLoginCipherWithFields( Guid organizationId, string orgKeyBase64, string name, @@ -98,10 +91,10 @@ public class CipherSeeder return EncryptAndTransform(cipherView, orgKeyBase64, organizationId); } - private Cipher EncryptAndTransform(CipherViewDto cipherView, string keyBase64, Guid organizationId) + private static Cipher EncryptAndTransform(CipherViewDto cipherView, string keyBase64, Guid organizationId) { var viewJson = JsonSerializer.Serialize(cipherView, SdkJsonOptions); - var encryptedJson = _sdkService.EncryptCipher(viewJson, keyBase64); + var encryptedJson = RustSdkService.EncryptCipher(viewJson, keyBase64); var encryptedDto = JsonSerializer.Deserialize(encryptedJson, SdkJsonOptions) ?? throw new InvalidOperationException("Failed to parse encrypted cipher"); diff --git a/util/Seeder/Factories/CollectionSeeder.cs b/util/Seeder/Factories/CollectionSeeder.cs index 8d86335911..231fe86b43 100644 --- a/util/Seeder/Factories/CollectionSeeder.cs +++ b/util/Seeder/Factories/CollectionSeeder.cs @@ -3,15 +3,15 @@ using Bit.RustSDK; namespace Bit.Seeder.Factories; -public class CollectionSeeder(RustSdkService sdkService) +public class CollectionSeeder { - public Collection CreateCollection(Guid organizationId, string orgKey, string name) + public static Collection CreateCollection(Guid organizationId, string orgKey, string name) { return new Collection { Id = Guid.NewGuid(), OrganizationId = organizationId, - Name = sdkService.EncryptString(name, orgKey), + Name = RustSdkService.EncryptString(name, orgKey), CreationDate = DateTime.UtcNow, RevisionDate = DateTime.UtcNow }; diff --git a/util/Seeder/Factories/FolderSeeder.cs b/util/Seeder/Factories/FolderSeeder.cs index d8674552bd..8cf7413bbc 100644 --- a/util/Seeder/Factories/FolderSeeder.cs +++ b/util/Seeder/Factories/FolderSeeder.cs @@ -8,7 +8,7 @@ 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(RustSdkService sdkService) +internal sealed class FolderSeeder { /// /// Creates a folder with an encrypted name. @@ -16,13 +16,13 @@ internal sealed class FolderSeeder(RustSdkService sdkService) /// The user who owns this folder. /// The user's symmetric key (not org key). /// The plaintext folder name to encrypt. - public Folder CreateFolder(Guid userId, string userKeyBase64, string name) + public static Folder CreateFolder(Guid userId, string userKeyBase64, string name) { return new Folder { Id = CoreHelpers.GenerateComb(), UserId = userId, - Name = sdkService.EncryptString(name, userKeyBase64) + Name = RustSdkService.EncryptString(name, userKeyBase64) }; } } diff --git a/util/Seeder/Factories/OrganizationSeeder.cs b/util/Seeder/Factories/OrganizationSeeder.cs index 0646fdd9ee..30b790c343 100644 --- a/util/Seeder/Factories/OrganizationSeeder.cs +++ b/util/Seeder/Factories/OrganizationSeeder.cs @@ -7,9 +7,6 @@ namespace Bit.Seeder.Factories; public class OrganizationSeeder { - private static readonly string _defaultPublicKey = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAmIJbGMk6eZqVE7UxhZ46Weu2jKciqOiOkSVYtGvs61rfe9AXxtLaaZEKN4d4DmkZcF6dna2eXNxZmb7U4pwlttye8ksqISe6IUAZQox7auBpjopdCEPhKRg3BD/u8ks9UxSxgWe+fpebjt6gd5hsl1/5HOObn7SeU6EEU04cp3/eH7a4OTdXxB8oN62HGV9kM/ubM1goILgjoSJDbihMK0eb7b8hPHwcA/YOgKKiu/N3FighccdSMD5Pk+HfjacsFNZQa2EsqW09IvvSZ+iL6HQeZ1vwc/6TO1J7EOfJZFQcjoEL9LVI693efYoMZSmrPEWziZ4PvwpOOGo6OObyMQIDAQAB"; - private static readonly string _defaultPrivateKey = "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="; - public static Organization CreateEnterprise(string name, string domain, int seats, string? publicKey = null, string? privateKey = null) { return new Organization @@ -43,36 +40,14 @@ public class OrganizationSeeder SyncSeats = true, Status = OrganizationStatusType.Created, MaxStorageGb = 10, - PublicKey = publicKey ?? _defaultPublicKey, - PrivateKey = privateKey ?? _defaultPrivateKey, + PublicKey = publicKey, + PrivateKey = privateKey }; } } 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. - /// - public static OrganizationUser CreateOrganizationUser( - this Organization organization, User user, OrganizationUserType type, OrganizationUserStatusType status) - { - var isInvited = status == OrganizationUserStatusType.Invited; - var isConfirmed = 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, - Type = type, - Status = status - }; - } - /// /// Creates an OrganizationUser with a dynamically provided encrypted org key. /// The encryptedOrgKey should be generated using sdkService.GenerateUserOrganizationKey(). diff --git a/util/Seeder/Factories/UserSeeder.cs b/util/Seeder/Factories/UserSeeder.cs index f355bde705..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 public static User CreateUserWithSdkKeys( string email, - RustSdkService sdkService, IPasswordHasher passwordHasher) { - var keys = sdkService.GenerateUserKeys(email, DefaultPassword); + var keys = RustSdkService.GenerateUserKeys(email, DefaultPassword); return CreateUserFromKeys(email, keys, passwordHasher); } 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 index 44c86f49f0..6b729273f1 100644 --- a/util/Seeder/Recipes/OrganizationWithVaultRecipe.cs +++ b/util/Seeder/Recipes/OrganizationWithVaultRecipe.cs @@ -27,12 +27,8 @@ namespace Bit.Seeder.Recipes; public class OrganizationWithVaultRecipe( DatabaseContext db, IMapper mapper, - RustSdkService sdkService, IPasswordHasher passwordHasher) { - private readonly CollectionSeeder _collectionSeeder = new(sdkService); - private readonly CipherSeeder _cipherSeeder = new(sdkService); - private readonly FolderSeeder _folderSeeder = new(sdkService); /// /// Tracks a user with their symmetric key for folder encryption. @@ -47,15 +43,15 @@ public class OrganizationWithVaultRecipe( public Guid Seed(OrganizationVaultOptions options) { var seats = Math.Max(options.Users + 1, 1000); - var orgKeys = sdkService.GenerateOrganizationKeys(); + 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}", sdkService, passwordHasher); - var ownerOrgKey = sdkService.GenerateUserOrganizationKey(ownerUser.PublicKey!, orgKeys.Key); + 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); @@ -67,7 +63,7 @@ public class OrganizationWithVaultRecipe( for (var i = 0; i < options.Users; i++) { var email = $"user{i}@{options.Domain}"; - var userKeys = sdkService.GenerateUserKeys(email, UserSeeder.DefaultPassword); + var userKeys = RustSdkService.GenerateUserKeys(email, UserSeeder.DefaultPassword); var memberUser = UserSeeder.CreateUserFromKeys(email, userKeys, passwordHasher); memberUsersWithKeys.Add(new UserWithKey(memberUser, userKeys.Key)); @@ -77,7 +73,7 @@ public class OrganizationWithVaultRecipe( var memberOrgKey = (status == OrganizationUserStatusType.Confirmed || status == OrganizationUserStatusType.Revoked) - ? sdkService.GenerateUserOrganizationKey(memberUser.PublicKey!, orgKeys.Key) + ? RustSdkService.GenerateUserOrganizationKey(memberUser.PublicKey!, orgKeys.Key) : null; memberOrgUsers.Add(organization.CreateOrganizationUserWithKey( @@ -124,12 +120,12 @@ public class OrganizationWithVaultRecipe( { var structure = OrgStructures.GetStructure(structureModel.Value); collections = structure.Units - .Select(unit => _collectionSeeder.CreateCollection(organizationId, orgKeyBase64, unit.Name)) + .Select(unit => CollectionSeeder.CreateCollection(organizationId, orgKeyBase64, unit.Name)) .ToList(); } else { - collections = [_collectionSeeder.CreateCollection(organizationId, orgKeyBase64, "Default Collection")]; + collections = [CollectionSeeder.CreateCollection(organizationId, orgKeyBase64, "Default Collection")]; } db.BulkCopy(collections); @@ -191,7 +187,7 @@ public class OrganizationWithVaultRecipe( .Select(i => { var company = companies[i % companies.Length]; - return _cipherSeeder.CreateOrganizationLoginCipher( + return CipherSeeder.CreateOrganizationLoginCipher( organizationId, orgKeyBase64, name: $"{company.Name} ({company.Category})", @@ -285,7 +281,7 @@ public class OrganizationWithVaultRecipe( { var folderCount = GetFolderCountForUser(userIndex, usersWithKeys.Count, random); return Enumerable.Range(0, folderCount) - .Select(folderIndex => _folderSeeder.CreateFolder( + .Select(folderIndex => FolderSeeder.CreateFolder( uwk.User.Id, uwk.SymmetricKey, folderNameGenerator.GetFolderName(userIndex * 15 + folderIndex))); 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(); From 51aa4195852dc52e8cdaad47906405b75c7f4621 Mon Sep 17 00:00:00 2001 From: Nik Gilmore Date: Fri, 30 Jan 2026 09:57:10 -0800 Subject: [PATCH 6/6] [PM-31280] Specify UTC dates for Archive, Unarchive, Restore, and RestoreByIds (#6919) --- .../Vault/Repositories/CipherRepository.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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); } }