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);
+ }
+}