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:
@@ -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
|
||||
```
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
136
util/Seeder/Factories/CipherComposer.cs
Normal file
136
util/Seeder/Factories/CipherComposer.cs
Normal 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()}\"}}";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
219
util/Seeder/Factories/PlanFeatures.cs
Normal file
219
util/Seeder/Factories/PlanFeatures.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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 < 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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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}";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"$schema": "../../schemas/organization.schema.json",
|
||||
"name": "Dunder Mifflin",
|
||||
"domain": "dundermifflin.com",
|
||||
"seats": 70
|
||||
"domain": "dundermifflin.com"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "../../schemas/organization.schema.json",
|
||||
"name": "Maple & Pine Trading Co",
|
||||
"domain": "maplepine.com"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "../../schemas/organization.schema.json",
|
||||
"name": "Stark Industries",
|
||||
"domain": "stark.dev"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "../../schemas/organization.schema.json",
|
||||
"name": "Wonka Confections",
|
||||
"domain": "wonka.co"
|
||||
}
|
||||
@@ -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"
|
||||
22
util/Seeder/Seeds/fixtures/presets/stark-free-basic.json
Normal file
22
util/Seeder/Seeds/fixtures/presets/stark-free-basic.json
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
21
util/Seeder/Seeds/fixtures/presets/wonka-teams-small.json
Normal file
21
util/Seeder/Seeds/fixtures/presets/wonka-teams-small.json
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
38
util/Seeder/Steps/GenerateFoldersStep.cs
Normal file
38
util/Seeder/Steps/GenerateFoldersStep.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
60
util/Seeder/Steps/GeneratePersonalCiphersStep.cs
Normal file
60
util/Seeder/Steps/GeneratePersonalCiphersStep.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user