diff --git a/util/Seeder/CLAUDE.md b/util/Seeder/CLAUDE.md index 6f1d06e59b..5ea8211f81 100644 --- a/util/Seeder/CLAUDE.md +++ b/util/Seeder/CLAUDE.md @@ -35,7 +35,7 @@ Need to create test data? **Modern pattern for composable fixture-based and generated seeding.** -**Flow**: Preset JSON → PresetLoader → RecipeBuilder → IStep[] → RecipeExecutor → SeederContext → BulkCommitter +**Flow**: Preset JSON or Options → RecipeOrchestrator → RecipeBuilder → IStep[] → RecipeExecutor → SeederContext → BulkCommitter **Key actors**: @@ -43,7 +43,7 @@ Need to create test data? - **IStep**: Isolated units of work (CreateOrganizationStep, CreateUsersStep, etc.) - **SeederContext**: Shared mutable state bag (NOT thread-safe) - **RecipeExecutor**: Executes steps sequentially, captures statistics, commits via BulkCommitter -- **PresetExecutor**: Orchestrates preset loading and execution +- **RecipeOrchestrator**: Orchestrates recipe building and execution (from presets or options) **Phase order**: Org → Owner → Generator → Roster → Users → Groups → Collections → Folders → Ciphers → PersonalCiphers diff --git a/util/Seeder/Factories/OrganizationSeeder.cs b/util/Seeder/Factories/OrganizationSeeder.cs index 0b1d908f8d..84d877285c 100644 --- a/util/Seeder/Factories/OrganizationSeeder.cs +++ b/util/Seeder/Factories/OrganizationSeeder.cs @@ -1,22 +1,24 @@ -using Bit.Core.AdminConsole.Entities; +using System.Security.Cryptography; +using System.Text; +using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Enums; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Utilities; using Bit.Seeder.Services; - namespace Bit.Seeder.Factories; internal static class OrganizationSeeder { internal static Organization Create(string name, string domain, int seats, IManglerService manglerService, string? publicKey = null, string? privateKey = null, PlanType planType = PlanType.EnterpriseAnnually) { + var billingHash = DeriveShortHash(domain); var org = new Organization { Id = CoreHelpers.GenerateComb(), Identifier = manglerService.Mangle(domain), Name = manglerService.Mangle(name), - BillingEmail = $"billing@{domain}", + BillingEmail = $"billing{billingHash}@{billingHash}.{domain}", Seats = seats, Status = OrganizationStatusType.Created, PublicKey = publicKey, @@ -27,6 +29,16 @@ internal static class OrganizationSeeder return org; } + + /// + /// Derives a deterministic 8-char hex string from a domain for safe billing email generation. + /// Always applied regardless of mangle flag — billing emails must never be deliverable. + /// + private static string DeriveShortHash(string domain) + { + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(domain)); + return Convert.ToHexString(bytes, 0, 4).ToLowerInvariant(); + } } internal static class OrganizationExtensions diff --git a/util/Seeder/Pipeline/PresetExecutor.cs b/util/Seeder/Pipeline/RecipeOrchestrator.cs similarity index 61% rename from util/Seeder/Pipeline/PresetExecutor.cs rename to util/Seeder/Pipeline/RecipeOrchestrator.cs index e4ee538957..c1bfbb5349 100644 --- a/util/Seeder/Pipeline/PresetExecutor.cs +++ b/util/Seeder/Pipeline/RecipeOrchestrator.cs @@ -1,6 +1,7 @@ using AutoMapper; using Bit.Core.Entities; using Bit.Infrastructure.EntityFramework.Repositories; +using Bit.Seeder.Options; using Bit.Seeder.Services; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.DependencyInjection; @@ -8,9 +9,9 @@ using Microsoft.Extensions.DependencyInjection; namespace Bit.Seeder.Pipeline; /// -/// Orchestrates preset-based seeding by coordinating the Pipeline infrastructure. +/// Orchestrates recipe-based seeding by coordinating the Pipeline infrastructure. /// -internal sealed class PresetExecutor(DatabaseContext db, IMapper mapper) +internal sealed class RecipeOrchestrator(DatabaseContext db, IMapper mapper) { /// /// Executes a preset by registering its recipe, building a service provider, and running all steps. @@ -44,6 +45,56 @@ internal sealed class PresetExecutor(DatabaseContext db, IMapper mapper) return executor.Execute(); } + /// + /// Executes a recipe built programmatically from CLI options. + /// + internal ExecutionResult Execute( + OrganizationVaultOptions options, + IPasswordHasher passwordHasher, + IManglerService manglerService) + { + var services = new ServiceCollection(); + services.AddSingleton(passwordHasher); + services.AddSingleton(manglerService); + services.AddSingleton(new SeederSettings(options.Password)); + + var recipeName = "from-options"; + var builder = services.AddRecipe(recipeName); + + builder.CreateOrganization(options.Name, options.Domain, options.Users + 1, options.PlanType); + builder.AddOwner(); + builder.WithGenerator(options.Domain); + builder.AddUsers(options.Users, options.RealisticStatusMix); + + if (options.Groups > 0) + { + builder.AddGroups(options.Groups); + } + + if (options.StructureModel.HasValue) + { + builder.AddCollections(options.StructureModel.Value); + } + else if (options.Ciphers > 0) + { + builder.AddCollections(1); + } + + if (options.Ciphers > 0) + { + builder.AddFolders(); + builder.AddCiphers(options.Ciphers, options.CipherTypeDistribution, options.PasswordDistribution); + } + + builder.Validate(); + + using var serviceProvider = services.BuildServiceProvider(); + var committer = new BulkCommitter(db, mapper); + var executor = new RecipeExecutor(recipeName, serviceProvider, committer); + + return executor.Execute(); + } + /// /// Lists all available embedded presets and fixtures. /// diff --git a/util/Seeder/README.md b/util/Seeder/README.md index e19c1384e1..7c856a9a77 100644 --- a/util/Seeder/README.md +++ b/util/Seeder/README.md @@ -46,7 +46,7 @@ The Seeder is organized around six core patterns, each with a specific responsib - **RecipeBuilder**: Fluent API with dependency validation - **IStep**: Isolated unit of work (CreateOrganizationStep, CreateUsersStep, etc.) - **RecipeExecutor**: Executes steps, captures statistics, commits -- **PresetExecutor**: Orchestrates preset loading and execution +- **RecipeOrchestrator**: Orchestrates recipe building and execution (from presets or options) - **SeederContext**: Shared mutable state (NOT thread-safe) **Why this architecture wins**: diff --git a/util/Seeder/Recipes/OrganizationFromPresetRecipe.cs b/util/Seeder/Recipes/OrganizationRecipe.cs similarity index 64% rename from util/Seeder/Recipes/OrganizationFromPresetRecipe.cs rename to util/Seeder/Recipes/OrganizationRecipe.cs index 1670318fc6..f996c1d7dd 100644 --- a/util/Seeder/Recipes/OrganizationFromPresetRecipe.cs +++ b/util/Seeder/Recipes/OrganizationRecipe.cs @@ -1,6 +1,7 @@ using AutoMapper; using Bit.Core.Entities; using Bit.Infrastructure.EntityFramework.Repositories; +using Bit.Seeder.Options; using Bit.Seeder.Pipeline; using Bit.Seeder.Services; using Microsoft.AspNetCore.Identity; @@ -8,20 +9,20 @@ using Microsoft.AspNetCore.Identity; namespace Bit.Seeder.Recipes; /// -/// Seeds an organization from an embedded preset. +/// Seeds an organization from an embedded preset or programmatic options. /// /// -/// This recipe is a thin facade over the internal Pipeline architecture (PresetExecutor). +/// Thin facade over the internal Pipeline architecture (RecipeOrchestrator). /// All orchestration logic is encapsulated within the Pipeline, keeping this Recipe simple. /// The CLI remains "dumb" - it creates this recipe and calls Seed(). /// -public class OrganizationFromPresetRecipe( +public class OrganizationRecipe( DatabaseContext db, IMapper mapper, IPasswordHasher passwordHasher, IManglerService manglerService) { - private readonly PresetExecutor _executor = new(db, mapper); + private readonly RecipeOrchestrator _orchestrator = new(db, mapper); /// /// Seeds an organization from an embedded preset. @@ -31,7 +32,25 @@ public class OrganizationFromPresetRecipe( /// The organization ID and summary statistics. public SeedResult Seed(string presetName, string? password = null) { - var result = _executor.Execute(presetName, passwordHasher, manglerService, password); + var result = _orchestrator.Execute(presetName, passwordHasher, manglerService, password); + + return new SeedResult( + result.OrganizationId, + result.OwnerEmail, + result.UsersCount, + result.GroupsCount, + result.CollectionsCount, + result.CiphersCount); + } + + /// + /// Seeds an organization from programmatic options (CLI arguments). + /// + /// Options specifying what to seed. + /// The organization ID and summary statistics. + public SeedResult Seed(OrganizationVaultOptions options) + { + var result = _orchestrator.Execute(options, passwordHasher, manglerService); return new SeedResult( result.OrganizationId, @@ -48,7 +67,7 @@ public class OrganizationFromPresetRecipe( /// Available presets grouped by category. public static AvailableSeeds ListAvailable() { - var internalResult = PresetExecutor.ListAvailable(); + var internalResult = RecipeOrchestrator.ListAvailable(); return new AvailableSeeds(internalResult.Presets, internalResult.Fixtures); } diff --git a/util/Seeder/Recipes/OrganizationWithVaultRecipe.cs b/util/Seeder/Recipes/OrganizationWithVaultRecipe.cs deleted file mode 100644 index 785534e817..0000000000 --- a/util/Seeder/Recipes/OrganizationWithVaultRecipe.cs +++ /dev/null @@ -1,369 +0,0 @@ -using AutoMapper; -using Bit.Core.Entities; -using Bit.Core.Enums; -using Bit.Core.Vault.Entities; -using Bit.Core.Vault.Enums; -using Bit.Infrastructure.EntityFramework.Repositories; -using Bit.RustSDK; -using Bit.Seeder.Data; -using Bit.Seeder.Data.Distributions; -using Bit.Seeder.Data.Enums; -using Bit.Seeder.Data.Generators; -using Bit.Seeder.Data.Static; -using Bit.Seeder.Factories; -using Bit.Seeder.Options; -using Bit.Seeder.Services; -using LinqToDB.Data; -using LinqToDB.EntityFrameworkCore; -using Microsoft.AspNetCore.Identity; -using EfFolder = Bit.Infrastructure.EntityFramework.Vault.Models.Folder; -using EfOrganization = Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization; -using EfOrganizationUser = Bit.Infrastructure.EntityFramework.Models.OrganizationUser; -using EfUser = Bit.Infrastructure.EntityFramework.Models.User; - -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, - IPasswordHasher passwordHasher, - IManglerService manglerService) -{ - private const int _minimumOrgSeats = 1000; - - private GeneratorContext _ctx = null!; - - /// - /// Tracks a user with their symmetric key for folder encryption. - /// - private record UserWithKey(User User, string SymmetricKey); - - /// - /// Seeds an organization with users, collections, groups, and encrypted ciphers. - /// - /// Options specifying what to seed. - /// The organization ID. - public Guid Seed(OrganizationVaultOptions options) - { - _ctx = GeneratorContext.FromOptions(options); - var password = options.Password ?? UserSeeder.DefaultPassword; - - var seats = Math.Max(options.Users + 1, _minimumOrgSeats); - var orgKeys = RustSdkService.GenerateOrganizationKeys(); - - // Create organization via factory - var organization = OrganizationSeeder.Create( - options.Name, options.Domain, seats, manglerService, orgKeys.PublicKey, orgKeys.PrivateKey, options.PlanType); - - // Create owner user via factory - var ownerEmail = $"owner@{options.Domain}"; - var mangledOwnerEmail = manglerService.Mangle(ownerEmail); - var ownerKeys = RustSdkService.GenerateUserKeys(mangledOwnerEmail, password); - var ownerUser = UserSeeder.Create(mangledOwnerEmail, passwordHasher, manglerService, keys: ownerKeys, password: password); - - var ownerOrgKey = RustSdkService.GenerateUserOrganizationKey(ownerUser.PublicKey!, orgKeys.Key); - var ownerOrgUser = organization.CreateOrganizationUserWithKey( - ownerUser, OrganizationUserType.Owner, OrganizationUserStatusType.Confirmed, ownerOrgKey); - - // Create member users via factory, retaining keys for folder encryption - var memberUsersWithKeys = new List(); - var memberOrgUsers = new List(); - var useRealisticMix = options.RealisticStatusMix && options.Users >= 10; - - for (var i = 0; i < options.Users; i++) - { - var email = $"user{i}@{options.Domain}"; - var mangledEmail = manglerService.Mangle(email); - var userKeys = RustSdkService.GenerateUserKeys(mangledEmail, password); - var memberUser = UserSeeder.Create(mangledEmail, passwordHasher, manglerService, keys: userKeys, password: password); - memberUsersWithKeys.Add(new UserWithKey(memberUser, userKeys.Key)); - - var status = useRealisticMix - ? UserStatusDistributions.Realistic.Select(i, options.Users) - : OrganizationUserStatusType.Confirmed; - - var memberOrgKey = (status == OrganizationUserStatusType.Confirmed || - status == OrganizationUserStatusType.Revoked) - ? RustSdkService.GenerateUserOrganizationKey(memberUser.PublicKey!, orgKeys.Key) - : null; - - memberOrgUsers.Add(organization.CreateOrganizationUserWithKey( - memberUser, OrganizationUserType.User, status, memberOrgKey)); - } - - var memberUsers = memberUsersWithKeys.Select(uwk => uwk.User).ToList(); - - // Persist organization and users - db.Add(mapper.Map(organization)); - db.Add(mapper.Map(ownerUser)); - 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 org user IDs for collection/group 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.PasswordDistribution, options.CipherTypeDistribution); - CreateFolders(memberUsersWithKeys); - - 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.Create(organizationId, orgKeyBase64, unit.Name)) - .ToList(); - } - else - { - collections = [CollectionSeeder.Create(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 maxAssignments = Math.Min((userIndex % 3) + 1, collections.Count); - return Enumerable.Range(0, maxAssignments) - .Select(j => CollectionUserSeeder.Create( - collections[(userIndex + j) % collections.Count].Id, - orgUserId, - readOnly: j > 0, - manage: j == 0)); - }) - .ToList(); - db.BulkCopy(new BulkCopyOptions { TableName = nameof(CollectionUser) }, 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.Create(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) => GroupUserSeeder.Create( - groupList[i % groupList.Count].Id, - orgUserId)) - .ToList(); - db.BulkCopy(groupUsers); - } - } - - private void CreateCiphers( - Guid organizationId, - string orgKeyBase64, - List collectionIds, - int cipherCount, - Distribution passwordDistribution, - Distribution typeDistribution) - { - if (cipherCount == 0) - { - return; - } - - var companies = Companies.All; - - var cipherList = Enumerable.Range(0, cipherCount) - .Select(i => - { - var cipherType = typeDistribution.Select(i, cipherCount); - return cipherType switch - { - CipherType.Login => CreateLoginCipher(i, organizationId, orgKeyBase64, companies, cipherCount, passwordDistribution), - CipherType.Card => CreateCardCipher(i, organizationId, orgKeyBase64), - CipherType.Identity => CreateIdentityCipher(i, organizationId, orgKeyBase64), - CipherType.SecureNote => CreateSecureNoteCipher(i, organizationId, orgKeyBase64), - CipherType.SSHKey => CreateSshKeyCipher(i, organizationId, orgKeyBase64), - _ => throw new ArgumentException($"Unsupported cipher type: {cipherType}") - }; - }) - .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 [primary]; - }).ToList(); - - db.BulkCopy(collectionCiphers); - } - } - private Cipher CreateLoginCipher( - int index, - Guid organizationId, - string orgKeyBase64, - Company[] companies, - int cipherCount, - Distribution passwordDistribution) - { - var company = companies[index % companies.Length]; - return LoginCipherSeeder.Create( - orgKeyBase64, - name: $"{company.Name} ({company.Category})", - organizationId: organizationId, - username: _ctx.Username.GenerateByIndex(index, totalHint: _ctx.CipherCount, domain: company.Domain), - password: Passwords.GetPassword(index, cipherCount, passwordDistribution), - uri: $"https://{company.Domain}"); - } - - private Cipher CreateCardCipher(int index, Guid organizationId, string orgKeyBase64) - { - var card = _ctx.Card.GenerateByIndex(index); - return CardCipherSeeder.Create( - orgKeyBase64, - name: $"{card.CardholderName}'s {card.Brand}", - card: card, - organizationId: organizationId); - } - - private Cipher CreateIdentityCipher(int index, Guid organizationId, string orgKeyBase64) - { - var identity = _ctx.Identity.GenerateByIndex(index); - var name = $"{identity.FirstName} {identity.LastName}"; - if (!string.IsNullOrEmpty(identity.Company)) - { - name += $" ({identity.Company})"; - } - return IdentityCipherSeeder.Create( - orgKeyBase64, - name: name, - identity: identity, - organizationId: organizationId); - } - - private Cipher CreateSecureNoteCipher(int index, Guid organizationId, string orgKeyBase64) - { - var (name, notes) = _ctx.SecureNote.GenerateByIndex(index); - return SecureNoteCipherSeeder.Create( - orgKeyBase64, - name: name, - organizationId: organizationId, - notes: notes); - } - - private Cipher CreateSshKeyCipher(int index, Guid organizationId, string orgKeyBase64) - { - var sshKey = SshKeyDataGenerator.GenerateByIndex(index); - return SshKeyCipherSeeder.Create( - orgKeyBase64, - name: $"SSH Key {index + 1}", - sshKey: sshKey, - organizationId: organizationId); - } - - /// - /// Creates personal vault folders for users with realistic distribution. - /// - private void CreateFolders(List usersWithKeys) - { - if (usersWithKeys.Count == 0) - { - return; - } - - var allFolders = usersWithKeys - .SelectMany((uwk, userIndex) => - { - var folderCount = GetFolderCountForUser(userIndex, usersWithKeys.Count, _ctx.Seed); - return Enumerable.Range(0, folderCount) - .Select(folderIndex => FolderSeeder.Create( - uwk.User.Id, - uwk.SymmetricKey, - _ctx.Folder.GetFolderName(userIndex * 15 + folderIndex))); - }) - .ToList(); - - if (allFolders.Count > 0) - { - var efFolders = allFolders.Select(f => mapper.Map(f)).ToList(); - db.BulkCopy(efFolders); - } - } - - private static int GetFolderCountForUser(int userIndex, int totalUsers, int seed) - { - var (min, max) = FolderCountDistributions.Realistic.Select(userIndex, totalUsers); - return GetDeterministicValueInRange(userIndex, seed, min, max); - } - - /// - /// Returns a deterministic value in [min, max) based on index and seed. - /// - private static int GetDeterministicValueInRange(int index, int seed, int min, int max) - { - unchecked - { - var hash = seed; - hash = hash * 397 ^ index; - hash = hash * 397 ^ min; - var range = max - min; - return min + ((hash % range) + range) % range; - } - } -} diff --git a/util/SeederUtility/Commands/VaultOrganizationArgs.cs b/util/SeederUtility/Commands/OrganizationArgs.cs similarity index 84% rename from util/SeederUtility/Commands/VaultOrganizationArgs.cs rename to util/SeederUtility/Commands/OrganizationArgs.cs index 3675f662cd..8956b1773b 100644 --- a/util/SeederUtility/Commands/VaultOrganizationArgs.cs +++ b/util/SeederUtility/Commands/OrganizationArgs.cs @@ -6,10 +6,10 @@ using CommandDotNet; namespace Bit.SeederUtility.Commands; /// -/// CLI argument model for the vault-organization command. +/// CLI argument model for the organization command. /// Maps to for the Seeder library. /// -public class VaultOrganizationArgs : IArgumentModel +public class OrganizationArgs : IArgumentModel { [Option('n', "name", Description = "Name of organization")] public string Name { get; set; } = null!; @@ -20,11 +20,11 @@ public class VaultOrganizationArgs : IArgumentModel [Option('d', "domain", Description = "Email domain for users")] public string Domain { get; set; } = null!; - [Option('c', "ciphers", Description = "Number of login ciphers to create (minimum 1)")] - public int Ciphers { get; set; } + [Option('c', "ciphers", Description = "Number of ciphers to create (default: 0, no vault data)")] + public int? Ciphers { get; set; } - [Option('g', "groups", Description = "Number of groups to create (minimum 1)")] - public int Groups { get; set; } + [Option('g', "groups", Description = "Number of groups to create (default: 0, no groups)")] + public int? Groups { get; set; } [Option('m', "mix-user-statuses", Description = "Use realistic status mix (85% confirmed, 5% each invited/accepted/revoked). Requires >= 10 users.")] public bool MixStatuses { get; set; } = true; @@ -48,17 +48,12 @@ public class VaultOrganizationArgs : IArgumentModel { if (Users < 1) { - throw new ArgumentException("Users must be at least 1. Use another command for orgs without users."); + throw new ArgumentException("Users must be at least 1."); } - if (Ciphers < 1) + if (!Domain.EndsWith(".example", StringComparison.OrdinalIgnoreCase)) { - 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."); + throw new ArgumentException("Domain must end with '.example' (RFC 2606). Example: myorg.example"); } if (!string.IsNullOrEmpty(Structure)) @@ -79,8 +74,8 @@ public class VaultOrganizationArgs : IArgumentModel Name = Name, Domain = Domain, Users = Users, - Ciphers = Ciphers, - Groups = Groups, + Ciphers = Ciphers ?? 0, + Groups = Groups ?? 0, RealisticStatusMix = MixStatuses, StructureModel = ParseOrgStructure(Structure), Region = ParseGeographicRegion(Region), diff --git a/util/SeederUtility/Commands/OrganizationCommand.cs b/util/SeederUtility/Commands/OrganizationCommand.cs index 1304bd3126..66f7c90bc7 100644 --- a/util/SeederUtility/Commands/OrganizationCommand.cs +++ b/util/SeederUtility/Commands/OrganizationCommand.cs @@ -10,31 +10,62 @@ using Microsoft.Extensions.DependencyInjection; namespace Bit.SeederUtility.Commands; -[Command("organization", Description = "Seed an organization and organization users")] +[Command("organization", Description = "Seed an organization with users and optional vault data (ciphers, collections, groups)")] public class OrganizationCommand { [DefaultCommand] - public void Execute( - [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 - ) + public void Execute(OrganizationArgs args) { + args.Validate(); + var services = new ServiceCollection(); - ServiceCollectionExtension.ConfigureServices(services); + ServiceCollectionExtension.ConfigureServices(services, enableMangling: args.Mangle); var serviceProvider = services.BuildServiceProvider(); using var scope = serviceProvider.CreateScope(); var scopedServices = scope.ServiceProvider; - var db = scopedServices.GetRequiredService(); - var mapper = scopedServices.GetRequiredService(); - var passwordHasher = scopedServices.GetRequiredService>(); var manglerService = scopedServices.GetRequiredService(); - var recipe = new OrganizationWithUsersRecipe(db, mapper, passwordHasher, manglerService); - recipe.Seed(name: name, domain: domain, users: users); + var recipe = new OrganizationRecipe( + scopedServices.GetRequiredService(), + scopedServices.GetRequiredService(), + scopedServices.GetRequiredService>(), + manglerService); + + var result = recipe.Seed(args.ToOptions()); + + Console.WriteLine($"✓ Created organization (ID: {result.OrganizationId})"); + if (result.OwnerEmail is not null) + { + Console.WriteLine($"✓ Owner: {result.OwnerEmail}"); + } + if (result.UsersCount > 0) + { + Console.WriteLine($"✓ Created {result.UsersCount} users"); + } + if (result.GroupsCount > 0) + { + Console.WriteLine($"✓ Created {result.GroupsCount} groups"); + } + if (result.CollectionsCount > 0) + { + Console.WriteLine($"✓ Created {result.CollectionsCount} collections"); + } + if (result.CiphersCount > 0) + { + Console.WriteLine($"✓ Created {result.CiphersCount} ciphers"); + } + + if (!manglerService.IsEnabled) + { + return; + } + + var map = manglerService.GetMangleMap(); + Console.WriteLine("--- Mangled Data Map ---"); + foreach (var (original, mangled) in map) + { + Console.WriteLine($"{original} -> {mangled}"); + } } } diff --git a/util/SeederUtility/Commands/SeedCommand.cs b/util/SeederUtility/Commands/SeedCommand.cs index f1779bd118..c0e0d45c6f 100644 --- a/util/SeederUtility/Commands/SeedCommand.cs +++ b/util/SeederUtility/Commands/SeedCommand.cs @@ -22,7 +22,7 @@ public class SeedCommand if (args.List) { - var available = OrganizationFromPresetRecipe.ListAvailable(); + var available = OrganizationRecipe.ListAvailable(); PrintAvailableSeeds(available); return; } @@ -39,7 +39,7 @@ public class SeedCommand var passwordHasher = scopedServices.GetRequiredService>(); var manglerService = scopedServices.GetRequiredService(); - var recipe = new OrganizationFromPresetRecipe(db, mapper, passwordHasher, manglerService); + var recipe = new OrganizationRecipe(db, mapper, passwordHasher, manglerService); Console.WriteLine($"Seeding organization from preset '{args.Preset}'..."); var result = recipe.Seed(args.Preset!, args.Password); diff --git a/util/SeederUtility/Commands/VaultOrganizationCommand.cs b/util/SeederUtility/Commands/VaultOrganizationCommand.cs deleted file mode 100644 index 41851daa8e..0000000000 --- a/util/SeederUtility/Commands/VaultOrganizationCommand.cs +++ /dev/null @@ -1,49 +0,0 @@ -using AutoMapper; -using Bit.Core.Entities; -using Bit.Infrastructure.EntityFramework.Repositories; -using Bit.Seeder.Recipes; -using Bit.Seeder.Services; -using Bit.SeederUtility.Configuration; -using CommandDotNet; -using Microsoft.AspNetCore.Identity; -using Microsoft.Extensions.DependencyInjection; - -namespace Bit.SeederUtility.Commands; - -[Command("vault-organization", Description = "Seed an organization with users and encrypted vault data (ciphers, collections, groups)")] -public class VaultOrganizationCommand -{ - [DefaultCommand] - public void Execute(VaultOrganizationArgs args) - { - args.Validate(); - - var services = new ServiceCollection(); - ServiceCollectionExtension.ConfigureServices(services, enableMangling: args.Mangle); - var serviceProvider = services.BuildServiceProvider(); - - using var scope = serviceProvider.CreateScope(); - var scopedServices = scope.ServiceProvider; - - var manglerService = scopedServices.GetRequiredService(); - var recipe = new OrganizationWithVaultRecipe( - scopedServices.GetRequiredService(), - scopedServices.GetRequiredService(), - scopedServices.GetRequiredService>(), - manglerService); - - recipe.Seed(args.ToOptions()); - - if (!manglerService.IsEnabled) - { - return; - } - - var map = manglerService.GetMangleMap(); - Console.WriteLine("--- Mangled Data Map ---"); - foreach (var (original, mangled) in map) - { - Console.WriteLine($"{original} -> {mangled}"); - } - } -} diff --git a/util/SeederUtility/Program.cs b/util/SeederUtility/Program.cs index cba08c2191..28c970e9e9 100644 --- a/util/SeederUtility/Program.cs +++ b/util/SeederUtility/Program.cs @@ -93,9 +93,6 @@ public class Program [Subcommand] public OrganizationCommand Organization { get; set; } = null!; - [Subcommand] - public VaultOrganizationCommand VaultOrganization { get; set; } = null!; - [Subcommand] public SeedCommand Seed { get; set; } = null!; } diff --git a/util/SeederUtility/README.md b/util/SeederUtility/README.md index 7c99c9044c..63fc6a72d8 100644 --- a/util/SeederUtility/README.md +++ b/util/SeederUtility/README.md @@ -15,14 +15,42 @@ dotnet run -- [options] ## Commands -### `organization` - Users Only (No Vault Data) +### `organization` - Seed an Organization ```bash -# 100 users +# Users only — no vault data dotnet run -- organization -n MyOrgNoCiphers -u 100 -d myorg-no-ciphers.example # 10,000 users for load testing dotnet run -- organization -n LargeOrgNoCiphers -u 10000 -d large-org-no-ciphers.example + +# With vault data (ciphers, groups, collections) +dotnet run -- organization -n SmallOrg -d small.example -u 3 -c 10 -g 5 -o Traditional -m + +# Mid-size Traditional org with realistic status mix +dotnet run -- organization -n MidOrg -d mid.example -u 50 -c 1000 -g 15 -o Traditional -m + +# Large Modern org +dotnet run -- organization -n LargeOrg -d large.example -u 500 -c 10000 -g 85 -o Modern -m + +# Stress test — massive Spotify-style org +dotnet run -- organization -n StressOrg -d stress.example -u 8000 -c 100000 -g 125 -o Spotify -m + +# Regional data variants +dotnet run -- organization -n EuropeOrg -d europe.example -u 10 -c 100 -g 5 --region Europe +dotnet run -- organization -n ApacOrg -d apac.example -u 17 -c 600 -g 12 --region AsiaPacific + +# With ID mangling for test isolation +dotnet run -- organization -n IsolatedOrg -d isolated.example -u 5 -c 25 -g 4 -o Spotify --mangle + +# With custom password and plan type +dotnet run -- organization -n CustomPwOrg -d custom-password-05.example -u 10 -c 100 -g 3 --password "MyTestPassword1" --plan-type teams-annually + +# Free plan org +dotnet run -- organization -n FreeOrg -d free.example -u 1 -c 10 -g 1 --plan-type free + +# Teams plan org +dotnet run -- organization -n TeamsOrg -d teams.example -u 20 -c 200 -g 5 --plan-type teams-annually ``` ### `seed` - Fixture-Based Seeding @@ -45,37 +73,3 @@ dotnet run -- seed --preset large-enterprise dotnet run -- seed --preset dunder-mifflin-enterprise-full --password "MyTestPassword1" --mangle ``` -### `vault-organization` - Users + Encrypted Vault Data - -```bash -# Tiny org — quick sanity check -dotnet run -- vault-organization -n SmallOrg -d small.example -u 3 -c 10 -g 5 -o Traditional -m - -# Mid-size Traditional org with realistic status mix -dotnet run -- vault-organization -n MidOrg -d mid.example -u 50 -c 1000 -g 15 -o Traditional -m - -# Mid-size with dense cipher-to-user ratio -dotnet run -- vault-organization -n DenseOrg -d dense.example -u 75 -c 650 -g 20 -o Traditional -m - -# Large Modern org -dotnet run -- vault-organization -n LargeOrg -d large.example -u 500 -c 10000 -g 85 -o Modern -m - -# Stress test — massive Spotify-style org -dotnet run -- vault-organization -n StressOrg -d stress.example -u 8000 -c 100000 -g 125 -o Spotify -m - -# Regional data variants -dotnet run -- vault-organization -n EuropeOrg -d europe.example -u 10 -c 100 -g 5 --region Europe -dotnet run -- vault-organization -n ApacOrg -d apac.example -u 17 -c 600 -g 12 --region AsiaPacific - -# With ID mangling for test isolation (prevents collisions with existing data) -dotnet run -- vault-organization -n IsolatedOrg -d isolated.example -u 5 -c 25 -g 4 -o Spotify --mangle - -# With custom password for all accounts -dotnet run -- vault-organization -n CustomPwOrg -d custom-password-05.example -u 10 -c 100 -g 3 --password "MyTestPassword1" --plan-type teams-annually - -# Free plan org (limited to 2 seats, 2 collections) -dotnet run -- vault-organization -n FreeOrg -d free.example -u 1 -c 10 -g 1 --plan-type free - -# Teams plan org -dotnet run -- vault-organization -n TeamsOrg -d teams.example -u 20 -c 200 -g 5 --plan-type teams-annually -```