From 32b611aa08ee5a7bb1a9e5a0a56930386d9d3748 Mon Sep 17 00:00:00 2001 From: Mick Letofsky Date: Tue, 27 Jan 2026 15:15:15 +0100 Subject: [PATCH] Refactor into brand new recipe & proper organization per Seeder standards --- .../GroupsControllerPerformanceTests.cs | 9 +- ...nizationUsersControllerPerformanceTests.cs | 101 ++++--- ...OrganizationsControllerPerformanceTests.cs | 20 +- util/DbSeederUtility/Program.cs | 83 +++++- util/Seeder/Data/Enums/PasswordStrength.cs | 23 +- util/Seeder/Data/Passwords.cs | 137 ++++++++-- util/Seeder/Factories/GroupSeeder.cs | 41 +++ .../Factories/OrganizationDomainSeeder.cs | 32 +++ util/Seeder/Factories/OrganizationSeeder.cs | 22 ++ util/Seeder/Factories/UserSeeder.cs | 21 ++ .../Options/OrganizationVaultOptions.cs | 57 ++++ util/Seeder/Recipes/CiphersRecipe.cs | 124 --------- util/Seeder/Recipes/CollectionsRecipe.cs | 163 ++++++----- util/Seeder/Recipes/GroupsRecipe.cs | 90 +++++-- .../Recipes/OrganizationWithUsersRecipe.cs | 132 ++------- .../Recipes/OrganizationWithVaultRecipe.cs | 255 ++++++++++++++++++ 16 files changed, 885 insertions(+), 425 deletions(-) create mode 100644 util/Seeder/Factories/GroupSeeder.cs create mode 100644 util/Seeder/Factories/OrganizationDomainSeeder.cs create mode 100644 util/Seeder/Options/OrganizationVaultOptions.cs delete mode 100644 util/Seeder/Recipes/CiphersRecipe.cs create mode 100644 util/Seeder/Recipes/OrganizationWithVaultRecipe.cs diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/GroupsControllerPerformanceTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/GroupsControllerPerformanceTests.cs index 0e3b77d0ec..71c6bf104c 100644 --- a/test/Api.IntegrationTest/AdminConsole/Controllers/GroupsControllerPerformanceTests.cs +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/GroupsControllerPerformanceTests.cs @@ -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); diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerPerformanceTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerPerformanceTests.cs index b617ef1a0d..fc64930777 100644 --- a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerPerformanceTests.cs +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerPerformanceTests.cs @@ -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}"); diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationsControllerPerformanceTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationsControllerPerformanceTests.cs index 8c68ef07f5..238a9a5d53 100644 --- a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationsControllerPerformanceTests.cs +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationsControllerPerformanceTests.cs @@ -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); diff --git a/util/DbSeederUtility/Program.cs b/util/DbSeederUtility/Program.cs index f1cbfc8b8f..6b704fe590 100644 --- a/util/DbSeederUtility/Program.cs +++ b/util/DbSeederUtility/Program.cs @@ -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(); + + 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(), + scopedServices.GetRequiredService(), + scopedServices.GetRequiredService(), + scopedServices.GetRequiredService>()); + + 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)) { diff --git a/util/Seeder/Data/Enums/PasswordStrength.cs b/util/Seeder/Data/Enums/PasswordStrength.cs index 0c39fe8645..bd7f72e2b6 100644 --- a/util/Seeder/Data/Enums/PasswordStrength.cs +++ b/util/Seeder/Data/Enums/PasswordStrength.cs @@ -1,6 +1,25 @@ namespace Bit.Seeder.Data.Enums; /// -/// Password strength levels for test data generation. +/// Password strength levels aligned with zxcvbn scoring (0-4). /// -public enum PasswordStrength { Weak, Medium, Strong, Mixed } +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/Passwords.cs b/util/Seeder/Data/Passwords.cs index b39f0ae48a..1717c2b408 100644 --- a/util/Seeder/Data/Passwords.cs +++ b/util/Seeder/Data/Passwords.cs @@ -3,64 +3,145 @@ namespace Bit.Seeder.Data; /// -/// Password collections by strength level for realistic test data. +/// Password collections by zxcvbn strength level (0-4) for realistic test data. /// internal static class Passwords { /// - /// Top breached passwords - use for security testing scenarios. + /// 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 = [ - "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" ]; /// - /// Meets basic complexity requirements but follows predictable patterns (season+year, name+numbers). + /// Score 2 - Somewhat guessable: meets basic complexity but predictable patterns. /// - 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$" ]; /// - /// High-entropy passwords: random strings (password manager style) and diceware passphrases. + /// 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$", - "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" ]; - /// Must be declared after strength arrays (S3263). - public static readonly string[] All = [.. Weak, .. Medium, .. Strong]; + /// + /// 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.Medium => Medium, + PasswordStrength.Fair => Fair, PasswordStrength.Strong => Strong, - PasswordStrength.Mixed => All, + 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/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 fe09e6ddab..e93f6bbc5c 100644 --- a/util/Seeder/Factories/OrganizationSeeder.cs +++ b/util/Seeder/Factories/OrganizationSeeder.cs @@ -52,6 +52,28 @@ public class OrganizationSeeder public static class OrganizationExtensions { + /// + /// Creates an OrganizationUser with hardcoded keys (no SDK calls). + /// Used by OrganizationWithUsersRecipe for fast user creation without encryption needs. + /// + 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 670dc2e96f..74b1d1c458 100644 --- a/util/Seeder/Factories/UserSeeder.cs +++ b/util/Seeder/Factories/UserSeeder.cs @@ -57,6 +57,27 @@ public class UserSeeder(RustSdkService sdkService, IPasswordHasher 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 + { + 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, + }; + } + /// /// Creates a user with SDK-generated cryptographic keys (no email mangling). /// The user can log in with email and password = "asdfasdfasdf". diff --git a/util/Seeder/Options/OrganizationVaultOptions.cs b/util/Seeder/Options/OrganizationVaultOptions.cs new file mode 100644 index 0000000000..0ebe79cf09 --- /dev/null +++ b/util/Seeder/Options/OrganizationVaultOptions.cs @@ -0,0 +1,57 @@ +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; +} diff --git a/util/Seeder/Recipes/CiphersRecipe.cs b/util/Seeder/Recipes/CiphersRecipe.cs deleted file mode 100644 index 810a67089d..0000000000 --- a/util/Seeder/Recipes/CiphersRecipe.cs +++ /dev/null @@ -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; - -/// -/// Creates encrypted ciphers for seeding organization vaults. -/// -/// -/// Currently supports: -/// -/// Login ciphers -/// -/// TODO: Add support for Card, Identity, and SecureNote cipher types. -/// -public class CiphersRecipe(DatabaseContext db, RustSdkService sdkService) -{ - private readonly CipherSeeder _cipherSeeder = new(sdkService); - - public List AddLoginCiphersToOrganization( - Guid organizationId, - string orgKeyBase64, - List 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 AddLoginCiphersToOrganization( - Guid organizationId, - string orgKeyBase64, - List 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 SaveCiphersWithCollections(List ciphers, List 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(); - } -} diff --git a/util/Seeder/Recipes/CollectionsRecipe.cs b/util/Seeder/Recipes/CollectionsRecipe.cs index 340e7e8413..e0f9057418 100644 --- a/util/Seeder/Recipes/CollectionsRecipe.cs +++ b/util/Seeder/Recipes/CollectionsRecipe.cs @@ -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; -/// -/// Creates collections for seeding organization vaults. -/// -public class CollectionsRecipe(DatabaseContext db, RustSdkService? sdkService = null) +public class CollectionsRecipe(DatabaseContext db) { - private readonly CollectionSeeder? _collectionSeeder = sdkService != null ? new(sdkService) : null; - /// - /// Creates collections from an organizational structure (e.g., Traditional departments, Spotify tribes). - /// Collection names are properly encrypted. - /// - public List AddFromStructure( - Guid organizationId, - string orgKeyBase64, - OrgStructureModel model, - List 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(); - } - - /// - /// Adds generic numbered collections (unencrypted names - use AddFromStructure for realistic data). + /// Adds collections to an organization and creates relationships between users and collections. /// + /// The ID of the organization to add collections to. + /// 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) { - 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 CreateAndSaveCollections(Guid organizationId, int count) + { + var collectionList = new List(); + + 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; } - /// - /// Creates user-to-collection relationships with varied assignment patterns. - /// Each user gets 1-3 collections (cycling). First collection has Manage rights. - /// - private static List BuildCollectionUserRelationships( + private void CreateAndSaveCollectionUserRelationships( List collections, List 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); + } + } + + /// + /// Creates user-to-collection relationships with varied assignment patterns for realistic test data. + /// Each user gets 1-3 collections based on a rotating pattern. + /// + private List BuildCollectionUserRelationships( + List collections, + List organizationUserIds, + int maxUsersWithRelationships) + { + var maxRelationships = Math.Min(organizationUserIds.Count, maxUsersWithRelationships); + var collectionUsers = new List(); + + for (var i = 0; i < maxRelationships; i++) + { + var orgUserId = organizationUserIds[i]; + var userCollectionAssignments = CreateCollectionAssignmentsForUser(collections, orgUserId, i); + collectionUsers.AddRange(userCollectionAssignments); + } + + return collectionUsers; + } + + /// + /// 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. + /// + private List CreateCollectionAssignmentsForUser( + List collections, + Guid organizationUserId, + int userIndex) + { + var assignments = new List(); + 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; } } diff --git a/util/Seeder/Recipes/GroupsRecipe.cs b/util/Seeder/Recipes/GroupsRecipe.cs index e4945837c0..3c8156d921 100644 --- a/util/Seeder/Recipes/GroupsRecipe.cs +++ b/util/Seeder/Recipes/GroupsRecipe.cs @@ -15,30 +15,80 @@ public class GroupsRecipe(DatabaseContext db) /// The maximum number of users to create relationships with. public List AddToOrganization(Guid organizationId, int groups, List 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 CreateAndSaveGroups(Guid organizationId, int count) + { + var groupList = new List(); + + 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 groups, + List organizationUserIds, + int maxUsersWithRelationships) + { + if (!organizationUserIds.Any() || maxUsersWithRelationships <= 0) + { + return; + } + + var groupUsers = BuildGroupUserRelationships(groups, organizationUserIds, maxUsersWithRelationships); + + if (groupUsers.Any()) + { + db.BulkCopy(groupUsers); + } + } + + /// + /// 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. + /// + private List BuildGroupUserRelationships( + List groups, + List organizationUserIds, + int maxUsersWithRelationships) + { + var maxRelationships = Math.Min(organizationUserIds.Count, maxUsersWithRelationships); + var groupUsers = new List(); + + 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; + } } diff --git a/util/Seeder/Recipes/OrganizationWithUsersRecipe.cs b/util/Seeder/Recipes/OrganizationWithUsersRecipe.cs index 8f7a2a0bec..87fcc1967b 100644 --- a/util/Seeder/Recipes/OrganizationWithUsersRecipe.cs +++ b/util/Seeder/Recipes/OrganizationWithUsersRecipe.cs @@ -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 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(); - var mapper = services.GetRequiredService(); - var sdkService = services.GetRequiredService(); - var passwordHasher = services.GetRequiredService>(); - - var recipe = new OrganizationWithUsersRecipe(db, mapper, sdkService, passwordHasher); - return recipe.Seed(name, domain, users, ciphers, usersStatus, structureModel); - } - - /// - /// 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. - /// - /// Optional org structure for realistic collection names (e.g., Traditional departments, Spotify tribes). - 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(); - var memberOrgUsers = new List(); + var additionalUsers = new List(); + var additionalOrgUsers = new List(); 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(organization)); - db.Add(mapper.Map(ownerUser)); - db.Add(mapper.Map(ownerOrgUser)); - - // BulkCopy for performance with large user counts - 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.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 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; } diff --git a/util/Seeder/Recipes/OrganizationWithVaultRecipe.cs b/util/Seeder/Recipes/OrganizationWithVaultRecipe.cs new file mode 100644 index 0000000000..88e8e9169d --- /dev/null +++ b/util/Seeder/Recipes/OrganizationWithVaultRecipe.cs @@ -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; + +/// +/// 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); + + /// + /// 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); + 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(); + 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 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(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 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 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 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 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) + { + 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); + } + } + + /// + /// 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; + } +}