1
0
mirror of https://github.com/bitwarden/server synced 2026-02-13 23:13:22 +00:00

Refactor into brand new recipe & proper organization per Seeder standards

This commit is contained in:
Mick Letofsky
2026-01-27 15:15:15 +01:00
parent 4c4b463a3d
commit 32b611aa08
16 changed files with 885 additions and 425 deletions

View File

@@ -26,14 +26,13 @@ public class GroupsControllerPerformanceTests(ITestOutputHelper testOutputHelper
var client = factory.CreateClient();
var db = factory.GetDatabaseContext();
using var scope = factory.Services.CreateScope();
var domain = OrganizationTestHelpers.GenerateRandomDomain();
var orgId = OrganizationWithUsersRecipe.SeedFromServices(scope.ServiceProvider, "Org", domain, userCount);
var orgSeeder = new OrganizationWithUsersRecipe(db);
var collectionsSeeder = new CollectionsRecipe(db);
var groupsSeeder = new GroupsRecipe(db);
var domain = OrganizationTestHelpers.GenerateRandomDomain();
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);

View File

@@ -28,14 +28,14 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
var client = factory.CreateClient();
var db = factory.GetDatabaseContext();
using var scope = factory.Services.CreateScope();
var domain = OrganizationTestHelpers.GenerateRandomDomain();
var orgId = OrganizationWithUsersRecipe.SeedFromServices(scope.ServiceProvider, "Org", domain, seats);
var orgSeeder = new OrganizationWithUsersRecipe(db);
var collectionsSeeder = new CollectionsRecipe(db);
var groupsSeeder = new GroupsRecipe(db);
var domain = OrganizationTestHelpers.GenerateRandomDomain();
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);
@@ -64,14 +64,13 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
var client = factory.CreateClient();
var db = factory.GetDatabaseContext();
using var scope = factory.Services.CreateScope();
var domain = OrganizationTestHelpers.GenerateRandomDomain();
var orgId = OrganizationWithUsersRecipe.SeedFromServices(scope.ServiceProvider, "Org", domain, seats);
var orgSeeder = new OrganizationWithUsersRecipe(db);
var collectionsSeeder = new CollectionsRecipe(db);
var groupsSeeder = new GroupsRecipe(db);
var domain = OrganizationTestHelpers.GenerateRandomDomain();
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);
@@ -99,12 +98,11 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
var client = factory.CreateClient();
var db = factory.GetDatabaseContext();
using var scope = factory.Services.CreateScope();
var orgSeeder = new OrganizationWithUsersRecipe(db);
var groupsSeeder = new GroupsRecipe(db);
var domain = OrganizationTestHelpers.GenerateRandomDomain();
var orgId = OrganizationWithUsersRecipe.SeedFromServices(scope.ServiceProvider, "Org", domain, 1);
var groupsSeeder = new GroupsRecipe(db);
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]);
@@ -132,10 +130,10 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
var client = factory.CreateClient();
var db = factory.GetDatabaseContext();
using var scope = factory.Services.CreateScope();
var orgSeeder = new OrganizationWithUsersRecipe(db);
var domain = OrganizationTestHelpers.GenerateRandomDomain();
var orgId = OrganizationWithUsersRecipe.SeedFromServices(scope.ServiceProvider, name: "Org", domain: domain, users: 1);
var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: 1);
var orgUserId = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).FirstOrDefault();
@@ -165,10 +163,14 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
var client = factory.CreateClient();
var db = factory.GetDatabaseContext();
using var scope = factory.Services.CreateScope();
var orgSeeder = new OrganizationWithUsersRecipe(db);
var domain = OrganizationTestHelpers.GenerateRandomDomain();
var orgId = OrganizationWithUsersRecipe.SeedFromServices(scope.ServiceProvider, "Org", domain, userCount, usersStatus: OrganizationUserStatusType.Accepted);
var orgId = orgSeeder.Seed(
name: "Org",
domain: domain,
users: userCount,
usersStatus: OrganizationUserStatusType.Accepted);
await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}");
@@ -209,10 +211,10 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
var client = factory.CreateClient();
var db = factory.GetDatabaseContext();
using var scope = factory.Services.CreateScope();
var orgSeeder = new OrganizationWithUsersRecipe(db);
var domain = OrganizationTestHelpers.GenerateRandomDomain();
var orgId = OrganizationWithUsersRecipe.SeedFromServices(scope.ServiceProvider, name: "Org", domain: domain, users: userCount);
var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: userCount);
await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}");
@@ -249,10 +251,14 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
var client = factory.CreateClient();
var db = factory.GetDatabaseContext();
using var scope = factory.Services.CreateScope();
var orgSeeder = new OrganizationWithUsersRecipe(db);
var domain = OrganizationTestHelpers.GenerateRandomDomain();
var orgId = OrganizationWithUsersRecipe.SeedFromServices(scope.ServiceProvider, "Org", domain, userCount, usersStatus: OrganizationUserStatusType.Confirmed);
var orgId = orgSeeder.Seed(
name: "Org",
domain: domain,
users: userCount,
usersStatus: OrganizationUserStatusType.Confirmed);
await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}");
@@ -289,10 +295,14 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
var client = factory.CreateClient();
var db = factory.GetDatabaseContext();
using var scope = factory.Services.CreateScope();
var orgSeeder = new OrganizationWithUsersRecipe(db);
var domain = OrganizationTestHelpers.GenerateRandomDomain();
var orgId = OrganizationWithUsersRecipe.SeedFromServices(scope.ServiceProvider, "Org", domain, userCount, usersStatus: OrganizationUserStatusType.Revoked);
var orgId = orgSeeder.Seed(
name: "Org",
domain: domain,
users: userCount,
usersStatus: OrganizationUserStatusType.Revoked);
await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}");
@@ -329,11 +339,15 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
var client = factory.CreateClient();
var db = factory.GetDatabaseContext();
using var scope = factory.Services.CreateScope();
var orgSeeder = new OrganizationWithUsersRecipe(db);
var domainSeeder = new OrganizationDomainRecipe(db);
var domain = OrganizationTestHelpers.GenerateRandomDomain();
var orgId = OrganizationWithUsersRecipe.SeedFromServices(scope.ServiceProvider, "Org", domain, userCount,
var orgId = orgSeeder.Seed(
name: "Org",
domain: domain,
users: userCount,
usersStatus: OrganizationUserStatusType.Confirmed);
domainSeeder.AddVerifiedDomainToOrganization(orgId, domain);
@@ -370,13 +384,13 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
var client = factory.CreateClient();
var db = factory.GetDatabaseContext();
using var scope = factory.Services.CreateScope();
var domain = OrganizationTestHelpers.GenerateRandomDomain();
var orgId = OrganizationWithUsersRecipe.SeedFromServices(scope.ServiceProvider, "Org", domain, 1);
var orgSeeder = new OrganizationWithUsersRecipe(db);
var collectionsSeeder = new CollectionsRecipe(db);
var groupsSeeder = new GroupsRecipe(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, 3, orgUserIds, 0);
var groupIds = groupsSeeder.AddToOrganization(orgId, 2, orgUserIds, 0);
@@ -420,10 +434,10 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
var client = factory.CreateClient();
var db = factory.GetDatabaseContext();
using var scope = factory.Services.CreateScope();
var orgSeeder = new OrganizationWithUsersRecipe(db);
var domain = OrganizationTestHelpers.GenerateRandomDomain();
var orgId = OrganizationWithUsersRecipe.SeedFromServices(scope.ServiceProvider, name: "Org", domain: domain, users: userCount);
var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: userCount);
await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}");
@@ -457,11 +471,14 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
var client = factory.CreateClient();
var db = factory.GetDatabaseContext();
using var scope = factory.Services.CreateScope();
var orgSeeder = new OrganizationWithUsersRecipe(db);
var domainSeeder = new OrganizationDomainRecipe(db);
var domain = OrganizationTestHelpers.GenerateRandomDomain();
var orgId = OrganizationWithUsersRecipe.SeedFromServices(scope.ServiceProvider, "Org", domain, 2,
var orgId = orgSeeder.Seed(
name: "Org",
domain: domain,
users: 2,
usersStatus: OrganizationUserStatusType.Confirmed);
domainSeeder.AddVerifiedDomainToOrganization(orgId, domain);
@@ -495,12 +512,12 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
var client = factory.CreateClient();
var db = factory.GetDatabaseContext();
using var scope = factory.Services.CreateScope();
var orgSeeder = new OrganizationWithUsersRecipe(db);
var collectionsSeeder = new CollectionsRecipe(db);
var domain = OrganizationTestHelpers.GenerateRandomDomain();
var orgId = OrganizationWithUsersRecipe.SeedFromServices(scope.ServiceProvider, "Org", domain, 1);
var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: 1);
var collectionsSeeder = new CollectionsRecipe(db);
var orgUserIds = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).ToList();
var collectionIds = collectionsSeeder.AddToOrganization(orgId, 2, orgUserIds, 0);
@@ -543,10 +560,14 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
var client = factory.CreateClient();
var db = factory.GetDatabaseContext();
using var scope = factory.Services.CreateScope();
var orgSeeder = new OrganizationWithUsersRecipe(db);
var domain = OrganizationTestHelpers.GenerateRandomDomain();
var orgId = OrganizationWithUsersRecipe.SeedFromServices(scope.ServiceProvider, "Org", domain, userCount, usersStatus: OrganizationUserStatusType.Invited);
var orgId = orgSeeder.Seed(
name: "Org",
domain: domain,
users: userCount,
usersStatus: OrganizationUserStatusType.Invited);
await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}");

