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
-```