1
0
mirror of https://github.com/bitwarden/server synced 2026-02-17 18:09:11 +00:00

Seeder Enhancements - Phase 3 (#6973)

This commit is contained in:
Mick Letofsky
2026-02-17 07:42:53 +01:00
committed by GitHub
parent b03f8f8cae
commit 07049b367a
61 changed files with 4982 additions and 51 deletions

View File

@@ -0,0 +1,51 @@
using Bit.Seeder.Recipes;
using Xunit;
namespace Bit.SeederApi.IntegrationTest;
public class OrganizationFromPresetRecipeTests
{
// NOTE: Issue #1 (SeedResult counts) is verified by the implementation fix.
// The Recipe now captures counts BEFORE BulkCommitter.Commit() clears the lists.
// Full database integration tests will verify the counts match actual seeded entities.
// This fix ensures context.Users.Count etc. are captured before being cleared to zero.
[Fact]
public void ListAvailable_HandlesPresetWithPresetInMiddle()
{
// Issue #3: String.Replace bug - should only remove prefix, not all occurrences
var available = OrganizationFromPresetRecipe.ListAvailable();
// Verify presets don't have "presets." prefix removed from middle
// If we had a preset named "my-presets-collection", it should become "my-presets-collection"
// not "my--collection" (which would happen with Replace)
Assert.NotNull(available);
Assert.NotNull(available.Presets);
// All preset names should not start with "presets."
Assert.All(available.Presets, name => Assert.DoesNotContain("presets.", name.Substring(0, Math.Min(8, name.Length))));
// Verify known presets are listed correctly
Assert.Contains("dunder-mifflin-full", available.Presets);
Assert.Contains("large-enterprise", available.Presets);
}
[Fact]
public void ListAvailable_GroupsFixturesByCategory()
{
var available = OrganizationFromPresetRecipe.ListAvailable();
// Verify fixtures are grouped by category
Assert.NotNull(available.Fixtures);
Assert.True(available.Fixtures.ContainsKey("ciphers"));
Assert.True(available.Fixtures.ContainsKey("organizations"));
Assert.True(available.Fixtures.ContainsKey("rosters"));
// Verify ciphers category has expected fixtures
Assert.Contains("ciphers.autofill-testing", available.Fixtures["ciphers"]);
Assert.Contains("ciphers.public-site-logins", available.Fixtures["ciphers"]);
}
}

View File

@@ -0,0 +1,35 @@
using Bit.Seeder;
using Bit.Seeder.Pipeline;
using Xunit;
namespace Bit.SeederApi.IntegrationTest;
public class PresetLoaderTests
{
[Fact]
public void Load_FixtureOrgWithGeneratedCiphers_InitializesGenerator()
{
// Issue #2: Fixture-based org + generated ciphers should resolve domain from fixture
// This test verifies that when a preset uses a fixture org (no explicit domain)
// but wants to generate ciphers (needs domain for generator), the domain is
// automatically resolved by reading the org fixture.
var services = new ServiceCollection();
var builder = services.AddRecipe("fixture-org-test");
builder
.UseOrganization("dunder-mifflin") // Fixture org (domain in fixture)
.AddOwner()
.WithGenerator("dundermifflin.com") // Generator needs domain
.AddCiphers(50);
// This should NOT throw "Generated ciphers require a generator"
builder.Validate();
using var provider = services.BuildServiceProvider();
var steps = provider.GetKeyedServices<IStep>("fixture-org-test").ToList();
Assert.NotNull(steps);
Assert.NotEmpty(steps);
}
}

View File

@@ -0,0 +1,12 @@
{
"profiles": {
"SeederApi.IntegrationTest": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:53205;http://localhost:53206"
}
}
}

View File

@@ -0,0 +1,157 @@
using Bit.Seeder;
using Bit.Seeder.Pipeline;
using Xunit;
namespace Bit.SeederApi.IntegrationTest;
public class RecipeBuilderValidationTests
{
[Fact]
public void UseRoster_AfterAddUsers_ThrowsInvalidOperationException()
{
var services = new ServiceCollection();
var builder = services.AddRecipe("test");
builder.AddUsers(10);
var ex = Assert.Throws<InvalidOperationException>(() => builder.UseRoster("test"));
Assert.Contains("Cannot call UseRoster() after AddUsers()", ex.Message);
}
[Fact]
public void AddUsers_AfterUseRoster_ThrowsInvalidOperationException()
{
var services = new ServiceCollection();
var builder = services.AddRecipe("test");
builder.UseRoster("test");
var ex = Assert.Throws<InvalidOperationException>(() => builder.AddUsers(10));
Assert.Contains("Cannot call AddUsers() after UseRoster()", ex.Message);
}
[Fact]
public void UseCiphers_AfterAddCiphers_ThrowsInvalidOperationException()
{
var services = new ServiceCollection();
var builder = services.AddRecipe("test");
builder.AddCiphers(10);
var ex = Assert.Throws<InvalidOperationException>(() => builder.UseCiphers("test"));
Assert.Contains("Cannot call UseCiphers() after AddCiphers()", ex.Message);
}
[Fact]
public void AddCiphers_AfterUseCiphers_ThrowsInvalidOperationException()
{
var services = new ServiceCollection();
var builder = services.AddRecipe("test");
builder.UseCiphers("test");
var ex = Assert.Throws<InvalidOperationException>(() => builder.AddCiphers(10));
Assert.Contains("Cannot call AddCiphers() after UseCiphers()", ex.Message);
}
[Fact]
public void AddGroups_WithoutUsers_ThrowsInvalidOperationException()
{
var services = new ServiceCollection();
var builder = services.AddRecipe("test");
var ex = Assert.Throws<InvalidOperationException>(() => builder.AddGroups(5));
Assert.Contains("Groups require users", ex.Message);
}
[Fact]
public void AddCollections_WithoutUsers_ThrowsInvalidOperationException()
{
var services = new ServiceCollection();
var builder = services.AddRecipe("test");
var ex = Assert.Throws<InvalidOperationException>(() => builder.AddCollections(5));
Assert.Contains("Collections require users", ex.Message);
}
[Fact]
public void AddGroups_AfterAddUsers_Succeeds()
{
var services = new ServiceCollection();
var builder = services.AddRecipe("test");
builder.AddUsers(10);
builder.AddGroups(5);
}
[Fact]
public void AddCollections_AfterUseRoster_Succeeds()
{
var services = new ServiceCollection();
var builder = services.AddRecipe("test");
builder.UseRoster("test");
builder.AddCollections(5);
}
[Fact]
public void Validate_WithoutOrg_ThrowsInvalidOperationException()
{
var services = new ServiceCollection();
var builder = services.AddRecipe("test");
builder.AddOwner();
var ex = Assert.Throws<InvalidOperationException>(() => builder.Validate());
Assert.Contains("Organization is required", ex.Message);
}
[Fact]
public void Validate_WithoutOwner_ThrowsInvalidOperationException()
{
var services = new ServiceCollection();
var builder = services.AddRecipe("test");
builder.UseOrganization("test");
var ex = Assert.Throws<InvalidOperationException>(() => builder.Validate());
Assert.Contains("Owner is required", ex.Message);
}
[Fact]
public void Validate_AddCiphersWithoutGenerator_ThrowsInvalidOperationException()
{
var services = new ServiceCollection();
var builder = services.AddRecipe("test");
builder.UseOrganization("test");
builder.AddOwner();
builder.AddUsers(10);
builder.AddCiphers(50);
var ex = Assert.Throws<InvalidOperationException>(() => builder.Validate());
Assert.Contains("Generated ciphers require a generator", ex.Message);
}
[Fact]
public void StepsExecuteInRegistrationOrder()
{
var services = new ServiceCollection();
var builder = services.AddRecipe("test");
builder.UseOrganization("test-org");
builder.AddOwner();
builder.WithGenerator("test.com");
builder.AddUsers(5);
builder.AddGroups(2);
builder.AddCollections(3);
builder.AddCiphers(10);
builder.Validate();
using var provider = services.BuildServiceProvider();
var steps = provider.GetKeyedServices<IStep>("test").ToList();
Assert.Equal(7, steps.Count);
// Verify steps are wrapped in OrderedStep with sequential order values
var orderedSteps = steps.Cast<OrderedStep>().ToList();
for (var i = 0; i < orderedSteps.Count; i++)
{
Assert.Equal(i, orderedSteps[i].Order);
}
}
}

View File

@@ -0,0 +1,128 @@
using Bit.Seeder.Models;
using Bit.Seeder.Services;
using Xunit;
namespace Bit.SeederApi.IntegrationTest;
public class SeedReaderTests
{
private readonly SeedReader _reader = new();
[Fact]
public void ListAvailable_ReturnsAllSeedFiles()
{
var available = _reader.ListAvailable();
Assert.Contains("ciphers.autofill-testing", available);
Assert.Contains("ciphers.public-site-logins", available);
Assert.Contains("organizations.dunder-mifflin", available);
Assert.Contains("rosters.dunder-mifflin", available);
Assert.Contains("presets.dunder-mifflin-full", available);
Assert.Contains("presets.large-enterprise", available);
Assert.Equal(6, available.Count);
}
[Fact]
public void Read_AutofillTesting_DeserializesAllItems()
{
var seedFile = _reader.Read<SeedFile>("ciphers.autofill-testing");
Assert.Equal(18, seedFile.Items.Count);
var types = seedFile.Items.Select(i => i.Type).Distinct().OrderBy(t => t).ToList();
Assert.Contains("login", types);
Assert.Contains("card", types);
Assert.Contains("identity", types);
var logins = seedFile.Items.Where(i => i.Type == "login").ToList();
Assert.All(logins, l => Assert.NotEmpty(l.Login!.Uris!));
}
[Fact]
public void Read_PublicSiteLogins_DeserializesAllItems()
{
var seedFile = _reader.Read<SeedFile>("ciphers.public-site-logins");
Assert.True(seedFile.Items.Count >= 90,
$"Expected at least 90 public site logins, got {seedFile.Items.Count}");
}
[Fact]
public void Read_NonExistentSeed_ThrowsWithAvailableList()
{
var ex = Assert.Throws<InvalidOperationException>(
() => _reader.Read<SeedFile>("does-not-exist"));
Assert.Contains("does-not-exist", ex.Message);
Assert.Contains("ciphers.autofill-testing", ex.Message);
}
[Fact]
public void Read_CipherSeeds_ItemNamesAreUnique()
{
var cipherSeeds = _reader.ListAvailable()
.Where(n => n.StartsWith("ciphers."));
foreach (var seedName in cipherSeeds)
{
var seedFile = _reader.Read<SeedFile>(seedName);
var duplicates = seedFile.Items
.GroupBy(i => i.Name)
.Where(g => g.Count() > 1)
.Select(g => g.Key)
.ToList();
Assert.True(duplicates.Count == 0,
$"Seed '{seedName}' has duplicate item names: {string.Join(", ", duplicates)}");
}
}
[Fact]
public void Read_DunderMifflin_DeserializesOrganization()
{
var org = _reader.Read<SeedOrganization>("organizations.dunder-mifflin");
Assert.Equal("Dunder Mifflin", org.Name);
Assert.Equal("dundermifflin.com", org.Domain);
Assert.Equal(70, org.Seats);
}
[Fact]
public void Read_DunderMifflinRoster_DeserializesRoster()
{
var roster = _reader.Read<SeedRoster>("rosters.dunder-mifflin");
Assert.Equal(58, roster.Users.Count);
Assert.NotNull(roster.Groups);
Assert.Equal(14, roster.Groups.Count);
Assert.NotNull(roster.Collections);
Assert.Equal(15, roster.Collections.Count);
// Verify no duplicate email prefixes
var prefixes = roster.Users
.Select(u => $"{u.FirstName}.{u.LastName}".ToLowerInvariant())
.ToList();
Assert.Equal(prefixes.Count, prefixes.Distinct().Count());
// Verify all group members reference valid users
var prefixSet = new HashSet<string>(prefixes, StringComparer.OrdinalIgnoreCase);
foreach (var group in roster.Groups)
{
Assert.All(group.Members, m => Assert.Contains(m, prefixSet));
}
// Verify all collection user/group refs are valid
var groupNames = new HashSet<string>(roster.Groups.Select(g => g.Name), StringComparer.OrdinalIgnoreCase);
foreach (var collection in roster.Collections)
{
if (collection.Groups is not null)
{
Assert.All(collection.Groups, cg => Assert.Contains(cg.Group, groupNames));
}
if (collection.Users is not null)
{
Assert.All(collection.Users, cu => Assert.Contains(cu.User, prefixSet));
}
}
}
}

View File

@@ -1,4 +1,5 @@
using AutoMapper;
using System.Diagnostics;
using AutoMapper;
using Bit.Core.Entities;
using Bit.Infrastructure.EntityFramework.Repositories;
using Bit.Seeder.Recipes;
@@ -77,4 +78,112 @@ public class Program
Console.WriteLine($"{original} -> {mangled}");
}
}
[Command("seed", Description = "Seed database using fixture-based presets")]
public void Seed(SeedArgs args)
{
try
{
args.Validate();
// Handle list mode - no database needed
if (args.List)
{
var available = OrganizationFromPresetRecipe.ListAvailable();
PrintAvailableSeeds(available);
return;
}
// Create service provider - same pattern as other commands
var services = new ServiceCollection();
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>();
// Create recipe - CLI is "dumb", recipe handles complexity
var recipe = new OrganizationFromPresetRecipe(db, mapper, passwordHasher, manglerService);
var stopwatch = Stopwatch.StartNew();
Console.WriteLine($"Seeding organization from preset '{args.Preset}'...");
var result = recipe.Seed(args.Preset!);
stopwatch.Stop();
PrintSeedResult(result, stopwatch.Elapsed);
}
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
{
Console.Error.WriteLine($"Error: {ex.Message}");
Environment.Exit(1);
}
}
private static void PrintAvailableSeeds(AvailableSeeds available)
{
Console.WriteLine("Available Presets:");
foreach (var preset in available.Presets)
{
Console.WriteLine($" - {preset}");
}
Console.WriteLine();
Console.WriteLine("Available Fixtures:");
foreach (var (category, fixtures) in available.Fixtures.OrderBy(kvp => kvp.Key))
{
// Guard: Skip empty or single-character categories to prevent IndexOutOfRangeException
if (string.IsNullOrEmpty(category) || category.Length < 2)
{
continue;
}
var categoryName = char.ToUpperInvariant(category[0]) + category[1..];
Console.WriteLine($" {categoryName}:");
foreach (var fixture in fixtures)
{
Console.WriteLine($" - {fixture}");
}
}
Console.WriteLine();
Console.WriteLine("Use: DbSeeder.exe seed --preset <name>");
}
private static void PrintSeedResult(SeedResult result, TimeSpan elapsed)
{
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");
}
Console.WriteLine($"Done in {elapsed.TotalSeconds:F1}s");
}
}

View File

