1
0
mirror of https://github.com/bitwarden/server synced 2026-02-20 03:13:35 +00:00

Implement plan types, personal ciphers and fix folder assignment (#7030)

* Implement plan types, personal ciphers and fix folder assignment
This commit is contained in:
Mick Letofsky
2026-02-19 15:47:37 +01:00
committed by GitHub
parent 4d91350fb7
commit 10044397c1
33 changed files with 759 additions and 153 deletions

View File

@@ -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
```

View File

@@ -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)

View File

@@ -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.

View File

@@ -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;
/// <summary>
/// Composes cipher entities from generated data, handling encryption and ownership assignment.
/// Used by generation steps to create realistic ciphers for organizations or personal vaults.
/// </summary>
internal static class CipherComposer
{
internal static Cipher Compose(
int index,
CipherType cipherType,
string encryptionKey,
Company[] companies,
GeneratorContext generator,
Distribution<PasswordStrength> 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<PasswordStrength> 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);
}
/// <summary>
/// Assigns a folder to a cipher via round-robin selection from the user's folder list.
/// </summary>
internal static void AssignFolder(Cipher cipher, Guid userId, int index, Dictionary<Guid, List<Guid>> userFolderIds)
{
if (userFolderIds.TryGetValue(userId, out var folderIds) && folderIds.Count > 0)
{
cipher.Folders = $"{{\"{userId.ToString().ToUpperInvariant()}\":\"{folderIds[index % folderIds.Count].ToString().ToUpperInvariant()}\"}}";
}
}
}

View File

@@ -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;
}
}

View File

@@ -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;
/// <summary>
/// Maps PlanType to organization feature flags.
/// Values sourced from MockPlans in test/Core.Test/Billing/Mocks/Plans/.
/// </summary>
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.")
};
}
/// <summary>
/// Deterministic seat count from a log-normal distribution seeded by domain.
/// Ranges sourced from our production data.
/// </summary>
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);
}
/// <summary>
/// Baseline: all plan-gated features off. Free and Families start here then enable selectively.
/// </summary>
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;
}
}

View File

@@ -15,6 +15,7 @@ internal static class UserSeeder
string email,
IPasswordHasher<User> 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,

View File

@@ -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

View File

@@ -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; }
}

View File

@@ -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
/// <summary>
/// When true and Users >= 10, creates a realistic mix of user statuses:
/// 85% Confirmed, 5% Invited, 5% Accepted, 5% Revoked.
/// When false or Users &lt; 10, all users are Confirmed.
/// When false or Users less than 10, all users are Confirmed.
/// </summary>
public bool RealisticStatusMix { get; init; } = false;
@@ -55,7 +56,6 @@ public class OrganizationVaultOptions
/// <summary>
/// Distribution of username categories (corporate email, personal email, social handles, etc.).
/// Use <see cref="UsernameDistributions.Realistic"/> for a typical enterprise mix (45% corporate).
/// Defaults to Realistic if not specified.
/// </summary>
public Distribution<UsernameCategory> UsernameDistribution { get; init; } = UsernameDistributions.Realistic;
@@ -63,7 +63,6 @@ public class OrganizationVaultOptions
/// Distribution of password strengths for cipher logins.
/// Use <see cref="PasswordDistributions.Realistic"/> for breach-data distribution
/// (25% VeryWeak, 30% Weak, 25% Fair, 15% Strong, 5% VeryStrong).
/// Defaults to Realistic if not specified.
/// </summary>
public Distribution<PasswordStrength> PasswordDistribution { get; init; } = PasswordDistributions.Realistic;
@@ -88,4 +87,9 @@ public class OrganizationVaultOptions
/// Password for all seeded accounts. Defaults to "asdfasdfasdf" if not specified.
/// </summary>
public string? Password { get; init; }
/// <summary>
/// Billing plan type for the organization.
/// </summary>
public PlanType PlanType { get; init; } = PlanType.EnterpriseAnnually;
}

View File

@@ -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 <see cref="SeederContext"/> to the database via BulkCopy.
/// </summary>
/// <remarks>
/// 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<Core.Entities.CollectionGroup, EfCollectionGroup>(context.CollectionGroups, nameof(Core.Entities.CollectionGroup));
MapCopyAndClear<Core.Vault.Entities.Folder, EfFolder>(context.Folders);
CopyAndClear(context.Ciphers);
CopyAndClear(context.CollectionCiphers);

View File

@@ -46,6 +46,11 @@ internal sealed class EntityRegistry
/// </summary>
internal List<Guid> CipherIds { get; } = [];
/// <summary>
/// Folder IDs per user, for cipher-to-folder assignment.
/// </summary>
internal Dictionary<Guid, List<Guid>> UserFolderIds { get; } = [];
/// <summary>
/// Clears all registry lists. Called by <see cref="RecipeExecutor"/> before each pipeline run.
/// </summary>
@@ -56,5 +61,6 @@ internal sealed class EntityRegistry
GroupIds.Clear();
CollectionIds.Clear();
CipherIds.Clear();
UserFolderIds.Clear();
}
}

View File

@@ -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.
/// </summary>
/// <remarks>
/// Resolution order: Org → Owner → Generator → Roster → Users → Groups → Collections → Ciphers
/// Resolution order: Org → Owner → Generator → Roster → Users → Groups → Collections → Folders → Ciphers → PersonalCiphers
/// </remarks>
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();

View File

@@ -7,9 +7,9 @@ namespace Bit.Seeder.Pipeline;
/// Fluent API for building seeding pipelines with DI-based step registration and validation.
/// </summary>
/// <remarks>
/// RecipeBuilder wraps <see cref="IServiceCollection"/> and a recipe name.
/// It tracks step count for deterministic ordering and validation flags for dependency rules.
/// <strong>Phase Order:</strong> Org → Owner → Generator → Roster → Users → Groups → Collections → Ciphers
/// Wraps <see cref="IServiceCollection"/> and a recipe name, tracking step count for
/// deterministic ordering and validation flags for dependency rules.
/// <strong>Phase Order:</strong> Org → Owner → Generator → Roster → Users → Groups → Collections → Folders → Ciphers → PersonalCiphers
/// </remarks>
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; }
/// <summary>
/// Registers a step as a keyed singleton service with preserved ordering.
/// </summary>
/// <remarks>
/// Steps execute in the order they are registered. Callers must register steps
/// in the correct phase order: Org, Owner, Generator, Roster, Users, Groups,
/// Collections, Ciphers.
/// Collections, Folders, Ciphers, PersonalCiphers.
/// </remarks>
/// <param name="factory">Factory function that creates the step from an IServiceProvider</param>
/// <returns>This builder for fluent chaining</returns>

View File

@@ -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
{
/// <summary>
/// Use an organization from embedded fixtures.
/// Use an organization from embedded fixtures with optional plan/seats overrides from the preset.
/// </summary>
/// <param name="builder">The recipe builder</param>
/// <param name="fixture">Organization fixture name without extension</param>
/// <param name="planType">Optional plan type override (from preset)</param>
/// <param name="seats">Optional seats override (from preset)</param>
/// <returns>The builder for fluent chaining</returns>
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
/// <param name="name">Organization display name</param>
/// <param name="domain">Organization domain (used for email generation)</param>
/// <param name="seats">Number of user seats</param>
/// <param name="planType">Billing plan type (defaults to EnterpriseAnnually)</param>
/// <returns>The builder for fluent chaining</returns>
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;
}
/// <summary>
/// Generate folders for each user using a realistic distribution.
/// </summary>
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;
}
/// <summary>
/// Use ciphers from embedded fixtures.
/// </summary>
@@ -190,13 +210,15 @@ public static class RecipeBuilderExtensions
/// <param name="count">Number of ciphers to generate</param>
/// <param name="typeDist">Distribution of cipher types. Uses realistic defaults if null.</param>
/// <param name="pwDist">Distribution of password strengths. Uses realistic defaults if null.</param>
/// <param name="assignFolders">When true, assigns ciphers to user folders round-robin.</param>
/// <returns>The builder for fluent chaining</returns>
/// <exception cref="InvalidOperationException">Thrown when UseCiphers() was already called</exception>
public static RecipeBuilder AddCiphers(
this RecipeBuilder builder,
int count,
Distribution<CipherType>? typeDist = null,
Distribution<PasswordStrength>? pwDist = null)
Distribution<PasswordStrength>? 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;
}
/// <summary>
/// Generate personal ciphers for each user, encrypted with their individual symmetric key.
/// </summary>
/// <param name="builder">The recipe builder</param>
/// <param name="countPerUser">Number of personal ciphers per user</param>
/// <param name="typeDist">Distribution of cipher types. Uses realistic defaults if null.</param>
/// <param name="pwDist">Distribution of password strengths. Uses realistic defaults if null.</param>
/// <returns>The builder for fluent chaining</returns>
/// <exception cref="InvalidOperationException">Thrown when no users exist</exception>
public static RecipeBuilder AddPersonalCiphers(
this RecipeBuilder builder, int countPerUser,
Distribution<CipherType>? typeDist = null,
Distribution<PasswordStrength>? 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;
}
}

View File

@@ -72,6 +72,8 @@ public sealed class SeederContext(IServiceProvider services)
internal List<CollectionCipher> CollectionCiphers { get; } = [];
internal List<Folder> Folders { get; } = [];
internal EntityRegistry Registry { get; } = new();
internal GeneratorContext? Generator { get; set; }

View File

@@ -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`

View File

@@ -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}";

