From 959860a8e0089212e26f637df4a78acf2d57126f Mon Sep 17 00:00:00 2001 From: Mick Letofsky Date: Tue, 27 Jan 2026 19:16:19 +0100 Subject: [PATCH] Construct a folder seeder --- util/Seeder/Data/FolderNameGenerator.cs | 31 +++++++ util/Seeder/Factories/FolderSeeder.cs | 28 +++++++ util/Seeder/Factories/UserSeeder.cs | 12 +++ .../Recipes/OrganizationWithVaultRecipe.cs | 83 ++++++++++++++++++- 4 files changed, 150 insertions(+), 4 deletions(-) create mode 100644 util/Seeder/Data/FolderNameGenerator.cs create mode 100644 util/Seeder/Factories/FolderSeeder.cs 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/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/UserSeeder.cs b/util/Seeder/Factories/UserSeeder.cs index 74b1d1c458..7026466043 100644 --- a/util/Seeder/Factories/UserSeeder.cs +++ b/util/Seeder/Factories/UserSeeder.cs @@ -88,7 +88,19 @@ public class UserSeeder(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(), diff --git a/util/Seeder/Recipes/OrganizationWithVaultRecipe.cs b/util/Seeder/Recipes/OrganizationWithVaultRecipe.cs index 43ea0aa156..321cabeea4 100644 --- a/util/Seeder/Recipes/OrganizationWithVaultRecipe.cs +++ b/util/Seeder/Recipes/OrganizationWithVaultRecipe.cs @@ -9,6 +9,7 @@ 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; @@ -31,6 +32,12 @@ public class OrganizationWithVaultRecipe( { 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. @@ -53,15 +60,17 @@ public class OrganizationWithVaultRecipe( var ownerOrgUser = organization.CreateOrganizationUserWithKey( ownerUser, OrganizationUserType.Owner, OrganizationUserStatusType.Confirmed, ownerOrgKey); - // Create member users via factory - var memberUsers = new List(); + // 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 memberUser = UserSeeder.CreateUserWithSdkKeys($"user{i}@{options.Domain}", sdkService, passwordHasher); - memberUsers.Add(memberUser); + 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) @@ -76,6 +85,8 @@ public class OrganizationWithVaultRecipe( 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)); @@ -97,6 +108,7 @@ public class OrganizationWithVaultRecipe( 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; } @@ -253,4 +265,67 @@ public class OrganizationWithVaultRecipe( 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 + } }