View File

@@ -29,13 +29,13 @@ public class OrganizationsControllerPerformanceTests(ITestOutputHelper testOutpu
var client = factory.CreateClient();
var db = factory.GetDatabaseContext();
using var scope = factory.Services.CreateScope();
var domain = OrganizationTestHelpers.GenerateRandomDomain();
var orgId = OrganizationWithUsersRecipe.SeedFromServices(scope.ServiceProvider, "Org", domain, userCount);
var orgSeeder = new OrganizationWithUsersRecipe(db);
var collectionsSeeder = new CollectionsRecipe(db);
var groupsSeeder = new GroupsRecipe(db);
var domain = OrganizationTestHelpers.GenerateRandomDomain();
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);
@@ -77,13 +77,13 @@ public class OrganizationsControllerPerformanceTests(ITestOutputHelper testOutpu
var client = factory.CreateClient();
var db = factory.GetDatabaseContext();
using var scope = factory.Services.CreateScope();
var domain = OrganizationTestHelpers.GenerateRandomDomain();
var orgId = OrganizationWithUsersRecipe.SeedFromServices(scope.ServiceProvider, "Org", domain, userCount);
var orgSeeder = new OrganizationWithUsersRecipe(db);
var collectionsSeeder = new CollectionsRecipe(db);
var groupsSeeder = new GroupsRecipe(db);
var domain = OrganizationTestHelpers.GenerateRandomDomain();
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);

View File

