1
0
mirror of https://github.com/bitwarden/server synced 2026-02-19 10:53:34 +00:00

Construct a folder seeder

This commit is contained in:
Mick Letofsky
2026-01-27 19:16:19 +01:00
parent 014e930147
commit 959860a8e0
4 changed files with 150 additions and 4 deletions

View File

@@ -0,0 +1,31 @@
using Bogus;
namespace Bit.Seeder.Data;
/// <summary>
/// Generates deterministic folder names using Bogus Commerce.Department().
/// Pre-generates a pool of business-themed names for consistent index-based access.
/// </summary>
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();
}
/// <summary>
/// Gets a folder name by index, wrapping around if index exceeds pool size.
/// </summary>
public string GetFolderName(int index) => _folderNames[index % _folderNames.Length];
}

View File

@@ -0,0 +1,28 @@
using Bit.Core.Utilities;
using Bit.Core.Vault.Entities;
using Bit.RustSDK;
namespace Bit.Seeder.Factories;
/// <summary>
/// Factory for creating Folder entities with encrypted names.
/// Folders are per-user constructs encrypted with the user's symmetric key.
/// </summary>
internal sealed class FolderSeeder(RustSdkService sdkService)
{
/// <summary>
/// Creates a folder with an encrypted name.
/// </summary>
/// <param name="userId">The user who owns this folder.</param>
/// <param name="userKeyBase64">The user's symmetric key (not org key).</param>
/// <param name="name">The plaintext folder name to encrypt.</param>
public Folder CreateFolder(Guid userId, string userKeyBase64, string name)
{
return new Folder
{
Id = CoreHelpers.GenerateComb(),
UserId = userId,
Name = sdkService.EncryptString(name, userKeyBase64)
};
}
}

View File

@@ -88,7 +88,19 @@ public class UserSeeder(RustSdkService sdkService, IPasswordHasher<Bit.Core.Enti
IPasswordHasher<User> passwordHasher)
{
var keys = sdkService.GenerateUserKeys(email, DefaultPassword);
return CreateUserFromKeys(email, keys, passwordHasher);
}
/// <summary>
/// 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).
/// </summary>
public static User CreateUserFromKeys(
string email,
UserKeys keys,
IPasswordHasher<User> passwordHasher)
{
var user = new User
{
Id = CoreHelpers.GenerateComb(),

View File

@@ -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);
/// <summary>
/// Tracks a user with their symmetric key for folder encryption.
/// </summary>
private record UserWithKey(User User, string SymmetricKey);
/// <summary>
/// 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<User>();
// Create member users via factory, retaining keys for folder encryption
var memberUsersWithKeys = new List<UserWithKey>();
var memberOrgUsers = new List<OrganizationUser>();
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<EfOrganization>(organization));
db.Add(mapper.Map<EfUser>(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;
}
/// <summary>
/// Creates personal vault folders for users with realistic distribution.
/// Folders are encrypted with each user's individual symmetric key.
/// </summary>
private void CreateFolders(List<UserWithKey> 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<EfFolder>(f)).ToList();
db.BulkCopy(efFolders);
}
}
/// <summary>
/// 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)
/// </summary>
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
}
}