diff --git a/test/SeederApi.IntegrationTest/OrganizationFromPresetRecipeTests.cs b/test/SeederApi.IntegrationTest/OrganizationFromPresetRecipeTests.cs new file mode 100644 index 0000000000..f456fd337b --- /dev/null +++ b/test/SeederApi.IntegrationTest/OrganizationFromPresetRecipeTests.cs @@ -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"]); + } +} diff --git a/test/SeederApi.IntegrationTest/PresetLoaderTests.cs b/test/SeederApi.IntegrationTest/PresetLoaderTests.cs new file mode 100644 index 0000000000..73715b63cd --- /dev/null +++ b/test/SeederApi.IntegrationTest/PresetLoaderTests.cs @@ -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("fixture-org-test").ToList(); + + Assert.NotNull(steps); + Assert.NotEmpty(steps); + } +} diff --git a/test/SeederApi.IntegrationTest/Properties/launchSettings.json b/test/SeederApi.IntegrationTest/Properties/launchSettings.json new file mode 100644 index 0000000000..9fa48f5841 --- /dev/null +++ b/test/SeederApi.IntegrationTest/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "SeederApi.IntegrationTest": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:53205;http://localhost:53206" + } + } +} \ No newline at end of file diff --git a/test/SeederApi.IntegrationTest/RecipeBuilderValidationTests.cs b/test/SeederApi.IntegrationTest/RecipeBuilderValidationTests.cs new file mode 100644 index 0000000000..38b1049b6d --- /dev/null +++ b/test/SeederApi.IntegrationTest/RecipeBuilderValidationTests.cs @@ -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(() => 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(() => 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(() => 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(() => 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(() => 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(() => 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(() => 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(() => 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(() => 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("test").ToList(); + + Assert.Equal(7, steps.Count); + + // Verify steps are wrapped in OrderedStep with sequential order values + var orderedSteps = steps.Cast().ToList(); + for (var i = 0; i < orderedSteps.Count; i++) + { + Assert.Equal(i, orderedSteps[i].Order); + } + } +} diff --git a/test/SeederApi.IntegrationTest/SeedReaderTests.cs b/test/SeederApi.IntegrationTest/SeedReaderTests.cs new file mode 100644 index 0000000000..34adf94da9 --- /dev/null +++ b/test/SeederApi.IntegrationTest/SeedReaderTests.cs @@ -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("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("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( + () => _reader.Read("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(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("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("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(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(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)); + } + } + } +} diff --git a/util/DbSeederUtility/Program.cs b/util/DbSeederUtility/Program.cs index 60936ca67d..969b453ef7 100644 --- a/util/DbSeederUtility/Program.cs +++ b/util/DbSeederUtility/Program.cs @@ -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(); + var mapper = scopedServices.GetRequiredService(); + var passwordHasher = scopedServices.GetRequiredService>(); + var manglerService = scopedServices.GetRequiredService(); + + // 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 "); + } + + 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"); + } } diff --git a/util/DbSeederUtility/README.md b/util/DbSeederUtility/README.md index 154cbd018b..e2f2b8f6b1 100644 --- a/util/DbSeederUtility/README.md +++ b/util/DbSeederUtility/README.md @@ -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 -- [options] ``` -Or directly using the compiled executable: +**Login Credentials:** All seeded users use password `asdfasdfasdf`. The owner email is `owner@`. -``` -DbSeeder.exe [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 +``` diff --git a/util/DbSeederUtility/SeedArgs.cs b/util/DbSeederUtility/SeedArgs.cs new file mode 100644 index 0000000000..699a065de4 --- /dev/null +++ b/util/DbSeederUtility/SeedArgs.cs @@ -0,0 +1,35 @@ +using CommandDotNet; + +namespace Bit.DbSeederUtility; + +/// +/// CLI argument model for the seed command. +/// Supports loading presets from embedded resources. +/// +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."); + } + } +} diff --git a/util/DbSeederUtility/ServiceCollectionExtension.cs b/util/DbSeederUtility/ServiceCollectionExtension.cs index 26a198d03a..0a2288a4c2 100644 --- a/util/DbSeederUtility/ServiceCollectionExtension.cs +++ b/util/DbSeederUtility/ServiceCollectionExtension.cs @@ -25,6 +25,7 @@ public static class ServiceCollectionExtension }); services.AddSingleton(globalSettings); services.AddSingleton, PasswordHasher>(); + services.TryAddSingleton(); // Add Data Protection services services.AddDataProtection() diff --git a/util/Seeder/CLAUDE.md b/util/Seeder/CLAUDE.md index 21e3f03fb1..dbdc13dc1f 100644 --- a/util/Seeder/CLAUDE.md +++ b/util/Seeder/CLAUDE.md @@ -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: diff --git a/util/Seeder/Factories/CardCipherSeeder.cs b/util/Seeder/Factories/CardCipherSeeder.cs index 28355bbd62..e6f4518f11 100644 --- a/util/Seeder/Factories/CardCipherSeeder.cs +++ b/util/Seeder/Factories/CardCipherSeeder.cs @@ -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); + } } diff --git a/util/Seeder/Factories/CollectionGroupSeeder.cs b/util/Seeder/Factories/CollectionGroupSeeder.cs new file mode 100644 index 0000000000..c1bd57cf75 --- /dev/null +++ b/util/Seeder/Factories/CollectionGroupSeeder.cs @@ -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 + }; + } +} diff --git a/util/Seeder/Factories/IdentityCipherSeeder.cs b/util/Seeder/Factories/IdentityCipherSeeder.cs index 1f0f5975bb..c6d340d400 100644 --- a/util/Seeder/Factories/IdentityCipherSeeder.cs +++ b/util/Seeder/Factories/IdentityCipherSeeder.cs @@ -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); + } } diff --git a/util/Seeder/Factories/LoginCipherSeeder.cs b/util/Seeder/Factories/LoginCipherSeeder.cs index 76fcf57764..4683a2c7b6 100644 --- a/util/Seeder/Factories/LoginCipherSeeder.cs +++ b/util/Seeder/Factories/LoginCipherSeeder.cs @@ -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); + } } diff --git a/util/Seeder/Factories/SecureNoteCipherSeeder.cs b/util/Seeder/Factories/SecureNoteCipherSeeder.cs index 3fb6dab2ea..9756d1f67a 100644 --- a/util/Seeder/Factories/SecureNoteCipherSeeder.cs +++ b/util/Seeder/Factories/SecureNoteCipherSeeder.cs @@ -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); + } } diff --git a/util/Seeder/Factories/SeedItemMapping.cs b/util/Seeder/Factories/SeedItemMapping.cs new file mode 100644 index 0000000000..91b5d41aeb --- /dev/null +++ b/util/Seeder/Factories/SeedItemMapping.cs @@ -0,0 +1,35 @@ +using Bit.Seeder.Models; + +namespace Bit.Seeder.Factories; + +/// +/// Shared mapping helpers for converting SeedItem fields to CipherViewDto fields. +/// +internal static class SeedItemMapping +{ + internal static int MapFieldType(string type) => type switch + { + "hidden" => 1, + "boolean" => 2, + "linked" => 3, + _ => 0 // text + }; + + internal static List? MapFields(List? 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 + }; +} diff --git a/util/Seeder/IStep.cs b/util/Seeder/IStep.cs new file mode 100644 index 0000000000..af5e529624 --- /dev/null +++ b/util/Seeder/IStep.cs @@ -0,0 +1,8 @@ +using Bit.Seeder.Pipeline; + +namespace Bit.Seeder; + +public interface IStep +{ + void Execute(SeederContext context); +} diff --git a/util/Seeder/Models/SeedModels.cs b/util/Seeder/Models/SeedModels.cs new file mode 100644 index 0000000000..6c7a6eaf3b --- /dev/null +++ b/util/Seeder/Models/SeedModels.cs @@ -0,0 +1,122 @@ +namespace Bit.Seeder.Models; + +internal record SeedFile +{ + public required List 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? Fields { get; init; } +} + +internal record SeedLogin +{ + public string? Username { get; init; } + public string? Password { get; init; } + public List? 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 Users { get; init; } + public List? Groups { get; init; } + public List? 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 Members { get; init; } +} + +internal record SeedRosterCollection +{ + public required string Name { get; init; } + public List? Groups { get; init; } + public List? 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; } +} diff --git a/util/Seeder/Models/SeedPreset.cs b/util/Seeder/Models/SeedPreset.cs new file mode 100644 index 0000000000..bc4e5467eb --- /dev/null +++ b/util/Seeder/Models/SeedPreset.cs @@ -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; } +} diff --git a/util/Seeder/Pipeline/BulkCommitter.cs b/util/Seeder/Pipeline/BulkCommitter.cs new file mode 100644 index 0000000000..fa0a89c9ec --- /dev/null +++ b/util/Seeder/Pipeline/BulkCommitter.cs @@ -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; + +/// +/// Flushes accumulated entities from to the database via BulkCopy. +/// +/// +/// 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. +/// +/// +/// +internal sealed class BulkCommitter(DatabaseContext db, IMapper mapper) +{ + internal void Commit(SeederContext context) + { + MapCopyAndClear(context.Organizations); + + MapCopyAndClear(context.Users); + + MapCopyAndClear(context.OrganizationUsers); + + MapCopyAndClear(context.Groups); + + MapCopyAndClear(context.GroupUsers); + + MapCopyAndClear(context.Collections); + + MapCopyAndClear(context.CollectionUsers, nameof(Core.Entities.CollectionUser)); + + MapCopyAndClear(context.CollectionGroups, nameof(Core.Entities.CollectionGroup)); + + CopyAndClear(context.Ciphers); + + CopyAndClear(context.CollectionCiphers); + } + + private void MapCopyAndClear(List entities, string? tableName = null) where TEf : class + { + if (entities.Count is 0) + { + return; + } + + var mapped = entities.Select(e => mapper.Map(e)); + + if (tableName is not null) + { + db.BulkCopy(new BulkCopyOptions { TableName = tableName }, mapped); + } + else + { + db.BulkCopy(mapped); + } + + entities.Clear(); + } + + private void CopyAndClear(List entities) where T : class + { + if (entities.Count is 0) + { + return; + } + + db.BulkCopy(entities); + entities.Clear(); + } +} diff --git a/util/Seeder/Pipeline/EntityRegistry.cs b/util/Seeder/Pipeline/EntityRegistry.cs new file mode 100644 index 0000000000..45061b5479 --- /dev/null +++ b/util/Seeder/Pipeline/EntityRegistry.cs @@ -0,0 +1,60 @@ +namespace Bit.Seeder.Pipeline; + +/// +/// Persistent cross-step reference store that survives bulk-commit flushes. +/// +/// +/// When commits entities to the database, it clears the entity +/// lists on (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. +/// +/// Steps populate the registry as they create entities. Later steps read from it. +/// calls before each run to prevent stale state. +/// +/// +internal sealed class EntityRegistry +{ + /// + /// A user's core IDs and symmetric key, needed for per-user encryption (e.g. personal folders). + /// + internal record UserDigest(Guid UserId, Guid OrgUserId, string SymmetricKey); + + /// + /// Organization user IDs for hardened (key-bearing) members. Used by group and collection steps for assignment. + /// + internal List HardenedOrgUserIds { get; } = []; + + /// + /// Full user references including symmetric keys. Used for per-user encrypted content. + /// + /// + internal List UserDigests { get; } = []; + + /// + /// Group IDs for collection-group assignment. + /// + internal List GroupIds { get; } = []; + + /// + /// Collection IDs for cipher-collection assignment. + /// + internal List CollectionIds { get; } = []; + + /// + /// Cipher IDs for downstream reference. + /// + internal List CipherIds { get; } = []; + + /// + /// Clears all registry lists. Called by before each pipeline run. + /// + internal void Clear() + { + HardenedOrgUserIds.Clear(); + UserDigests.Clear(); + GroupIds.Clear(); + CollectionIds.Clear(); + CipherIds.Clear(); + } +} diff --git a/util/Seeder/Pipeline/OrderedStep.cs b/util/Seeder/Pipeline/OrderedStep.cs new file mode 100644 index 0000000000..381c8c4b63 --- /dev/null +++ b/util/Seeder/Pipeline/OrderedStep.cs @@ -0,0 +1,12 @@ +namespace Bit.Seeder.Pipeline; + +/// +/// Wraps an with an order index for keyed DI registration +/// where GetKeyedServices does not guarantee order. +/// +internal sealed class OrderedStep(IStep inner, int order) : IStep +{ + internal int Order { get; } = order; + + public void Execute(SeederContext context) => inner.Execute(context); +} diff --git a/util/Seeder/Pipeline/PresetExecutor.cs b/util/Seeder/Pipeline/PresetExecutor.cs new file mode 100644 index 0000000000..83ee8fea1c --- /dev/null +++ b/util/Seeder/Pipeline/PresetExecutor.cs @@ -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; + +/// +/// Orchestrates preset-based seeding by coordinating the Pipeline infrastructure. +/// +internal sealed class PresetExecutor(DatabaseContext db, IMapper mapper) +{ + /// + /// Executes a preset by registering its recipe, building a service provider, and running all steps. + /// + /// Name of the embedded preset (e.g., "dunder-mifflin-full") + /// Password hasher for user creation + /// Mangler service for test isolation + /// Execution result with organization ID and entity counts + internal ExecutionResult Execute( + string presetName, + IPasswordHasher passwordHasher, + IManglerService manglerService) + { + var reader = new SeedReader(); + + var services = new ServiceCollection(); + services.AddSingleton(passwordHasher); + services.AddSingleton(manglerService); + services.AddSingleton(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(); + } + + /// + /// Lists all available embedded presets and fixtures. + /// + /// Available presets grouped by category + 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)g.ToList()); + + return new AvailableSeeds(presets, fixtures); + } +} + +/// +/// Result of pipeline execution with organization ID and entity counts. +/// +internal record ExecutionResult( + Guid OrganizationId, + string? OwnerEmail, + int UsersCount, + int GroupsCount, + int CollectionsCount, + int CiphersCount); + +/// +/// Available presets and fixtures grouped by category. +/// +internal record AvailableSeeds( + IReadOnlyList Presets, + IReadOnlyDictionary> Fixtures); diff --git a/util/Seeder/Pipeline/PresetLoader.cs b/util/Seeder/Pipeline/PresetLoader.cs new file mode 100644 index 0000000000..900644e870 --- /dev/null +++ b/util/Seeder/Pipeline/PresetLoader.cs @@ -0,0 +1,102 @@ +using Bit.Seeder.Models; +using Bit.Seeder.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace Bit.Seeder.Pipeline; + +/// +/// Loads preset fixtures and registers them as recipes on . +/// +internal static class PresetLoader +{ + /// + /// Loads a preset from embedded fixtures and registers its steps as a recipe. + /// + /// Preset name without extension (e.g., "dunder-mifflin-full") + /// Service for reading embedded seed JSON files + /// The service collection to register steps in + /// Thrown when preset lacks organization configuration + internal static void RegisterRecipe(string presetName, ISeedReader reader, IServiceCollection services) + { + var preset = reader.Read($"presets.{presetName}"); + + if (preset.Organization is null) + { + throw new InvalidOperationException( + $"Preset '{presetName}' must specify an organization."); + } + + BuildRecipe(presetName, preset, reader, services); + } + + /// + /// Builds a recipe from preset configuration, resolving fixtures and generation counts. + /// + /// + /// Resolution order: Org → Owner → Generator → Roster → Users → Groups → Collections → Ciphers + /// + 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($"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(); + } +} diff --git a/util/Seeder/Pipeline/RecipeBuilder.cs b/util/Seeder/Pipeline/RecipeBuilder.cs new file mode 100644 index 0000000000..bd96517c8a --- /dev/null +++ b/util/Seeder/Pipeline/RecipeBuilder.cs @@ -0,0 +1,71 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Bit.Seeder.Pipeline; + +/// +/// Fluent API for building seeding pipelines with DI-based step registration and validation. +/// +/// +/// RecipeBuilder wraps and a recipe name. +/// It tracks step count for deterministic ordering and validation flags for dependency rules. +/// Phase Order: Org → Owner → Generator → Roster → Users → Groups → Collections → Ciphers +/// +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; } + + /// + /// Registers a step as a keyed singleton service with preserved ordering. + /// + /// + /// 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. + /// + /// Factory function that creates the step from an IServiceProvider + /// This builder for fluent chaining + public RecipeBuilder AddStep(Func factory) + { + var order = _stepOrder++; + Services.AddKeyedSingleton(Name, (sp, _) => new OrderedStep(factory(sp), order)); + return this; + } + + /// + /// Registers a step type as a keyed singleton with preserved ordering. + /// + /// The step implementation type + /// This builder for fluent chaining + public RecipeBuilder AddStep() where T : class, IStep + { + var order = _stepOrder++; + Services.AddKeyedSingleton(Name, (sp, _) => new OrderedStep(sp.GetRequiredService(), order)); + Services.TryAddSingleton(); + return this; + } +} diff --git a/util/Seeder/Pipeline/RecipeBuilderExtensions.cs b/util/Seeder/Pipeline/RecipeBuilderExtensions.cs new file mode 100644 index 0000000000..555c0cd3d5 --- /dev/null +++ b/util/Seeder/Pipeline/RecipeBuilderExtensions.cs @@ -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; + +/// +/// Step registration extension methods for . +/// Each method validates constraints, sets validation flags, and registers the step via DI. +/// +public static class RecipeBuilderExtensions +{ + /// + /// Use an organization from embedded fixtures. + /// + /// The recipe builder + /// Organization fixture name without extension + /// The builder for fluent chaining + public static RecipeBuilder UseOrganization(this RecipeBuilder builder, string fixture) + { + builder.HasOrg = true; + builder.AddStep(_ => CreateOrganizationStep.FromFixture(fixture)); + return builder; + } + + /// + /// Create an organization inline with specified parameters. + /// + /// The recipe builder + /// Organization display name + /// Organization domain (used for email generation) + /// Number of user seats + /// The builder for fluent chaining + 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; + } + + /// + /// Add an organization owner user with admin privileges. + /// + /// The recipe builder + /// The builder for fluent chaining + public static RecipeBuilder AddOwner(this RecipeBuilder builder) + { + builder.HasOwner = true; + builder.AddStep(_ => new CreateOwnerStep()); + return builder; + } + + /// + /// Initialize seeded random generator for reproducible test data. + /// + /// The recipe builder + /// Organization domain (used for seeding randomness) + /// Optional explicit seed. If null, domain hash is used. + /// The builder for fluent chaining + public static RecipeBuilder WithGenerator(this RecipeBuilder builder, string domain, int? seed = null) + { + builder.HasGenerator = true; + builder.AddStep(_ => InitGeneratorStep.FromDomain(domain, seed)); + return builder; + } + + /// + /// Use a roster from embedded fixtures (users, groups, collections). + /// + /// The recipe builder + /// Roster fixture name without extension + /// The builder for fluent chaining + /// Thrown when AddUsers() was already called + 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; + } + + /// + /// Generate users with seeded random data. + /// + /// The recipe builder + /// Number of users to generate + /// If true, includes revoked/invited users; if false, all confirmed + /// The builder for fluent chaining + /// Thrown when UseRoster() was already called + 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; + } + + /// + /// Generate groups with random members from existing users. + /// + /// The recipe builder + /// Number of groups to generate + /// The builder for fluent chaining + /// Thrown when no users exist + 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; + } + + /// + /// Generate collections with random assignments. + /// + /// The recipe builder + /// Number of collections to generate + /// The builder for fluent chaining + /// Thrown when no users exist + 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; + } + + /// + /// Generate collections based on organizational structure model. + /// + /// The recipe builder + /// Organizational structure (Traditional, Spotify, Modern) + /// The builder for fluent chaining + /// Thrown when no users exist + 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; + } + + /// + /// Use ciphers from embedded fixtures. + /// + /// The recipe builder + /// Cipher fixture name without extension + /// The builder for fluent chaining + /// Thrown when AddCiphers() was already called + 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; + } + + /// + /// Generate ciphers with configurable type and password strength distributions. + /// + /// The recipe builder + /// Number of ciphers to generate + /// Distribution of cipher types. Uses realistic defaults if null. + /// Distribution of password strengths. Uses realistic defaults if null. + /// The builder for fluent chaining + /// Thrown when UseCiphers() was already called + public static RecipeBuilder AddCiphers( + this RecipeBuilder builder, + int count, + Distribution? typeDist = null, + Distribution? 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; + } + + /// + /// Validates the builder state to ensure all required steps are present and dependencies are met. + /// + /// The recipe builder + /// The builder for fluent chaining + /// Thrown when required steps missing or dependencies violated + 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; + } +} diff --git a/util/Seeder/Pipeline/RecipeExecutor.cs b/util/Seeder/Pipeline/RecipeExecutor.cs new file mode 100644 index 0000000000..afeb12aba2 --- /dev/null +++ b/util/Seeder/Pipeline/RecipeExecutor.cs @@ -0,0 +1,55 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace Bit.Seeder.Pipeline; + +/// +/// Resolves steps from DI by recipe key and executes them in order. +/// +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; + } + + /// + /// Executes the recipe by resolving keyed steps, running them in order, and committing results. + /// + /// + /// Clears the EntityRegistry at the start to ensure a clean slate for each run. + /// + internal ExecutionResult Execute() + { + var steps = _serviceProvider.GetKeyedServices(_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; + } +} diff --git a/util/Seeder/Pipeline/RecipeServiceCollectionExtensions.cs b/util/Seeder/Pipeline/RecipeServiceCollectionExtensions.cs new file mode 100644 index 0000000000..39db193495 --- /dev/null +++ b/util/Seeder/Pipeline/RecipeServiceCollectionExtensions.cs @@ -0,0 +1,20 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace Bit.Seeder.Pipeline; + +/// +/// Entry point extension method for registering recipes on . +/// +public static class RecipeServiceCollectionExtensions +{ + /// + /// Creates a new for registering steps as keyed services. + /// + /// The service collection to register steps in + /// Unique name used as the keyed service key + /// A new RecipeBuilder for fluent step registration + public static RecipeBuilder AddRecipe(this IServiceCollection services, string recipeName) + { + return new RecipeBuilder(recipeName, services); + } +} diff --git a/util/Seeder/Pipeline/SeederContext.cs b/util/Seeder/Pipeline/SeederContext.cs new file mode 100644 index 0000000000..b27290e031 --- /dev/null +++ b/util/Seeder/Pipeline/SeederContext.cs @@ -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; + +/// +/// Shared mutable state bag passed through every 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. +/// +/// +/// +/// Steps resolve services from instead of accessing fixed properties. +/// Use the convenience extension methods in for common services. +/// +/// +/// Context Lifecycle: +/// +/// Create fresh context for each pipeline run +/// Pass to RecipeExecutor.Execute() +/// Steps mutate context progressively +/// BulkCommitter flushes entity lists to database +/// Return org ID from context +/// Discard context (do not reuse) +/// +/// +/// +/// Use the Require*() methods instead of accessing nullable properties directly — +/// they throw with step-ordering guidance if a prerequisite step hasn't run yet. +/// +/// +/// +/// Service provider for resolving dependencies. Steps access services via +/// convenience methods. +/// +/// +/// +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 Organizations { get; } = []; + + internal List Users { get; } = []; + + internal List OrganizationUsers { get; } = []; + + internal List Ciphers { get; } = []; + + internal List Groups { get; } = []; + + internal List GroupUsers { get; } = []; + + internal List Collections { get; } = []; + + internal List CollectionUsers { get; } = []; + + internal List CollectionGroups { get; } = []; + + internal List 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."); +} diff --git a/util/Seeder/Pipeline/SeederContextExtensions.cs b/util/Seeder/Pipeline/SeederContextExtensions.cs new file mode 100644 index 0000000000..8281dafb01 --- /dev/null +++ b/util/Seeder/Pipeline/SeederContextExtensions.cs @@ -0,0 +1,22 @@ +using Bit.Core.Entities; +using Bit.Seeder.Services; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.DependencyInjection; + +namespace Bit.Seeder.Pipeline; + +/// +/// Convenience extension methods for resolving common services from . +/// Minimizes churn in step implementations when transitioning from direct property access to DI. +/// +internal static class SeederContextExtensions +{ + internal static IPasswordHasher GetPasswordHasher(this SeederContext context) => + context.Services.GetRequiredService>(); + + internal static IManglerService GetMangler(this SeederContext context) => + context.Services.GetRequiredService(); + + internal static ISeedReader GetSeedReader(this SeederContext context) => + context.Services.GetRequiredService(); +} diff --git a/util/Seeder/README.md b/util/Seeder/README.md index b3eeeb42aa..722c970d1c 100644 --- a/util/Seeder/README.md +++ b/util/Seeder/README.md @@ -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. diff --git a/util/Seeder/Recipes/CollectionsRecipe.cs b/util/Seeder/Recipes/CollectionsRecipe.cs index d587dafbb4..73fe0c8822 100644 --- a/util/Seeder/Recipes/CollectionsRecipe.cs +++ b/util/Seeder/Recipes/CollectionsRecipe.cs @@ -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); } } diff --git a/util/Seeder/Recipes/OrganizationFromPresetRecipe.cs b/util/Seeder/Recipes/OrganizationFromPresetRecipe.cs new file mode 100644 index 0000000000..1a00c7307c --- /dev/null +++ b/util/Seeder/Recipes/OrganizationFromPresetRecipe.cs @@ -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; + +/// +/// Seeds an organization from an embedded preset. +/// +/// +/// 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(). +/// +public class OrganizationFromPresetRecipe( + DatabaseContext db, + IMapper mapper, + IPasswordHasher passwordHasher, + IManglerService manglerService) +{ + private readonly PresetExecutor _executor = new(db, mapper); + + /// + /// Seeds an organization from an embedded preset. + /// + /// Name of the embedded preset (e.g., "dunder-mifflin-full") + /// The organization ID and summary statistics. + 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); + } + + /// + /// Lists all available embedded presets and fixtures. + /// + /// Available presets grouped by category. + public static AvailableSeeds ListAvailable() + { + var internalResult = PresetExecutor.ListAvailable(); + + return new AvailableSeeds(internalResult.Presets, internalResult.Fixtures); + } +} + +/// +/// Result of seeding operation with summary statistics. +/// +public record SeedResult( + Guid OrganizationId, + string? OwnerEmail, + int UsersCount, + int GroupsCount, + int CollectionsCount, + int CiphersCount); + +/// +/// Available presets and fixtures grouped by category. +/// +public record AvailableSeeds( + IReadOnlyList Presets, + IReadOnlyDictionary> Fixtures); diff --git a/util/Seeder/Recipes/OrganizationWithVaultRecipe.cs b/util/Seeder/Recipes/OrganizationWithVaultRecipe.cs index f73a3c7228..1003c5b0c2 100644 --- a/util/Seeder/Recipes/OrganizationWithVaultRecipe.cs +++ b/util/Seeder/Recipes/OrganizationWithVaultRecipe.cs @@ -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(); diff --git a/util/Seeder/Seeder.csproj b/util/Seeder/Seeder.csproj index 1551eaba6c..1988c7d393 100644 --- a/util/Seeder/Seeder.csproj +++ b/util/Seeder/Seeder.csproj @@ -27,6 +27,11 @@ + + + + + diff --git a/util/Seeder/Seeds/README.md b/util/Seeder/Seeds/README.md new file mode 100644 index 0000000000..23a2c7a8f0 --- /dev/null +++ b/util/Seeder/Seeds/README.md @@ -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("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) diff --git a/util/Seeder/Seeds/fixtures/ciphers/autofill-testing.json b/util/Seeder/Seeds/fixtures/ciphers/autofill-testing.json new file mode 100644 index 0000000000..06f6dc0c5b --- /dev/null +++ b/util/Seeder/Seeds/fixtures/ciphers/autofill-testing.json @@ -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" } + ] + } + } + ] +} diff --git a/util/Seeder/Seeds/fixtures/ciphers/public-site-logins.json b/util/Seeder/Seeds/fixtures/ciphers/public-site-logins.json new file mode 100644 index 0000000000..96eda999b6 --- /dev/null +++ b/util/Seeder/Seeds/fixtures/ciphers/public-site-logins.json @@ -0,0 +1,1035 @@ +{ + "$schema": "../../schemas/cipher.schema.json", + "items": [ + { + "type": "login", + "name": "LinkedIn Homepage", + "login": { + "username": "testuser@example.com", + "password": "fakeLinkedInHomepageLoginPassword", + "uris": [ + { "uri": "https://www.linkedin.com/", "match": "exact" }, + { "uri": "https://www.linkedin.com/?original_referer=", "match": "exact" } + ] + } + }, + { + "type": "login", + "name": "LinkedIn Login", + "login": { + "username": "testuser@example.com", + "password": "fakeLinkedInPassword", + "uris": [ + { "uri": "https://www.linkedin.com/login", "match": "exact" } + ] + } + }, + { + "type": "login", + "name": "Samsung", + "login": { + "username": "testuser@example.com", + "uris": [ + { "uri": "https://account.samsung.com/membership/auth/sign-in" } + ] + } + }, + { + "type": "login", + "name": "Zillow", + "login": { + "username": "testuser@example.com", + "password": "fakeZillowPassword", + "uris": [ + { "uri": "https://www.zillow.com" } + ] + } + }, + { + "type": "login", + "name": "Character.AI", + "login": { + "username": "testuser@example.com", + "password": "fakeCharacterAIPassword", + "uris": [ + { "uri": "https://beta.character.ai/", "match": "host" }, + { "uri": "https://character-ai.us.auth0.com", "match": "host" } + ] + } + }, + { + "type": "login", + "name": "Indeed", + "login": { + "username": "testuser@example.com", + "password": "fakeIndeedPassword", + "uris": [ + { "uri": "https://secure.indeed.com/auth" } + ] + } + }, + { + "type": "login", + "name": "Home Depot", + "login": { + "username": "testuser@example.com", + "password": "fakeHomeDepotPassword", + "uris": [ + { "uri": "https://www.homedepot.com/auth/view/signin" } + ] + } + }, + { + "type": "login", + "name": "Daily Mail", + "login": { + "username": "testuser@example.com", + "password": "fakeDailyMailPassword", + "uris": [ + { "uri": "https://www.dailymail.co.uk/registration/login.html" } + ] + } + }, + { + "type": "login", + "name": "Google", + "login": { + "username": "testuser@example.com", + "password": "fakeGooglePassword", + "uris": [ + { "uri": "https://accounts.google.com" } + ] + } + }, + { + "type": "login", + "name": "Facebook", + "login": { + "username": "testuser@example.com", + "password": "fakeFacebookPassword", + "uris": [ + { "uri": "https://www.facebook.com" } + ] + } + }, + { + "type": "login", + "name": "Reddit Login Page", + "login": { + "username": "bwplaywright", + "password": "fakeRedditLoginPagePassword", + "uris": [ + { "uri": "https://www.reddit.com/login" } + ] + } + }, + { + "type": "login", + "name": "Amazon", + "login": { + "username": "testuser@example.com", + "password": "fakeAmazonPassword", + "uris": [ + { "uri": "https://www.amazon.com/gp/sign-in.html" } + ] + } + }, + { + "type": "login", + "name": "Twitter", + "login": { + "username": "testuser@example.com", + "password": "fakeTwitterPassword", + "uris": [ + { "uri": "https://twitter.com/login?lang=en" } + ] + } + }, + { + "type": "login", + "name": "Yahoo", + "login": { + "username": "bwplaywright", + "password": "fakeYahooPassword", + "uris": [ + { "uri": "https://login.yahoo.com" } + ] + } + }, + { + "type": "login", + "name": "Wikipedia", + "login": { + "username": "bwplaywright", + "password": "fakeWikipediaPassword", + "uris": [ + { "uri": "https://en.wikipedia.org/w/index.php?title=Special:UserLogin&returnto=Main+Page" } + ] + } + }, + { + "type": "login", + "name": "Instagram", + "login": { + "username": "bwplaywright", + "password": "fakeInstagramPassword", + "uris": [ + { "uri": "https://www.instagram.com/accounts/login" } + ] + } + }, + { + "type": "login", + "name": "Fandom", + "login": { + "username": "Bwplaywright", + "password": "fakeFandomPassword", + "uris": [ + { "uri": "https://auth.fandom.com/signin" } + ] + } + }, + { + "type": "login", + "name": "Weather.com", + "login": { + "username": "testuser@example.com", + "password": "fakeWeatherPassword", + "uris": [ + { "uri": "https://weather.com/login" } + ] + } + }, + { + "type": "login", + "name": "Microsoft Live", + "login": { + "username": "testuser@example.com", + "password": "fakeLivePassword", + "uris": [ + { "uri": "https://login.live.com" } + ] + } + }, + { + "type": "login", + "name": "Microsoft Online", + "login": { + "username": "testuser@example.com", + "password": "fakeMicrosoftOnlinePassword", + "uris": [ + { "uri": "https://login.microsoftonline.com" } + ] + } + }, + { + "type": "login", + "name": "TikTok", + "login": { + "username": "testuser@example.com", + "password": "fakeTikTokPassword", + "uris": [ + { "uri": "https://www.tiktok.com" } + ] + } + }, + { + "type": "login", + "name": "TikTok Login Page", + "login": { + "username": "testuser@example.com", + "password": "fakeTikTokPassword", + "uris": [ + { "uri": "https://www.tiktok.com/login/phone-or-email/email" } + ] + } + }, + { + "type": "login", + "name": "Taboola", + "login": { + "username": "testuser@example.com", + "password": "fakeTaboolaPassword", + "uris": [ + { "uri": "https://authentication.taboola.com" } + ] + } + }, + { + "type": "login", + "name": "CNN", + "login": { + "username": "testuser@example.com", + "password": "fakeCNNPassword", + "uris": [ + { "uri": "https://www.cnn.com/account/log-in" } + ] + } + }, + { + "type": "login", + "name": "eBay", + "login": { + "username": "testuser@example.com", + "password": "fakeEbayPassword", + "uris": [ + { "uri": "https://signin.ebay.com/signin" } + ] + } + }, + { + "type": "login", + "name": "Twitch", + "login": { + "username": "testuser@example.com", + "password": "fakeTwitchPassword", + "uris": [ + { "uri": "https://www.twitch.tv" } + ] + } + }, + { + "type": "login", + "name": "Walmart", + "login": { + "username": "testuser@example.com", + "password": "fakeWalmartPassword", + "uris": [ + { "uri": "https://www.walmart.com/account/login" } + ] + } + }, + { + "type": "login", + "name": "Quora", + "login": { + "username": "testuser@example.com", + "password": "fakeQuoraPassword", + "uris": [ + { "uri": "https://www.quora.com" } + ] + } + }, + { + "type": "login", + "name": "NY Times", + "login": { + "username": "testuser@example.com", + "password": "fakeNYTimesPassword", + "uris": [ + { "uri": "https://myaccount.nytimes.com/auth/login" } + ] + } + }, + { + "type": "login", + "name": "Fox News", + "login": { + "username": "testuser@example.com", + "password": "fakeFoxNewsPassword", + "uris": [ + { "uri": "https://my.foxnews.com" } + ] + } + }, + { + "type": "login", + "name": "USPS", + "login": { + "username": "testuser@example.com", + "password": "fakeUSPSPassword", + "uris": [ + { "uri": "https://reg.usps.com/entreg/LoginAction_input" } + ] + } + }, + { + "type": "login", + "name": "IMDB", + "login": { + "username": "testuser@example.com", + "password": "fakeIMDBPassword", + "uris": [ + { "uri": "https://www.imdb.com/registration/signin" } + ] + } + }, + { + "type": "login", + "name": "PayPal", + "login": { + "username": "testuser@example.com", + "password": "fakePaypalPassword", + "uris": [ + { "uri": "https://www.sandbox.paypal.com/signin" } + ], + "totp": "ABCD EFGH IJKL MNOPpaypal-signin" + } + }, + { + "type": "login", + "name": "Zoom", + "login": { + "username": "testuser@example.com", + "password": "fakeZoomPassword", + "uris": [ + { "uri": "https://zoom.us/signin" } + ] + } + }, + { + "type": "login", + "name": "Discord", + "login": { + "username": "testuser@example.com", + "password": "fakeDiscordPassword", + "uris": [ + { "uri": "https://discord.com/login" } + ] + } + }, + { + "type": "login", + "name": "Netflix", + "login": { + "username": "testuser@example.com", + "password": "fakeNetflixPassword", + "uris": [ + { "uri": "https://www.netflix.com/login" } + ] + } + }, + { + "type": "login", + "name": "Etsy", + "login": { + "username": "testuser@example.com", + "password": "fakeEtsyPassword", + "uris": [ + { "uri": "https://www.etsy.com" } + ] + } + }, + { + "type": "login", + "name": "Pinterest", + "login": { + "username": "testuser@example.com", + "password": "fakePinterestPassword", + "uris": [ + { "uri": "https://www.pinterest.com/", "match": "exact" }, + { "uri": "https://www.pinterest.com/login/", "match": "exact" } + ] + } + }, + { + "type": "login", + "name": "GitHub", + "login": { + "username": "testuser@example.com", + "password": "fakeGithubPassword", + "uris": [ + { "uri": "https://github.com/login" } + ] + } + }, + { + "type": "login", + "name": "NY Post", + "login": { + "username": "testuser@example.com", + "password": "fakeNYPostPassword", + "uris": [ + { "uri": "https://nypost.com/account/login" } + ] + } + }, + { + "type": "login", + "name": "AccuWeather", + "login": { + "username": "testuser@example.com", + "password": "fakeAccuweatherPassword", + "uris": [ + { "uri": "https://wwwl.accuweather.com/premium_login.php" } + ] + } + }, + { + "type": "login", + "name": "OpenAI", + "login": { + "username": "testuser@example.com", + "password": "fakeOpenAIPassword", + "uris": [ + { "uri": "https://platform.openai.com/login?launch" } + ] + } + }, + { + "type": "login", + "name": "UPS", + "login": { + "username": "testuser@example.com", + "password": "fakeUPSPassword", + "uris": [ + { "uri": "https://www.ups.com/lasso/login" } + ] + } + }, + { + "type": "login", + "name": "Patreon", + "login": { + "username": "testuser@example.com", + "password": "fakePatreonPassword", + "uris": [ + { "uri": "https://www.patreon.com/login" } + ] + } + }, + { + "type": "login", + "name": "Imgur", + "login": { + "username": "testuser@example.com", + "password": "fakeImgurPassword", + "uris": [ + { "uri": "https://imgur.com/signin" } + ] + } + }, + { + "type": "login", + "name": "IGN", + "login": { + "username": "testuser@example.com", + "password": "fakeIGNPassword", + "uris": [ + { "uri": "https://ign.com" } + ] + } + }, + { + "type": "login", + "name": "AWS", + "login": { + "username": "testuser@example.com", + "uris": [ + { "uri": "https://aws.amazon.com" }, + { "uri": "https://signin.aws.amazon.com" } + ] + } + }, + { + "type": "login", + "name": "Roblox", + "login": { + "username": "testuser@example.com", + "password": "fakeRobloxPassword", + "uris": [ + { "uri": "https://www.roblox.com/login" } + ] + } + }, + { + "type": "login", + "name": "Spotify", + "login": { + "username": "testuser@example.com", + "password": "fakeSpotifyPassword", + "uris": [ + { "uri": "https://accounts.spotify.com/en/login" } + ] + } + }, + { + "type": "login", + "name": "Instructure Canvas", + "login": { + "username": "testuser@example.com", + "password": "fakeInstructurePassword", + "uris": [ + { "uri": "https://www.instructure.com/canvas/login/free-for-teacher" } + ] + } + }, + { + "type": "login", + "name": "Badgr", + "login": { + "username": "testuser@example.com", + "password": "fakeBadgrPassword", + "uris": [ + { "uri": "https://badgr.com/auth/login" } + ] + } + }, + { + "type": "login", + "name": "Portfolium", + "login": { + "username": "testuser@example.com", + "password": "fakePortfoliumPassword", + "uris": [ + { "uri": "https://portfolium.com/login" } + ] + } + }, + { + "type": "login", + "name": "Mastery Connect", + "login": { + "username": "testuser@example.com", + "password": "fakeMasteryPassword", + "uris": [ + { "uri": "https://app.masteryconnect.com/login" } + ] + } + }, + { + "type": "login", + "name": "Elevate DS (Kimono Cloud)", + "login": { + "username": "testuser@example.com", + "password": "fakeElevateDSPassword", + "uris": [ + { "uri": "https://identity.us2.kimonocloud.com/login" } + ] + } + }, + { + "type": "login", + "name": "LearnPlatform", + "login": { + "username": "testuser@example.com", + "password": "fakeLearnPlatformPassword", + "uris": [ + { "uri": "https://app.learnplatform.com/users/sign_in" } + ] + } + }, + { + "type": "login", + "name": "Target", + "login": { + "username": "testuser@example.com", + "password": "fakeTargetPassword", + "uris": [ + { "uri": "https://www.target.com/account" } + ] + } + }, + { + "type": "login", + "name": "Craigslist", + "login": { + "username": "testuser@example.com", + "password": "fakeCraigsListPassword", + "uris": [ + { "uri": "https://accounts.craigslist.org/login" } + ] + } + }, + { + "type": "login", + "name": "Capital One Widget", + "login": { + "username": "testuser@example.com", + "password": "fakeCapitalOneWidgetPassword", + "uris": [ + { "uri": "https://www.capitalone.com", "match": "host" } + ] + } + }, + { + "type": "login", + "name": "Capital One", + "login": { + "username": "testuser@example.com", + "password": "fakeCapitalOnePassword", + "uris": [ + { "uri": "https://verified.capitalone.com/auth/signin", "match": "host" } + ] + } + }, + { + "type": "login", + "name": "FedEx", + "login": { + "username": "testuser@example.com", + "password": "fakeFedExPassword", + "uris": [ + { "uri": "https://www.fedex.com/secure-login" } + ] + } + }, + { + "type": "login", + "name": "Tumblr", + "login": { + "username": "testuser@example.com", + "uris": [ + { "uri": "https://www.tumblr.com" } + ] + } + }, + { + "type": "login", + "name": "Marca", + "login": { + "username": "testuser@example.com", + "uris": [ + { "uri": "https://seguro.marca.com/registro/v3/?view=login" } + ] + } + }, + { + "type": "login", + "name": "Best Buy", + "login": { + "username": "testuser@example.com", + "password": "fakeBestBuyPassword", + "uris": [ + { "uri": "https://www.bestbuy.com/identity/global/signin" } + ] + } + }, + { + "type": "login", + "name": "Adobe", + "login": { + "username": "testuser@example.com", + "uris": [ + { "uri": "https://www.adobe.com" } + ] + } + }, + { + "type": "login", + "name": "Hulu", + "login": { + "username": "testuser@example.com", + "password": "fakeHuluPassword", + "uris": [ + { "uri": "https://auth.hulu.com/web/login" } + ] + } + }, + { + "type": "login", + "name": "BBC", + "login": { + "username": "testuser@example.com", + "password": "fakeBBCPassword", + "uris": [ + { "uri": "https://account.bbc.com/signin" } + ] + } + }, + { + "type": "login", + "name": "Steam Community", + "login": { + "username": "testuser@example.com", + "password": "fakeSteamCommunityPassword", + "uris": [ + { "uri": "https://steamcommunity.com/login/home", "match": "exact" } + ] + } + }, + { + "type": "login", + "name": "Steam Store", + "login": { + "username": "testuser@example.com", + "password": "fakeSteamStorePassword", + "uris": [ + { "uri": "https://store.steampowered.com/login", "match": "exact" } + ] + } + }, + { + "type": "login", + "name": "Lowes", + "login": { + "username": "testuser@example.com", + "password": "fakeLowesPassword", + "uris": [ + { "uri": "https://www.lowes.com/u/login" } + ] + } + }, + { + "type": "login", + "name": "Xfinity", + "login": { + "username": "testuser@example.com", + "uris": [ + { "uri": "https://login.xfinity.com/login" } + ] + } + }, + { + "type": "login", + "name": "Bezzy PSA", + "login": { + "username": "testuser@example.com", + "password": "fakeBezzyPassword", + "uris": [ + { "uri": "https://www.bezzypsa.com/signin/SIGNIN" } + ] + } + }, + { + "type": "login", + "name": "Yelp", + "login": { + "username": "testuser@example.com", + "password": "fakeYelpPassword", + "uris": [ + { "uri": "https://www.yelp.com/login" } + ] + } + }, + { + "type": "login", + "name": "WordPress", + "login": { + "username": "testuser@example.com", + "uris": [ + { "uri": "https://wordpress.com/log-in" } + ] + } + }, + { + "type": "login", + "name": "Nextdoor", + "login": { + "username": "testuser@example.com", + "password": "fakeNextdoorPassword", + "uris": [ + { "uri": "https://nextdoor.com/login" } + ] + } + }, + { + "type": "login", + "name": "Linktree", + "login": { + "username": "bwplaywright", + "password": "fakeLinktreePassword", + "uris": [ + { "uri": "https://linktr.ee/login" } + ] + } + }, + { + "type": "login", + "name": "Quizlet", + "login": { + "username": "testuser@example.com", + "password": "fakeQuizletPassword", + "uris": [ + { "uri": "https://quizlet.com" } + ] + } + }, + { + "type": "login", + "name": "Realtor.com", + "login": { + "username": "testuser@example.com", + "password": "fakeRealtorPassword", + "uris": [ + { "uri": "https://realtor.com" } + ] + } + }, + { + "type": "login", + "name": "Canva", + "login": { + "username": "testuser@example.com", + "uris": [ + { "uri": "https://www.canva.com" } + ] + } + }, + { + "type": "login", + "name": "AT&T", + "login": { + "username": "testuser@example.com", + "password": "fakeATTPassword", + "uris": [ + { "uri": "https://www.att.com/acctmgmt/login" } + ] + } + }, + { + "type": "login", + "name": "Auth0", + "login": { + "username": "testuser@example.com", + "password": "fakeAuth0Password", + "uris": [ + { "uri": "https://auth0.com/api/auth/login", "match": "host" }, + { "uri": "https://auth0.auth0.com", "match": "host" } + ] + } + }, + { + "type": "login", + "name": "Washington Post", + "login": { + "username": "testuser@example.com", + "uris": [ + { "uri": "https://www.washingtonpost.com/subscribe/signin" } + ] + } + }, + { + "type": "login", + "name": "AOL", + "login": { + "username": "testuser@example.com", + "uris": [ + { "uri": "https://login.aol.com" } + ] + } + }, + { + "type": "login", + "name": "T-Mobile", + "login": { + "username": "testuser@example.com", + "uris": [ + { "uri": "https://www.t-mobile.com" } + ] + } + }, + { + "type": "login", + "name": "Okta", + "login": { + "username": "testuser@example.com", + "password": "fakeOktaSupportPassword", + "uris": [ + { "uri": "https://login.okta.com/signin" } + ] + } + }, + { + "type": "login", + "name": "Bethesda", + "login": { + "username": "testuser@example.com", + "password": "fakeBethesdaPassword", + "uris": [ + { "uri": "https://bethesda.net" } + ] + } + }, + { + "type": "login", + "name": "Reddit Inline", + "login": { + "username": "bwplaywright", + "password": "fakeRedditInlineLoginPassword", + "uris": [ + { "uri": "https://www.reddit.com" } + ] + } + }, + { + "type": "login", + "name": "Max", + "login": { + "username": "maxcom_user", + "password": "maxcom_password", + "uris": [ + { "uri": "https://auth.max.com/login" } + ] + } + }, + { + "type": "login", + "name": "Clear (Brazil)", + "login": { + "username": "12345678901111", + "password": "098765", + "uris": [ + { "uri": "https://login.clear.com.br" } + ] + } + }, + { + "type": "login", + "name": "GameSpot", + "login": { + "username": "testuser@example.com", + "password": "fakeGamespotPassword", + "uris": [ + { "uri": "https://www.gamespot.com/login-signup" } + ] + } + }, + { + "type": "login", + "name": "Temu", + "login": { + "username": "testuser@example.com", + "password": "fakeTemuPassword", + "uris": [ + { "uri": "https://temu.com" } + ] + } + }, + { + "type": "login", + "name": "Temu Login Page", + "login": { + "username": "testuser@example.com", + "password": "fakeTemuLoginPagePassword", + "uris": [ + { "uri": "https://www.temu.com/login.html" } + ] + } + }, + { + "type": "login", + "name": "Apple", + "login": { + "username": "testuser@example.com", + "password": "fakeApplePassword", + "uris": [ + { "uri": "https://www.apple.com" } + ] + } + }, + { + "type": "login", + "name": "Marvel", + "login": { + "username": "testuser@example.com", + "password": "fakeMarvelPassword", + "uris": [ + { "uri": "https://www.marvel.com/signin" } + ] + } + }, + { + "type": "login", + "name": "ESPN", + "login": { + "username": "testuser@example.com", + "password": "fakeESPNPassword", + "uris": [ + { "uri": "https://www.espn.com" }, + { "uri": "https://cdn.registerdisney.go.com/v4/bundle/web/ESPN-ONESITE.WEB" } + ] + } + } + ] +} diff --git a/util/Seeder/Seeds/fixtures/organizations/dunder-mifflin.json b/util/Seeder/Seeds/fixtures/organizations/dunder-mifflin.json new file mode 100644 index 0000000000..a442a5e1bd --- /dev/null +++ b/util/Seeder/Seeds/fixtures/organizations/dunder-mifflin.json @@ -0,0 +1,6 @@ +{ + "$schema": "../../schemas/organization.schema.json", + "name": "Dunder Mifflin", + "domain": "dundermifflin.com", + "seats": 70 +} diff --git a/util/Seeder/Seeds/fixtures/presets/dunder-mifflin-full.json b/util/Seeder/Seeds/fixtures/presets/dunder-mifflin-full.json new file mode 100644 index 0000000000..7a6b7b4e62 --- /dev/null +++ b/util/Seeder/Seeds/fixtures/presets/dunder-mifflin-full.json @@ -0,0 +1,12 @@ +{ + "$schema": "../../schemas/preset.schema.json", + "organization": { + "fixture": "dunder-mifflin" + }, + "roster": { + "fixture": "dunder-mifflin" + }, + "ciphers": { + "fixture": "autofill-testing" + } +} diff --git a/util/Seeder/Seeds/fixtures/presets/large-enterprise.json b/util/Seeder/Seeds/fixtures/presets/large-enterprise.json new file mode 100644 index 0000000000..1936713356 --- /dev/null +++ b/util/Seeder/Seeds/fixtures/presets/large-enterprise.json @@ -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 + } +} diff --git a/util/Seeder/Seeds/fixtures/rosters/dunder-mifflin.json b/util/Seeder/Seeds/fixtures/rosters/dunder-mifflin.json new file mode 100644 index 0000000000..938199a7b8 --- /dev/null +++ b/util/Seeder/Seeds/fixtures/rosters/dunder-mifflin.json @@ -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" } + ] + } + ] +} diff --git a/util/Seeder/Seeds/schemas/cipher.schema.json b/util/Seeder/Seeds/schemas/cipher.schema.json new file mode 100644 index 0000000000..a094f00acd --- /dev/null +++ b/util/Seeder/Seeds/schemas/cipher.schema.json @@ -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" + } + } + } + } +} diff --git a/util/Seeder/Seeds/schemas/organization.schema.json b/util/Seeder/Seeds/schemas/organization.schema.json new file mode 100644 index 0000000000..ac4abcb70b --- /dev/null +++ b/util/Seeder/Seeds/schemas/organization.schema.json @@ -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." + } + } +} diff --git a/util/Seeder/Seeds/schemas/preset.schema.json b/util/Seeder/Seeds/schemas/preset.schema.json new file mode 100644 index 0000000000..e17e827342 --- /dev/null +++ b/util/Seeder/Seeds/schemas/preset.schema.json @@ -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." + } + } + } + } +} diff --git a/util/Seeder/Seeds/schemas/roster.schema.json b/util/Seeder/Seeds/schemas/roster.schema.json new file mode 100644 index 0000000000..2ae31cb93d --- /dev/null +++ b/util/Seeder/Seeds/schemas/roster.schema.json @@ -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 + } + } + } + } +} diff --git a/util/Seeder/Seeds/templates/cipher.template.json b/util/Seeder/Seeds/templates/cipher.template.json new file mode 100644 index 0000000000..ebdc9a68a4 --- /dev/null +++ b/util/Seeder/Seeds/templates/cipher.template.json @@ -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." + } + ] +} diff --git a/util/Seeder/Seeds/templates/organization.template.json b/util/Seeder/Seeds/templates/organization.template.json new file mode 100644 index 0000000000..85f682e23c --- /dev/null +++ b/util/Seeder/Seeds/templates/organization.template.json @@ -0,0 +1,6 @@ +{ + "$schema": "../schemas/organization.schema.json", + "name": "Acme Corporation", + "domain": "acme.example.com", + "seats": 10 +} diff --git a/util/Seeder/Seeds/templates/preset.template.json b/util/Seeder/Seeds/templates/preset.template.json new file mode 100644 index 0000000000..2d0f95e301 --- /dev/null +++ b/util/Seeder/Seeds/templates/preset.template.json @@ -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" + } +} diff --git a/util/Seeder/Seeds/templates/roster.template.json b/util/Seeder/Seeds/templates/roster.template.json new file mode 100644 index 0000000000..105d2591f9 --- /dev/null +++ b/util/Seeder/Seeds/templates/roster.template.json @@ -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 + } + ] + } + ] +} diff --git a/util/Seeder/Services/ISeedReader.cs b/util/Seeder/Services/ISeedReader.cs new file mode 100644 index 0000000000..e944b5711a --- /dev/null +++ b/util/Seeder/Services/ISeedReader.cs @@ -0,0 +1,19 @@ +namespace Bit.Seeder.Services; + +/// +/// Reads seed data files from embedded JSON resources. +/// Seeds are pantry ingredients for Recipes, Steps, and Scenes. +/// +public interface ISeedReader +{ + /// + /// Reads and deserializes a seed file by name (without extension). + /// Names use dot-separated paths: "ciphers.autofill-testing", "organizations.dunder-mifflin" + /// + T Read(string seedName); + + /// + /// Lists available seed file names (without extension). + /// + IReadOnlyList ListAvailable(); +} diff --git a/util/Seeder/Services/SeedReader.cs b/util/Seeder/Services/SeedReader.cs new file mode 100644 index 0000000000..4e90604a3a --- /dev/null +++ b/util/Seeder/Services/SeedReader.cs @@ -0,0 +1,62 @@ +using System.Reflection; +using System.Text.Json; + +namespace Bit.Seeder.Services; + +/// +/// Reads seed data from embedded JSON resources shipped with the Seeder library. +/// +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(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(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 ListAvailable() + { + return _assembly.GetManifestResourceNames() + .Where(n => n.StartsWith(_resourcePrefix) && n.EndsWith(_resourceSuffix)) + .Select(n => n[_resourcePrefix.Length..^_resourceSuffix.Length]) + .OrderBy(n => n) + .ToList(); + } +} diff --git a/util/Seeder/Steps/CreateCiphersStep.cs b/util/Seeder/Steps/CreateCiphersStep.cs new file mode 100644 index 0000000000..5f474083b7 --- /dev/null +++ b/util/Seeder/Steps/CreateCiphersStep.cs @@ -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; + +/// +/// Loads cipher items from a fixture and creates encrypted cipher entities. +/// +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($"ciphers.{fixtureName}"); + var collectionIds = context.Registry.CollectionIds; + + var ciphers = new List(); + var collectionCiphers = new List(); + + 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); + } +} diff --git a/util/Seeder/Steps/CreateCollectionsStep.cs b/util/Seeder/Steps/CreateCollectionsStep.cs new file mode 100644 index 0000000000..a6dd859c6e --- /dev/null +++ b/util/Seeder/Steps/CreateCollectionsStep.cs @@ -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 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(); + + // 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); + } +} diff --git a/util/Seeder/Steps/CreateGroupsStep.cs b/util/Seeder/Steps/CreateGroupsStep.cs new file mode 100644 index 0000000000..7daa621017 --- /dev/null +++ b/util/Seeder/Steps/CreateGroupsStep.cs @@ -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(count); + var groupIds = new List(count); + var groupUsers = new List(); + + 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); + } +} diff --git a/util/Seeder/Steps/CreateOrganizationStep.cs b/util/Seeder/Steps/CreateOrganizationStep.cs new file mode 100644 index 0000000000..6db94990b4 --- /dev/null +++ b/util/Seeder/Steps/CreateOrganizationStep.cs @@ -0,0 +1,66 @@ +using Bit.RustSDK; +using Bit.Seeder.Factories; +using Bit.Seeder.Models; +using Bit.Seeder.Pipeline; + +namespace Bit.Seeder.Steps; + +/// +/// Creates an organization from a fixture or explicit parameters. +/// +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($"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); + } +} diff --git a/util/Seeder/Steps/CreateOwnerStep.cs b/util/Seeder/Steps/CreateOwnerStep.cs new file mode 100644 index 0000000000..329f4ffc65 --- /dev/null +++ b/util/Seeder/Steps/CreateOwnerStep.cs @@ -0,0 +1,29 @@ +using Bit.Core.Enums; +using Bit.RustSDK; +using Bit.Seeder.Factories; +using Bit.Seeder.Pipeline; + +namespace Bit.Seeder.Steps; + +/// +/// Creates the owner user and links them to the current organization. +/// +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); + } +} diff --git a/util/Seeder/Steps/CreateRosterStep.cs b/util/Seeder/Steps/CreateRosterStep.cs new file mode 100644 index 0000000000..a231769593 --- /dev/null +++ b/util/Seeder/Steps/CreateRosterStep.cs @@ -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; + +/// +/// Loads a roster fixture and creates users, groups, and collections with permissions. +/// +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($"rosters.{fixtureName}"); + + // Phase 1: Create users — build emailPrefix → orgUserId lookup + var userLookup = new Dictionary(StringComparer.OrdinalIgnoreCase); + var emailPrefixes = new HashSet(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(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 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.") + }; +} diff --git a/util/Seeder/Steps/CreateUsersStep.cs b/util/Seeder/Steps/CreateUsersStep.cs new file mode 100644 index 0000000000..194840fbb3 --- /dev/null +++ b/util/Seeder/Steps/CreateUsersStep.cs @@ -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; + +/// +/// Creates member users and links them to the current organization. +/// When realisticStatusMix is enabled (and count >= 10), users receive a +/// realistic distribution of Confirmed/Invited/Accepted/Revoked statuses. +/// +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(count); + var organizationUsers = new List(count); + var hardenedOrgUserIds = new List(); + var userDigests = new List(); + + 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; +} diff --git a/util/Seeder/Steps/GenerateCiphersStep.cs b/util/Seeder/Steps/GenerateCiphersStep.cs new file mode 100644 index 0000000000..dc794500b8 --- /dev/null +++ b/util/Seeder/Steps/GenerateCiphersStep.cs @@ -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; + +/// +/// Creates N random cipher entities using the deterministic . +/// +/// +/// Requires 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. +/// +/// +/// +internal sealed class GenerateCiphersStep( + int count, + Distribution? typeDist = null, + Distribution? 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(count); + var cipherIds = new List(count); + var collectionCiphers = new List(); + + 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 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); + } +} diff --git a/util/Seeder/Steps/InitGeneratorStep.cs b/util/Seeder/Steps/InitGeneratorStep.cs new file mode 100644 index 0000000000..647121e24d --- /dev/null +++ b/util/Seeder/Steps/InitGeneratorStep.cs @@ -0,0 +1,41 @@ +using Bit.Seeder.Data; +using Bit.Seeder.Options; +using Bit.Seeder.Pipeline; + +namespace Bit.Seeder.Steps; + +/// +/// Initializes the deterministic random data engine on . +/// +/// +/// Produces no entities itself. Derives a repeatable seed from the domain string (same domain +/// always yields the same generated data). Downstream steps like +/// consume the generator for realistic usernames, cards, identities, and notes. +/// +/// +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); + } +}