diff --git a/util/DbSeederUtility/README.md b/util/DbSeederUtility/README.md index 4ceb322921..82cd4c528c 100644 --- a/util/DbSeederUtility/README.md +++ b/util/DbSeederUtility/README.md @@ -69,5 +69,11 @@ dotnet run -- vault-organization -n ApacOrg -d apac.test -u 17 -c 600 -g 12 --re dotnet run -- vault-organization -n IsolatedOrg -d isolated.test -u 5 -c 25 -g 4 -o Spotify --mangle # With custom password for all accounts -dotnet run -- vault-organization -n CustomPwOrg -d custom-password-02.test -u 10 -c 100 -g 3 --password "MyTestPassword1" +dotnet run -- vault-organization -n CustomPwOrg -d custom-password-05.test -u 10 -c 100 -g 3 --password "MyTestPassword1" --plan-type teams-annually + +# Free plan org (limited to 2 seats, 2 collections) +dotnet run -- vault-organization -n FreeOrg -d free.test -u 1 -c 10 -g 1 --plan-type free + +# Teams plan org +dotnet run -- vault-organization -n TeamsOrg -d teams.test -u 20 -c 200 -g 5 --plan-type teams-annually ``` diff --git a/util/DbSeederUtility/VaultOrganizationArgs.cs b/util/DbSeederUtility/VaultOrganizationArgs.cs index ae7d77bf2a..c54bddea37 100644 --- a/util/DbSeederUtility/VaultOrganizationArgs.cs +++ b/util/DbSeederUtility/VaultOrganizationArgs.cs @@ -1,4 +1,5 @@ using Bit.Seeder.Data.Enums; +using Bit.Seeder.Factories; using Bit.Seeder.Options; using CommandDotNet; @@ -40,6 +41,9 @@ public class VaultOrganizationArgs : IArgumentModel [Option("password", Description = "Password for all seeded accounts (default: asdfasdfasdf)")] public string? Password { get; set; } + [Option("plan-type", Description = "Billing plan type: free, teams-monthly, teams-annually, enterprise-monthly, enterprise-annually, teams-starter, families-annually. Defaults to enterprise-annually.")] + public string PlanType { get; set; } = "enterprise-annually"; + public void Validate() { if (Users < 1) @@ -66,6 +70,8 @@ public class VaultOrganizationArgs : IArgumentModel { ParseGeographicRegion(Region); } + + PlanFeatures.Parse(PlanType); } public OrganizationVaultOptions ToOptions() => new() @@ -78,7 +84,8 @@ public class VaultOrganizationArgs : IArgumentModel RealisticStatusMix = MixStatuses, StructureModel = ParseOrgStructure(Structure), Region = ParseGeographicRegion(Region), - Password = Password + Password = Password, + PlanType = PlanFeatures.Parse(PlanType) }; private static OrgStructureModel? ParseOrgStructure(string? structure) diff --git a/util/Seeder/CLAUDE.md b/util/Seeder/CLAUDE.md index d235313489..9713c4c35b 100644 --- a/util/Seeder/CLAUDE.md +++ b/util/Seeder/CLAUDE.md @@ -45,7 +45,7 @@ Need to create test data? - **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 +**Phase order**: Org → Owner → Generator → Roster → Users → Groups → Collections → Folders → Ciphers → PersonalCiphers See `Pipeline/` folder for implementation. diff --git a/util/Seeder/Factories/CipherComposer.cs b/util/Seeder/Factories/CipherComposer.cs new file mode 100644 index 0000000000..769c13b486 --- /dev/null +++ b/util/Seeder/Factories/CipherComposer.cs @@ -0,0 +1,136 @@ +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; + +namespace Bit.Seeder.Factories; + +/// +/// Composes cipher entities from generated data, handling encryption and ownership assignment. +/// Used by generation steps to create realistic ciphers for organizations or personal vaults. +/// +internal static class CipherComposer +{ + internal static Cipher Compose( + int index, + CipherType cipherType, + string encryptionKey, + Company[] companies, + GeneratorContext generator, + Distribution passwordDistribution, + Guid? organizationId = null, + Guid? userId = null) + { + return cipherType switch + { + CipherType.Login => ComposeLogin(index, encryptionKey, companies, generator, passwordDistribution, organizationId, userId), + CipherType.Card => ComposeCard(index, encryptionKey, generator, organizationId, userId), + CipherType.Identity => ComposeIdentity(index, encryptionKey, generator, organizationId, userId), + CipherType.SecureNote => ComposeSecureNote(index, encryptionKey, generator, organizationId, userId), + CipherType.SSHKey => ComposeSshKey(index, encryptionKey, organizationId, userId), + _ => throw new ArgumentException($"Unsupported cipher type: {cipherType}") + }; + } + + private static Cipher ComposeLogin( + int index, + string encryptionKey, + Company[] companies, + GeneratorContext generator, + Distribution passwordDistribution, + Guid? organizationId = null, + Guid? userId = null) + { + var company = companies[index % companies.Length]; + return LoginCipherSeeder.Create( + encryptionKey, + name: $"{company.Name} ({company.Category})", + organizationId: organizationId, + userId: userId, + 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 ComposeCard( + int index, + string encryptionKey, + GeneratorContext generator, + Guid? organizationId = null, + Guid? userId = null) + { + var card = generator.Card.GenerateByIndex(index); + return CardCipherSeeder.Create( + encryptionKey, + name: $"{card.CardholderName}'s {card.Brand}", + card: card, + organizationId: organizationId, + userId: userId); + } + + private static Cipher ComposeIdentity( + int index, + string encryptionKey, + GeneratorContext generator, + Guid? organizationId = null, + Guid? userId = null) + { + var identity = generator.Identity.GenerateByIndex(index); + var name = $"{identity.FirstName} {identity.LastName}"; + if (!string.IsNullOrEmpty(identity.Company)) + { + name += $" ({identity.Company})"; + } + return IdentityCipherSeeder.Create( + encryptionKey, + name: name, + identity: identity, + organizationId: organizationId, + userId: userId); + } + + private static Cipher ComposeSecureNote( + int index, + string encryptionKey, + GeneratorContext generator, + Guid? organizationId = null, + Guid? userId = null) + { + var (name, notes) = generator.SecureNote.GenerateByIndex(index); + return SecureNoteCipherSeeder.Create( + encryptionKey, + name: name, + organizationId: organizationId, + userId: userId, + notes: notes); + } + + private static Cipher ComposeSshKey( + int index, + string encryptionKey, + Guid? organizationId = null, + Guid? userId = null) + { + var sshKey = SshKeyDataGenerator.GenerateByIndex(index); + return SshKeyCipherSeeder.Create( + encryptionKey, + name: $"SSH Key {index + 1}", + sshKey: sshKey, + organizationId: organizationId, + userId: userId); + } + + /// + /// Assigns a folder to a cipher via round-robin selection from the user's folder list. + /// + internal static void AssignFolder(Cipher cipher, Guid userId, int index, Dictionary> userFolderIds) + { + if (userFolderIds.TryGetValue(userId, out var folderIds) && folderIds.Count > 0) + { + cipher.Folders = $"{{\"{userId.ToString().ToUpperInvariant()}\":\"{folderIds[index % folderIds.Count].ToString().ToUpperInvariant()}\"}}"; + } + } +} diff --git a/util/Seeder/Factories/OrganizationSeeder.cs b/util/Seeder/Factories/OrganizationSeeder.cs index 27a8bb491a..0abd2349aa 100644 --- a/util/Seeder/Factories/OrganizationSeeder.cs +++ b/util/Seeder/Factories/OrganizationSeeder.cs @@ -8,42 +8,23 @@ namespace Bit.Seeder.Factories; internal static class OrganizationSeeder { - internal static Organization Create(string name, string domain, int seats, string? publicKey = null, string? privateKey = null) + internal static Organization Create(string name, string domain, int seats, string? publicKey = null, string? privateKey = null, PlanType planType = PlanType.EnterpriseAnnually) { - return new Organization + var org = new Organization { Id = CoreHelpers.GenerateComb(), + Identifier = domain, Name = name, BillingEmail = $"billing@{domain}", - Plan = "Enterprise (Annually)", - PlanType = PlanType.EnterpriseAnnually, Seats = seats, - UseCustomPermissions = true, - UseOrganizationDomains = true, - UseSecretsManager = true, - UseGroups = true, - UseDirectory = true, - UseEvents = true, - UseTotp = true, - Use2fa = true, - UseApi = true, - UseResetPassword = true, - UsePasswordManager = true, - UseAutomaticUserConfirmation = true, - SelfHost = true, - UsersGetPremium = true, - LimitCollectionCreation = true, - LimitCollectionDeletion = true, - LimitItemDeletion = true, - AllowAdminAccessToAllCollectionItems = true, - UseRiskInsights = true, - UseAdminSponsoredFamilies = true, - SyncSeats = true, Status = OrganizationStatusType.Created, - MaxStorageGb = 10, PublicKey = publicKey, PrivateKey = privateKey }; + + PlanFeatures.Apply(org, planType); + + return org; } } diff --git a/util/Seeder/Factories/PlanFeatures.cs b/util/Seeder/Factories/PlanFeatures.cs new file mode 100644 index 0000000000..a45ccc65e3 --- /dev/null +++ b/util/Seeder/Factories/PlanFeatures.cs @@ -0,0 +1,219 @@ +using System.Security.Cryptography; +using System.Text; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Enums; + +namespace Bit.Seeder.Factories; + +/// +/// Maps PlanType to organization feature flags. +/// Values sourced from MockPlans in test/Core.Test/Billing/Mocks/Plans/. +/// +public static class PlanFeatures +{ + internal static void Apply(Organization org, PlanType planType) + { + // Org-level admin settings — not plan-gated, safe defaults for seeding + org.UseAutomaticUserConfirmation = true; + org.AllowAdminAccessToAllCollectionItems = true; + org.LimitCollectionCreation = true; + org.LimitCollectionDeletion = true; + org.LimitItemDeletion = true; + + switch (planType) + { + case PlanType.Free: + org.Plan = "Free"; + org.PlanType = PlanType.Free; + org.MaxCollections = 2; + org.MaxStorageGb = null; + ApplyMinimalFeatures(org); + break; + + case PlanType.TeamsMonthly: + org.Plan = "Teams (Monthly)"; + org.PlanType = PlanType.TeamsMonthly; + ApplyTeamsFeatures(org); + break; + + case PlanType.TeamsAnnually: + org.Plan = "Teams (Annually)"; + org.PlanType = PlanType.TeamsAnnually; + ApplyTeamsFeatures(org); + break; + + case PlanType.TeamsStarter: + org.Plan = "Teams Starter"; + org.PlanType = PlanType.TeamsStarter; + ApplyTeamsFeatures(org); + break; + + case PlanType.EnterpriseMonthly: + org.Plan = "Enterprise (Monthly)"; + org.PlanType = PlanType.EnterpriseMonthly; + ApplyEnterpriseFeatures(org); + break; + + case PlanType.EnterpriseAnnually: + org.Plan = "Enterprise (Annually)"; + org.PlanType = PlanType.EnterpriseAnnually; + ApplyEnterpriseFeatures(org); + break; + + case PlanType.FamiliesAnnually: + org.Plan = "Families"; + org.PlanType = PlanType.FamiliesAnnually; + org.MaxCollections = null; + org.MaxStorageGb = 1; + ApplyMinimalFeatures(org); + org.UseTotp = true; + org.Use2fa = true; + org.UsersGetPremium = true; + break; + + default: + throw new ArgumentException( + $"Unsupported PlanType '{planType}'. Supported types: Free, TeamsMonthly, TeamsAnnually, " + + "TeamsStarter, EnterpriseMonthly, EnterpriseAnnually, FamiliesAnnually."); + } + } + + public static PlanType Parse(string? planTypeString) + { + if (string.IsNullOrEmpty(planTypeString)) + { + return PlanType.EnterpriseAnnually; + } + + return planTypeString switch + { + "free" => PlanType.Free, + "teams-monthly" => PlanType.TeamsMonthly, + "teams-annually" => PlanType.TeamsAnnually, + "teams-starter" => PlanType.TeamsStarter, + "enterprise-monthly" => PlanType.EnterpriseMonthly, + "enterprise-annually" => PlanType.EnterpriseAnnually, + "families-annually" => PlanType.FamiliesAnnually, + _ => throw new ArgumentException( + $"Invalid planType string '{planTypeString}'. Valid values: free, teams-monthly, " + + "teams-annually, teams-starter, enterprise-monthly, enterprise-annually, families-annually.") + }; + } + + /// + /// Deterministic seat count from a log-normal distribution seeded by domain. + /// Ranges sourced from our production data. + /// + internal static int GenerateRealisticSeatCount(PlanType planType, string domain) + { + var (min, max, avg) = planType switch + { + PlanType.Free => (1, 2, 2), + PlanType.FamiliesAnnually => (6, 6, 6), + PlanType.TeamsMonthly => (1, 300, 15), + PlanType.TeamsAnnually => (1, 100, 7), + PlanType.TeamsStarter => (10, 10, 10), + PlanType.EnterpriseMonthly => (1, 185, 17), + PlanType.EnterpriseAnnually => (1, 12000, 60), + _ => (1, 100, 10) + }; + + if (min == max) + { + return min; + } + + var logAvg = Math.Log(avg); + var logMax = Math.Log(max); + var sigma = (logMax - logAvg) / 2.0; + var mu = logAvg - (sigma * sigma / 2.0); + + var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(domain)); + var random = new Random(BitConverter.ToInt32(hashBytes, 0)); + + var u1 = 1.0 - random.NextDouble(); + var u2 = random.NextDouble(); + var stdNormal = Math.Sqrt(-2.0 * Math.Log(u1)) * Math.Cos(2.0 * Math.PI * u2); + + return Math.Clamp((int)Math.Round(Math.Exp(mu + sigma * stdNormal)), min, max); + } + + /// + /// Baseline: all plan-gated features off. Free and Families start here then enable selectively. + /// + private static void ApplyMinimalFeatures(Organization org) + { + org.UseGroups = false; + org.UseDirectory = false; + org.UseEvents = false; + org.UseTotp = false; + org.Use2fa = false; + org.UseApi = false; + org.UseScim = false; + org.UseSso = false; + org.UsePolicies = false; + org.UseKeyConnector = false; + org.UseResetPassword = false; + org.UseCustomPermissions = false; + org.UseOrganizationDomains = false; + org.UsersGetPremium = false; + org.SelfHost = false; + org.UsePasswordManager = true; + org.UseSecretsManager = false; + org.UseRiskInsights = false; + org.UseAdminSponsoredFamilies = false; + org.SyncSeats = false; + } + + private static void ApplyTeamsFeatures(Organization org) + { + org.MaxCollections = null; + org.MaxStorageGb = 1; + org.UseGroups = true; + org.UseDirectory = true; + org.UseEvents = true; + org.UseTotp = true; + org.Use2fa = true; + org.UseApi = true; + org.UseScim = true; + org.UseSso = false; + org.UsePolicies = false; + org.UseKeyConnector = false; + org.UseResetPassword = false; + org.UseCustomPermissions = false; + org.UseOrganizationDomains = false; + org.UsersGetPremium = true; + org.SelfHost = false; + org.UsePasswordManager = true; + org.UseSecretsManager = true; + org.UseRiskInsights = false; + org.UseAdminSponsoredFamilies = false; + org.SyncSeats = true; + } + + private static void ApplyEnterpriseFeatures(Organization org) + { + org.MaxCollections = null; + org.MaxStorageGb = 1; + org.UseGroups = true; + org.UseDirectory = true; + org.UseEvents = true; + org.UseTotp = true; + org.Use2fa = true; + org.UseApi = true; + org.UseScim = true; + org.UseSso = true; + org.UsePolicies = true; + org.UseKeyConnector = true; + org.UseResetPassword = true; + org.UseCustomPermissions = true; + org.UseOrganizationDomains = true; + org.UsersGetPremium = true; + org.SelfHost = true; + org.UsePasswordManager = true; + org.UseSecretsManager = true; + org.UseRiskInsights = true; + org.UseAdminSponsoredFamilies = true; + org.SyncSeats = true; + } +} diff --git a/util/Seeder/Factories/UserSeeder.cs b/util/Seeder/Factories/UserSeeder.cs index a615b665a0..6550e4c3df 100644 --- a/util/Seeder/Factories/UserSeeder.cs +++ b/util/Seeder/Factories/UserSeeder.cs @@ -15,6 +15,7 @@ internal static class UserSeeder string email, IPasswordHasher passwordHasher, IManglerService manglerService, + string? name = null, bool emailVerified = true, bool premium = false, UserKeys? keys = null, @@ -28,6 +29,7 @@ internal static class UserSeeder var user = new User { Id = CoreHelpers.GenerateComb(), + Name = name ?? mangledEmail.Split('@')[0], Email = mangledEmail, EmailVerified = emailVerified, MasterPassword = null, diff --git a/util/Seeder/Models/SeedModels.cs b/util/Seeder/Models/SeedModels.cs index 6c7a6eaf3b..2104b13203 100644 --- a/util/Seeder/Models/SeedModels.cs +++ b/util/Seeder/Models/SeedModels.cs @@ -72,7 +72,6 @@ internal record SeedOrganization { public required string Name { get; init; } public required string Domain { get; init; } - public int Seats { get; init; } = 10; } internal record SeedRoster diff --git a/util/Seeder/Models/SeedPreset.cs b/util/Seeder/Models/SeedPreset.cs index bc4e5467eb..64afa26f71 100644 --- a/util/Seeder/Models/SeedPreset.cs +++ b/util/Seeder/Models/SeedPreset.cs @@ -7,7 +7,9 @@ internal record SeedPreset public SeedPresetUsers? Users { get; init; } public SeedPresetGroups? Groups { get; init; } public SeedPresetCollections? Collections { get; init; } + public bool? Folders { get; init; } public SeedPresetCiphers? Ciphers { get; init; } + public SeedPresetPersonalCiphers? PersonalCiphers { get; init; } } internal record SeedPresetOrganization @@ -15,7 +17,8 @@ internal record SeedPresetOrganization public string? Fixture { get; init; } public string? Name { get; init; } public string? Domain { get; init; } - public int Seats { get; init; } = 10; + public int? Seats { get; init; } + public string? PlanType { get; init; } } internal record SeedPresetRoster @@ -43,4 +46,10 @@ internal record SeedPresetCiphers { public string? Fixture { get; init; } public int Count { get; init; } + public bool AssignFolders { get; init; } +} + +internal record SeedPresetPersonalCiphers +{ + public int CountPerUser { get; init; } } diff --git a/util/Seeder/Options/OrganizationVaultOptions.cs b/util/Seeder/Options/OrganizationVaultOptions.cs index 9e46f2a7b2..0f6cfb9c11 100644 --- a/util/Seeder/Options/OrganizationVaultOptions.cs +++ b/util/Seeder/Options/OrganizationVaultOptions.cs @@ -1,4 +1,5 @@ -using Bit.Core.Vault.Enums; +using Bit.Core.Billing.Enums; +using Bit.Core.Vault.Enums; using Bit.Seeder.Data.Distributions; using Bit.Seeder.Data.Enums; @@ -37,7 +38,7 @@ public class OrganizationVaultOptions /// /// When true and Users >= 10, creates a realistic mix of user statuses: /// 85% Confirmed, 5% Invited, 5% Accepted, 5% Revoked. - /// When false or Users < 10, all users are Confirmed. + /// When false or Users less than 10, all users are Confirmed. /// public bool RealisticStatusMix { get; init; } = false; @@ -55,7 +56,6 @@ public class OrganizationVaultOptions /// /// Distribution of username categories (corporate email, personal email, social handles, etc.). /// Use for a typical enterprise mix (45% corporate). - /// Defaults to Realistic if not specified. /// public Distribution UsernameDistribution { get; init; } = UsernameDistributions.Realistic; @@ -63,7 +63,6 @@ public class OrganizationVaultOptions /// Distribution of password strengths for cipher logins. /// Use for breach-data distribution /// (25% VeryWeak, 30% Weak, 25% Fair, 15% Strong, 5% VeryStrong). - /// Defaults to Realistic if not specified. /// public Distribution PasswordDistribution { get; init; } = PasswordDistributions.Realistic; @@ -88,4 +87,9 @@ public class OrganizationVaultOptions /// Password for all seeded accounts. Defaults to "asdfasdfasdf" if not specified. /// public string? Password { get; init; } + + /// + /// Billing plan type for the organization. + /// + public PlanType PlanType { get; init; } = PlanType.EnterpriseAnnually; } diff --git a/util/Seeder/Pipeline/BulkCommitter.cs b/util/Seeder/Pipeline/BulkCommitter.cs index fa0a89c9ec..53768fa5ff 100644 --- a/util/Seeder/Pipeline/BulkCommitter.cs +++ b/util/Seeder/Pipeline/BulkCommitter.cs @@ -5,6 +5,7 @@ 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 EfFolder = Bit.Infrastructure.EntityFramework.Vault.Models.Folder; using EfGroup = Bit.Infrastructure.EntityFramework.Models.Group; using EfGroupUser = Bit.Infrastructure.EntityFramework.Models.GroupUser; using EfOrganization = Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization; @@ -17,7 +18,7 @@ namespace Bit.Seeder.Pipeline; /// Flushes accumulated entities from to the database via BulkCopy. /// /// -/// Entities are committed in foreign-key-safe order (Organizations → Users → OrgUsers → …). +/// Entities are committed in foreign-key-safe order (Organizations → Users → OrgUsers → … → Folders → Ciphers). /// 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. @@ -48,6 +49,8 @@ internal sealed class BulkCommitter(DatabaseContext db, IMapper mapper) MapCopyAndClear(context.CollectionGroups, nameof(Core.Entities.CollectionGroup)); + MapCopyAndClear(context.Folders); + CopyAndClear(context.Ciphers); CopyAndClear(context.CollectionCiphers); diff --git a/util/Seeder/Pipeline/EntityRegistry.cs b/util/Seeder/Pipeline/EntityRegistry.cs index 45061b5479..6da8b2482e 100644 --- a/util/Seeder/Pipeline/EntityRegistry.cs +++ b/util/Seeder/Pipeline/EntityRegistry.cs @@ -46,6 +46,11 @@ internal sealed class EntityRegistry /// internal List CipherIds { get; } = []; + /// + /// Folder IDs per user, for cipher-to-folder assignment. + /// + internal Dictionary> UserFolderIds { get; } = []; + /// /// Clears all registry lists. Called by before each pipeline run. /// @@ -56,5 +61,6 @@ internal sealed class EntityRegistry GroupIds.Clear(); CollectionIds.Clear(); CipherIds.Clear(); + UserFolderIds.Clear(); } } diff --git a/util/Seeder/Pipeline/PresetLoader.cs b/util/Seeder/Pipeline/PresetLoader.cs index 900644e870..ac369007a1 100644 --- a/util/Seeder/Pipeline/PresetLoader.cs +++ b/util/Seeder/Pipeline/PresetLoader.cs @@ -1,4 +1,5 @@ -using Bit.Seeder.Models; +using Bit.Seeder.Factories; +using Bit.Seeder.Models; using Bit.Seeder.Services; using Microsoft.Extensions.DependencyInjection; @@ -33,7 +34,7 @@ internal static class PresetLoader /// Builds a recipe from preset configuration, resolving fixtures and generation counts. /// /// - /// Resolution order: Org → Owner → Generator → Roster → Users → Groups → Collections → Ciphers + /// Resolution order: Org → Owner → Generator → Roster → Users → Groups → Collections → Folders → Ciphers → PersonalCiphers /// private static void BuildRecipe(string presetName, SeedPreset preset, ISeedReader reader, IServiceCollection services) { @@ -45,7 +46,7 @@ internal static class PresetLoader if (org.Fixture is not null) { - builder.UseOrganization(org.Fixture); + builder.UseOrganization(org.Fixture, org.PlanType, org.Seats); // If using a fixture and domain not explicitly provided, read it from the fixture if (domain is null) @@ -56,14 +57,15 @@ internal static class PresetLoader } else if (org.Name is not null && org.Domain is not null) { - builder.CreateOrganization(org.Name, org.Domain, org.Seats); + var planType = PlanFeatures.Parse(org.PlanType); + builder.CreateOrganization(org.Name, org.Domain, org.Seats, planType); 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) + // Generator requires a domain and is needed for generated ciphers, personal ciphers, or folders + if (domain is not null && (preset.Ciphers?.Count > 0 || preset.PersonalCiphers?.CountPerUser > 0 || preset.Folders == true)) { builder.WithGenerator(domain); } @@ -88,13 +90,23 @@ internal static class PresetLoader builder.AddCollections(preset.Collections.Count); } + if (preset.Folders == true) + { + builder.AddFolders(); + } + 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.AddCiphers(preset.Ciphers.Count, assignFolders: preset.Ciphers.AssignFolders); + } + + if (preset.PersonalCiphers is not null && preset.PersonalCiphers.CountPerUser > 0) + { + builder.AddPersonalCiphers(preset.PersonalCiphers.CountPerUser); } builder.Validate(); diff --git a/util/Seeder/Pipeline/RecipeBuilder.cs b/util/Seeder/Pipeline/RecipeBuilder.cs index bd96517c8a..6f4cfc6751 100644 --- a/util/Seeder/Pipeline/RecipeBuilder.cs +++ b/util/Seeder/Pipeline/RecipeBuilder.cs @@ -7,9 +7,9 @@ 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 +/// Wraps and a recipe name, tracking step count for +/// deterministic ordering and validation flags for dependency rules. +/// Phase Order: Org → Owner → Generator → Roster → Users → Groups → Collections → Folders → Ciphers → PersonalCiphers /// public class RecipeBuilder { @@ -39,13 +39,19 @@ public class RecipeBuilder internal bool HasGeneratedCiphers { get; set; } + internal bool HasFolders { get; set; } + + internal bool HasCipherFolderAssignment { get; set; } + + internal bool HasPersonalCiphers { 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. + /// Collections, Folders, Ciphers, PersonalCiphers. /// /// Factory function that creates the step from an IServiceProvider /// This builder for fluent chaining diff --git a/util/Seeder/Pipeline/RecipeBuilderExtensions.cs b/util/Seeder/Pipeline/RecipeBuilderExtensions.cs index 555c0cd3d5..b44b8d3b65 100644 --- a/util/Seeder/Pipeline/RecipeBuilderExtensions.cs +++ b/util/Seeder/Pipeline/RecipeBuilderExtensions.cs @@ -1,4 +1,5 @@ -using Bit.Core.Vault.Enums; +using Bit.Core.Billing.Enums; +using Bit.Core.Vault.Enums; using Bit.Seeder.Data.Distributions; using Bit.Seeder.Data.Enums; using Bit.Seeder.Steps; @@ -12,15 +13,17 @@ namespace Bit.Seeder.Pipeline; public static class RecipeBuilderExtensions { /// - /// Use an organization from embedded fixtures. + /// Use an organization from embedded fixtures with optional plan/seats overrides from the preset. /// /// The recipe builder /// Organization fixture name without extension + /// Optional plan type override (from preset) + /// Optional seats override (from preset) /// The builder for fluent chaining - public static RecipeBuilder UseOrganization(this RecipeBuilder builder, string fixture) + public static RecipeBuilder UseOrganization(this RecipeBuilder builder, string fixture, string? planType = null, int? seats = null) { builder.HasOrg = true; - builder.AddStep(_ => CreateOrganizationStep.FromFixture(fixture)); + builder.AddStep(_ => CreateOrganizationStep.FromFixture(fixture, planType, seats)); return builder; } @@ -31,11 +34,12 @@ public static class RecipeBuilderExtensions /// Organization display name /// Organization domain (used for email generation) /// Number of user seats + /// Billing plan type (defaults to EnterpriseAnnually) /// The builder for fluent chaining - public static RecipeBuilder CreateOrganization(this RecipeBuilder builder, string name, string domain, int seats) + public static RecipeBuilder CreateOrganization(this RecipeBuilder builder, string name, string domain, int? seats = null, PlanType planType = PlanType.EnterpriseAnnually) { builder.HasOrg = true; - builder.AddStep(_ => CreateOrganizationStep.FromParams(name, domain, seats)); + builder.AddStep(_ => CreateOrganizationStep.FromParams(name, domain, seats, planType)); return builder; } @@ -163,6 +167,22 @@ public static class RecipeBuilderExtensions return builder; } + /// + /// Generate folders for each user using a realistic distribution. + /// + public static RecipeBuilder AddFolders(this RecipeBuilder builder) + { + if (!builder.HasRosterUsers && !builder.HasGeneratedUsers) + { + throw new InvalidOperationException( + "Folders require users. Call UseRoster() or AddUsers() first."); + } + + builder.HasFolders = true; + builder.AddStep(_ => new GenerateFoldersStep()); + return builder; + } + /// /// Use ciphers from embedded fixtures. /// @@ -190,13 +210,15 @@ public static class RecipeBuilderExtensions /// Number of ciphers to generate /// Distribution of cipher types. Uses realistic defaults if null. /// Distribution of password strengths. Uses realistic defaults if null. + /// When true, assigns ciphers to user folders round-robin. /// 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) + Distribution? pwDist = null, + bool assignFolders = false) { if (builder.HasFixtureCiphers) { @@ -205,7 +227,36 @@ public static class RecipeBuilderExtensions } builder.HasGeneratedCiphers = true; - builder.AddStep(_ => new GenerateCiphersStep(count, typeDist, pwDist)); + if (assignFolders) + { + builder.HasCipherFolderAssignment = true; + } + builder.AddStep(_ => new GenerateCiphersStep(count, typeDist, pwDist, assignFolders)); + return builder; + } + + /// + /// Generate personal ciphers for each user, encrypted with their individual symmetric key. + /// + /// The recipe builder + /// Number of personal ciphers per user + /// 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 no users exist + public static RecipeBuilder AddPersonalCiphers( + this RecipeBuilder builder, int countPerUser, + Distribution? typeDist = null, + Distribution? pwDist = null) + { + if (!builder.HasRosterUsers && !builder.HasGeneratedUsers) + { + throw new InvalidOperationException( + "Personal ciphers require users. Call UseRoster() or AddUsers() first."); + } + + builder.HasPersonalCiphers = true; + builder.AddStep(_ => new GeneratePersonalCiphersStep(countPerUser, typeDist, pwDist)); return builder; } @@ -235,6 +286,24 @@ public static class RecipeBuilderExtensions "Generated ciphers require a generator. Call WithGenerator() first."); } + if (builder.HasPersonalCiphers && !builder.HasGenerator) + { + throw new InvalidOperationException( + "Personal ciphers require a generator. Call WithGenerator() first."); + } + + if (builder.HasFolders && !builder.HasGenerator) + { + throw new InvalidOperationException( + "Folders require a generator. Call WithGenerator() first."); + } + + if (builder.HasCipherFolderAssignment && !builder.HasFolders) + { + throw new InvalidOperationException( + "Cipher folder assignment requires folders. Set 'folders: true' or call AddFolders() first."); + } + return builder; } } diff --git a/util/Seeder/Pipeline/SeederContext.cs b/util/Seeder/Pipeline/SeederContext.cs index b27290e031..fe561451ba 100644 --- a/util/Seeder/Pipeline/SeederContext.cs +++ b/util/Seeder/Pipeline/SeederContext.cs @@ -72,6 +72,8 @@ public sealed class SeederContext(IServiceProvider services) internal List CollectionCiphers { get; } = []; + internal List Folders { get; } = []; + internal EntityRegistry Registry { get; } = new(); internal GeneratorContext? Generator { get; set; } diff --git a/util/Seeder/README.md b/util/Seeder/README.md index 722c970d1c..f3e3fa45da 100644 --- a/util/Seeder/README.md +++ b/util/Seeder/README.md @@ -56,7 +56,7 @@ The Seeder is organized around six core patterns, each with a specific responsib - **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 +**Phase order**: Org → Owner → Generator → Roster → Users → Groups → Collections → Folders → Ciphers → PersonalCiphers **Naming**: `{Purpose}Step` classes implementing `IStep` diff --git a/util/Seeder/Recipes/OrganizationWithVaultRecipe.cs b/util/Seeder/Recipes/OrganizationWithVaultRecipe.cs index d083a2864d..cbceacf88a 100644 --- a/util/Seeder/Recipes/OrganizationWithVaultRecipe.cs +++ b/util/Seeder/Recipes/OrganizationWithVaultRecipe.cs @@ -61,7 +61,7 @@ public class OrganizationWithVaultRecipe( // Create organization via factory var organization = OrganizationSeeder.Create( - options.Name, options.Domain, seats, orgKeys.PublicKey, orgKeys.PrivateKey); + options.Name, options.Domain, seats, orgKeys.PublicKey, orgKeys.PrivateKey, options.PlanType); // Create owner user via factory var ownerEmail = $"owner@{options.Domain}"; diff --git a/util/Seeder/Scenes/SingleUserScene.cs b/util/Seeder/Scenes/SingleUserScene.cs index a2122974bb..f7167b2bf9 100644 --- a/util/Seeder/Scenes/SingleUserScene.cs +++ b/util/Seeder/Scenes/SingleUserScene.cs @@ -43,8 +43,8 @@ public class SingleUserScene( request.Email, passwordHasher, manglerService, - request.EmailVerified, - request.Premium, + emailVerified: request.EmailVerified, + premium: request.Premium, password: request.Password); await userRepository.CreateAsync(user); diff --git a/util/Seeder/Seeds/fixtures/organizations/dunder-mifflin.json b/util/Seeder/Seeds/fixtures/organizations/dunder-mifflin.json index a442a5e1bd..1ab246a5c7 100644 --- a/util/Seeder/Seeds/fixtures/organizations/dunder-mifflin.json +++ b/util/Seeder/Seeds/fixtures/organizations/dunder-mifflin.json @@ -1,6 +1,5 @@ { "$schema": "../../schemas/organization.schema.json", "name": "Dunder Mifflin", - "domain": "dundermifflin.com", - "seats": 70 + "domain": "dundermifflin.com" } diff --git a/util/Seeder/Seeds/fixtures/organizations/maple-pine-trading.json b/util/Seeder/Seeds/fixtures/organizations/maple-pine-trading.json new file mode 100644 index 0000000000..cad448471d --- /dev/null +++ b/util/Seeder/Seeds/fixtures/organizations/maple-pine-trading.json @@ -0,0 +1,5 @@ +{ + "$schema": "../../schemas/organization.schema.json", + "name": "Maple & Pine Trading Co", + "domain": "maplepine.com" +} diff --git a/util/Seeder/Seeds/fixtures/organizations/stark-industries.json b/util/Seeder/Seeds/fixtures/organizations/stark-industries.json new file mode 100644 index 0000000000..758692b29b --- /dev/null +++ b/util/Seeder/Seeds/fixtures/organizations/stark-industries.json @@ -0,0 +1,5 @@ +{ + "$schema": "../../schemas/organization.schema.json", + "name": "Stark Industries", + "domain": "stark.dev" +} diff --git a/util/Seeder/Seeds/fixtures/organizations/wonka-confections.json b/util/Seeder/Seeds/fixtures/organizations/wonka-confections.json new file mode 100644 index 0000000000..9b7126ad8f --- /dev/null +++ b/util/Seeder/Seeds/fixtures/organizations/wonka-confections.json @@ -0,0 +1,5 @@ +{ + "$schema": "../../schemas/organization.schema.json", + "name": "Wonka Confections", + "domain": "wonka.co" +} diff --git a/util/Seeder/Seeds/fixtures/presets/dunder-mifflin-full.json b/util/Seeder/Seeds/fixtures/presets/dunder-mifflin-enterprise-full.json similarity index 67% rename from util/Seeder/Seeds/fixtures/presets/dunder-mifflin-full.json rename to util/Seeder/Seeds/fixtures/presets/dunder-mifflin-enterprise-full.json index 7a6b7b4e62..7e0b8f93d7 100644 --- a/util/Seeder/Seeds/fixtures/presets/dunder-mifflin-full.json +++ b/util/Seeder/Seeds/fixtures/presets/dunder-mifflin-enterprise-full.json @@ -1,7 +1,9 @@ { "$schema": "../../schemas/preset.schema.json", "organization": { - "fixture": "dunder-mifflin" + "fixture": "dunder-mifflin", + "planType": "enterprise-annually", + "seats": 70 }, "roster": { "fixture": "dunder-mifflin" diff --git a/util/Seeder/Seeds/fixtures/presets/stark-free-basic.json b/util/Seeder/Seeds/fixtures/presets/stark-free-basic.json new file mode 100644 index 0000000000..dbbf949193 --- /dev/null +++ b/util/Seeder/Seeds/fixtures/presets/stark-free-basic.json @@ -0,0 +1,22 @@ +{ + "$schema": "../../schemas/preset.schema.json", + "organization": { + "fixture": "stark-industries", + "planType": "free", + "seats": 2 + }, + "users": { + "count": 1, + "realisticStatusMix": false + }, + "collections": { + "count": 1 + }, + "folders": true, + "ciphers": { + "fixture": "autofill-testing" + }, + "personalCiphers": { + "countPerUser": 15 + } +} diff --git a/util/Seeder/Seeds/fixtures/presets/wonka-teams-personal-vaults.json b/util/Seeder/Seeds/fixtures/presets/wonka-teams-personal-vaults.json new file mode 100644 index 0000000000..463ba3bcaf --- /dev/null +++ b/util/Seeder/Seeds/fixtures/presets/wonka-teams-personal-vaults.json @@ -0,0 +1,26 @@ +{ + "$schema": "../../schemas/preset.schema.json", + "organization": { + "fixture": "wonka-confections", + "planType": "teams-annually", + "seats": 25 + }, + "users": { + "count": 10, + "realisticStatusMix": true + }, + "groups": { + "count": 3 + }, + "collections": { + "count": 5 + }, + "folders": true, + "ciphers": { + "count": 50, + "assignFolders": true + }, + "personalCiphers": { + "countPerUser": 20 + } +} diff --git a/util/Seeder/Seeds/fixtures/presets/wonka-teams-small.json b/util/Seeder/Seeds/fixtures/presets/wonka-teams-small.json new file mode 100644 index 0000000000..6d7384eae9 --- /dev/null +++ b/util/Seeder/Seeds/fixtures/presets/wonka-teams-small.json @@ -0,0 +1,21 @@ +{ + "$schema": "../../schemas/preset.schema.json", + "organization": { + "fixture": "wonka-confections", + "planType": "teams-annually", + "seats": 25 + }, + "users": { + "count": 10, + "realisticStatusMix": true + }, + "groups": { + "count": 3 + }, + "collections": { + "count": 5 + }, + "ciphers": { + "count": 100 + } +} diff --git a/util/Seeder/Seeds/schemas/organization.schema.json b/util/Seeder/Seeds/schemas/organization.schema.json index ac4abcb70b..a3fb52b6e6 100644 --- a/util/Seeder/Seeds/schemas/organization.schema.json +++ b/util/Seeder/Seeds/schemas/organization.schema.json @@ -19,12 +19,6 @@ "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 index e17e827342..308d6e8922 100644 --- a/util/Seeder/Seeds/schemas/preset.schema.json +++ b/util/Seeder/Seeds/schemas/preset.schema.json @@ -31,6 +31,11 @@ "minimum": 1, "default": 10, "description": "Number of seats in the organization." + }, + "planType": { + "type": "string", + "enum": ["free", "teams-monthly", "teams-annually", "enterprise-monthly", "enterprise-annually", "teams-starter", "families-annually"], + "description": "Billing plan type. Defaults to enterprise-annually if omitted." } } }, @@ -90,6 +95,11 @@ }, "required": ["count"] }, + "folders": { + "type": "boolean", + "default": false, + "description": "When true, generates folders for each user using a realistic distribution." + }, "ciphers": { "type": "object", "description": "Cipher configuration. Use 'fixture' for a named fixture, or 'count' for random generation.", @@ -103,8 +113,26 @@ "type": "integer", "minimum": 1, "description": "Number of random ciphers to generate." + }, + "assignFolders": { + "type": "boolean", + "default": false, + "description": "When true, assigns generated ciphers to user folders round-robin. Requires 'folders' to be true." } } + }, + "personalCiphers": { + "type": "object", + "description": "Generate personal ciphers for each user's personal vault.", + "additionalProperties": false, + "properties": { + "countPerUser": { + "type": "integer", + "minimum": 1, + "description": "Number of personal ciphers to generate per user." + } + }, + "required": ["countPerUser"] } } } diff --git a/util/Seeder/Steps/CreateOrganizationStep.cs b/util/Seeder/Steps/CreateOrganizationStep.cs index 6db94990b4..a8d9b8ebc2 100644 --- a/util/Seeder/Steps/CreateOrganizationStep.cs +++ b/util/Seeder/Steps/CreateOrganizationStep.cs @@ -1,4 +1,5 @@ -using Bit.RustSDK; +using Bit.Core.Billing.Enums; +using Bit.RustSDK; using Bit.Seeder.Factories; using Bit.Seeder.Models; using Bit.Seeder.Pipeline; @@ -13,9 +14,10 @@ internal sealed class CreateOrganizationStep : IStep private readonly string? _fixtureName; private readonly string? _name; private readonly string? _domain; - private readonly int _seats; + private readonly int? _seats; + private readonly PlanType _planType; - private CreateOrganizationStep(string? fixtureName, string? name, string? domain, int seats) + private CreateOrganizationStep(string? fixtureName, string? name, string? domain, int? seats, PlanType planType) { if (fixtureName is null && (name is null || domain is null)) { @@ -27,35 +29,34 @@ internal sealed class CreateOrganizationStep : IStep _name = name; _domain = domain; _seats = seats; + _planType = planType; } - internal static CreateOrganizationStep FromFixture(string fixtureName) => - new(fixtureName, null, null, 0); + internal static CreateOrganizationStep FromFixture(string fixtureName, string? planType = null, int? seats = null) => + new(fixtureName, null, null, seats, PlanFeatures.Parse(planType)); - internal static CreateOrganizationStep FromParams(string name, string domain, int seats) => - new(null, name, domain, seats); + internal static CreateOrganizationStep FromParams(string name, string domain, int? seats = null, PlanType planType = PlanType.EnterpriseAnnually) => + new(null, name, domain, seats, planType); 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 seats = _seats ?? PlanFeatures.GenerateRealisticSeatCount(_planType, domain); var orgKeys = RustSdkService.GenerateOrganizationKeys(); - var organization = OrganizationSeeder.Create(name, domain, seats, orgKeys.PublicKey, orgKeys.PrivateKey); + var organization = OrganizationSeeder.Create(name, domain, seats, orgKeys.PublicKey, orgKeys.PrivateKey, _planType); context.Organization = organization; context.OrgKeys = orgKeys; @@ -63,4 +64,5 @@ internal sealed class CreateOrganizationStep : IStep context.Organizations.Add(organization); } + } diff --git a/util/Seeder/Steps/GenerateCiphersStep.cs b/util/Seeder/Steps/GenerateCiphersStep.cs index dc794500b8..d5c0bea99c 100644 --- a/util/Seeder/Steps/GenerateCiphersStep.cs +++ b/util/Seeder/Steps/GenerateCiphersStep.cs @@ -4,7 +4,6 @@ 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; @@ -25,7 +24,8 @@ namespace Bit.Seeder.Steps; internal sealed class GenerateCiphersStep( int count, Distribution? typeDist = null, - Distribution? pwDist = null) : IStep + Distribution? pwDist = null, + bool assignFolders = false) : IStep { public void Execute(SeederContext context) { @@ -43,6 +43,9 @@ internal sealed class GenerateCiphersStep( var passwordDistribution = pwDist ?? PasswordDistributions.Realistic; var companies = Companies.All; + var userDigests = assignFolders ? context.Registry.UserDigests : null; + var userFolderIds = assignFolders ? context.Registry.UserFolderIds : null; + var ciphers = new List(count); var cipherIds = new List(count); var collectionCiphers = new List(); @@ -50,21 +53,19 @@ internal sealed class GenerateCiphersStep( for (var i = 0; i < count; i++) { var cipherType = typeDistribution.Select(i, count); - var cipher = cipherType switch + var cipher = CipherComposer.Compose(i, cipherType, orgKey, companies, generator, passwordDistribution, organizationId: orgId); + + if (userDigests is { Count: > 0 } && userFolderIds is not null) { - 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}") - }; + var userDigest = userDigests[i % userDigests.Count]; + CipherComposer.AssignFolder(cipher, userDigest.UserId, i, userFolderIds); + } ciphers.Add(cipher); cipherIds.Add(cipher.Id); // Collection assignment - if (collectionIds.Count <= 0) + if (collectionIds.Count == 0) { continue; } @@ -90,67 +91,4 @@ internal sealed class GenerateCiphersStep( 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/GenerateFoldersStep.cs b/util/Seeder/Steps/GenerateFoldersStep.cs new file mode 100644 index 0000000000..bd856a7805 --- /dev/null +++ b/util/Seeder/Steps/GenerateFoldersStep.cs @@ -0,0 +1,38 @@ +using Bit.Seeder.Data.Distributions; +using Bit.Seeder.Factories; +using Bit.Seeder.Pipeline; + +namespace Bit.Seeder.Steps; + +/// +/// Generates folders for each user based on a realistic distribution, encrypted with each user's symmetric key. +/// +internal sealed class GenerateFoldersStep : IStep +{ + public void Execute(SeederContext context) + { + var generator = context.RequireGenerator(); + var userDigests = context.Registry.UserDigests; + var distribution = FolderCountDistributions.Realistic; + + for (var index = 0; index < userDigests.Count; index++) + { + var digest = userDigests[index]; + var range = distribution.Select(index, userDigests.Count); + var count = range.Min + (index % Math.Max(range.Max - range.Min, 1)); + var folderIds = new List(count); + + for (var i = 0; i < count; i++) + { + var folder = FolderSeeder.Create( + digest.UserId, + digest.SymmetricKey, + generator.Folder.GetFolderName(i)); + context.Folders.Add(folder); + folderIds.Add(folder.Id); + } + + context.Registry.UserFolderIds[digest.UserId] = folderIds; + } + } +} diff --git a/util/Seeder/Steps/GeneratePersonalCiphersStep.cs b/util/Seeder/Steps/GeneratePersonalCiphersStep.cs new file mode 100644 index 0000000000..a9e0391f23 --- /dev/null +++ b/util/Seeder/Steps/GeneratePersonalCiphersStep.cs @@ -0,0 +1,60 @@ +using Bit.Core.Vault.Entities; +using Bit.Core.Vault.Enums; +using Bit.Seeder.Data.Distributions; +using Bit.Seeder.Data.Enums; +using Bit.Seeder.Data.Static; +using Bit.Seeder.Factories; +using Bit.Seeder.Pipeline; + +namespace Bit.Seeder.Steps; + +/// +/// Creates N personal cipher entities per user, encrypted with each user's symmetric key. +/// +/// +/// Iterates over and creates ciphers with +/// UserId set and OrganizationId null. Personal ciphers are not assigned +/// to collections. +/// +internal sealed class GeneratePersonalCiphersStep( + int countPerUser, + Distribution? typeDist = null, + Distribution? pwDist = null) : IStep +{ + public void Execute(SeederContext context) + { + if (countPerUser == 0) + { + return; + } + + var generator = context.RequireGenerator(); + + var userDigests = context.Registry.UserDigests; + var typeDistribution = typeDist ?? CipherTypeDistributions.Realistic; + var passwordDistribution = pwDist ?? PasswordDistributions.Realistic; + var companies = Companies.All; + + var ciphers = new List(userDigests.Count * countPerUser); + var cipherIds = new List(userDigests.Count * countPerUser); + var globalIndex = 0; + + foreach (var userDigest in userDigests) + { + for (var i = 0; i < countPerUser; i++) + { + var cipherType = typeDistribution.Select(globalIndex, userDigests.Count * countPerUser); + var cipher = CipherComposer.Compose(globalIndex, cipherType, userDigest.SymmetricKey, companies, generator, passwordDistribution, userId: userDigest.UserId); + + CipherComposer.AssignFolder(cipher, userDigest.UserId, i, context.Registry.UserFolderIds); + + ciphers.Add(cipher); + cipherIds.Add(cipher.Id); + globalIndex++; + } + } + + context.Ciphers.AddRange(ciphers); + context.Registry.CipherIds.AddRange(cipherIds); + } +}