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:
@@ -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"]);
|
||||
}
|
||||
}
|
||||
35
test/SeederApi.IntegrationTest/PresetLoaderTests.cs
Normal file
35
test/SeederApi.IntegrationTest/PresetLoaderTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"profiles": {
|
||||
"SeederApi.IntegrationTest": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"applicationUrl": "https://localhost:53205;http://localhost:53206"
|
||||
}
|
||||
}
|
||||
}
|
||||
157
test/SeederApi.IntegrationTest/RecipeBuilderValidationTests.cs
Normal file
157
test/SeederApi.IntegrationTest/RecipeBuilderValidationTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
128
test/SeederApi.IntegrationTest/SeedReaderTests.cs
Normal file
128
test/SeederApi.IntegrationTest/SeedReaderTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
35
util/DbSeederUtility/SeedArgs.cs
Normal file
35
util/DbSeederUtility/SeedArgs.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
23
util/Seeder/Factories/CollectionGroupSeeder.cs
Normal file
23
util/Seeder/Factories/CollectionGroupSeeder.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
35
util/Seeder/Factories/SeedItemMapping.cs
Normal file
35
util/Seeder/Factories/SeedItemMapping.cs
Normal 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
8
util/Seeder/IStep.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
using Bit.Seeder.Pipeline;
|
||||
|
||||
namespace Bit.Seeder;
|
||||
|
||||
public interface IStep
|
||||
{
|
||||
void Execute(SeederContext context);
|
||||
}
|
||||
122
util/Seeder/Models/SeedModels.cs
Normal file
122
util/Seeder/Models/SeedModels.cs
Normal 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; }
|
||||
}
|
||||
46
util/Seeder/Models/SeedPreset.cs
Normal file
46
util/Seeder/Models/SeedPreset.cs
Normal 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; }
|
||||
}
|
||||
87
util/Seeder/Pipeline/BulkCommitter.cs
Normal file
87
util/Seeder/Pipeline/BulkCommitter.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
60
util/Seeder/Pipeline/EntityRegistry.cs
Normal file
60
util/Seeder/Pipeline/EntityRegistry.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
12
util/Seeder/Pipeline/OrderedStep.cs
Normal file
12
util/Seeder/Pipeline/OrderedStep.cs
Normal 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);
|
||||
}
|
||||
83
util/Seeder/Pipeline/PresetExecutor.cs
Normal file
83
util/Seeder/Pipeline/PresetExecutor.cs
Normal 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);
|
||||
102
util/Seeder/Pipeline/PresetLoader.cs
Normal file
102
util/Seeder/Pipeline/PresetLoader.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
71
util/Seeder/Pipeline/RecipeBuilder.cs
Normal file
71
util/Seeder/Pipeline/RecipeBuilder.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
240
util/Seeder/Pipeline/RecipeBuilderExtensions.cs
Normal file
240
util/Seeder/Pipeline/RecipeBuilderExtensions.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
55
util/Seeder/Pipeline/RecipeExecutor.cs
Normal file
55
util/Seeder/Pipeline/RecipeExecutor.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
20
util/Seeder/Pipeline/RecipeServiceCollectionExtensions.cs
Normal file
20
util/Seeder/Pipeline/RecipeServiceCollectionExtensions.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
93
util/Seeder/Pipeline/SeederContext.cs
Normal file
93
util/Seeder/Pipeline/SeederContext.cs
Normal 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.");
|
||||
}
|
||||
22
util/Seeder/Pipeline/SeederContextExtensions.cs
Normal file
22
util/Seeder/Pipeline/SeederContextExtensions.cs
Normal 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>();
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
72
util/Seeder/Recipes/OrganizationFromPresetRecipe.cs
Normal file
72
util/Seeder/Recipes/OrganizationFromPresetRecipe.cs
Normal 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);
|
||||
@@ -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();
|
||||
|
||||
@@ -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
149
util/Seeder/Seeds/README.md
Normal 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)
|
||||
214
util/Seeder/Seeds/fixtures/ciphers/autofill-testing.json
Normal file
214
util/Seeder/Seeds/fixtures/ciphers/autofill-testing.json
Normal 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" }
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
1035
util/Seeder/Seeds/fixtures/ciphers/public-site-logins.json
Normal file
1035
util/Seeder/Seeds/fixtures/ciphers/public-site-logins.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "../../schemas/organization.schema.json",
|
||||
"name": "Dunder Mifflin",
|
||||
"domain": "dundermifflin.com",
|
||||
"seats": 70
|
||||
}
|
||||
12
util/Seeder/Seeds/fixtures/presets/dunder-mifflin-full.json
Normal file
12
util/Seeder/Seeds/fixtures/presets/dunder-mifflin-full.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "../../schemas/preset.schema.json",
|
||||
"organization": {
|
||||
"fixture": "dunder-mifflin"
|
||||
},
|
||||
"roster": {
|
||||
"fixture": "dunder-mifflin"
|
||||
},
|
||||
"ciphers": {
|
||||
"fixture": "autofill-testing"
|
||||
}
|
||||
}
|
||||
21
util/Seeder/Seeds/fixtures/presets/large-enterprise.json
Normal file
21
util/Seeder/Seeds/fixtures/presets/large-enterprise.json
Normal 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
|
||||
}
|
||||
}
|
||||
310
util/Seeder/Seeds/fixtures/rosters/dunder-mifflin.json
Normal file
310
util/Seeder/Seeds/fixtures/rosters/dunder-mifflin.json
Normal 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" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
149
util/Seeder/Seeds/schemas/cipher.schema.json
Normal file
149
util/Seeder/Seeds/schemas/cipher.schema.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
30
util/Seeder/Seeds/schemas/organization.schema.json
Normal file
30
util/Seeder/Seeds/schemas/organization.schema.json
Normal 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."
|
||||
}
|
||||
}
|
||||
}
|
||||
110
util/Seeder/Seeds/schemas/preset.schema.json
Normal file
110
util/Seeder/Seeds/schemas/preset.schema.json
Normal 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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
152
util/Seeder/Seeds/schemas/roster.schema.json
Normal file
152
util/Seeder/Seeds/schemas/roster.schema.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
50
util/Seeder/Seeds/templates/cipher.template.json
Normal file
50
util/Seeder/Seeds/templates/cipher.template.json
Normal 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."
|
||||
}
|
||||
]
|
||||
}
|
||||
6
util/Seeder/Seeds/templates/organization.template.json
Normal file
6
util/Seeder/Seeds/templates/organization.template.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "../schemas/organization.schema.json",
|
||||
"name": "Acme Corporation",
|
||||
"domain": "acme.example.com",
|
||||
"seats": 10
|
||||
}
|
||||
14
util/Seeder/Seeds/templates/preset.template.json
Normal file
14
util/Seeder/Seeds/templates/preset.template.json
Normal 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"
|
||||
}
|
||||
}
|
||||
77
util/Seeder/Seeds/templates/roster.template.json
Normal file
77
util/Seeder/Seeds/templates/roster.template.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
19
util/Seeder/Services/ISeedReader.cs
Normal file
19
util/Seeder/Services/ISeedReader.cs
Normal 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();
|
||||
}
|
||||
62
util/Seeder/Services/SeedReader.cs
Normal file
62
util/Seeder/Services/SeedReader.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
64
util/Seeder/Steps/CreateCiphersStep.cs
Normal file
64
util/Seeder/Steps/CreateCiphersStep.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
70
util/Seeder/Steps/CreateCollectionsStep.cs
Normal file
70
util/Seeder/Steps/CreateCollectionsStep.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
39
util/Seeder/Steps/CreateGroupsStep.cs
Normal file
39
util/Seeder/Steps/CreateGroupsStep.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
66
util/Seeder/Steps/CreateOrganizationStep.cs
Normal file
66
util/Seeder/Steps/CreateOrganizationStep.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
29
util/Seeder/Steps/CreateOwnerStep.cs
Normal file
29
util/Seeder/Steps/CreateOwnerStep.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
129
util/Seeder/Steps/CreateRosterStep.cs
Normal file
129
util/Seeder/Steps/CreateRosterStep.cs
Normal 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.")
|
||||
};
|
||||
}
|
||||
66
util/Seeder/Steps/CreateUsersStep.cs
Normal file
66
util/Seeder/Steps/CreateUsersStep.cs
Normal 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;
|
||||
}
|
||||
156
util/Seeder/Steps/GenerateCiphersStep.cs
Normal file
156
util/Seeder/Steps/GenerateCiphersStep.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
41
util/Seeder/Steps/InitGeneratorStep.cs
Normal file
41
util/Seeder/Steps/InitGeneratorStep.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user