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