@@ -1,66 +1,68 @@
# Bitwarden Database Seeder Utility
A command-line utility for generating and managing test data for Bitwarden databases.
A CLI wrapper around the Seeder library for generating test data in your local Bitwarden database.
## Overview
## Getting Started
DbSeederUtility is an executable wrapper around the Seeder class library that provides a convenient command-line
interface for executing seed-recipes in your local environment.
Build and run from the `util/DbSeederUtility` directory:
## Installation
The utility can be built and run as a .NET 8 application:
```
```bash
dotnet build
dotnet run -- <command> [options]
```
Or directly using the compiled executable:
**Login Credentials:** All seeded users use password `asdfasdfasdf`. The owner email is `owner@<domain>`.
```
DbSeeder.exe <command> [options]
```
## Commands
## Examples
### Generate and load test organization
### `seed` - Fixture-Based Seeding
```bash
# Generate an organization called "seeded" with 10000 users using the @large.test email domain.
# Login using "owner@large.test" with password "asdfasdfasdf"
DbSeeder.exe organization -n seeded -u 10000 -d large.test
# List available presets and fixtures
dotnet run -- seed --list
# Generate an organization with 5 users and 100 encrypted ciphers
DbSeeder.exe vault-organization -n TestOrg -u 5 -d test.com -c 100
# Load the Dunder Mifflin preset (58 users, 14 groups, 15 collections, ciphers)
dotnet run -- seed --preset dunder-mifflin-full
# Generate with Spotify-style collections (tribes, chapters, guilds)
DbSeeder.exe vault-organization -n TestOrg -u 10 -d test.com -c 50 -o Spotify
# Generate a small test organization with ciphers for manual testing
DbSeeder.exe vault-organization -n DevOrg -u 2 -d dev.local -c 10
# Generate an organization using a traditional structure
dotnet run --project DbSeederUtility.csproj -- vault-organization -n Test001 -d test001.com -u 50 -c 1000 -g 15 -o Traditional -m
# Generate an organization using a modern structure with a small vault
dotnet run --project DbSeederUtility.csproj -- vault-organization -n Test002 -d test002.com -u 500 -c 10000 -g 85 -o Modern -m
# Generate an organization using a spotify structure with a large vault
dotnet run --project DbSeederUtility.csproj -- vault-organization -n Test003 -d test003.com -u 8000 -c 100000 -g 125 -o Spotify -m
# Generate an organization using a traditional structure with a very small vault with European regional data
dotnet run --project DbSeederUtility.csproj -- vault-organization -n “TestOneEurope” -u 10 -c 100 -g 5 -d testOneEurope.com -o Traditional --region Europe
# Generate an organization using a traditional structure with a very small vault with Asia Pacific regional data
dotnet run --project DbSeederUtility.csproj -- vault-organization -n “TestOneAsiaPacific” -u 17 -c 600 -g 12 -d testOneAsiaPacific.com -o Traditional --region AsiaPacific
# Load with ID mangling for test isolation
dotnet run -- seed --preset dunder-mifflin-full --mangle
# Large enterprise preset for performance testing
dotnet run -- seed --preset large-enterprise
```
## Dependencies
### `organization` - Users Only (No Vault Data)
This utility depends on:
```bash
# 100 users
dotnet run -- organization -n MyOrg -u 100 -d myorg.com
- The Seeder class library
- CommandDotNet for command-line parsing
- .NET 8.0 runtime
# 10,000 users for load testing
dotnet run -- organization -n seeded -u 10000 -d large.test
```
### `vault-organization` - Users + Encrypted Vault Data
```bash
# Tiny org — quick sanity check
dotnet run -- vault-organization -n SmallOrg -d small.test -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.test -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.test -u 75 -c 650 -g 20 -o Traditional -m
# Large Modern org
dotnet run -- vault-organization -n LargeOrg -d large.test -u 500 -c 10000 -g 85 -o Modern -m
# Stress test — massive Spotify-style org
dotnet run -- vault-organization -n StressOrg -d stress.test -u 8000 -c 100000 -g 125 -o Spotify -m
# Regional data variants
dotnet run -- vault-organization -n EuropeOrg -d europe.test -u 10 -c 100 -g 5 --region Europe
dotnet run -- vault-organization -n ApacOrg -d apac.test -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.test -u 5 -c 25 -g 4 -o Spotify --mangle
```

View File

@@ -0,0 +1,35 @@
using CommandDotNet;
namespace Bit.DbSeederUtility;
/// <summary>
/// CLI argument model for the seed command.
/// Supports loading presets from embedded resources.
/// </summary>
public class SeedArgs : IArgumentModel
{
[Option('p', "preset", Description = "Name of embedded preset to load (e.g., 'dunder-mifflin-full')")]
public string? Preset { get; set; }
[Option('l', "list", Description = "List all available presets and fixtures")]
public bool List { get; set; }
[Option("mangle", Description = "Enable mangling for test isolation")]
public bool Mangle { get; set; }
public void Validate()
{
// List mode is standalone
if (List)
{
return;
}
// Must specify preset
if (string.IsNullOrEmpty(Preset))
{
throw new ArgumentException(
"--preset must be specified. Use --list to see available presets.");
}
}
}

View File

@@ -25,6 +25,7 @@ public static class ServiceCollectionExtension
});
services.AddSingleton(globalSettings);
services.AddSingleton<IPasswordHasher<User>, PasswordHasher<User>>();
services.TryAddSingleton<ISeedReader, SeedReader>();
// Add Data Protection services
services.AddDataProtection()

View File

@@ -24,12 +24,31 @@ dotnet test test/SeederApi.IntegrationTest/ --filter "FullyQualifiedName~TestMet
```
Need to create test data?
├─ ONE entity with encryption? → Factory
├─ MANY entities as cohesive operation? → Recipe
├─ Complete test scenario with ID mangling for SeederApi? → Scene
├─ MANY entities as cohesive operation? → Recipe or Pipeline
├─ Flexible preset-based seeding? → Pipeline (RecipeBuilder + Steps)
├─ Complete test scenario with ID mangling? → Scene
├─ READ existing seeded data? → Query
└─ Data transformation SDK ↔ Server? → Model
```
## Pipeline Architecture
**Modern pattern for composable fixture-based and generated seeding.**
**Flow**: Preset JSON → PresetLoader → RecipeBuilder → IStep[] → RecipeExecutor → SeederContext → BulkCommitter
**Key actors**:
- **RecipeBuilder**: Fluent API with dependency validation
- **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
**Phase order**: Org → Owner → Generator → Roster → Users → Groups → Collections → Ciphers
See `Pipeline/` folder for implementation.
## The Recipe Contract
Recipes follow strict rules:

View File

@@ -26,4 +26,32 @@ internal static class CardCipherSeeder
var encrypted = CipherEncryption.Encrypt(cipherView, encryptionKey);
return CipherEncryption.CreateEntity(encrypted, encrypted.ToCardData(), CipherType.Card, organizationId, userId);
}
internal static Cipher CreateFromSeed(
string encryptionKey,
SeedVaultItem item,
Guid? organizationId = null,
Guid? userId = null)
{
var cipherView = new CipherViewDto
{
OrganizationId = organizationId,
Name = item.Name,
Notes = item.Notes,
Type = CipherTypes.Card,
Card = item.Card == null ? null : new CardViewDto
{
CardholderName = item.Card.CardholderName,
Brand = item.Card.Brand,
Number = item.Card.Number,
ExpMonth = item.Card.ExpMonth,
ExpYear = item.Card.ExpYear,
Code = item.Card.Code
},
Fields = SeedItemMapping.MapFields(item.Fields)
};
var encrypted = CipherEncryption.Encrypt(cipherView, encryptionKey);
return CipherEncryption.CreateEntity(encrypted, encrypted.ToCardData(), CipherType.Card, organizationId, userId);
}
}

View File

@@ -0,0 +1,23 @@
using Bit.Core.Entities;
namespace Bit.Seeder.Factories;
internal static class CollectionGroupSeeder
{
internal static CollectionGroup Create(
Guid collectionId,
Guid groupId,
bool readOnly = false,
bool hidePasswords = false,
bool manage = false)
{
return new CollectionGroup
{
CollectionId = collectionId,
GroupId = groupId,
ReadOnly = readOnly,
HidePasswords = hidePasswords,
Manage = manage
};
}
}

View File

@@ -26,4 +26,43 @@ internal static class IdentityCipherSeeder
var encrypted = CipherEncryption.Encrypt(cipherView, encryptionKey);
return CipherEncryption.CreateEntity(encrypted, encrypted.ToIdentityData(), CipherType.Identity, organizationId, userId);
}
internal static Cipher CreateFromSeed(
string encryptionKey,
SeedVaultItem item,
Guid? organizationId = null,
Guid? userId = null)
{
var cipherView = new CipherViewDto
{
OrganizationId = organizationId,
Name = item.Name,
Notes = item.Notes,
Type = CipherTypes.Identity,
Identity = item.Identity == null ? null : new IdentityViewDto
{
FirstName = item.Identity.FirstName,
MiddleName = item.Identity.MiddleName,
LastName = item.Identity.LastName,
Address1 = item.Identity.Address1,
Address2 = item.Identity.Address2,
Address3 = item.Identity.Address3,
City = item.Identity.City,
State = item.Identity.State,
PostalCode = item.Identity.PostalCode,
Country = item.Identity.Country,
Company = item.Identity.Company,
Email = item.Identity.Email,
Phone = item.Identity.Phone,
SSN = item.Identity.Ssn,
Username = item.Identity.Username,
PassportNumber = item.Identity.PassportNumber,
LicenseNumber = item.Identity.LicenseNumber
},
Fields = SeedItemMapping.MapFields(item.Fields)
};
var encrypted = CipherEncryption.Encrypt(cipherView, encryptionKey);
return CipherEncryption.CreateEntity(encrypted, encrypted.ToIdentityData(), CipherType.Identity, organizationId, userId);
}
}

View File

@@ -40,4 +40,34 @@ internal static class LoginCipherSeeder
var encrypted = CipherEncryption.Encrypt(cipherView, encryptionKey);
return CipherEncryption.CreateEntity(encrypted, encrypted.ToLoginData(), CipherType.Login, organizationId, userId);
}
internal static Cipher CreateFromSeed(
string encryptionKey,
SeedVaultItem item,
Guid? organizationId = null,
Guid? userId = null)
{
var cipherView = new CipherViewDto
{
OrganizationId = organizationId,
Name = item.Name,
Notes = item.Notes,
Type = CipherTypes.Login,
Login = item.Login == null ? null : new LoginViewDto
{
Username = item.Login.Username,
Password = item.Login.Password,
Totp = item.Login.Totp,
Uris = item.Login.Uris?.Select(u => new LoginUriViewDto
{
Uri = u.Uri,
Match = SeedItemMapping.MapUriMatchType(u.Match)
}).ToList()
},
Fields = SeedItemMapping.MapFields(item.Fields)
};
var encrypted = CipherEncryption.Encrypt(cipherView, encryptionKey);
return CipherEncryption.CreateEntity(encrypted, encrypted.ToLoginData(), CipherType.Login, organizationId, userId);
}
}

View File

@@ -25,4 +25,24 @@ internal static class SecureNoteCipherSeeder
var encrypted = CipherEncryption.Encrypt(cipherView, encryptionKey);
return CipherEncryption.CreateEntity(encrypted, encrypted.ToSecureNoteData(), CipherType.SecureNote, organizationId, userId);
}
internal static Cipher CreateFromSeed(
string encryptionKey,
SeedVaultItem item,
Guid? organizationId = null,
Guid? userId = null)
{
var cipherView = new CipherViewDto
{
OrganizationId = organizationId,
Name = item.Name,
Notes = item.Notes,
Type = CipherTypes.SecureNote,
SecureNote = new SecureNoteViewDto { Type = 0 },
Fields = SeedItemMapping.MapFields(item.Fields)
};
var encrypted = CipherEncryption.Encrypt(cipherView, encryptionKey);
return CipherEncryption.CreateEntity(encrypted, encrypted.ToSecureNoteData(), CipherType.SecureNote, organizationId, userId);
}
}

View File

@@ -0,0 +1,35 @@
using Bit.Seeder.Models;
namespace Bit.Seeder.Factories;
/// <summary>
/// Shared mapping helpers for converting SeedItem fields to CipherViewDto fields.
/// </summary>
internal static class SeedItemMapping
{
internal static int MapFieldType(string type) => type switch
{
"hidden" => 1,
"boolean" => 2,
"linked" => 3,
_ => 0 // text
};
internal static List<FieldViewDto>? MapFields(List<SeedField>? fields) =>
fields?.Select(f => new FieldViewDto
{
Name = f.Name,
Value = f.Value,
Type = MapFieldType(f.Type)
}).ToList();
internal static int MapUriMatchType(string match) => match switch
{
"host" => 1,
"startsWith" => 2,
"exact" => 3,
"regex" => 4,
"never" => 5,
_ => 0 // domain
};
}

8
util/Seeder/IStep.cs Normal file
View File

@@ -0,0 +1,8 @@
using Bit.Seeder.Pipeline;
namespace Bit.Seeder;
public interface IStep
{
void Execute(SeederContext context);
}

View File

@@ -0,0 +1,122 @@
namespace Bit.Seeder.Models;
internal record SeedFile
{
public required List<SeedVaultItem> Items { get; init; }
}
internal record SeedVaultItem
{
public required string Type { get; init; }
public required string Name { get; init; }
public string? Notes { get; init; }
public SeedLogin? Login { get; init; }
public SeedCard? Card { get; init; }
public SeedIdentity? Identity { get; init; }
public List<SeedField>? Fields { get; init; }
}
internal record SeedLogin
{
public string? Username { get; init; }
public string? Password { get; init; }
public List<SeedLoginUri>? Uris { get; init; }
public string? Totp { get; init; }
}
internal record SeedLoginUri
{
public required string Uri { get; init; }
public string Match { get; init; } = "domain";
}
internal record SeedCard
{
public string? CardholderName { get; init; }
public string? Brand { get; init; }
public string? Number { get; init; }
public string? ExpMonth { get; init; }
public string? ExpYear { get; init; }
public string? Code { get; init; }
}
internal record SeedIdentity
{
public string? FirstName { get; init; }
public string? MiddleName { get; init; }
public string? LastName { get; init; }
public string? Address1 { get; init; }
public string? Address2 { get; init; }
public string? Address3 { get; init; }
public string? City { get; init; }
public string? State { get; init; }
public string? PostalCode { get; init; }
public string? Country { get; init; }
public string? Company { get; init; }
public string? Email { get; init; }
public string? Phone { get; init; }
public string? Ssn { get; init; }
public string? Username { get; init; }
public string? PassportNumber { get; init; }
public string? LicenseNumber { get; init; }
}
internal record SeedField
{
public string? Name { get; init; }
public string? Value { get; init; }
public string Type { get; init; } = "text";
}
internal record SeedOrganization
{
public required string Name { get; init; }
public required string Domain { get; init; }
public int Seats { get; init; } = 10;
}
internal record SeedRoster
{
public required List<SeedRosterUser> Users { get; init; }
public List<SeedRosterGroup>? Groups { get; init; }
public List<SeedRosterCollection>? Collections { get; init; }
}
internal record SeedRosterUser
{
public required string FirstName { get; init; }
public required string LastName { get; init; }
public string? Title { get; init; }
public string Role { get; init; } = "user";
public string? Branch { get; init; }
public string? Department { get; init; }
}
internal record SeedRosterGroup
{
public required string Name { get; init; }
public required List<string> Members { get; init; }
}
internal record SeedRosterCollection
{
public required string Name { get; init; }
public List<SeedRosterCollectionGroup>? Groups { get; init; }
public List<SeedRosterCollectionUser>? Users { get; init; }
}
internal record SeedRosterCollectionGroup
{
public required string Group { get; init; }
public bool ReadOnly { get; init; }
public bool HidePasswords { get; init; }
public bool Manage { get; init; }
}
internal record SeedRosterCollectionUser
{
public required string User { get; init; }
public bool ReadOnly { get; init; }
public bool HidePasswords { get; init; }
public bool Manage { get; init; }
}

View File

@@ -0,0 +1,46 @@
namespace Bit.Seeder.Models;
internal record SeedPreset
{
public SeedPresetOrganization? Organization { get; init; }
public SeedPresetRoster? Roster { get; init; }
public SeedPresetUsers? Users { get; init; }
public SeedPresetGroups? Groups { get; init; }
public SeedPresetCollections? Collections { get; init; }
public SeedPresetCiphers? Ciphers { get; init; }
}
internal record SeedPresetOrganization
{
public string? Fixture { get; init; }
public string? Name { get; init; }
public string? Domain { get; init; }
public int Seats { get; init; } = 10;
}
internal record SeedPresetRoster
{
public string? Fixture { get; init; }
}
internal record SeedPresetUsers
{
public int Count { get; init; }
public bool RealisticStatusMix { get; init; }
}
internal record SeedPresetGroups
{
public int Count { get; init; }
}
internal record SeedPresetCollections
{
public int Count { get; init; }
}
internal record SeedPresetCiphers
{
public string? Fixture { get; init; }
public int Count { get; init; }
}

View File

@@ -0,0 +1,87 @@
using AutoMapper;
using Bit.Infrastructure.EntityFramework.Repositories;
using LinqToDB.Data;
using LinqToDB.EntityFrameworkCore;
using EfCollection = Bit.Infrastructure.EntityFramework.Models.Collection;
using EfCollectionGroup = Bit.Infrastructure.EntityFramework.Models.CollectionGroup;
using EfCollectionUser = Bit.Infrastructure.EntityFramework.Models.CollectionUser;
using EfGroup = Bit.Infrastructure.EntityFramework.Models.Group;
using EfGroupUser = Bit.Infrastructure.EntityFramework.Models.GroupUser;
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.Pipeline;
/// <summary>
/// Flushes accumulated entities from <see cref="SeederContext"/> to the database via BulkCopy.
/// </summary>
/// <remarks>
/// Entities are committed in foreign-key-safe order (Organizations → Users → OrgUsers → …).
/// Most Core entities require AutoMapper conversion to their EF counterparts before insert;
/// a few (Cipher, CollectionCipher) share the same type across layers and copy directly.
/// Each list is cleared after insert so the context is ready for the next pipeline run.
///
/// CollectionUser and CollectionGroup require an explicit table name in BulkCopyOptions because
/// they lack both IEntityTypeConfiguration and .ToTable() mappings in DatabaseContext, so LinqToDB
/// cannot resolve their table names automatically.
/// </remarks>
/// <seealso cref="SeederContext"/>
/// <seealso cref="RecipeExecutor"/>
internal sealed class BulkCommitter(DatabaseContext db, IMapper mapper)
{
internal void Commit(SeederContext context)
{
MapCopyAndClear<Core.AdminConsole.Entities.Organization, EfOrganization>(context.Organizations);
MapCopyAndClear<Core.Entities.User, EfUser>(context.Users);
MapCopyAndClear<Core.Entities.OrganizationUser, EfOrganizationUser>(context.OrganizationUsers);
MapCopyAndClear<Core.AdminConsole.Entities.Group, EfGroup>(context.Groups);
MapCopyAndClear<Core.AdminConsole.Entities.GroupUser, EfGroupUser>(context.GroupUsers);
MapCopyAndClear<Core.Entities.Collection, EfCollection>(context.Collections);
MapCopyAndClear<Core.Entities.CollectionUser, EfCollectionUser>(context.CollectionUsers, nameof(Core.Entities.CollectionUser));
MapCopyAndClear<Core.Entities.CollectionGroup, EfCollectionGroup>(context.CollectionGroups, nameof(Core.Entities.CollectionGroup));
CopyAndClear(context.Ciphers);
CopyAndClear(context.CollectionCiphers);
}
private void MapCopyAndClear<TCore, TEf>(List<TCore> entities, string? tableName = null) where TEf : class
{
if (entities.Count is 0)
{
return;
}
var mapped = entities.Select(e => mapper.Map<TEf>(e));
if (tableName is not null)
{
db.BulkCopy(new BulkCopyOptions { TableName = tableName }, mapped);
}
else
{
db.BulkCopy(mapped);
}
entities.Clear();
}
private void CopyAndClear<T>(List<T> entities) where T : class
{
if (entities.Count is 0)
{
return;
}
db.BulkCopy(entities);
entities.Clear();
}
}

View File

@@ -0,0 +1,60 @@
namespace Bit.Seeder.Pipeline;
/// <summary>
/// Persistent cross-step reference store that survives bulk-commit flushes.
/// </summary>
/// <remarks>
/// When <see cref="BulkCommitter"/> commits entities to the database, it clears the entity
/// lists on <see cref="SeederContext"/> (users, groups, ciphers, etc.). The registry preserves
/// the IDs and keys that downstream steps need to reference those already-committed entities
/// — for example, a cipher step needs collection IDs to create join records.
/// <para>
/// Steps populate the registry as they create entities. Later steps read from it.
/// <see cref="RecipeExecutor"/> calls <see cref="Clear"/> before each run to prevent stale state.
/// </para>
/// </remarks>
internal sealed class EntityRegistry
{
/// <summary>
/// A user's core IDs and symmetric key, needed for per-user encryption (e.g. personal folders).
/// </summary>
internal record UserDigest(Guid UserId, Guid OrgUserId, string SymmetricKey);
/// <summary>
/// Organization user IDs for hardened (key-bearing) members. Used by group and collection steps for assignment.
/// </summary>
internal List<Guid> HardenedOrgUserIds { get; } = [];
/// <summary>
/// Full user references including symmetric keys. Used for per-user encrypted content.
/// </summary>
/// <seealso cref="UserDigest"/>
internal List<UserDigest> UserDigests { get; } = [];
/// <summary>
/// Group IDs for collection-group assignment.
/// </summary>
internal List<Guid> GroupIds { get; } = [];
/// <summary>
/// Collection IDs for cipher-collection assignment.
/// </summary>
internal List<Guid> CollectionIds { get; } = [];
/// <summary>
/// Cipher IDs for downstream reference.
/// </summary>
internal List<Guid> CipherIds { get; } = [];
/// <summary>
/// Clears all registry lists. Called by <see cref="RecipeExecutor"/> before each pipeline run.
/// </summary>
internal void Clear()
{
HardenedOrgUserIds.Clear();
UserDigests.Clear();
GroupIds.Clear();
CollectionIds.Clear();
CipherIds.Clear();
}
}

View File

@@ -0,0 +1,12 @@
namespace Bit.Seeder.Pipeline;
/// <summary>
/// Wraps an <see cref="IStep"/> with an order index for keyed DI registration
/// where GetKeyedServices does not guarantee order.
/// </summary>
internal sealed class OrderedStep(IStep inner, int order) : IStep
{
internal int Order { get; } = order;
public void Execute(SeederContext context) => inner.Execute(context);
}

View File

@@ -0,0 +1,83 @@
using AutoMapper;
using Bit.Core.Entities;
using Bit.Infrastructure.EntityFramework.Repositories;
using Bit.Seeder.Services;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;
namespace Bit.Seeder.Pipeline;
/// <summary>
/// Orchestrates preset-based seeding by coordinating the Pipeline infrastructure.
/// </summary>
internal sealed class PresetExecutor(DatabaseContext db, IMapper mapper)
{
/// <summary>
/// Executes a preset by registering its recipe, building a service provider, and running all steps.
/// </summary>
/// <param name="presetName">Name of the embedded preset (e.g., "dunder-mifflin-full")</param>
/// <param name="passwordHasher">Password hasher for user creation</param>
/// <param name="manglerService">Mangler service for test isolation</param>
/// <returns>Execution result with organization ID and entity counts</returns>
internal ExecutionResult Execute(
string presetName,
IPasswordHasher<User> passwordHasher,
IManglerService manglerService)
{
var reader = new SeedReader();
var services = new ServiceCollection();
services.AddSingleton(passwordHasher);
services.AddSingleton(manglerService);
services.AddSingleton<ISeedReader>(reader);
services.AddSingleton(db);
PresetLoader.RegisterRecipe(presetName, reader, services);
using var serviceProvider = services.BuildServiceProvider();
var committer = new BulkCommitter(db, mapper);
var executor = new RecipeExecutor(presetName, serviceProvider, committer);
return executor.Execute();
}
/// <summary>
/// Lists all available embedded presets and fixtures.
/// </summary>
/// <returns>Available presets grouped by category</returns>
internal static AvailableSeeds ListAvailable()
{
var seedReader = new SeedReader();
var all = seedReader.ListAvailable();
var presets = all.Where(n => n.StartsWith("presets."))
.Select(n => n["presets.".Length..])
.ToList();
var fixtures = all.Where(n => !n.StartsWith("presets."))
.GroupBy(n => n.Split('.')[0])
.ToDictionary(
g => g.Key,
g => (IReadOnlyList<string>)g.ToList());
return new AvailableSeeds(presets, fixtures);
}
}
/// <summary>
/// Result of pipeline execution with organization ID and entity counts.
/// </summary>
internal record ExecutionResult(
Guid OrganizationId,
string? OwnerEmail,
int UsersCount,
int GroupsCount,
int CollectionsCount,
int CiphersCount);
/// <summary>
/// Available presets and fixtures grouped by category.
/// </summary>
internal record AvailableSeeds(
IReadOnlyList<string> Presets,
IReadOnlyDictionary<string, IReadOnlyList<string>> Fixtures);

View File

@@ -0,0 +1,102 @@
using Bit.Seeder.Models;
using Bit.Seeder.Services;
using Microsoft.Extensions.DependencyInjection;
namespace Bit.Seeder.Pipeline;
/// <summary>
/// Loads preset fixtures and registers them as recipes on <see cref="IServiceCollection"/>.
/// </summary>
internal static class PresetLoader
{
/// <summary>
/// Loads a preset from embedded fixtures and registers its steps as a recipe.
/// </summary>
/// <param name="presetName">Preset name without extension (e.g., "dunder-mifflin-full")</param>
/// <param name="reader">Service for reading embedded seed JSON files</param>
/// <param name="services">The service collection to register steps in</param>
/// <exception cref="InvalidOperationException">Thrown when preset lacks organization configuration</exception>
internal static void RegisterRecipe(string presetName, ISeedReader reader, IServiceCollection services)
{
var preset = reader.Read<SeedPreset>($"presets.{presetName}");
if (preset.Organization is null)
{
throw new InvalidOperationException(
$"Preset '{presetName}' must specify an organization.");
}
BuildRecipe(presetName, preset, reader, services);
}
/// <summary>
/// Builds a recipe from preset configuration, resolving fixtures and generation counts.
/// </summary>
/// <remarks>
/// Resolution order: Org → Owner → Generator → Roster → Users → Groups → Collections → Ciphers
/// </remarks>
private static void BuildRecipe(string presetName, SeedPreset preset, ISeedReader reader, IServiceCollection services)
{
var builder = services.AddRecipe(presetName);
var org = preset.Organization!;
// Resolve domain - either from preset or from fixture
var domain = org.Domain;
if (org.Fixture is not null)
{
builder.UseOrganization(org.Fixture);
// If using a fixture and domain not explicitly provided, read it from the fixture
if (domain is null)
{
var orgFixture = reader.Read<SeedOrganization>($"organizations.{org.Fixture}");
domain = orgFixture.Domain;
}
}
else if (org.Name is not null && org.Domain is not null)
{
builder.CreateOrganization(org.Name, org.Domain, org.Seats);
domain = org.Domain;
}
builder.AddOwner();
// Generator requires a domain and is only needed for generated ciphers
if (domain is not null && preset.Ciphers?.Count > 0)
{
builder.WithGenerator(domain);
}
if (preset.Roster?.Fixture is not null)
{
builder.UseRoster(preset.Roster.Fixture);
}
if (preset.Users is not null)
{
builder.AddUsers(preset.Users.Count, preset.Users.RealisticStatusMix);
}
if (preset.Groups is not null)
{
builder.AddGroups(preset.Groups.Count);
}
if (preset.Collections is not null)
{
builder.AddCollections(preset.Collections.Count);
}
if (preset.Ciphers?.Fixture is not null)
{
builder.UseCiphers(preset.Ciphers.Fixture);
}
else if (preset.Ciphers is not null && preset.Ciphers.Count > 0)
{
builder.AddCiphers(preset.Ciphers.Count);
}
builder.Validate();
}
}

View File

@@ -0,0 +1,71 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace Bit.Seeder.Pipeline;
/// <summary>
/// Fluent API for building seeding pipelines with DI-based step registration and validation.
/// </summary>
/// <remarks>
/// RecipeBuilder wraps <see cref="IServiceCollection"/> and a recipe name.
/// It tracks step count for deterministic ordering and validation flags for dependency rules.
/// <strong>Phase Order:</strong> Org → Owner → Generator → Roster → Users → Groups → Collections → Ciphers
/// </remarks>
public class RecipeBuilder
{
private int _stepOrder;
public RecipeBuilder(string name, IServiceCollection services)
{
Name = name;
Services = services;
}
public string Name { get; }
public IServiceCollection Services { get; }
internal bool HasOrg { get; set; }
internal bool HasOwner { get; set; }
internal bool HasGenerator { get; set; }
internal bool HasRosterUsers { get; set; }
internal bool HasGeneratedUsers { get; set; }
internal bool HasFixtureCiphers { get; set; }
internal bool HasGeneratedCiphers { get; set; }
/// <summary>
/// Registers a step as a keyed singleton service with preserved ordering.
/// </summary>
/// <remarks>
/// Steps execute in the order they are registered. Callers must register steps
/// in the correct phase order: Org, Owner, Generator, Roster, Users, Groups,
/// Collections, Ciphers.
/// </remarks>
/// <param name="factory">Factory function that creates the step from an IServiceProvider</param>
/// <returns>This builder for fluent chaining</returns>
public RecipeBuilder AddStep(Func<IServiceProvider, IStep> factory)
{
var order = _stepOrder++;
Services.AddKeyedSingleton<IStep>(Name, (sp, _) => new OrderedStep(factory(sp), order));
return this;
}
/// <summary>
/// Registers a step type as a keyed singleton with preserved ordering.
/// </summary>
/// <typeparam name="T">The step implementation type</typeparam>
/// <returns>This builder for fluent chaining</returns>
public RecipeBuilder AddStep<T>() where T : class, IStep
{
var order = _stepOrder++;
Services.AddKeyedSingleton<IStep>(Name, (sp, _) => new OrderedStep(sp.GetRequiredService<T>(), order));
Services.TryAddSingleton<T>();
return this;
}
}

View File

@@ -0,0 +1,240 @@
using Bit.Core.Vault.Enums;
using Bit.Seeder.Data.Distributions;
using Bit.Seeder.Data.Enums;
using Bit.Seeder.Steps;
namespace Bit.Seeder.Pipeline;
/// <summary>
/// Step registration extension methods for <see cref="RecipeBuilder"/>.
/// Each method validates constraints, sets validation flags, and registers the step via DI.
/// </summary>
public static class RecipeBuilderExtensions
{
/// <summary>
/// Use an organization from embedded fixtures.
/// </summary>
/// <param name="builder">The recipe builder</param>
/// <param name="fixture">Organization fixture name without extension</param>
/// <returns>The builder for fluent chaining</returns>
public static RecipeBuilder UseOrganization(this RecipeBuilder builder, string fixture)
{
builder.HasOrg = true;
builder.AddStep(_ => CreateOrganizationStep.FromFixture(fixture));
return builder;
}
/// <summary>
/// Create an organization inline with specified parameters.
/// </summary>
/// <param name="builder">The recipe builder</param>
/// <param name="name">Organization display name</param>
/// <param name="domain">Organization domain (used for email generation)</param>
/// <param name="seats">Number of user seats</param>
/// <returns>The builder for fluent chaining</returns>
public static RecipeBuilder CreateOrganization(this RecipeBuilder builder, string name, string domain, int seats)
{
builder.HasOrg = true;
builder.AddStep(_ => CreateOrganizationStep.FromParams(name, domain, seats));
return builder;
}
/// <summary>
/// Add an organization owner user with admin privileges.
/// </summary>
/// <param name="builder">The recipe builder</param>
/// <returns>The builder for fluent chaining</returns>
public static RecipeBuilder AddOwner(this RecipeBuilder builder)
{
builder.HasOwner = true;
builder.AddStep(_ => new CreateOwnerStep());
return builder;
}
/// <summary>
/// Initialize seeded random generator for reproducible test data.
/// </summary>
/// <param name="builder">The recipe builder</param>
/// <param name="domain">Organization domain (used for seeding randomness)</param>
/// <param name="seed">Optional explicit seed. If null, domain hash is used.</param>
/// <returns>The builder for fluent chaining</returns>
public static RecipeBuilder WithGenerator(this RecipeBuilder builder, string domain, int? seed = null)
{
builder.HasGenerator = true;
builder.AddStep(_ => InitGeneratorStep.FromDomain(domain, seed));
return builder;
}
/// <summary>
/// Use a roster from embedded fixtures (users, groups, collections).
/// </summary>
/// <param name="builder">The recipe builder</param>
/// <param name="fixture">Roster fixture name without extension</param>
/// <returns>The builder for fluent chaining</returns>
/// <exception cref="InvalidOperationException">Thrown when AddUsers() was already called</exception>
public static RecipeBuilder UseRoster(this RecipeBuilder builder, string fixture)
{
if (builder.HasGeneratedUsers)
{
throw new InvalidOperationException(
"Cannot call UseRoster() after AddUsers(). Choose one user source.");
}
builder.HasRosterUsers = true;
builder.AddStep(_ => new CreateRosterStep(fixture));
return builder;
}
/// <summary>
/// Generate users with seeded random data.
/// </summary>
/// <param name="builder">The recipe builder</param>
/// <param name="count">Number of users to generate</param>
/// <param name="realisticStatusMix">If true, includes revoked/invited users; if false, all confirmed</param>
/// <returns>The builder for fluent chaining</returns>
/// <exception cref="InvalidOperationException">Thrown when UseRoster() was already called</exception>
public static RecipeBuilder AddUsers(this RecipeBuilder builder, int count, bool realisticStatusMix = false)
{
if (builder.HasRosterUsers)
{
throw new InvalidOperationException(
"Cannot call AddUsers() after UseRoster(). Choose one user source.");
}
builder.HasGeneratedUsers = true;
builder.AddStep(_ => new CreateUsersStep(count, realisticStatusMix));
return builder;
}
/// <summary>
/// Generate groups with random members from existing users.
/// </summary>
/// <param name="builder">The recipe builder</param>
/// <param name="count">Number of groups to generate</param>
/// <returns>The builder for fluent chaining</returns>
/// <exception cref="InvalidOperationException">Thrown when no users exist</exception>
public static RecipeBuilder AddGroups(this RecipeBuilder builder, int count)
{
if (!builder.HasRosterUsers && !builder.HasGeneratedUsers)
{
throw new InvalidOperationException(
"Groups require users. Call UseRoster() or AddUsers() first.");
}
builder.AddStep(_ => new CreateGroupsStep(count));
return builder;
}
/// <summary>
/// Generate collections with random assignments.
/// </summary>
/// <param name="builder">The recipe builder</param>
/// <param name="count">Number of collections to generate</param>
/// <returns>The builder for fluent chaining</returns>
/// <exception cref="InvalidOperationException">Thrown when no users exist</exception>
public static RecipeBuilder AddCollections(this RecipeBuilder builder, int count)
{
if (!builder.HasRosterUsers && !builder.HasGeneratedUsers)
{
throw new InvalidOperationException(
"Collections require users. Call UseRoster() or AddUsers() first.");
}
builder.AddStep(_ => CreateCollectionsStep.FromCount(count));
return builder;
}
/// <summary>
/// Generate collections based on organizational structure model.
/// </summary>
/// <param name="builder">The recipe builder</param>
/// <param name="structure">Organizational structure (Traditional, Spotify, Modern)</param>
/// <returns>The builder for fluent chaining</returns>
/// <exception cref="InvalidOperationException">Thrown when no users exist</exception>
public static RecipeBuilder AddCollections(this RecipeBuilder builder, OrgStructureModel structure)
{
if (!builder.HasRosterUsers && !builder.HasGeneratedUsers)
{
throw new InvalidOperationException(
"Collections require users. Call UseRoster() or AddUsers() first.");
}
builder.AddStep(_ => CreateCollectionsStep.FromStructure(structure));
return builder;
}
/// <summary>
/// Use ciphers from embedded fixtures.
/// </summary>
/// <param name="builder">The recipe builder</param>
/// <param name="fixture">Cipher fixture name without extension</param>
/// <returns>The builder for fluent chaining</returns>
/// <exception cref="InvalidOperationException">Thrown when AddCiphers() was already called</exception>
public static RecipeBuilder UseCiphers(this RecipeBuilder builder, string fixture)
{
if (builder.HasGeneratedCiphers)
{
throw new InvalidOperationException(
"Cannot call UseCiphers() after AddCiphers(). Choose one cipher source.");
}
builder.HasFixtureCiphers = true;
builder.AddStep(_ => new CreateCiphersStep(fixture));
return builder;
}
/// <summary>
/// Generate ciphers with configurable type and password strength distributions.
/// </summary>
/// <param name="builder">The recipe builder</param>
/// <param name="count">Number of ciphers to generate</param>
/// <param name="typeDist">Distribution of cipher types. Uses realistic defaults if null.</param>
/// <param name="pwDist">Distribution of password strengths. Uses realistic defaults if null.</param>
/// <returns>The builder for fluent chaining</returns>
/// <exception cref="InvalidOperationException">Thrown when UseCiphers() was already called</exception>
public static RecipeBuilder AddCiphers(
this RecipeBuilder builder,
int count,
Distribution<CipherType>? typeDist = null,
Distribution<PasswordStrength>? pwDist = null)
{
if (builder.HasFixtureCiphers)
{
throw new InvalidOperationException(
"Cannot call AddCiphers() after UseCiphers(). Choose one cipher source.");
}
builder.HasGeneratedCiphers = true;
builder.AddStep(_ => new GenerateCiphersStep(count, typeDist, pwDist));
return builder;
}
/// <summary>
/// Validates the builder state to ensure all required steps are present and dependencies are met.
/// </summary>
/// <param name="builder">The recipe builder</param>
/// <returns>The builder for fluent chaining</returns>
/// <exception cref="InvalidOperationException">Thrown when required steps missing or dependencies violated</exception>
public static RecipeBuilder Validate(this RecipeBuilder builder)
{
if (!builder.HasOrg)
{
throw new InvalidOperationException(
"Organization is required. Call UseOrganization() or CreateOrganization().");
}
if (!builder.HasOwner)
{
throw new InvalidOperationException(
"Owner is required. Call AddOwner().");
}
if (builder.HasGeneratedCiphers && !builder.HasGenerator)
{
throw new InvalidOperationException(
"Generated ciphers require a generator. Call WithGenerator() first.");
}
return builder;
}
}

View File

@@ -0,0 +1,55 @@
using Microsoft.Extensions.DependencyInjection;
namespace Bit.Seeder.Pipeline;
/// <summary>
/// Resolves steps from DI by recipe key and executes them in order.
/// </summary>
internal sealed class RecipeExecutor
{
private readonly string _recipeName;
private readonly IServiceProvider _serviceProvider;
private readonly BulkCommitter _committer;
internal RecipeExecutor(string recipeName, IServiceProvider serviceProvider, BulkCommitter committer)
{
_recipeName = recipeName;
_serviceProvider = serviceProvider;
_committer = committer;
}
/// <summary>
/// Executes the recipe by resolving keyed steps, running them in order, and committing results.
/// </summary>
/// <remarks>
/// Clears the EntityRegistry at the start to ensure a clean slate for each run.
/// </remarks>
internal ExecutionResult Execute()
{
var steps = _serviceProvider.GetKeyedServices<IStep>(_recipeName)
.OrderBy(s => s is OrderedStep os ? os.Order : int.MaxValue)
.ToList();
var context = new SeederContext(_serviceProvider);
context.Registry.Clear();
foreach (var step in steps)
{
step.Execute(context);
}
// Capture counts BEFORE committing (commit clears the lists)
var result = new ExecutionResult(
context.RequireOrgId(),
context.Owner?.Email,
context.Users.Count,
context.Groups.Count,
context.Collections.Count,
context.Ciphers.Count);
_committer.Commit(context);
return result;
}
}

View File

@@ -0,0 +1,20 @@
using Microsoft.Extensions.DependencyInjection;
namespace Bit.Seeder.Pipeline;
/// <summary>
/// Entry point extension method for registering recipes on <see cref="IServiceCollection"/>.
/// </summary>
public static class RecipeServiceCollectionExtensions
{
/// <summary>
/// Creates a new <see cref="RecipeBuilder"/> for registering steps as keyed services.
/// </summary>
/// <param name="services">The service collection to register steps in</param>
/// <param name="recipeName">Unique name used as the keyed service key</param>
/// <returns>A new RecipeBuilder for fluent step registration</returns>
public static RecipeBuilder AddRecipe(this IServiceCollection services, string recipeName)
{
return new RecipeBuilder(recipeName, services);
}
}

View File

@@ -0,0 +1,93 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Entities;
using Bit.Core.Vault.Entities;
using Bit.RustSDK;
using Bit.Seeder.Data;
namespace Bit.Seeder.Pipeline;
/// <summary>
/// Shared mutable state bag passed through every <see cref="IStep"/> in a pipeline run.
/// WARNING: This class is NOT thread-safe. Each pipeline execution must use its own context instance.
/// Do not share a context instance between concurrent pipeline runs.
/// </summary>
/// <remarks>
/// <para>
/// Steps resolve services from <see cref="Services"/> instead of accessing fixed properties.
/// Use the convenience extension methods in <see cref="SeederContextExtensions"/> for common services.
/// </para>
/// <para>
/// <strong>Context Lifecycle:</strong>
/// <list type="number">
/// <item><description>Create fresh context for each pipeline run</description></item>
/// <item><description>Pass to RecipeExecutor.Execute()</description></item>
/// <item><description>Steps mutate context progressively</description></item>
/// <item><description>BulkCommitter flushes entity lists to database</description></item>
/// <item><description>Return org ID from context</description></item>
/// <item><description>Discard context (do not reuse)</description></item>
/// </list>
/// </para>
/// <para>
/// Use the <c>Require*()</c> methods instead of accessing nullable properties directly —
/// they throw with step-ordering guidance if a prerequisite step hasn't run yet.
/// </para>
/// </remarks>
/// <param name="services">
/// Service provider for resolving dependencies. Steps access services via
/// <see cref="SeederContextExtensions"/> convenience methods.
/// </param>
/// <seealso cref="EntityRegistry"/>
/// <seealso cref="BulkCommitter"/>
public sealed class SeederContext(IServiceProvider services)
{
internal IServiceProvider Services { get; } = services;
internal Organization? Organization { get; set; }
internal OrganizationKeys? OrgKeys { get; set; }
internal string? Domain { get; set; }
internal User? Owner { get; set; }
internal OrganizationUser? OwnerOrgUser { get; set; }
internal List<Organization> Organizations { get; } = [];
internal List<User> Users { get; } = [];
internal List<OrganizationUser> OrganizationUsers { get; } = [];
internal List<Cipher> Ciphers { get; } = [];
internal List<Group> Groups { get; } = [];
internal List<GroupUser> GroupUsers { get; } = [];
internal List<Collection> Collections { get; } = [];
internal List<CollectionUser> CollectionUsers { get; } = [];
internal List<CollectionGroup> CollectionGroups { get; } = [];
internal List<CollectionCipher> CollectionCiphers { get; } = [];
internal EntityRegistry Registry { get; } = new();
internal GeneratorContext? Generator { get; set; }
internal Organization RequireOrganization() =>
Organization ?? throw new InvalidOperationException("Organization not set. Run CreateOrganizationStep first.");
internal string RequireOrgKey() =>
OrgKeys?.Key ?? throw new InvalidOperationException("Organization keys not set. Run CreateOrganizationStep first.");
internal Guid RequireOrgId() =>
Organization?.Id ?? throw new InvalidOperationException("Organization not set. Run CreateOrganizationStep first.");
internal string RequireDomain() =>
Domain ?? throw new InvalidOperationException("Domain not set. Run CreateOrganizationStep first.");
internal GeneratorContext RequireGenerator() =>
Generator ?? throw new InvalidOperationException("Generator not set. Call WithGenerator() / InitGeneratorStep first.");
}

View File

@@ -0,0 +1,22 @@
using Bit.Core.Entities;
using Bit.Seeder.Services;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;
namespace Bit.Seeder.Pipeline;
/// <summary>
/// Convenience extension methods for resolving common services from <see cref="SeederContext.Services"/>.
/// Minimizes churn in step implementations when transitioning from direct property access to DI.
/// </summary>
internal static class SeederContextExtensions
{
internal static IPasswordHasher<User> GetPasswordHasher(this SeederContext context) =>
context.Services.GetRequiredService<IPasswordHasher<User>>();
internal static IManglerService GetMangler(this SeederContext context) =>
context.Services.GetRequiredService<IManglerService>();
internal static ISeedReader GetSeedReader(this SeederContext context) =>
context.Services.GetRequiredService<ISeedReader>();
}

View File

@@ -33,6 +33,37 @@ The seeder transforms SDK output to server format before database insertion.
The Seeder is organized around six core patterns, each with a specific responsibility:
#### Pipeline
**Purpose:** Composable architecture for fixture-based and generated seeding.
**When to use:** New bulk operations, especially with presets. Provides ultimate flexibility.
**Flow**: Preset JSON → PresetLoader → RecipeBuilder → IStep[] → RecipeExecutor → SeederContext → BulkCommitter
**Key actors**:
- **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
- **SeederContext**: Shared mutable state (NOT thread-safe)
**Why this architecture wins**:
- **Infrastructure as Code**: JSON presets define complete scenarios
- **Mix & Match**: Fixtures + generation in one preset
- **Extensible**: Add entity types via new IStep implementations
- **Future-ready**: Supports custom DSLs on top of RecipeBuilder
**Phase order**: Org → Owner → Generator → Roster → Users → Groups → Collections → Ciphers
**Naming**: `{Purpose}Step` classes implementing `IStep`
**Files**: `Pipeline/` folder
---
#### Factories
**Purpose:** Create individual domain entities with cryptographically correct encrypted data.

View File

@@ -1,6 +1,7 @@
using Bit.Core.Enums;
using Bit.Core.Utilities;
using Bit.Infrastructure.EntityFramework.Repositories;
using LinqToDB.Data;
using LinqToDB.EntityFrameworkCore;
namespace Bit.Seeder.Recipes;
@@ -65,7 +66,7 @@ public class CollectionsRecipe(DatabaseContext db)
if (collectionUsers.Any())
{
db.BulkCopy(collectionUsers);
db.BulkCopy(new BulkCopyOptions { TableName = nameof(Core.Entities.CollectionUser) }, collectionUsers);
}
}

View File

@@ -0,0 +1,72 @@
using AutoMapper;
using Bit.Core.Entities;
using Bit.Infrastructure.EntityFramework.Repositories;
using Bit.Seeder.Pipeline;
using Bit.Seeder.Services;
using Microsoft.AspNetCore.Identity;
namespace Bit.Seeder.Recipes;
/// <summary>
/// Seeds an organization from an embedded preset.
/// </summary>
/// <remarks>
/// This recipe is a thin facade over the internal Pipeline architecture (PresetExecutor).
/// 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(
DatabaseContext db,
IMapper mapper,
IPasswordHasher<User> passwordHasher,
IManglerService manglerService)
{
private readonly PresetExecutor _executor = new(db, mapper);
/// <summary>
/// Seeds an organization from an embedded preset.
/// </summary>
/// <param name="presetName">Name of the embedded preset (e.g., "dunder-mifflin-full")</param>
/// <returns>The organization ID and summary statistics.</returns>
public SeedResult Seed(string presetName)
{
var result = _executor.Execute(presetName, passwordHasher, manglerService);
return new SeedResult(
result.OrganizationId,
result.OwnerEmail,
result.UsersCount,
result.GroupsCount,
result.CollectionsCount,
result.CiphersCount);
}
/// <summary>
/// Lists all available embedded presets and fixtures.
/// </summary>
/// <returns>Available presets grouped by category.</returns>
public static AvailableSeeds ListAvailable()
{
var internalResult = PresetExecutor.ListAvailable();
return new AvailableSeeds(internalResult.Presets, internalResult.Fixtures);
}
}
/// <summary>
/// Result of seeding operation with summary statistics.
/// </summary>
public record SeedResult(
Guid OrganizationId,
string? OwnerEmail,
int UsersCount,
int GroupsCount,
int CollectionsCount,
int CiphersCount);
/// <summary>
/// Available presets and fixtures grouped by category.
/// </summary>
public record AvailableSeeds(
IReadOnlyList<string> Presets,
IReadOnlyDictionary<string, IReadOnlyList<string>> Fixtures);

View File

@@ -13,6 +13,7 @@ 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;
@@ -158,7 +159,7 @@ public class OrganizationWithVaultRecipe(
manage: j == 0));
})
.ToList();
db.BulkCopy(collectionUsers);
db.BulkCopy(new BulkCopyOptions { TableName = nameof(CollectionUser) }, collectionUsers);
}
return collections.Select(c => c.Id).ToList();

View File

@@ -27,6 +27,11 @@
<Compile Remove="..\..\Program.cs" />
</ItemGroup>
<!-- Embed seed data JSON files as assembly resources -->
<ItemGroup>
<EmbeddedResource Include="Seeds\fixtures\**\*.json" />
</ItemGroup>
<!-- Allow integration tests to access internal seeders for validation -->
<ItemGroup>
<InternalsVisibleTo Include="SeederApi.IntegrationTest" />

149
util/Seeder/Seeds/README.md Normal file
View File

@@ -0,0 +1,149 @@
# Seeds
Hand-crafted JSON fixtures for Bitwarden Seeder test data.
## Quick Start
1. Copy template from `templates/` to appropriate `fixtures/` subfolder
2. Edit JSON (your editor validates against `$schema` automatically)
3. Build to verify: `dotnet build util/Seeder/Seeder.csproj`
## File Structure
```
Seeds/
├── fixtures/ Your seed data goes here
│ ├── ciphers/ Vault items (logins, cards, identities, notes)
│ ├── organizations/ Organization definitions
│ ├── rosters/ Users, groups, collections, permissions
│ └── presets/ Complete seeding scenarios
├── schemas/ JSON Schema validation (auto-checked by editors)
├── templates/ Starter files - copy these
│ └── CONTRIBUTING.md Detailed guide for contributors
└── README.md This file
```
## Fixtures Overview
### Ciphers
Vault items - logins, cards, identities, secure notes.
| Type | Required Object | Description |
| ------------ | --------------- | -------------------------- |
| `login` | `login` | Website credentials + URIs |
| `card` | `card` | Payment card details |
| `identity` | `identity` | Personal identity info |
| `secureNote` | — | Uses `notes` field only |
**Example** (`fixtures/ciphers/banking-logins.json`):
```json
{
"$schema": "../../schemas/cipher.schema.json",
"items": [
{
"type": "login",
"name": "Chase Bank",
"login": {
"username": "myuser",
"password": "MyP@ssw0rd",
"uris": [{ "uri": "https://chase.com", "match": "domain" }]
}
}
]
}
```
### Organizations
Organization definitions with name, domain, and seat count.
```json
{
"$schema": "../../schemas/organization.schema.json",
"name": "Acme Corp",
"domain": "acme.com",
"seats": 100
}
```
### Rosters
Complete user/group/collection structures with permissions. User emails auto-generated as `firstName.lastName@domain`.
**User roles**: `owner`, `admin`, `user`, `custom`
**Collection permissions**: `readOnly`, `hidePasswords`, `manage`
See `rosters/dunder-mifflin.json` for a complete 58-user example.
### Presets
Combine organization, roster, and ciphers into complete scenarios.
**From fixtures**:
```json
{
"$schema": "../../schemas/preset.schema.json",
"organization": { "fixture": "acme-corp" },
"roster": { "fixture": "acme-roster" },
"ciphers": { "fixture": "banking-logins" }
}
```
**Mixed approach**:
```json
{
"organization": { "fixture": "acme-corp" },
"users": { "count": 50 },
"ciphers": { "count": 500 }
}
```
## Validation
Modern editors validate against `$schema` automatically - errors appear as red squiggles.
Build errors catch schema violations:
```bash
dotnet build util/Seeder/Seeder.csproj
```
## Testing
Add integration test in `test/SeederApi.IntegrationTest/SeedReaderTests.cs`:
```csharp
[Fact]
public void Read_YourFixture_Success()
{
var result = _reader.Read<SeedFile>("ciphers.your-fixture");
Assert.NotEmpty(result.Items);
}
```
## Naming Conventions
| Element | Pattern | Example |
| ----------- | ------------------ | ------------------------ |
| File names | kebab-case | `banking-logins.json` |
| Item names | Title case, unique | `Chase Bank Login` |
| User refs | firstName.lastName | `jane.doe` |
| Org domains | Realistic or .test | `acme.com`, `test.local` |
## Security
- Test password: `asdfasdfasdf`
- Use fictional names/addresses
- Never commit real passwords or PII
- Never seed production databases
## Examples
- **Small org**: `presets/dunder-mifflin-full.json` (58 users, realistic structure)
- **Browser testing**: `ciphers/autofill-testing.json` (18 specialized items)
- **Real websites**: `ciphers/public-site-logins.json` (90+ website examples)

View File

@@ -0,0 +1,214 @@
{
"$schema": "../../schemas/cipher.schema.json",
"items": [
{
"type": "login",
"name": "Simple Login",
"login": {
"username": "bwplaywright",
"password": "fakeBasicFormPassword",
"uris": [
{ "uri": "https://localhost/forms/login/simple", "match": "startsWith" }
]
}
},
{
"type": "login",
"name": "Iframe Login",
"login": {
"username": "bwplaywright",
"password": "fakeIframeBasicFormPassword",
"uris": [
{ "uri": "https://localhost/forms/login/iframe-login", "match": "startsWith" },
{ "uri": "https://localhost/login-page-bare", "match": "startsWith" }
]
}
},
{
"type": "login",
"name": "Sandboxed Iframe Login",
"login": {
"username": "bwplaywright",
"password": "fakeSandboxedIframeBasicFormPassword",
"uris": [
{ "uri": "https://localhost/forms/login/iframe-sandboxed-login", "match": "startsWith" }
]
}
},
{
"type": "login",
"name": "Bare Inputs Login",
"login": {
"username": "bwplaywright",
"password": "fakeBareInputsPassword",
"uris": [
{ "uri": "https://localhost/forms/login/bare-inputs-login", "match": "startsWith" }
]
}
},
{
"type": "login",
"name": "Hidden Login",
"login": {
"username": "bwplaywright",
"password": "fakeHiddenFormPassword",
"uris": [
{ "uri": "https://localhost/forms/login/hidden-login", "match": "startsWith" }
]
},
"fields": [
{ "name": "email", "value": "bwplaywright@example.com", "type": "text" }
]
},
{
"type": "login",
"name": "Input Constraints Login",
"login": {
"username": "bwplaywright@example.com",
"password": "123456",
"uris": [
{ "uri": "https://localhost/forms/login/input-constraints-login", "match": "startsWith" }
]
}
},
{
"type": "login",
"name": "Login Honeypot",
"login": {
"username": "bwplaywright",
"password": "fakeLoginHoneypotPassword",
"uris": [
{ "uri": "https://localhost/forms/login/login-honeypot", "match": "startsWith" }
]
}
},
{
"type": "login",
"name": "Multi-Step Email Username Login",
"login": {
"username": "bwplaywright",
"password": "fakeMultiStepPassword",
"uris": [
{ "uri": "https://localhost/forms/multi-step/email-username-login", "match": "startsWith" }
]
},
"fields": [
{ "name": "email", "value": "bwplaywright@example.com", "type": "text" }
]
},
{
"type": "login",
"name": "Security Code Multi Input",
"login": {
"uris": [
{ "uri": "https://localhost/forms/login/security-code-multi-input", "match": "startsWith" }
],
"totp": "ABCD EFGH IJKL MNOPsecurity-code-multi-input"
}
},
{
"type": "login",
"name": "Shadow Root Inputs",
"login": {
"username": "bwplaywright",
"password": "fakeShadowRootInputsPassword",
"uris": [
{ "uri": "https://localhost/forms/login/shadow-root-inputs", "match": "startsWith" }
]
}
},
{
"type": "login",
"name": "Create Account",
"login": {
"username": "bwplaywright@example.com",
"password": "fakeCreateAccountPagePassword",
"uris": [
{ "uri": "https://localhost/forms/create/create-account", "match": "startsWith" }
]
}
},
{
"type": "identity",
"name": "NA Address - John Smith",
"identity": {
"firstName": "John",
"middleName": "M",
"lastName": "Smith",
"address1": "123 Main St",
"address2": "Apt 1",
"city": "New York",
"state": "NY",
"postalCode": "10001",
"country": "USA"
}
},
{
"type": "card",
"name": "Visa Test Card",
"card": {
"cardholderName": "John Smith",
"brand": "Visa",
"number": "4111111111111111",
"expMonth": "12",
"expYear": "2025",
"code": "123"
}
},
{
"type": "login",
"name": "Simple Search",
"login": {
"username": "bwplaywright",
"password": "fakeSimpleSearchPassword",
"uris": [
{ "uri": "https://localhost/forms/search/simple-search", "match": "startsWith" }
]
}
},
{
"type": "login",
"name": "Inline Search",
"login": {
"username": "bwplaywright",
"password": "fakeInlineSearchPassword",
"uris": [
{ "uri": "https://localhost/forms/search/inline-search", "match": "startsWith" }
]
}
},
{
"type": "login",
"name": "Typeless Search",
"login": {
"username": "bwplaywright",
"password": "fakeTypelessSearchPassword",
"uris": [
{ "uri": "https://localhost/forms/search/typeless-search", "match": "startsWith" }
]
}
},
{
"type": "login",
"name": "Update Email",
"login": {
"username": "bwplaywright@example.com",
"password": "fakeUpdateEmailPagePassword",
"uris": [
{ "uri": "https://localhost/forms/update/update-email", "match": "startsWith" }
]
}
},
{
"type": "login",
"name": "Update Password",
"login": {
"username": "bwplaywright@example.com",
"password": "fakeUpdatePasswordPagePassword",
"uris": [
{ "uri": "https://localhost/forms/update/update-password", "match": "startsWith" }
]
}
}
]
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,6 @@
{
"$schema": "../../schemas/organization.schema.json",
"name": "Dunder Mifflin",
"domain": "dundermifflin.com",
"seats": 70
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "../../schemas/preset.schema.json",
"organization": {
"fixture": "dunder-mifflin"
},
"roster": {
"fixture": "dunder-mifflin"
},
"ciphers": {
"fixture": "autofill-testing"
}
}

View File

@@ -0,0 +1,21 @@
{
"$schema": "../../schemas/preset.schema.json",
"organization": {
"name": "Globex Corp",
"domain": "globex.com",
"seats": 10000
},
"users": {
"count": 5000,
"realisticStatusMix": true
},
"groups": {
"count": 100
},
"collections": {
"count": 200
},
"ciphers": {
"count": 50000
}
}

View File

@@ -0,0 +1,310 @@
{
"$schema": "../../schemas/roster.schema.json",
"users": [
{ "firstName": "David", "lastName": "Wallace", "title": "CFO", "role": "owner", "branch": "Corporate", "department": "Executive" },
{ "firstName": "Jan", "lastName": "Levinson", "title": "VP Northeast Sales", "role": "owner", "branch": "Corporate", "department": "Sales" },
{ "firstName": "Robert", "lastName": "California", "title": "CEO", "role": "owner", "branch": "Corporate", "department": "Executive" },
{ "firstName": "Jo", "lastName": "Bennett", "title": "CEO Sabre", "role": "owner", "branch": "Corporate", "department": "Executive" },
{ "firstName": "Charles", "lastName": "Miner", "title": "VP Northeast", "role": "owner", "branch": "Corporate", "department": "Executive" },
{ "firstName": "Michael", "lastName": "Scott", "title": "Regional Manager", "role": "admin", "branch": "Scranton", "department": "Management" },
{ "firstName": "Dwight", "lastName": "Schrute", "title": "Assistant to the Regional Manager", "role": "admin", "branch": "Scranton", "department": "Sales" },
{ "firstName": "Jim", "lastName": "Halpert", "title": "Co-Regional Manager", "role": "admin", "branch": "Scranton", "department": "Sales" },
{ "firstName": "Andy", "lastName": "Bernard", "title": "Regional Manager", "role": "admin", "branch": "Scranton", "department": "Sales" },
{ "firstName": "Josh", "lastName": "Porter", "title": "Regional Manager", "role": "admin", "branch": "Stamford", "department": "Management" },
{ "firstName": "Darryl", "lastName": "Philbin", "title": "Warehouse Foreman", "role": "admin", "branch": "Scranton", "department": "Warehouse" },
{ "firstName": "Karen", "lastName": "Filippelli", "title": "Regional Manager", "role": "admin", "branch": "Utica", "department": "Sales" },
{ "firstName": "Deangelo", "lastName": "Vickers", "title": "Regional Manager", "role": "admin", "branch": "Scranton", "department": "Management" },
{ "firstName": "Gabe", "lastName": "Lewis", "title": "Corporate Coordinator", "role": "admin", "branch": "Scranton", "department": "Management" },
{ "firstName": "Nellie", "lastName": "Bertram", "title": "Special Projects Manager", "role": "admin", "branch": "Scranton", "department": "Management" },
{ "firstName": "Ed", "lastName": "Truck", "title": "Regional Manager (Former)", "role": "admin", "branch": "Scranton", "department": "Management" },
{ "firstName": "Stanley", "lastName": "Hudson", "title": "Sales Representative", "branch": "Scranton", "department": "Sales" },
{ "firstName": "Phyllis", "lastName": "Vance", "title": "Sales Representative", "branch": "Scranton", "department": "Sales" },
{ "firstName": "Ryan", "lastName": "Howard", "title": "Temp / VP", "branch": "Scranton", "department": "Sales" },
{ "firstName": "Todd", "lastName": "Packer", "title": "Traveling Salesman", "branch": "Scranton", "department": "Sales" },
{ "firstName": "Devon", "lastName": "White", "title": "Sales Representative", "branch": "Scranton", "department": "Sales" },
{ "firstName": "Danny", "lastName": "Cordray", "title": "Traveling Salesman", "branch": "Scranton", "department": "Sales" },
{ "firstName": "Clark", "lastName": "Green", "title": "Sales Intern", "branch": "Scranton", "department": "Sales" },
{ "firstName": "Angela", "lastName": "Martin", "title": "Senior Accountant", "branch": "Scranton", "department": "Accounting" },
{ "firstName": "Kevin", "lastName": "Malone", "title": "Accountant", "branch": "Scranton", "department": "Accounting" },
{ "firstName": "Oscar", "lastName": "Martinez", "title": "Accountant", "branch": "Scranton", "department": "Accounting" },
{ "firstName": "Pam", "lastName": "Beesly", "title": "Receptionist / Office Administrator", "branch": "Scranton", "department": "Administration" },
{ "firstName": "Erin", "lastName": "Hannon", "title": "Receptionist", "branch": "Scranton", "department": "Administration" },
{ "firstName": "Cathy", "lastName": "Simms", "title": "Temporary Receptionist", "branch": "Scranton", "department": "Administration" },
{ "firstName": "Jordan", "lastName": "Garfield", "title": "Executive Assistant", "branch": "Scranton", "department": "Administration" },
{ "firstName": "Toby", "lastName": "Flenderson", "title": "HR Representative", "branch": "Scranton", "department": "Human Resources" },
{ "firstName": "Holly", "lastName": "Flax", "title": "HR Representative", "branch": "Nashua", "department": "Human Resources" },
{ "firstName": "Kendall", "lastName": "No-Name", "title": "HR Representative", "branch": "Corporate", "department": "Human Resources" },
{ "firstName": "Kelly", "lastName": "Kapoor", "title": "Customer Service Representative", "branch": "Scranton", "department": "Customer Service" },
{ "firstName": "Pete", "lastName": "Miller", "title": "Customer Service Representative", "branch": "Scranton", "department": "Customer Service" },
{ "firstName": "Martin", "lastName": "Nash", "title": "Customer Service Representative", "branch": "Stamford", "department": "Customer Service" },
{ "firstName": "Creed", "lastName": "Bratton", "title": "Quality Assurance Director", "branch": "Scranton", "department": "Quality Assurance" },
{ "firstName": "Meredith", "lastName": "Palmer", "title": "Supplier Relations", "branch": "Scranton", "department": "Supplier Relations" },
{ "firstName": "Nick", "lastName": "No-Name", "title": "IT Technician", "branch": "Scranton", "department": "IT" },
{ "firstName": "Roy", "lastName": "Anderson", "title": "Warehouse Worker", "branch": "Scranton", "department": "Warehouse" },
{ "firstName": "Lonny", "lastName": "Collins", "title": "Warehouse Worker", "branch": "Scranton", "department": "Warehouse" },
{ "firstName": "Madge", "lastName": "Madsen", "title": "Warehouse Worker", "branch": "Scranton", "department": "Warehouse" },
{ "firstName": "Hidetoshi", "lastName": "Hasagawa", "title": "Warehouse Worker", "branch": "Scranton", "department": "Warehouse" },
{ "firstName": "Nate", "lastName": "Nickerson", "title": "Warehouse Worker", "branch": "Scranton", "department": "Warehouse" },
{ "firstName": "Val", "lastName": "Johnson", "title": "Warehouse Worker", "branch": "Scranton", "department": "Warehouse" },
{ "firstName": "Glenn", "lastName": "No-Name", "title": "Warehouse Worker", "branch": "Scranton", "department": "Warehouse" },
{ "firstName": "Jerry", "lastName": "DiCanio", "title": "Warehouse Worker", "branch": "Scranton", "department": "Warehouse" },
{ "firstName": "Mose", "lastName": "Schrute", "title": "Beet Farmer / Temp", "branch": "Scranton", "department": "Warehouse" },
{ "firstName": "Zeke", "lastName": "Schrute", "title": "Warehouse Worker", "branch": "Scranton", "department": "Warehouse" },
{ "firstName": "Kenny", "lastName": "Anderson", "title": "Warehouse Worker", "branch": "Scranton", "department": "Warehouse" },
{ "firstName": "Hank", "lastName": "Tate", "title": "Security Guard", "branch": "Scranton", "department": "Security" },
{ "firstName": "Bob", "lastName": "Vance", "title": "Owner, Vance Refrigeration", "branch": "Scranton", "department": "External" },
{ "firstName": "Hannah", "lastName": "Smoterich-Barr", "title": "Sales Representative", "branch": "Stamford", "department": "Sales" },
{ "firstName": "Tony", "lastName": "Gardner", "title": "Sales Representative", "branch": "Stamford", "department": "Sales" },
{ "firstName": "Troy", "lastName": "Underbridge", "title": "Sales Representative", "branch": "Stamford", "department": "Sales" },
{ "firstName": "Luke", "lastName": "Cooper", "title": "Intern", "branch": "Scranton", "department": "Administration" },
{ "firstName": "Billy", "lastName": "Merchant", "title": "Building Manager", "branch": "Scranton", "department": "Facilities" },
{ "firstName": "Brian", "lastName": "Wittle", "title": "Boom Operator", "branch": "Scranton", "department": "Documentary" }
],
"groups": [
{
"name": "Scranton",
"members": [
"michael.scott", "dwight.schrute", "jim.halpert", "andy.bernard", "darryl.philbin",
"deangelo.vickers", "gabe.lewis", "nellie.bertram",
"stanley.hudson", "phyllis.vance", "ryan.howard", "todd.packer", "devon.white",
"danny.cordray", "clark.green",
"angela.martin", "kevin.malone", "oscar.martinez",
"pam.beesly", "erin.hannon", "cathy.simms", "jordan.garfield",
"toby.flenderson",
"kelly.kapoor", "pete.miller",
"creed.bratton", "meredith.palmer", "nick.no-name",
"roy.anderson", "lonny.collins", "madge.madsen", "hidetoshi.hasagawa",
"nate.nickerson", "val.johnson", "glenn.no-name", "jerry.dicanio",
"mose.schrute", "zeke.schrute", "kenny.anderson",
"hank.tate", "bob.vance", "luke.cooper", "billy.merchant"
]
},
{
"name": "Corporate",
"members": [
"david.wallace", "jan.levinson", "robert.california", "jo.bennett",
"charles.miner", "ryan.howard", "kendall.no-name"
]
},
{
"name": "Stamford",
"members": [
"josh.porter", "karen.filippelli", "andy.bernard", "jim.halpert",
"hannah.smoterich-barr", "tony.gardner", "martin.nash", "troy.underbridge"
]
},
{
"name": "Utica",
"members": [
"karen.filippelli"
]
},
{
"name": "Nashua",
"members": [
"holly.flax"
]
},
{
"name": "Party Planning Committee",
"members": [
"angela.martin", "phyllis.vance", "pam.beesly", "meredith.palmer",
"karen.filippelli", "kelly.kapoor", "erin.hannon"
]
},
{
"name": "Finer Things Club",
"members": [
"pam.beesly", "oscar.martinez", "toby.flenderson", "jim.halpert"
]
},
{
"name": "Safety Committee",
"members": [
"dwight.schrute", "andy.bernard", "darryl.philbin", "toby.flenderson", "hank.tate"
]
},
{
"name": "Call of Duty Crew",
"members": [
"jim.halpert", "karen.filippelli", "andy.bernard"
]
},
{
"name": "Michael Scott Paper Company",
"members": [
"michael.scott", "pam.beesly", "ryan.howard"
]
},
{
"name": "Office Basketball",
"members": [
"michael.scott", "jim.halpert", "ryan.howard", "stanley.hudson",
"oscar.martinez", "kevin.malone"
]
},
{
"name": "Warehouse Crew",
"members": [
"darryl.philbin", "roy.anderson", "lonny.collins", "madge.madsen",
"hidetoshi.hasagawa", "val.johnson", "nate.nickerson", "glenn.no-name",
"jerry.dicanio", "kenny.anderson", "mose.schrute", "zeke.schrute"
]
},
{
"name": "Office Olympics",
"members": [
"jim.halpert", "pam.beesly", "kevin.malone", "phyllis.vance",
"stanley.hudson", "oscar.martinez", "creed.bratton", "meredith.palmer"
]
},
{
"name": "Search Committee",
"members": [
"jim.halpert", "toby.flenderson", "gabe.lewis"
]
}
],
"collections": [
{
"name": "Departments/Sales",
"groups": [
{ "group": "Scranton" },
{ "group": "Stamford" }
],
"users": [
{ "user": "jan.levinson", "manage": true },
{ "user": "michael.scott", "manage": true }
]
},
{
"name": "Departments/Accounting",
"groups": [
{ "group": "Scranton", "readOnly": true }
],
"users": [
{ "user": "angela.martin", "manage": true },
{ "user": "kevin.malone" },
{ "user": "oscar.martinez" }
]
},
{
"name": "Departments/Human Resources",
"users": [
{ "user": "toby.flenderson", "manage": true },
{ "user": "holly.flax", "manage": true },
{ "user": "kendall.no-name", "manage": true },
{ "user": "michael.scott", "readOnly": true }
]
},
{
"name": "Departments/Warehouse",
"groups": [
{ "group": "Warehouse Crew" }
],
"users": [
{ "user": "darryl.philbin", "manage": true }
]
},
{
"name": "Departments/Customer Service",
"users": [
{ "user": "kelly.kapoor", "manage": true },
{ "user": "pete.miller" },
{ "user": "martin.nash" }
]
},
{
"name": "Departments/Quality Assurance",
"users": [
{ "user": "creed.bratton", "manage": true }
]
},
{
"name": "Departments/Supplier Relations",
"users": [
{ "user": "meredith.palmer", "manage": true }
]
},
{
"name": "Branches/Scranton",
"groups": [
{ "group": "Scranton" }
],
"users": [
{ "user": "michael.scott", "manage": true }
]
},
{
"name": "Branches/Corporate",
"groups": [
{ "group": "Corporate" }
],
"users": [
{ "user": "david.wallace", "manage": true }
]
},
{
"name": "Committees/Party Planning",
"groups": [
{ "group": "Party Planning Committee" }
],
"users": [
{ "user": "angela.martin", "manage": true }
]
},
{
"name": "Committees/Safety",
"groups": [
{ "group": "Safety Committee" }
],
"users": [
{ "user": "dwight.schrute", "manage": true }
]
},
{
"name": "Executive/Reports",
"groups": [
{ "group": "Corporate", "readOnly": true, "hidePasswords": true }
],
"users": [
{ "user": "david.wallace", "manage": true },
{ "user": "robert.california", "manage": true }
]
},
{
"name": "Executive/Financial",
"groups": [
{ "group": "Corporate", "readOnly": true }
],
"users": [
{ "user": "angela.martin", "manage": true },
{ "user": "david.wallace", "manage": true }
]
},
{
"name": "Clubs/Finer Things",
"groups": [
{ "group": "Finer Things Club" }
]
},
{
"name": "Clubs/Call of Duty",
"groups": [
{ "group": "Call of Duty Crew" }
]
}
]
}

View File

@@ -0,0 +1,149 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "cipher.schema.json",
"title": "Bitwarden Cipher Seed File",
"description": "Defines vault items (logins, cards, identities) for the Bitwarden Seeder.",
"type": "object",
"required": ["items"],
"additionalProperties": false,
"properties": {
"$schema": {
"type": "string"
},
"items": {
"type": "array",
"minItems": 1,
"items": {
"$ref": "#/$defs/item"
}
}
},
"$defs": {
"item": {
"type": "object",
"required": ["type", "name"],
"additionalProperties": false,
"properties": {
"type": {
"type": "string",
"enum": ["login", "card", "identity", "secureNote"]
},
"name": {
"type": "string",
"minLength": 1
},
"notes": {
"type": "string"
},
"login": {
"$ref": "#/$defs/login"
},
"card": {
"$ref": "#/$defs/card"
},
"identity": {
"$ref": "#/$defs/identity"
},
"fields": {
"type": "array",
"items": {
"$ref": "#/$defs/field"
}
}
},
"allOf": [
{
"if": { "properties": { "type": { "const": "login" } } },
"then": { "required": ["login"] }
},
{
"if": { "properties": { "type": { "const": "card" } } },
"then": { "required": ["card"] }
},
{
"if": { "properties": { "type": { "const": "identity" } } },
"then": { "required": ["identity"] }
}
]
},
"login": {
"type": "object",
"additionalProperties": false,
"properties": {
"username": { "type": "string" },
"password": { "type": "string" },
"totp": { "type": "string" },
"uris": {
"type": "array",
"items": {
"$ref": "#/$defs/loginUri"
}
}
}
},
"loginUri": {
"type": "object",
"required": ["uri"],
"additionalProperties": false,
"properties": {
"uri": {
"type": "string",
"minLength": 1
},
"match": {
"type": "string",
"enum": ["domain", "host", "startsWith", "exact", "regex", "never"],
"default": "domain"
}
}
},
"card": {
"type": "object",
"additionalProperties": false,
"properties": {
"cardholderName": { "type": "string" },
"brand": { "type": "string" },
"number": { "type": "string" },
"expMonth": { "type": "string" },
"expYear": { "type": "string" },
"code": { "type": "string" }
}
},
"identity": {
"type": "object",
"additionalProperties": false,
"properties": {
"firstName": { "type": "string" },
"middleName": { "type": "string" },
"lastName": { "type": "string" },
"address1": { "type": "string" },
"address2": { "type": "string" },
"address3": { "type": "string" },
"city": { "type": "string" },
"state": { "type": "string" },
"postalCode": { "type": "string" },
"country": { "type": "string" },
"company": { "type": "string" },
"email": { "type": "string" },
"phone": { "type": "string" },
"ssn": { "type": "string" },
"username": { "type": "string" },
"passportNumber": { "type": "string" },
"licenseNumber": { "type": "string" }
}
},
"field": {
"type": "object",
"additionalProperties": false,
"properties": {
"name": { "type": "string" },
"value": { "type": "string" },
"type": {
"type": "string",
"enum": ["text", "hidden", "boolean", "linked"],
"default": "text"
}
}
}
}
}

View File

@@ -0,0 +1,30 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "organization.schema.json",
"title": "Bitwarden Organization Seed File",
"description": "Defines an organization for the Bitwarden Seeder.",
"type": "object",
"required": ["name", "domain"],
"additionalProperties": false,
"properties": {
"$schema": {
"type": "string"
},
"name": {
"type": "string",
"minLength": 1,
"description": "Display name of the organization."
},
"domain": {
"type": "string",
"minLength": 1,
"description": "Domain used for billing email and identifier generation."
},
"seats": {
"type": "integer",
"minimum": 1,
"default": 10,
"description": "Number of seats (user slots) in the organization."
}
}
}

View File

@@ -0,0 +1,110 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "preset.schema.json",
"title": "Bitwarden Seeder Preset",
"description": "Defines a complete seeding preset that composes organization, users, groups, collections, and ciphers.",
"type": "object",
"additionalProperties": false,
"properties": {
"$schema": {
"type": "string"
},
"organization": {
"type": "object",
"description": "Organization configuration. Use 'fixture' for a named fixture, or 'name'+'domain' for inline creation.",
"additionalProperties": false,
"properties": {
"fixture": {
"type": "string",
"description": "Name of an organization fixture file (e.g., 'dunder-mifflin')."
},
"name": {
"type": "string",
"description": "Organization display name (used with inline creation)."
},
"domain": {
"type": "string",
"description": "Organization domain (used with inline creation and generator seeding)."
},
"seats": {
"type": "integer",
"minimum": 1,
"default": 10,
"description": "Number of seats in the organization."
}
}
},
"roster": {
"type": "object",
"description": "Named roster fixture for users, groups, and collections.",
"additionalProperties": false,
"properties": {
"fixture": {
"type": "string",
"description": "Name of a roster fixture file (e.g., 'dunder-mifflin')."
}
},
"required": ["fixture"]
},
"users": {
"type": "object",
"description": "Generate random users.",
"additionalProperties": false,
"properties": {
"count": {
"type": "integer",
"minimum": 1,
"description": "Number of users to generate."
},
"realisticStatusMix": {
"type": "boolean",
"default": false,
"description": "When true and count >= 10, creates realistic mix: 85% Confirmed, 5% Invited, 5% Accepted, 5% Revoked."
}
},
"required": ["count"]
},
"groups": {
"type": "object",
"description": "Generate random groups with round-robin user assignment.",
"additionalProperties": false,
"properties": {
"count": {
"type": "integer",
"minimum": 1,
"description": "Number of groups to generate."
}
},
"required": ["count"]
},
"collections": {
"type": "object",
"description": "Generate random collections with user assignments.",
"additionalProperties": false,
"properties": {
"count": {
"type": "integer",
"minimum": 1,
"description": "Number of collections to generate."
}
},
"required": ["count"]
},
"ciphers": {
"type": "object",
"description": "Cipher configuration. Use 'fixture' for a named fixture, or 'count' for random generation.",
"additionalProperties": false,
"properties": {
"fixture": {
"type": "string",
"description": "Name of a cipher fixture file (e.g., 'autofill-testing')."
},
"count": {
"type": "integer",
"minimum": 1,
"description": "Number of random ciphers to generate."
}
}
}
}
}

View File

@@ -0,0 +1,152 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "roster.schema.json",
"title": "Bitwarden Roster Seed File",
"description": "Defines an organization's people structure: users, groups, and collections with permissions.",
"type": "object",
"required": ["users"],
"additionalProperties": false,
"properties": {
"$schema": {
"type": "string"
},
"users": {
"type": "array",
"minItems": 1,
"description": "Organization members. Email prefix is derived as firstName.lastName@domain.",
"items": { "$ref": "#/$defs/user" }
},
"groups": {
"type": "array",
"description": "Groups within the organization.",
"items": { "$ref": "#/$defs/group" }
},
"collections": {
"type": "array",
"description": "Collections with group and user permission assignments. Use '/' for visual hierarchy.",
"items": { "$ref": "#/$defs/collection" }
}
},
"$defs": {
"user": {
"type": "object",
"required": ["firstName", "lastName"],
"additionalProperties": false,
"properties": {
"firstName": {
"type": "string",
"minLength": 1
},
"lastName": {
"type": "string",
"minLength": 1
},
"title": {
"type": "string",
"description": "Job title."
},
"role": {
"type": "string",
"enum": ["owner", "admin", "user", "custom"],
"default": "user",
"description": "Organization role."
},
"branch": {
"type": "string",
"description": "Branch office for grouping."
},
"department": {
"type": "string",
"description": "Department for grouping."
}
}
},
"group": {
"type": "object",
"required": ["name", "members"],
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
"minLength": 1,
"description": "Group display name (not encrypted)."
},
"members": {
"type": "array",
"items": {
"type": "string"
},
"description": "Email prefixes (firstName.lastName) of group members."
}
}
},
"collection": {
"type": "object",
"required": ["name"],
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
"minLength": 1,
"description": "Collection name. Use '/' for visual hierarchy (e.g., 'Departments/Sales')."
},
"groups": {
"type": "array",
"description": "Group permission assignments.",
"items": { "$ref": "#/$defs/collectionGroup" }
},
"users": {
"type": "array",
"description": "Direct user permission assignments.",
"items": { "$ref": "#/$defs/collectionUser" }
}
}
},
"collectionGroup": {
"type": "object",
"required": ["group"],
"additionalProperties": false,
"properties": {
"group": {
"type": "string",
"description": "Group name to assign."
},
"readOnly": {
"type": "boolean",
"default": false
},
"hidePasswords": {
"type": "boolean",
"default": false
},
"manage": {
"type": "boolean",
"default": false
}
}
},
"collectionUser": {
"type": "object",
"required": ["user"],
"additionalProperties": false,
"properties": {
"user": {
"type": "string",
"description": "Email prefix (firstName.lastName) of the user."
},
"readOnly": {
"type": "boolean",
"default": false
},
"hidePasswords": {
"type": "boolean",
"default": false
},
"manage": {
"type": "boolean",
"default": false
}
}
}
}
}

View File

@@ -0,0 +1,50 @@
{
"$schema": "../schemas/cipher.schema.json",
"items": [
{
"type": "login",
"name": "Example Login",
"login": {
"username": "user@example.com",
"password": "ChangeMe123!",
"uris": [
{
"uri": "https://example.com/login",
"match": "domain"
}
]
}
},
{
"type": "card",
"name": "Example Card",
"card": {
"cardholderName": "Jane Doe",
"brand": "Visa",
"number": "4111111111111111",
"expMonth": "12",
"expYear": "2030",
"code": "123"
}
},
{
"type": "identity",
"name": "Example Identity",
"identity": {
"firstName": "Jane",
"lastName": "Doe",
"email": "jane.doe@example.com",
"address1": "123 Main St",
"city": "Anytown",
"state": "CA",
"postalCode": "90210",
"country": "US"
}
},
{
"type": "secureNote",
"name": "Example Secure Note",
"notes": "This is a secure note with sensitive information."
}
]
}

View File

@@ -0,0 +1,6 @@
{
"$schema": "../schemas/organization.schema.json",
"name": "Acme Corporation",
"domain": "acme.example.com",
"seats": 10
}

View File

@@ -0,0 +1,14 @@
{
"$schema": "../schemas/preset.schema.json",
"organization": {
"name": "Acme Corporation",
"domain": "acme.example.com",
"seats": 50
},
"roster": {
"fixture": "my-roster-fixture"
},
"ciphers": {
"fixture": "my-cipher-fixture"
}
}

View File

@@ -0,0 +1,77 @@
{
"$schema": "../schemas/roster.schema.json",
"users": [
{
"firstName": "Alice",
"lastName": "Admin",
"title": "Administrator",
"role": "admin",
"branch": "Headquarters",
"department": "IT"
},
{
"firstName": "Bob",
"lastName": "User",
"title": "Developer",
"role": "user",
"branch": "Headquarters",
"department": "Engineering"
},
{
"firstName": "Carol",
"lastName": "Manager",
"title": "Engineering Manager",
"role": "admin",
"branch": "Headquarters",
"department": "Engineering"
}
],
"groups": [
{
"name": "Administrators",
"members": [
"alice.admin",
"carol.manager"
]
},
{
"name": "Engineering Team",
"members": [
"bob.user",
"carol.manager"
]
}
],
"collections": [
{
"name": "Company Shared",
"groups": [
{
"group": "Administrators",
"readOnly": false,
"hidePasswords": false,
"manage": true
}
],
"users": [
{
"user": "alice.admin",
"readOnly": false,
"hidePasswords": false,
"manage": true
}
]
},
{
"name": "Engineering/Development",
"groups": [
{
"group": "Engineering Team",
"readOnly": false,
"hidePasswords": false,
"manage": false
}
]
}
]
}

View File

@@ -0,0 +1,19 @@
namespace Bit.Seeder.Services;
/// <summary>
/// Reads seed data files from embedded JSON resources.
/// Seeds are pantry ingredients for Recipes, Steps, and Scenes.
/// </summary>
public interface ISeedReader
{
/// <summary>
/// Reads and deserializes a seed file by name (without extension).
/// Names use dot-separated paths: "ciphers.autofill-testing", "organizations.dunder-mifflin"
/// </summary>
T Read<T>(string seedName);
/// <summary>
/// Lists available seed file names (without extension).
/// </summary>
IReadOnlyList<string> ListAvailable();
}

View File

@@ -0,0 +1,62 @@
using System.Reflection;
using System.Text.Json;
namespace Bit.Seeder.Services;
/// <summary>
/// Reads seed data from embedded JSON resources shipped with the Seeder library.
/// </summary>
public sealed class SeedReader : ISeedReader
{
private const string _resourcePrefix = "Bit.Seeder.Seeds.fixtures.";
private const string _resourceSuffix = ".json";
private static readonly Assembly _assembly = typeof(SeedReader).Assembly;
private static readonly JsonSerializerOptions _jsonOptions = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true
};
public T Read<T>(string seedName)
{
var resourceName = $"{_resourcePrefix}{seedName}{_resourceSuffix}";
using var stream = _assembly.GetManifestResourceStream(resourceName);
if (stream is null)
{
var available = string.Join(", ", ListAvailable());
throw new InvalidOperationException(
$"Seed file '{seedName}' not found. Available seeds: {available}");
}
try
{
var result = JsonSerializer.Deserialize<T>(stream, _jsonOptions);
if (result is null)
{
throw new InvalidOperationException(
$"Seed file '{seedName}' deserialized to null. The JSON may be empty or incompatible with type {typeof(T).Name}.");
}
return result;
}
catch (JsonException ex)
{
throw new InvalidOperationException(
$"Failed to deserialize seed file '{seedName}' as {typeof(T).Name}: {ex.Message}", ex);
}
}
public IReadOnlyList<string> ListAvailable()
{
return _assembly.GetManifestResourceNames()
.Where(n => n.StartsWith(_resourcePrefix) && n.EndsWith(_resourceSuffix))
.Select(n => n[_resourcePrefix.Length..^_resourceSuffix.Length])
.OrderBy(n => n)
.ToList();
}
}

View File

@@ -0,0 +1,64 @@
using Bit.Core.Entities;
using Bit.Core.Vault.Entities;
using Bit.Seeder.Factories;
using Bit.Seeder.Models;
using Bit.Seeder.Pipeline;
namespace Bit.Seeder.Steps;
/// <summary>
/// Loads cipher items from a fixture and creates encrypted cipher entities.
/// </summary>
internal sealed class CreateCiphersStep(string fixtureName) : IStep
{
public void Execute(SeederContext context)
{
var orgId = context.RequireOrgId();
var orgKey = context.RequireOrgKey();
var seedFile = context.GetSeedReader().Read<SeedFile>($"ciphers.{fixtureName}");
var collectionIds = context.Registry.CollectionIds;
var ciphers = new List<Cipher>();
var collectionCiphers = new List<CollectionCipher>();
for (var i = 0; i < seedFile.Items.Count; i++)
{
var item = seedFile.Items[i];
var cipher = item.Type switch
{
"login" => LoginCipherSeeder.CreateFromSeed(orgKey, item, organizationId: orgId),
"card" => CardCipherSeeder.CreateFromSeed(orgKey, item, organizationId: orgId),
"identity" => IdentityCipherSeeder.CreateFromSeed(orgKey, item, organizationId: orgId),
"secureNote" => SecureNoteCipherSeeder.CreateFromSeed(orgKey, item, organizationId: orgId),
_ => throw new InvalidOperationException($"Unknown cipher type: {item.Type}")
};
ciphers.Add(cipher);
// Collection assignment (mirrors GenerateCiphersStep logic)
if (collectionIds.Count <= 0)
{
continue;
}
collectionCiphers.Add(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)
{
collectionCiphers.Add(new CollectionCipher
{
CipherId = cipher.Id,
CollectionId = collectionIds[(i + 1) % collectionIds.Count]
});
}
}
context.Ciphers.AddRange(ciphers);
context.CollectionCiphers.AddRange(collectionCiphers);
}
}

View File

@@ -0,0 +1,70 @@
using Bit.Core.Entities;
using Bit.Seeder.Data.Enums;
using Bit.Seeder.Data.Static;
using Bit.Seeder.Factories;
using Bit.Seeder.Pipeline;
namespace Bit.Seeder.Steps;
internal sealed class CreateCollectionsStep : IStep
{
private readonly int _count;
private readonly OrgStructureModel? _structure;
private CreateCollectionsStep(int count, OrgStructureModel? structure)
{
_count = count;
_structure = structure;
}
internal static CreateCollectionsStep FromCount(int count) => new(count, null);
internal static CreateCollectionsStep FromStructure(OrgStructureModel structure) => new(0, structure);
public void Execute(SeederContext context)
{
var orgId = context.RequireOrgId();
var orgKey = context.RequireOrgKey();
var hardenedOrgUserIds = context.Registry.HardenedOrgUserIds;
List<Collection> collections;
if (_structure.HasValue)
{
var orgStructure = OrgStructures.GetStructure(_structure.Value);
collections = orgStructure.Units
.Select(unit => CollectionSeeder.Create(orgId, orgKey, unit.Name))
.ToList();
}
else
{
collections = Enumerable.Range(0, _count)
.Select(i => CollectionSeeder.Create(orgId, orgKey, $"Collection {i + 1}"))
.ToList();
}
var collectionIds = collections.Select(c => c.Id).ToList();
var collectionUsers = new List<CollectionUser>();
// User assignment: cycling 1-3 collections per user
if (collections.Count > 0 && hardenedOrgUserIds.Count > 0)
{
foreach (var (orgUserId, userIndex) in hardenedOrgUserIds.Select((id, i) => (id, i)))
{
var maxAssignments = Math.Min((userIndex % 3) + 1, collections.Count);
for (var j = 0; j < maxAssignments; j++)
{
collectionUsers.Add(CollectionUserSeeder.Create(
collections[(userIndex + j) % collections.Count].Id,
orgUserId,
readOnly: j > 0,
manage: j == 0));
}
}
}
context.Collections.AddRange(collections);
context.Registry.CollectionIds.AddRange(collectionIds);
context.CollectionUsers.AddRange(collectionUsers);
}
}

View File

@@ -0,0 +1,39 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Seeder.Factories;
using Bit.Seeder.Pipeline;
namespace Bit.Seeder.Steps;
internal sealed class CreateGroupsStep(int count) : IStep
{
public void Execute(SeederContext context)
{
var orgId = context.RequireOrgId();
var hardenedOrgUserIds = context.Registry.HardenedOrgUserIds;
var groups = new List<Group>(count);
var groupIds = new List<Guid>(count);
var groupUsers = new List<GroupUser>();
for (var i = 0; i < count; i++)
{
var group = GroupSeeder.Create(orgId, $"Group {i + 1}");
groups.Add(group);
groupIds.Add(group.Id);
}
// Round-robin user assignment
if (groups.Count > 0 && hardenedOrgUserIds.Count > 0)
{
for (var i = 0; i < hardenedOrgUserIds.Count; i++)
{
var groupId = groupIds[i % groups.Count];
groupUsers.Add(GroupUserSeeder.Create(groupId, hardenedOrgUserIds[i]));
}
}
context.Groups.AddRange(groups);
context.Registry.GroupIds.AddRange(groupIds);
context.GroupUsers.AddRange(groupUsers);
}
}

View File

@@ -0,0 +1,66 @@
using Bit.RustSDK;
using Bit.Seeder.Factories;
using Bit.Seeder.Models;
using Bit.Seeder.Pipeline;
namespace Bit.Seeder.Steps;
/// <summary>
/// Creates an organization from a fixture or explicit parameters.
/// </summary>
internal sealed class CreateOrganizationStep : IStep
{
private readonly string? _fixtureName;
private readonly string? _name;
private readonly string? _domain;
private readonly int _seats;
private CreateOrganizationStep(string? fixtureName, string? name, string? domain, int seats)
{
if (fixtureName is null && (name is null || domain is null))
{
throw new ArgumentException(
"Either fixtureName OR (name AND domain) must be provided.");
}
_fixtureName = fixtureName;
_name = name;
_domain = domain;
_seats = seats;
}
internal static CreateOrganizationStep FromFixture(string fixtureName) =>
new(fixtureName, null, null, 0);
internal static CreateOrganizationStep FromParams(string name, string domain, int seats) =>
new(null, name, domain, seats);
public void Execute(SeederContext context)
{
string name, domain;
int seats;
if (_fixtureName is not null)
{
var fixture = context.GetSeedReader().Read<SeedOrganization>($"organizations.{_fixtureName}");
name = fixture.Name;
domain = fixture.Domain;
seats = fixture.Seats;
}
else
{
name = _name!;
domain = _domain!;
seats = _seats;
}
var orgKeys = RustSdkService.GenerateOrganizationKeys();
var organization = OrganizationSeeder.Create(name, domain, seats, orgKeys.PublicKey, orgKeys.PrivateKey);
context.Organization = organization;
context.OrgKeys = orgKeys;
context.Domain = domain;
context.Organizations.Add(organization);
}
}

View File

@@ -0,0 +1,29 @@
using Bit.Core.Enums;
using Bit.RustSDK;
using Bit.Seeder.Factories;
using Bit.Seeder.Pipeline;
namespace Bit.Seeder.Steps;
/// <summary>
/// Creates the owner user and links them to the current organization.
/// </summary>
internal sealed class CreateOwnerStep : IStep
{
public void Execute(SeederContext context)
{
var org = context.RequireOrganization();
var owner = UserSeeder.Create($"owner@{context.RequireDomain()}", context.GetPasswordHasher(), context.GetMangler());
var ownerOrgKey = RustSdkService.GenerateUserOrganizationKey(owner.PublicKey!, context.RequireOrgKey());
var ownerOrgUser = org.CreateOrganizationUserWithKey(
owner, OrganizationUserType.Owner, OrganizationUserStatusType.Confirmed, ownerOrgKey);
context.Owner = owner;
context.OwnerOrgUser = ownerOrgUser;
context.Users.Add(owner);
context.OrganizationUsers.Add(ownerOrgUser);
context.Registry.HardenedOrgUserIds.Add(ownerOrgUser.Id);
}
}

View File

@@ -0,0 +1,129 @@
using Bit.Core.Enums;
using Bit.RustSDK;
using Bit.Seeder.Factories;
using Bit.Seeder.Models;
using Bit.Seeder.Pipeline;
namespace Bit.Seeder.Steps;
/// <summary>
/// Loads a roster fixture and creates users, groups, and collections with permissions.
/// </summary>
internal sealed class CreateRosterStep(string fixtureName) : IStep
{
public void Execute(SeederContext context)
{
var org = context.RequireOrganization();
var orgKey = context.RequireOrgKey();
var orgId = context.RequireOrgId();
var domain = context.RequireDomain();
var roster = context.GetSeedReader().Read<SeedRoster>($"rosters.{fixtureName}");
// Phase 1: Create users — build emailPrefix → orgUserId lookup
var userLookup = new Dictionary<string, Guid>(StringComparer.OrdinalIgnoreCase);
var emailPrefixes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var rosterUser in roster.Users)
{
var emailPrefix = $"{rosterUser.FirstName}.{rosterUser.LastName}".ToLowerInvariant();
if (!emailPrefixes.Add(emailPrefix))
{
throw new InvalidOperationException(
$"Duplicate email prefix '{emailPrefix}' in roster '{fixtureName}'. " +
"Each user must have a unique FirstName.LastName combination.");
}
var email = $"{emailPrefix}@{domain}";
var mangledEmail = context.GetMangler().Mangle(email);
var userKeys = RustSdkService.GenerateUserKeys(mangledEmail, UserSeeder.DefaultPassword);
var user = UserSeeder.Create(mangledEmail, context.GetPasswordHasher(), context.GetMangler(), keys: userKeys);
var userOrgKey = RustSdkService.GenerateUserOrganizationKey(user.PublicKey!, orgKey);
var orgUserType = ParseRole(rosterUser.Role);
var orgUser = org.CreateOrganizationUserWithKey(
user, orgUserType, OrganizationUserStatusType.Confirmed, userOrgKey);
userLookup[emailPrefix] = orgUser.Id;
context.Users.Add(user);
context.OrganizationUsers.Add(orgUser);
context.Registry.HardenedOrgUserIds.Add(orgUser.Id);
context.Registry.UserDigests.Add(
new EntityRegistry.UserDigest(user.Id, orgUser.Id, userKeys.Key));
}
// Phase 2: Create groups — build groupName → groupId lookup
var groupLookup = new Dictionary<string, Guid>(StringComparer.OrdinalIgnoreCase);
if (roster.Groups is not null)
{
foreach (var rosterGroup in roster.Groups)
{
var group = GroupSeeder.Create(orgId, rosterGroup.Name);
groupLookup[rosterGroup.Name] = group.Id;
context.Groups.Add(group);
context.Registry.GroupIds.Add(group.Id);
foreach (var memberPrefix in rosterGroup.Members)
{
var orgUserId = RequireLookup(userLookup, memberPrefix,
$"Group '{rosterGroup.Name}' references unknown member '{memberPrefix}'.");
context.GroupUsers.Add(GroupUserSeeder.Create(group.Id, orgUserId));
}
}
}
// Phase 3: Create collections with group/user permission assignments
if (roster.Collections is null)
{
return;
}
foreach (var rosterCollection in roster.Collections)
{
var collection = CollectionSeeder.Create(orgId, orgKey, rosterCollection.Name);
context.Collections.Add(collection);
context.Registry.CollectionIds.Add(collection.Id);
if (rosterCollection.Groups is not null)
{
foreach (var cg in rosterCollection.Groups)
{
var groupId = RequireLookup(groupLookup, cg.Group,
$"Collection '{rosterCollection.Name}' references unknown group '{cg.Group}'.");
context.CollectionGroups.Add(
CollectionGroupSeeder.Create(collection.Id, groupId, cg.ReadOnly, cg.HidePasswords, cg.Manage));
}
}
if (rosterCollection.Users is null)
{
continue;
}
foreach (var cu in rosterCollection.Users)
{
var orgUserId = RequireLookup(userLookup, cu.User,
$"Collection '{rosterCollection.Name}' references unknown user '{cu.User}'.");
context.CollectionUsers.Add(
CollectionUserSeeder.Create(collection.Id, orgUserId, cu.ReadOnly, cu.HidePasswords, cu.Manage));
}
}
}
private static Guid RequireLookup(Dictionary<string, Guid> lookup, string key, string errorMessage) =>
lookup.TryGetValue(key, out var value)
? value
: throw new InvalidOperationException(errorMessage);
private static OrganizationUserType ParseRole(string role) =>
role.ToLowerInvariant() switch
{
"owner" => OrganizationUserType.Owner,
"admin" => OrganizationUserType.Admin,
"user" => OrganizationUserType.User,
"custom" => OrganizationUserType.Custom,
_ => throw new InvalidOperationException(
$"Unknown role '{role}'. Valid roles: owner, admin, user, custom.")
};
}

View File

@@ -0,0 +1,66 @@
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.RustSDK;
using Bit.Seeder.Data.Distributions;
using Bit.Seeder.Factories;
using Bit.Seeder.Pipeline;
namespace Bit.Seeder.Steps;
/// <summary>
/// Creates member users and links them to the current organization.
/// When <c>realisticStatusMix</c> is enabled (and count >= 10), users receive a
/// realistic distribution of Confirmed/Invited/Accepted/Revoked statuses.
/// </summary>
internal sealed class CreateUsersStep(int count, bool realisticStatusMix = false) : IStep
{
public void Execute(SeederContext context)
{
var org = context.RequireOrganization();
var orgKey = context.RequireOrgKey();
var domain = context.RequireDomain();
var statusDistribution = realisticStatusMix && count >= 10
? UserStatusDistributions.Realistic
: UserStatusDistributions.AllConfirmed;
var users = new List<User>(count);
var organizationUsers = new List<OrganizationUser>(count);
var hardenedOrgUserIds = new List<Guid>();
var userDigests = new List<EntityRegistry.UserDigest>();
for (var i = 0; i < count; i++)
{
var email = $"user{i}@{domain}";
var mangledEmail = context.GetMangler().Mangle(email);
var userKeys = RustSdkService.GenerateUserKeys(mangledEmail, UserSeeder.DefaultPassword);
var user = UserSeeder.Create(mangledEmail, context.GetPasswordHasher(), context.GetMangler(), keys: userKeys);
var status = statusDistribution.Select(i, count);
var memberOrgKey = StatusRequiresOrgKey(status)
? RustSdkService.GenerateUserOrganizationKey(user.PublicKey!, orgKey)
: null;
var orgUser = org.CreateOrganizationUserWithKey(
user, OrganizationUserType.User, status, memberOrgKey);
users.Add(user);
organizationUsers.Add(orgUser);
if (status == OrganizationUserStatusType.Confirmed)
{
hardenedOrgUserIds.Add(orgUser.Id);
userDigests.Add(new EntityRegistry.UserDigest(user.Id, orgUser.Id, userKeys.Key));
}
}
context.Users.AddRange(users);
context.OrganizationUsers.AddRange(organizationUsers);
context.Registry.HardenedOrgUserIds.AddRange(hardenedOrgUserIds);
context.Registry.UserDigests.AddRange(userDigests);
}
private static bool StatusRequiresOrgKey(OrganizationUserStatusType status) =>
status is OrganizationUserStatusType.Confirmed or OrganizationUserStatusType.Revoked;
}

View File

@@ -0,0 +1,156 @@
using Bit.Core.Entities;
using Bit.Core.Vault.Entities;
using Bit.Core.Vault.Enums;
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.Pipeline;
namespace Bit.Seeder.Steps;
/// <summary>
/// Creates N random cipher entities using the deterministic <see cref="GeneratorContext"/>.
/// </summary>
/// <remarks>
/// Requires <see cref="InitGeneratorStep"/> to have run first. Picks cipher types (login, card,
/// identity, secureNote, sshKey) from a configurable distribution, delegates to the existing
/// cipher factories, and assigns each cipher to collections round-robin. Designed for load
/// testing scenarios where you need thousands of realistic vault items.
/// </remarks>
/// <seealso cref="InitGeneratorStep"/>
/// <seealso cref="CreateCiphersStep"/>
internal sealed class GenerateCiphersStep(
int count,
Distribution<CipherType>? typeDist = null,
Distribution<PasswordStrength>? pwDist = null) : IStep
{
public void Execute(SeederContext context)
{
if (count == 0)
{
return;
}
var generator = context.RequireGenerator();
var orgId = context.RequireOrgId();
var orgKey = context.RequireOrgKey();
var collectionIds = context.Registry.CollectionIds;
var typeDistribution = typeDist ?? CipherTypeDistributions.Realistic;
var passwordDistribution = pwDist ?? PasswordDistributions.Realistic;
var companies = Companies.All;
var ciphers = new List<Cipher>(count);
var cipherIds = new List<Guid>(count);
var collectionCiphers = new List<CollectionCipher>();
for (var i = 0; i < count; i++)
{
var cipherType = typeDistribution.Select(i, count);
var cipher = cipherType switch
{
CipherType.Login => CreateLoginCipher(i, orgId, orgKey, companies, generator, passwordDistribution),
CipherType.Card => CreateCardCipher(i, orgId, orgKey, generator),
CipherType.Identity => CreateIdentityCipher(i, orgId, orgKey, generator),
CipherType.SecureNote => CreateSecureNoteCipher(i, orgId, orgKey, generator),
CipherType.SSHKey => CreateSshKeyCipher(i, orgId, orgKey),
_ => throw new ArgumentException($"Unsupported cipher type: {cipherType}")
};
ciphers.Add(cipher);
cipherIds.Add(cipher.Id);
// Collection assignment
if (collectionIds.Count <= 0)
{
continue;
}
collectionCiphers.Add(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)
{
collectionCiphers.Add(new CollectionCipher
{
CipherId = cipher.Id,
CollectionId = collectionIds[(i + 1) % collectionIds.Count]
});
}
}
context.Ciphers.AddRange(ciphers);
context.Registry.CipherIds.AddRange(cipherIds);
context.CollectionCiphers.AddRange(collectionCiphers);
}
private static Cipher CreateLoginCipher(
int index,
Guid organizationId,
string orgKey,
Company[] companies,
GeneratorContext generator,
Distribution<PasswordStrength> passwordDistribution)
{
var company = companies[index % companies.Length];
return LoginCipherSeeder.Create(
orgKey,
name: $"{company.Name} ({company.Category})",
organizationId: organizationId,
username: generator.Username.GenerateByIndex(index, totalHint: generator.CipherCount, domain: company.Domain),
password: Passwords.GetPassword(index, generator.CipherCount, passwordDistribution),
uri: $"https://{company.Domain}");
}
private static Cipher CreateCardCipher(int index, Guid organizationId, string orgKey, GeneratorContext generator)
{
var card = generator.Card.GenerateByIndex(index);
return CardCipherSeeder.Create(
orgKey,
name: $"{card.CardholderName}'s {card.Brand}",
card: card,
organizationId: organizationId);
}
private static Cipher CreateIdentityCipher(int index, Guid organizationId, string orgKey, GeneratorContext generator)
{
var identity = generator.Identity.GenerateByIndex(index);
var name = $"{identity.FirstName} {identity.LastName}";
if (!string.IsNullOrEmpty(identity.Company))
{
name += $" ({identity.Company})";
}
return IdentityCipherSeeder.Create(
orgKey,
name: name,
identity: identity,
organizationId: organizationId);
}
private static Cipher CreateSecureNoteCipher(int index, Guid organizationId, string orgKey, GeneratorContext generator)
{
var (name, notes) = generator.SecureNote.GenerateByIndex(index);
return SecureNoteCipherSeeder.Create(
orgKey,
name: name,
organizationId: organizationId,
notes: notes);
}
private static Cipher CreateSshKeyCipher(int index, Guid organizationId, string orgKey)
{
var sshKey = SshKeyDataGenerator.GenerateByIndex(index);
return SshKeyCipherSeeder.Create(
orgKey,
name: $"SSH Key {index + 1}",
sshKey: sshKey,
organizationId: organizationId);
}
}

View File

@@ -0,0 +1,41 @@
using Bit.Seeder.Data;
using Bit.Seeder.Options;
using Bit.Seeder.Pipeline;
namespace Bit.Seeder.Steps;
/// <summary>
/// Initializes the deterministic random data engine on <see cref="SeederContext.Generator"/>.
/// </summary>
/// <remarks>
/// Produces no entities itself. Derives a repeatable seed from the domain string (same domain
/// always yields the same generated data). Downstream steps like <see cref="GenerateCiphersStep"/>
/// consume the generator for realistic usernames, cards, identities, and notes.
/// </remarks>
/// <seealso cref="GeneratorContext"/>
internal sealed class InitGeneratorStep : IStep
{
private readonly OrganizationVaultOptions _options;
private InitGeneratorStep(OrganizationVaultOptions options)
{
_options = options;
}
internal static InitGeneratorStep FromDomain(string domain, int? seed = null)
{
var options = new OrganizationVaultOptions
{
Name = domain,
Domain = domain,
Users = 0,
Seed = seed
};
return new InitGeneratorStep(options);
}
public void Execute(SeederContext context)
{
context.Generator = GeneratorContext.FromOptions(_options);
}
}