mirror of
https://github.com/bitwarden/server
synced 2026-02-26 01:13:35 +00:00
Refactoring legacy Seeder Recipes (#7069)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Orchestrates preset-based seeding by coordinating the Pipeline infrastructure.
|
||||
/// Orchestrates recipe-based seeding by coordinating the Pipeline infrastructure.
|
||||
/// </summary>
|
||||
internal sealed class PresetExecutor(DatabaseContext db, IMapper mapper)
|
||||
internal sealed class RecipeOrchestrator(DatabaseContext db, IMapper mapper)
|
||||
{
|
||||
/// <summary>
|
||||
/// 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes a recipe built programmatically from CLI options.
|
||||
/// </summary>
|
||||
internal ExecutionResult Execute(
|
||||
OrganizationVaultOptions options,
|
||||
IPasswordHasher<User> 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lists all available embedded presets and fixtures.
|
||||
/// </summary>
|
||||
@@ -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**:
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Seeds an organization from an embedded preset.
|
||||
/// Seeds an organization from an embedded preset or programmatic options.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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().
|
||||
/// </remarks>
|
||||
public class OrganizationFromPresetRecipe(
|
||||
public class OrganizationRecipe(
|
||||
DatabaseContext db,
|
||||
IMapper mapper,
|
||||
IPasswordHasher<User> passwordHasher,
|
||||
IManglerService manglerService)
|
||||
{
|
||||
private readonly PresetExecutor _executor = new(db, mapper);
|
||||
private readonly RecipeOrchestrator _orchestrator = new(db, mapper);
|
||||
|
||||
/// <summary>
|
||||
/// Seeds an organization from an embedded preset.
|
||||
@@ -31,7 +32,25 @@ public class OrganizationFromPresetRecipe(
|
||||
/// <returns>The organization ID and summary statistics.</returns>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seeds an organization from programmatic options (CLI arguments).
|
||||
/// </summary>
|
||||
/// <param name="options">Options specifying what to seed.</param>
|
||||
/// <returns>The organization ID and summary statistics.</returns>
|
||||
public SeedResult Seed(OrganizationVaultOptions options)
|
||||
{
|
||||
var result = _orchestrator.Execute(options, passwordHasher, manglerService);
|
||||
|
||||
return new SeedResult(
|
||||
result.OrganizationId,
|
||||
@@ -48,7 +67,7 @@ public class OrganizationFromPresetRecipe(
|
||||
/// <returns>Available presets grouped by category.</returns>
|
||||
public static AvailableSeeds ListAvailable()
|
||||
{
|
||||
var internalResult = PresetExecutor.ListAvailable();
|
||||
var internalResult = RecipeOrchestrator.ListAvailable();
|
||||
|
||||
return new AvailableSeeds(internalResult.Presets, internalResult.Fixtures);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Seeds an organization with users, collections, groups, and encrypted ciphers.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This recipe creates a complete organization with vault data in a single operation.
|
||||
/// All entity creation is delegated to factories. Users can log in with their email
|
||||
/// and password "asdfasdfasdf". Organization and user keys are generated dynamically.
|
||||
/// </remarks>
|
||||
public class OrganizationWithVaultRecipe(
|
||||
DatabaseContext db,
|
||||
IMapper mapper,
|
||||
IPasswordHasher<User> passwordHasher,
|
||||
IManglerService manglerService)
|
||||
{
|
||||
private const int _minimumOrgSeats = 1000;
|
||||
|
||||
private GeneratorContext _ctx = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Tracks a user with their symmetric key for folder encryption.
|
||||
/// </summary>
|
||||
private record UserWithKey(User User, string SymmetricKey);
|
||||
|
||||
/// <summary>
|
||||
/// Seeds an organization with users, collections, groups, and encrypted ciphers.
|
||||
/// </summary>
|
||||
/// <param name="options">Options specifying what to seed.</param>
|
||||
/// <returns>The organization ID.</returns>
|
||||
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<UserWithKey>();
|
||||
var memberOrgUsers = new List<OrganizationUser>();
|
||||
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<EfOrganization>(organization));
|
||||
db.Add(mapper.Map<EfUser>(ownerUser));
|
||||
db.Add(mapper.Map<EfOrganizationUser>(ownerOrgUser));
|
||||
|
||||
var efMemberUsers = memberUsers.Select(u => mapper.Map<EfUser>(u)).ToList();
|
||||
var efMemberOrgUsers = memberOrgUsers.Select(ou => mapper.Map<EfOrganizationUser>(ou)).ToList();
|
||||
db.BulkCopy(efMemberUsers);
|
||||
db.BulkCopy(efMemberOrgUsers);
|
||||
db.SaveChanges();
|
||||
|
||||
// Get confirmed 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<Guid> CreateCollections(
|
||||
Guid organizationId,
|
||||
string orgKeyBase64,
|
||||
OrgStructureModel? structureModel,
|
||||
List<Guid> orgUserIds)
|
||||
{
|
||||
List<Collection> collections;
|
||||
|
||||
if (structureModel.HasValue)
|
||||
{
|
||||
var structure = OrgStructures.GetStructure(structureModel.Value);
|
||||
collections = structure.Units
|
||||
.Select(unit => CollectionSeeder.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<Guid> 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<Guid> collectionIds,
|
||||
int cipherCount,
|
||||
Distribution<PasswordStrength> passwordDistribution,
|
||||
Distribution<CipherType> 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<PasswordStrength> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates personal vault folders for users with realistic distribution.
|
||||
/// </summary>
|
||||
private void CreateFolders(List<UserWithKey> 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<EfFolder>(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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a deterministic value in [min, max) based on index and seed.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,10 @@ using CommandDotNet;
|
||||
namespace Bit.SeederUtility.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// CLI argument model for the vault-organization command.
|
||||
/// CLI argument model for the organization command.
|
||||
/// Maps to <see cref="OrganizationVaultOptions"/> for the Seeder library.
|
||||
/// </summary>
|
||||
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),
|
||||
@@ -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<DatabaseContext>();
|
||||
|
||||
var mapper = scopedServices.GetRequiredService<IMapper>();
|
||||
var passwordHasher = scopedServices.GetRequiredService<IPasswordHasher<User>>();
|
||||
var manglerService = scopedServices.GetRequiredService<IManglerService>();
|
||||
var recipe = new OrganizationWithUsersRecipe(db, mapper, passwordHasher, manglerService);
|
||||
recipe.Seed(name: name, domain: domain, users: users);
|
||||
var recipe = new OrganizationRecipe(
|
||||
scopedServices.GetRequiredService<DatabaseContext>(),
|
||||
scopedServices.GetRequiredService<IMapper>(),
|
||||
scopedServices.GetRequiredService<IPasswordHasher<User>>(),
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<IPasswordHasher<User>>();
|
||||
var manglerService = scopedServices.GetRequiredService<IManglerService>();
|
||||
|
||||
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);
|
||||
|
||||
@@ -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<IManglerService>();
|
||||
var recipe = new OrganizationWithVaultRecipe(
|
||||
scopedServices.GetRequiredService<DatabaseContext>(),
|
||||
scopedServices.GetRequiredService<IMapper>(),
|
||||
scopedServices.GetRequiredService<IPasswordHasher<User>>(),
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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!;
|
||||
}
|
||||
|
||||
@@ -15,14 +15,42 @@ dotnet run -- <command> [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
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user