@@ -1,6 +1,12 @@
using Bit.Seeder.Data.Enums;
using AutoMapper;
using Bit.Core.Entities;
using Bit.Infrastructure.EntityFramework.Repositories;
using Bit.RustSDK;
using Bit.Seeder.Data.Enums;
using Bit.Seeder.Options;
using Bit.Seeder.Recipes;
using CommandDotNet;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;
namespace Bit.DbSeederUtility;
@@ -13,32 +19,91 @@ public class Program
.Run(args);
}
[Command("organization", Description = "Seed an organization with users and optional ciphers")]
[Command("organization", Description = "Seed an organization and organization users")]
public void Organization(
[Option('n', "name", Description = "Name of organization")]
[Option('n', "Name", Description = "Name of organization")]
string name,
[Option('u', "users", Description = "Number of users to generate")]
int users,
[Option('d', "domain", Description = "Email domain for users")]
string domain
)
{
// Create service provider with necessary services
var services = new ServiceCollection();
ServiceCollectionExtension.ConfigureServices(services);
var serviceProvider = services.BuildServiceProvider();
// Get a scoped DB context
using var scope = serviceProvider.CreateScope();
var scopedServices = scope.ServiceProvider;
var db = scopedServices.GetRequiredService<DatabaseContext>();
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(
[Option('n', "name", Description = "Name of organization")]
string name,
[Option('u', "users", Description = "Number of users to generate (minimum 1)")]
int users,
[Option('d', "domain", Description = "Email domain for users")]
string domain,
[Option('c', "ciphers", Description = "Number of login ciphers to create")]
int ciphers = 0,
[Option('c', "ciphers", Description = "Number of login ciphers to create (required, minimum 1)")]
int ciphers,
[Option('g', "groups", Description = "Number of groups to create (required, minimum 1)")]
int groups,
[Option('m', "mix-user-statuses", Description = "Use realistic status mix (85% confirmed, 5% each invited/accepted/revoked). Requires >= 10 users.")]
bool mixStatuses = false,
[Option('o', "org-structure", Description = "Org structure for collections: Traditional, Spotify, or Modern")]
string? structure = null
)
{
var structureModel = ParseStructureModel(structure);
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.");
}
var structureModel = ParseOrgStructure(structure);
var services = new ServiceCollection();
ServiceCollectionExtension.ConfigureServices(services);
var serviceProvider = services.BuildServiceProvider();
using var scope = serviceProvider.CreateScope();
OrganizationWithUsersRecipe.SeedFromServices(scope.ServiceProvider, name, domain, users, ciphers,
structureModel: structureModel);
var scopedServices = scope.ServiceProvider;
var recipe = new OrganizationWithVaultRecipe(
scopedServices.GetRequiredService<DatabaseContext>(),
scopedServices.GetRequiredService<IMapper>(),
scopedServices.GetRequiredService<RustSdkService>(),
scopedServices.GetRequiredService<IPasswordHasher<User>>());
recipe.Seed(new OrganizationVaultOptions
{
Name = name,
Domain = domain,
Users = users,
Ciphers = ciphers,
Groups = groups,
RealisticStatusMix = mixStatuses,
StructureModel = structureModel
});
}
private static OrgStructureModel? ParseStructureModel(string? structure)
private static OrgStructureModel? ParseOrgStructure(string? structure)
{
if (string.IsNullOrEmpty(structure))
{

View File

@@ -1,6 +1,25 @@
namespace Bit.Seeder.Data.Enums;
/// <summary>
/// Password strength levels for test data generation.
/// Password strength levels aligned with zxcvbn scoring (0-4).
/// </summary>
public enum PasswordStrength { Weak, Medium, Strong, Mixed }
public enum PasswordStrength
{
/// <summary>Score 0: Too guessable (&lt; 10³ guesses)</summary>
VeryWeak = 0,
/// <summary>Score 1: Very guessable (&lt; 10⁶ guesses)</summary>
Weak = 1,
/// <summary>Score 2: Somewhat guessable (&lt; 10⁸ guesses)</summary>
Fair = 2,
/// <summary>Score 3: Safely unguessable (&lt; 10¹⁰ guesses)</summary>
Strong = 3,
/// <summary>Score 4: Very unguessable (≥ 10¹⁰ guesses)</summary>
VeryStrong = 4,
/// <summary>Realistic distribution based on breach data statistics.</summary>
Realistic = 99
}

View File

@@ -3,64 +3,145 @@
namespace Bit.Seeder.Data;
/// <summary>
/// Password collections by strength level for realistic test data.
/// Password collections by zxcvbn strength level (0-4) for realistic test data.
/// </summary>
internal static class Passwords
{
/// <summary>
/// Top breached passwords - use for security testing scenarios.
/// Score 0 - Too guessable: keyboard walks, simple sequences, single words.
/// </summary>
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"
];
/// <summary>
/// Score 1 - Very guessable: common patterns with minor complexity.
/// </summary>
public static readonly string[] Weak =
[
"password", "123456", "qwerty", "abc123", "letmein", "welcome", "admin", "dragon", "sunshine", "princess",
"football", "master", "shadow", "superman", "trustno1", "iloveyou", "passw0rd", "p@ssw0rd", "welcome1", "Password1",
"qwerty123", "123qwe", "1q2w3e", "password123", "12345678", "111111", "1234567890", "monkey", "baseball", "access"
"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"
];
/// <summary>
/// Meets basic complexity requirements but follows predictable patterns (season+year, name+numbers).
/// Score 2 - Somewhat guessable: meets basic complexity but predictable patterns.
/// </summary>
public static readonly string[] Medium =
public static readonly string[] Fair =
[
"Summer2024!", "Winter2023#", "Spring2024@", "Autumn2023$", "January2024!", "December2023#",
"Welcome123!", "Company2024#", "Secure123!", "Access2024@", "Login123!", "Portal2024#",
"Michael123!", "Jennifer2024@", "Robert456#", "Sarah789!",
"Qwerty123!", "Asdfgh456@", "Zxcvbn789#",
"Password123!", "Security2024@", "Admin123!", "User2024#", "Guest123!", "Test2024@",
"Football123!", "Baseball2024@", "Soccer456#", "Hockey789!"
"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$"
];
/// <summary>
/// High-entropy passwords: random strings (password manager style) and diceware passphrases.
/// Score 3 - Safely unguessable: good entropy, mixed character types.
/// </summary>
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$",
"Kx9#mL4$pQ7@wR2!vN5", "Yz3@hT8#bF1$cS6!nM9", "Wv5!rK2@jG9#tX4$mL7", "Qn7$sB3@yH6#pC1!zF8", "Tm2@xD5#kW9$vL4!rJ7",
"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", "rust-vapor-hidden-gate",
"Brave.Tiger.Runs.Fast.42", "Blue.Ocean.Deep.Wave.17", "Swift.Eagle.Soars.High.93",
"Calm.Forest.Green.Path.28", "Warm.Summer.Golden.Sun.61",
"maple#stream#winter#glow", "ember@cloud@silent@peak", "frost$dawn$valley$mist", "coral!reef!azure!tide", "stone&moss&ancient&oak",
"Kx9mL4pQ7wR2vN5hT8bF", "Yz3hT8bF1cS6nM9wK4pL", "Wv5rK2jG9tX4mL7nB3sH", "Qn7sB3yH6pC1zF8kW2xD", "Tm2xD5kW9vL4rJ7gN1cY"
"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"
];
/// <remarks>Must be declared after strength arrays (S3263).</remarks>
public static readonly string[] All = [.. Weak, .. Medium, .. Strong];
/// <summary>
/// Score 4 - Very unguessable: high entropy, long passphrases, random strings.
/// </summary>
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"
];
/// <summary>All passwords combined for mixed/random selection.</summary>
public static readonly string[] All = [.. VeryWeak, .. Weak, .. Fair, .. Strong, .. VeryStrong];
/// <summary>
/// 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
/// </summary>
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.Medium => Medium,
PasswordStrength.Fair => Fair,
PasswordStrength.Strong => Strong,
PasswordStrength.Mixed => All,
PasswordStrength.VeryStrong => VeryStrong,
PasswordStrength.Realistic => All, // For direct array access, use All
_ => Strong
};
/// <summary>
/// Gets a password with realistic strength distribution.
/// Uses deterministic selection based on index for reproducible test data.
/// </summary>
public static string GetRealisticPassword(int index)
{
var strength = GetRealisticStrength(index);
var passwords = GetByStrength(strength);
return passwords[index % passwords.Length];
}
/// <summary>
/// Gets a password strength following realistic distribution.
/// Deterministic based on index for reproducible results.
/// </summary>
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];
}