View File

@@ -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);

View File

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

View File

@@ -0,0 +1,5 @@
{
"$schema": "../../schemas/organization.schema.json",
"name": "Maple & Pine Trading Co",
"domain": "maplepine.com"
}

View File

@@ -0,0 +1,5 @@
{
"$schema": "../../schemas/organization.schema.json",
"name": "Stark Industries",
"domain": "stark.dev"
}

View File

@@ -0,0 +1,5 @@
{
"$schema": "../../schemas/organization.schema.json",
"name": "Wonka Confections",
"domain": "wonka.co"
}

View File

@@ -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"

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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."
}
}
}

View File

@@ -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"]
}
}
}

View File

@@ -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<SeedOrganization>($"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);
}
}

View File

@@ -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<CipherType>? typeDist = null,
Distribution<PasswordStrength>? pwDist = null) : IStep
Distribution<PasswordStrength>? 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<Cipher>(count);
var cipherIds = new List<Guid>(count);
var collectionCiphers = new List<CollectionCipher>();
@@ -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<PasswordStrength> passwordDistribution)
{
var company = companies[index % companies.Length];
return LoginCipherSeeder.Create(
orgKey,
name: $"{company.Name} ({company.Category})",
organizationId: organizationId,
username: generator.Username.GenerateByIndex(index, totalHint: generator.CipherCount, domain: company.Domain),
password: Passwords.GetPassword(index, generator.CipherCount, passwordDistribution),
uri: $"https://{company.Domain}");
}
private static Cipher CreateCardCipher(int index, Guid organizationId, string orgKey, GeneratorContext generator)
{
var card = generator.Card.GenerateByIndex(index);
return CardCipherSeeder.Create(
orgKey,
name: $"{card.CardholderName}'s {card.Brand}",
card: card,
organizationId: organizationId);
}
private static Cipher CreateIdentityCipher(int index, Guid organizationId, string orgKey, GeneratorContext generator)
{
var identity = generator.Identity.GenerateByIndex(index);
var name = $"{identity.FirstName} {identity.LastName}";
if (!string.IsNullOrEmpty(identity.Company))
{
name += $" ({identity.Company})";
}
return IdentityCipherSeeder.Create(
orgKey,
name: name,
identity: identity,
organizationId: organizationId);
}
private static Cipher CreateSecureNoteCipher(int index, Guid organizationId, string orgKey, GeneratorContext generator)
{
var (name, notes) = generator.SecureNote.GenerateByIndex(index);
return SecureNoteCipherSeeder.Create(
orgKey,
name: name,
organizationId: organizationId,
notes: notes);
}
private static Cipher CreateSshKeyCipher(int index, Guid organizationId, string orgKey)
{
var sshKey = SshKeyDataGenerator.GenerateByIndex(index);
return SshKeyCipherSeeder.Create(
orgKey,
name: $"SSH Key {index + 1}",
sshKey: sshKey,
organizationId: organizationId);
}
}

View File

@@ -0,0 +1,38 @@
using Bit.Seeder.Data.Distributions;
using Bit.Seeder.Factories;
using Bit.Seeder.Pipeline;
namespace Bit.Seeder.Steps;
/// <summary>
/// Generates folders for each user based on a realistic distribution, encrypted with each user's symmetric key.
/// </summary>
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<Guid>(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;
}
}
}

View File

@@ -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;
/// <summary>
/// Creates N personal cipher entities per user, encrypted with each user's symmetric key.
/// </summary>
/// <remarks>
/// Iterates over <see cref="EntityRegistry.UserDigests"/> and creates ciphers with
/// <c>UserId</c> set and <c>OrganizationId</c> null. Personal ciphers are not assigned
/// to collections.
/// </remarks>
internal sealed class GeneratePersonalCiphersStep(
int countPerUser,
Distribution<CipherType>? typeDist = null,
Distribution<PasswordStrength>? 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<Cipher>(userDigests.Count * countPerUser);
var cipherIds = new List<Guid>(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);
}
}