View File

@@ -0,0 +1,41 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Utilities;
namespace Bit.Seeder.Factories;
/// <summary>
/// Creates groups and group-user relationships for seeding.
/// </summary>
public static class GroupSeeder
{
/// <summary>
/// Creates a group entity for an organization.
/// </summary>
/// <param name="organizationId">The organization ID.</param>
/// <param name="name">The group name.</param>
/// <returns>A new Group entity (not persisted).</returns>
public static Group CreateGroup(Guid organizationId, string name)
{
return new Group
{
Id = CoreHelpers.GenerateComb(),
OrganizationId = organizationId,
Name = name
};
}
/// <summary>
/// Creates a group-user relationship entity.
/// </summary>
/// <param name="groupId">The group ID.</param>
/// <param name="organizationUserId">The organization user ID.</param>
/// <returns>A new GroupUser entity (not persisted).</returns>
public static GroupUser CreateGroupUser(Guid groupId, Guid organizationUserId)
{
return new GroupUser
{
GroupId = groupId,
OrganizationUserId = organizationUserId
};
}
}

View File

@@ -0,0 +1,32 @@
using Bit.Infrastructure.EntityFramework.Models;
namespace Bit.Seeder.Factories;
/// <summary>
/// Creates organization domain entities for seeding.
/// </summary>
public static class OrganizationDomainSeeder
{
/// <summary>
/// Creates a verified organization domain entity.
/// </summary>
/// <param name="organizationId">The organization ID.</param>
/// <param name="domainName">The domain name (e.g., "example.com").</param>
/// <returns>A new verified OrganizationDomain entity (not persisted).</returns>
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;
}
}

View File

@@ -52,6 +52,28 @@ public class OrganizationSeeder
public static class OrganizationExtensions
{
/// <summary>
/// Creates an OrganizationUser with hardcoded keys (no SDK calls).
/// Used by OrganizationWithUsersRecipe for fast user creation without encryption needs.
/// </summary>
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
};
}
/// <summary>
/// Creates an OrganizationUser with a dynamically provided encrypted org key.
/// The encryptedOrgKey should be generated using sdkService.GenerateUserOrganizationKey().

View File

@@ -57,6 +57,27 @@ public class UserSeeder(RustSdkService sdkService, IPasswordHasher<Bit.Core.Enti
/// </summary>
public const string DefaultPassword = "asdfasdfasdf";
/// <summary>
/// Creates a user with hardcoded keys (no email mangling, no SDK calls).
/// Used by OrganizationWithUsersRecipe for fast user creation without encryption needs.
/// </summary>
public static User CreateUserNoMangle(string email)
{
return new User
{
Id = Guid.NewGuid(),
Email = email,
MasterPassword = "AQAAAAIAAYagAAAAEBATmF66OHMpHuHKc1CsGZQ1ltHUHyhYK+7e4re3bVFi16SOpLpDfzdFswnvFQs2Rg==",
SecurityStamp = "4830e359-e150-4eae-be2a-996c81c5e609",
Key = "2.z/eLKFhd62qy9RzXu3UHgA==|fF6yNupiCIguFKSDTB3DoqcGR0Xu4j+9VlnMyT5F3PaWIcGhzQKIzxdB95nhslaCQv3c63M7LBnvzVo1J9SUN85RMbP/57bP1HvhhU1nvL8=|IQPtf8v7k83MFZEhazSYXSdu98BBU5rqtvC4keVWyHM=",
PublicKey = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0Ww2chogqCpaAR7Uw448am4b7vDFXiM5kXjFlGfXBlrAdAqTTggEvTDlMNYqPlCo+mBM6iFmTTUY9rpZBvFskMnKvsvpJ47/fehAH2o2e3Ulv/5NFevaVCMCmpkBDtbMbO1A4a3btdRtCP8DsKWMefHauEpaoLxNTLWnOIZVfCMjsSgx2EvULHAZPTtbFwm4+UVKniM4ds4jvOsD85h4jn2aLs/jWJXFfxN8iVSqEqpC2TBvsPdyHb49xQoWWfF0Z6BiNqeNGKEU9Uos1pjL+kzhEzzSpH31PZT/ufJ/oo4+93wrUt57hb6f0jxiXhwd5yQ+9F6wVwpbfkq0IwhjOwIDAQAB",
PrivateKey = "2.yN7l00BOlUE0Sb0M//Q53w==|EwKG/BduQRQ33Izqc/ogoBROIoI5dmgrxSo82sgzgAMIBt3A2FZ9vPRMY+GWT85JiqytDitGR3TqwnFUBhKUpRRAq4x7rA6A1arHrFp5Tp1p21O3SfjtvB3quiOKbqWk6ZaU1Np9HwqwAecddFcB0YyBEiRX3VwF2pgpAdiPbSMuvo2qIgyob0CUoC/h4Bz1be7Qa7B0Xw9/fMKkB1LpOm925lzqosyMQM62YpMGkjMsbZz0uPopu32fxzDWSPr+kekNNyLt9InGhTpxLmq1go/pXR2uw5dfpXc5yuta7DB0EGBwnQ8Vl5HPdDooqOTD9I1jE0mRyuBpWTTI3FRnu3JUh3rIyGBJhUmHqGZvw2CKdqHCIrQeQkkEYqOeJRJVdBjhv5KGJifqT3BFRwX/YFJIChAQpebNQKXe/0kPivWokHWwXlDB7S7mBZzhaAPidZvnuIhalE2qmTypDwHy22FyqV58T8MGGMchcASDi/QXI6kcdpJzPXSeU9o+NC68QDlOIrMVxKFeE7w7PvVmAaxEo0YwmuAzzKy9QpdlK0aab/xEi8V4iXj4hGepqAvHkXIQd+r3FNeiLfllkb61p6WTjr5urcmDQMR94/wYoilpG5OlybHdbhsYHvIzYoLrC7fzl630gcO6t4nM24vdB6Ymg9BVpEgKRAxSbE62Tqacxqnz9AcmgItb48NiR/He3n3ydGjPYuKk/ihZMgEwAEZvSlNxYONSbYrIGDtOY+8Nbt6KiH3l06wjZW8tcmFeVlWv+tWotnTY9IqlAfvNVTjtsobqtQnvsiDjdEVtNy/s2ci5TH+NdZluca2OVEr91Wayxh70kpM6ib4UGbfdmGgCo74gtKvKSJU0rTHakQ5L9JlaSDD5FamBRyI0qfL43Ad9qOUZ8DaffDCyuaVyuqk7cz9HwmEmvWU3VQ+5t06n/5kRDXttcw8w+3qClEEdGo1KeENcnXCB32dQe3tDTFpuAIMLqwXs6FhpawfZ5kPYvLPczGWaqftIs/RXJ/EltGc0ugw2dmTLpoQhCqrcKEBDoYVk0LDZKsnzitOGdi9mOWse7Se8798ib1UsHFUjGzISEt6upestxOeupSTOh0v4+AjXbDzRUyogHww3V+Bqg71bkcMxtB+WM+pn1XNbVTyl9NR040nhP7KEf6e9ruXAtmrBC2ah5cFEpLIot77VFZ9ilLuitSz+7T8n1yAh1IEG6xxXxninAZIzi2qGbH69O5RSpOJuJTv17zTLJQIIc781JwQ2TTwTGnx5wZLbffhCasowJKd2EVcyMJyhz6ru0PvXWJ4hUdkARJs3Xu8dus9a86N8Xk6aAPzBDqzYb1vyFIfBxP0oO8xFHgd30Cgmz8UrSE3qeWRrF8ftrI6xQnFjHBGWD/JWSvd6YMcQED0aVuQkuNW9ST/DzQThPzRfPUoiL10yAmV7Ytu4fR3x2sF0Yfi87YhHFuCMpV/DsqxmUizyiJuD938eRcH8hzR/VO53Qo3UIsqOLcyXtTv6THjSlTopQ+JOLOnHm1w8dzYbLN44OG44rRsbihMUQp+wUZ6bsI8rrOnm9WErzkbQFbrfAINdoCiNa6cimYIjvvnMTaFWNymqY1vZxGztQiMiHiHYwTfwHTXrb9j0uPM=|09J28iXv9oWzYtzK2LBT6Yht4IT4MijEkk0fwFdrVQ4=",
ApiKey = "7gp59kKHt9kMlks0BuNC4IjNXYkljR",
Kdf = KdfType.PBKDF2_SHA256,
KdfIterations = 600_000,
};
}
/// <summary>
/// Creates a user with SDK-generated cryptographic keys (no email mangling).
/// The user can log in with email and password = "asdfasdfasdf".

View File

@@ -0,0 +1,57 @@
using Bit.Seeder.Data.Enums;
namespace Bit.Seeder.Options;
/// <summary>
/// Options for seeding an organization with vault data.
/// </summary>
public class OrganizationVaultOptions
{
/// <summary>
/// Organization name.
/// </summary>
public required string Name { get; init; }
/// <summary>
/// Domain for user emails (e.g., "example.com").
/// </summary>
public required string Domain { get; init; }
/// <summary>
/// Number of member users to create.
/// </summary>
public required int Users { get; init; }
/// <summary>
/// Number of login ciphers to create.
/// </summary>
public int Ciphers { get; init; } = 0;
/// <summary>
/// Number of groups to create.
/// </summary>
public int Groups { get; init; } = 0;
/// <summary>
/// When true and Users >= 10, creates a realistic mix of user statuses:
/// 85% Confirmed, 5% Invited, 5% Accepted, 5% Revoked.
/// When false or Users &lt; 10, all users are Confirmed.
/// </summary>
public bool RealisticStatusMix { get; init; } = false;
/// <summary>
/// Org structure for realistic collection names.
/// </summary>
public OrgStructureModel? StructureModel { get; init; }
/// <summary>
/// Username pattern for cipher logins.
/// </summary>
public UsernamePatternType UsernamePattern { get; init; } = UsernamePatternType.FirstDotLast;
/// <summary>
/// Password strength for cipher logins. Defaults to Realistic distribution
/// (25% VeryWeak, 30% Weak, 25% Fair, 15% Strong, 5% VeryStrong).
/// </summary>
public PasswordStrength PasswordStrength { get; init; } = PasswordStrength.Realistic;
}

View File

@@ -1,124 +0,0 @@
using Bit.Core.Entities;
using Bit.Core.Vault.Entities;
using Bit.Infrastructure.EntityFramework.Repositories;
using Bit.RustSDK;
using Bit.Seeder.Data;
using Bit.Seeder.Data.Enums;
using Bit.Seeder.Factories;
using LinqToDB.EntityFrameworkCore;
namespace Bit.Seeder.Recipes;
/// <summary>
/// Creates encrypted ciphers for seeding organization vaults.
/// </summary>
/// <remarks>
/// Currently supports:
/// <list type="bullet">
/// <item><description>Login ciphers</description></item>
/// </list>
/// TODO: Add support for Card, Identity, and SecureNote cipher types.
/// </remarks>
public class CiphersRecipe(DatabaseContext db, RustSdkService sdkService)
{
private readonly CipherSeeder _cipherSeeder = new(sdkService);
public List<Guid> AddLoginCiphersToOrganization(
Guid organizationId,
string orgKeyBase64,
List<Guid> collectionIds,
int? count = null,
bool useEnterpriseUrls = false)
{
// Delegate to the new system - Enterprise filter for enterprise URLs, Consumer for popular
var companyType = useEnterpriseUrls ? CompanyType.Enterprise : CompanyType.Consumer;
return AddLoginCiphersToOrganization(
organizationId,
orgKeyBase64,
collectionIds,
count,
companyType,
region: null,
UsernamePatternType.FLast,
PasswordStrength.Weak);
}
public List<Guid> AddLoginCiphersToOrganization(
Guid organizationId,
string orgKeyBase64,
List<Guid> collectionIds,
int? count,
CompanyType? companyType,
GeographicRegion? region,
UsernamePatternType usernamePattern = UsernamePatternType.FirstDotLast,
PasswordStrength passwordStrength = PasswordStrength.Strong)
{
var companies = Companies.Filter(companyType, region);
if (companies.Length == 0)
{
companies = Companies.All;
}
var passwords = Passwords.GetByStrength(passwordStrength);
var cipherCount = count ?? companies.Length;
var usernameGenerator = new UsernameGenerator(organizationId.GetHashCode(), usernamePattern, region);
var ciphers = 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[i % passwords.Length],
uri: $"https://{company.Domain}");
})
.ToList();
return SaveCiphersWithCollections(ciphers, collectionIds);
}
private List<Guid> SaveCiphersWithCollections(List<Cipher> ciphers, List<Guid> collectionIds)
{
if (ciphers.Count == 0)
{
return [];
}
db.BulkCopy(ciphers);
if (collectionIds.Count > 0)
{
var collectionCiphers = ciphers.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 [primary];
}).ToList();
db.BulkCopy(collectionCiphers);
}
return ciphers.Select(c => c.Id).ToList();
}
}

View File

@@ -1,56 +1,38 @@
using Bit.Core.Enums;
using Bit.Core.Utilities;
using Bit.Infrastructure.EntityFramework.Repositories;
using Bit.RustSDK;
using Bit.Seeder.Data;
using Bit.Seeder.Data.Enums;
using Bit.Seeder.Factories;
using LinqToDB.EntityFrameworkCore;
namespace Bit.Seeder.Recipes;
/// <summary>
/// Creates collections for seeding organization vaults.
/// </summary>
public class CollectionsRecipe(DatabaseContext db, RustSdkService? sdkService = null)
public class CollectionsRecipe(DatabaseContext db)
{
private readonly CollectionSeeder? _collectionSeeder = sdkService != null ? new(sdkService) : null;
/// <summary>
/// Creates collections from an organizational structure (e.g., Traditional departments, Spotify tribes).
/// Collection names are properly encrypted.
/// </summary>
public List<Guid> AddFromStructure(
Guid organizationId,
string orgKeyBase64,
OrgStructureModel model,
List<Guid> organizationUserIds,
int maxUsersWithRelationships = 1000)
{
var structure = OrgStructures.GetStructure(model);
var collections = structure.Units
.Select(unit => _collectionSeeder!.CreateCollection(organizationId, orgKeyBase64, unit.Name))
.ToList();
db.BulkCopy(collections);
if (collections.Count > 0 && organizationUserIds.Count > 0 && maxUsersWithRelationships > 0)
{
var collectionUsers = BuildCollectionUserRelationships(collections, organizationUserIds, maxUsersWithRelationships);
db.BulkCopy(collectionUsers);
}
return collections.Select(c => c.Id).ToList();
}
/// <summary>
/// Adds generic numbered collections (unencrypted names - use AddFromStructure for realistic data).
/// Adds collections to an organization and creates relationships between users and collections.
/// </summary>
/// <param name="organizationId">The ID of the organization to add collections to.</param>
/// <param name="collections">The number of collections to add.</param>
/// <param name="organizationUserIds">The IDs of the users to create relationships with.</param>
/// <param name="maxUsersWithRelationships">The maximum number of users to create relationships with.</param>
public List<Guid> AddToOrganization(Guid organizationId, int collections, List<Guid> organizationUserIds, int maxUsersWithRelationships = 1000)
{
var collectionList = Enumerable.Range(0, collections)
.Select(i => new Core.Entities.Collection
var collectionList = CreateAndSaveCollections(organizationId, collections);
if (collectionList.Any())
{
CreateAndSaveCollectionUserRelationships(collectionList, organizationUserIds, maxUsersWithRelationships);
}
return collectionList.Select(c => c.Id).ToList();
}
private List<Core.Entities.Collection> CreateAndSaveCollections(Guid organizationId, int count)
{
var collectionList = new List<Core.Entities.Collection>();
for (var i = 0; i < count; i++)
{
collectionList.Add(new Core.Entities.Collection
{
Id = CoreHelpers.GenerateComb(),
OrganizationId = organizationId,
@@ -58,44 +40,83 @@ public class CollectionsRecipe(DatabaseContext db, RustSdkService? sdkService =
Type = CollectionType.SharedCollection,
CreationDate = DateTime.UtcNow,
RevisionDate = DateTime.UtcNow
})
.ToList();
db.BulkCopy(collectionList);
if (collectionList.Count > 0 && organizationUserIds.Count > 0 && maxUsersWithRelationships > 0)
{
var collectionUsers = BuildCollectionUserRelationships(collectionList, organizationUserIds, maxUsersWithRelationships);
db.BulkCopy(collectionUsers);
});
}
return collectionList.Select(c => c.Id).ToList();
if (collectionList.Any())
{
db.BulkCopy(collectionList);
}
return collectionList;
}
/// <summary>
/// Creates user-to-collection relationships with varied assignment patterns.
/// Each user gets 1-3 collections (cycling). First collection has Manage rights.
/// </summary>
private static List<Core.Entities.CollectionUser> BuildCollectionUserRelationships(
private void CreateAndSaveCollectionUserRelationships(
List<Core.Entities.Collection> collections,
List<Guid> organizationUserIds,
int maxUsersWithRelationships)
{
return organizationUserIds
.Take(maxUsersWithRelationships)
.SelectMany((orgUserId, userIndex) =>
if (!organizationUserIds.Any() || maxUsersWithRelationships <= 0)
{
return;
}
var collectionUsers = BuildCollectionUserRelationships(collections, organizationUserIds, maxUsersWithRelationships);
if (collectionUsers.Any())
{
db.BulkCopy(collectionUsers);
}
}
/// <summary>
/// Creates user-to-collection relationships with varied assignment patterns for realistic test data.
/// Each user gets 1-3 collections based on a rotating pattern.
/// </summary>
private List<Core.Entities.CollectionUser> BuildCollectionUserRelationships(
List<Core.Entities.Collection> collections,
List<Guid> organizationUserIds,
int maxUsersWithRelationships)
{
var maxRelationships = Math.Min(organizationUserIds.Count, maxUsersWithRelationships);
var collectionUsers = new List<Core.Entities.CollectionUser>();
for (var i = 0; i < maxRelationships; i++)
{
var orgUserId = organizationUserIds[i];
var userCollectionAssignments = CreateCollectionAssignmentsForUser(collections, orgUserId, i);
collectionUsers.AddRange(userCollectionAssignments);
}
return collectionUsers;
}
/// <summary>
/// Assigns collections to a user with varying permissions.
/// Pattern: 1-3 collections per user (cycles: 1, 2, 3, 1, 2, 3...).
/// First collection has Manage rights, subsequent ones are ReadOnly.
/// </summary>
private List<Core.Entities.CollectionUser> CreateCollectionAssignmentsForUser(
List<Core.Entities.Collection> collections,
Guid organizationUserId,
int userIndex)
{
var assignments = new List<Core.Entities.CollectionUser>();
var userCollectionCount = (userIndex % 3) + 1; // Cycles through 1, 2, or 3 collections
for (var j = 0; j < userCollectionCount; j++)
{
var collectionIndex = (userIndex + j) % collections.Count; // Distribute across available collections
assignments.Add(new Core.Entities.CollectionUser
{
var collectionCount = (userIndex % 3) + 1; // Cycles through 1, 2, or 3
return Enumerable.Range(0, collectionCount)
.Select(j => new Core.Entities.CollectionUser
{
CollectionId = collections[(userIndex + j) % collections.Count].Id,
OrganizationUserId = orgUserId,
ReadOnly = j > 0,
HidePasswords = false,
Manage = j == 0
});
})
.ToList();
CollectionId = collections[collectionIndex].Id,
OrganizationUserId = organizationUserId,
ReadOnly = j > 0, // First assignment gets write access
HidePasswords = false,
Manage = j == 0 // First assignment gets manage permissions
});
}
return assignments;
}
}

View File

@@ -15,30 +15,80 @@ public class GroupsRecipe(DatabaseContext db)
/// <param name="maxUsersWithRelationships">The maximum number of users to create relationships with.</param>
public List<Guid> AddToOrganization(Guid organizationId, int groups, List<Guid> organizationUserIds, int maxUsersWithRelationships = 1000)
{
var groupList = Enumerable.Range(0, groups)
.Select(i => new Core.AdminConsole.Entities.Group
{
Id = CoreHelpers.GenerateComb(),
OrganizationId = organizationId,
Name = $"Group {i + 1}"
})
.ToList();
var groupList = CreateAndSaveGroups(organizationId, groups);
db.BulkCopy(groupList);
if (groupList.Count > 0 && organizationUserIds.Count > 0 && maxUsersWithRelationships > 0)
if (groupList.Any())
{
var groupUsers = organizationUserIds
.Take(maxUsersWithRelationships)
.Select((orgUserId, i) => new Core.AdminConsole.Entities.GroupUser
{
GroupId = groupList[i % groupList.Count].Id,
OrganizationUserId = orgUserId
})
.ToList();
db.BulkCopy(groupUsers);
CreateAndSaveGroupUserRelationships(groupList, organizationUserIds, maxUsersWithRelationships);
}
return groupList.Select(g => g.Id).ToList();
}
private List<Core.AdminConsole.Entities.Group> CreateAndSaveGroups(Guid organizationId, int count)
{
var groupList = new List<Core.AdminConsole.Entities.Group>();
for (var i = 0; i < count; i++)
{
groupList.Add(new Core.AdminConsole.Entities.Group
{
Id = CoreHelpers.GenerateComb(),
OrganizationId = organizationId,
Name = $"Group {i + 1}"
});
}
if (groupList.Any())
{
db.BulkCopy(groupList);
}
return groupList;
}
private void CreateAndSaveGroupUserRelationships(
List<Core.AdminConsole.Entities.Group> groups,
List<Guid> organizationUserIds,
int maxUsersWithRelationships)
{
if (!organizationUserIds.Any() || maxUsersWithRelationships <= 0)
{
return;
}
var groupUsers = BuildGroupUserRelationships(groups, organizationUserIds, maxUsersWithRelationships);
if (groupUsers.Any())
{
db.BulkCopy(groupUsers);
}
}
/// <summary>
/// Creates user-to-group relationships with distributed assignment patterns for realistic test data.
/// Each user is assigned to one group, distributed evenly across available groups.
/// </summary>
private List<Core.AdminConsole.Entities.GroupUser> BuildGroupUserRelationships(
List<Core.AdminConsole.Entities.Group> groups,
List<Guid> organizationUserIds,
int maxUsersWithRelationships)
{
var maxRelationships = Math.Min(organizationUserIds.Count, maxUsersWithRelationships);
var groupUsers = new List<Core.AdminConsole.Entities.GroupUser>();
for (var i = 0; i < maxRelationships; i++)
{
var orgUserId = organizationUserIds[i];
var groupIndex = i % groups.Count; // Round-robin distribution across groups
groupUsers.Add(new Core.AdminConsole.Entities.GroupUser
{
GroupId = groups[groupIndex].Id,
OrganizationUserId = orgUserId
});
}
return groupUsers;
}
}

View File

@@ -1,138 +1,38 @@
using AutoMapper;
using Bit.Core.Entities;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Infrastructure.EntityFramework.Repositories;
using Bit.RustSDK;
using Bit.Seeder.Data.Enums;
using Bit.Seeder.Factories;
using LinqToDB.EntityFrameworkCore;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;
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,
IMapper mapper,
RustSdkService sdkService,
IPasswordHasher<User> passwordHasher)
public class OrganizationWithUsersRecipe(DatabaseContext db)
{
public static Guid SeedFromServices(
IServiceProvider services,
string name,
string domain,
int users,
int ciphers = 0,
OrganizationUserStatusType usersStatus = OrganizationUserStatusType.Confirmed,
OrgStructureModel? structureModel = null)
{
var db = services.GetRequiredService<DatabaseContext>();
var mapper = services.GetRequiredService<IMapper>();
var sdkService = services.GetRequiredService<RustSdkService>();
var passwordHasher = services.GetRequiredService<IPasswordHasher<User>>();
var recipe = new OrganizationWithUsersRecipe(db, mapper, sdkService, passwordHasher);
return recipe.Seed(name, domain, users, ciphers, usersStatus, structureModel);
}
/// <summary>
/// Seeds an organization with users and optionally encrypted ciphers.
/// Users can log in with their email and password "asdfasdfasdf".
/// Organization and user keys are generated dynamically for each run.
/// </summary>
/// <param name="structureModel">Optional org structure for realistic collection names (e.g., Traditional departments, Spotify tribes).</param>
public Guid Seed(
string name,
string domain,
int users,
int ciphers = 0,
OrganizationUserStatusType usersStatus = OrganizationUserStatusType.Confirmed,
OrgStructureModel? structureModel = null)
public Guid Seed(string name, string domain, int users, OrganizationUserStatusType usersStatus = OrganizationUserStatusType.Confirmed)
{
var seats = Math.Max(users + 1, 1000);
var orgKeys = sdkService.GenerateOrganizationKeys();
var organization = OrganizationSeeder.CreateEnterprise(name, domain, seats);
organization.PublicKey = orgKeys.PublicKey;
organization.PrivateKey = orgKeys.PrivateKey;
var ownerUser = UserSeeder.CreateUserNoMangle($"owner@{domain}");
var ownerOrgUser = organization.CreateOrganizationUser(ownerUser, OrganizationUserType.Owner, OrganizationUserStatusType.Confirmed);
var ownerUser = UserSeeder.CreateUserWithSdkKeys($"owner@{domain}", sdkService, passwordHasher);
var ownerOrgKey = sdkService.GenerateUserOrganizationKey(ownerUser.PublicKey!, orgKeys.Key);
var ownerOrgUser = organization.CreateOrganizationUserWithKey(
ownerUser, OrganizationUserType.Owner, OrganizationUserStatusType.Confirmed, ownerOrgKey);
var memberUsers = new List<User>();
var memberOrgUsers = new List<OrganizationUser>();
var additionalUsers = new List<User>();
var additionalOrgUsers = new List<OrganizationUser>();
for (var i = 0; i < users; i++)
{
var memberUser = UserSeeder.CreateUserWithSdkKeys($"user{i}@{domain}", sdkService, passwordHasher);
memberUsers.Add(memberUser);
var memberOrgKey = (usersStatus == OrganizationUserStatusType.Confirmed ||
usersStatus == OrganizationUserStatusType.Revoked)
? sdkService.GenerateUserOrganizationKey(memberUser.PublicKey!, orgKeys.Key)
: null;
memberOrgUsers.Add(organization.CreateOrganizationUserWithKey(
memberUser, OrganizationUserType.User, usersStatus, memberOrgKey));
var additionalUser = UserSeeder.CreateUserNoMangle($"user{i}@{domain}");
additionalUsers.Add(additionalUser);
additionalOrgUsers.Add(organization.CreateOrganizationUser(additionalUser, OrganizationUserType.User, usersStatus));
}
db.Add(mapper.Map<EfOrganization>(organization));
db.Add(mapper.Map<EfUser>(ownerUser));
db.Add(mapper.Map<EfOrganizationUser>(ownerOrgUser));
// BulkCopy for performance with large user counts
var efMemberUsers = memberUsers.Select(u => mapper.Map<EfUser>(u)).ToList();
var efMemberOrgUsers = memberOrgUsers.Select(ou => mapper.Map<EfOrganizationUser>(ou)).ToList();
db.BulkCopy(efMemberUsers);
db.BulkCopy(efMemberOrgUsers);
db.Add(organization);
db.Add(ownerUser);
db.Add(ownerOrgUser);
db.SaveChanges();
// Create collections - either from org structure or single default
var allOrgUserIds = memberOrgUsers
.Where(ou => ou.Status == OrganizationUserStatusType.Confirmed)
.Select(ou => ou.Id)
.Prepend(ownerOrgUser.Id)
.ToList();
List<Guid> collectionIds;
if (structureModel.HasValue)
{
var collectionsRecipe = new CollectionsRecipe(db, sdkService);
collectionIds = collectionsRecipe.AddFromStructure(
organization.Id,
orgKeys.Key,
structureModel.Value,
allOrgUserIds);
}
else
{
var defaultCollection = new CollectionSeeder(sdkService)
.CreateCollection(organization.Id, orgKeys.Key, "Default Collection");
db.BulkCopy(new[] { defaultCollection });
var collectionUsers = allOrgUserIds
.Select((id, i) => CollectionSeeder.CreateCollectionUser(defaultCollection.Id, id, manage: i == 0))
.ToList();
db.BulkCopy(collectionUsers);
collectionIds = [defaultCollection.Id];
}
if (ciphers > 0)
{
var cipherRecipe = new CiphersRecipe(db, sdkService);
cipherRecipe.AddLoginCiphersToOrganization(
organization.Id,
orgKeys.Key,
collectionIds,
count: ciphers);
}
// Use LinqToDB's BulkCopy for significant better performance
db.BulkCopy(additionalUsers);
db.BulkCopy(additionalOrgUsers);
return organization.Id;
}

View File

@@ -0,0 +1,255 @@
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 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;
/// <summary>
/// Seeds an organization with users, collections, groups, and encrypted ciphers.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public class OrganizationWithVaultRecipe(
DatabaseContext db,
IMapper mapper,
RustSdkService sdkService,
IPasswordHasher<User> passwordHasher)
{
private readonly CollectionSeeder _collectionSeeder = new(sdkService);
private readonly CipherSeeder _cipherSeeder = new(sdkService);
/// <summary>
/// Seeds an organization with users, collections, groups, and encrypted ciphers.
/// </summary>
/// <param name="options">Options specifying what to seed.</param>
/// <returns>The organization ID.</returns>
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);
organization.PublicKey = orgKeys.PublicKey;
organization.PrivateKey = 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
var memberUsers = new List<User>();
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 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));
}
// Persist organization and users
db.Add(mapper.Map<EfOrganization>(organization));
db.Add(mapper.Map<EfUser>(ownerUser));
db.Add(mapper.Map<EfOrganizationUser>(ownerOrgUser));
var efMemberUsers = memberUsers.Select(u => mapper.Map<EfUser>(u)).ToList();
var efMemberOrgUsers = memberOrgUsers.Select(ou => mapper.Map<EfOrganizationUser>(ou)).ToList();
db.BulkCopy(efMemberUsers);
db.BulkCopy(efMemberOrgUsers);
db.SaveChanges();
// Get confirmed user IDs for 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);
return organization.Id;
}
private List<Guid> CreateCollections(
Guid organizationId,
string orgKeyBase64,
OrgStructureModel? structureModel,
List<Guid> orgUserIds)
{
List<Collection> 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 collectionCount = (userIndex % 3) + 1;
return Enumerable.Range(0, collectionCount)
.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<Guid> 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<Guid> collectionIds,
int cipherCount,
UsernamePatternType usernamePattern,
PasswordStrength passwordStrength)
{
var companies = Companies.All;
var usernameGenerator = new UsernameGenerator(organizationId.GetHashCode(), usernamePattern);
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);
}
}
/// <summary>
/// Returns a realistic user status based on index position.
/// Distribution: 85% Confirmed, 5% Invited, 5% Accepted, 5% Revoked.
/// </summary>
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;
}
}