mirror of
https://github.com/bitwarden/server
synced 2026-02-28 10:23:24 +00:00
Craft density modeling for Seeded vaults (#7102)
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Seeder.Data.Distributions;
|
||||
using Bit.Seeder.Data.Enums;
|
||||
using Bit.Seeder.Options;
|
||||
using Bit.Seeder.Steps;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.SeederApi.IntegrationTest.DensityModel;
|
||||
|
||||
public class CreateCollectionsStepTests
|
||||
{
|
||||
private static readonly List<Guid> _collectionIds =
|
||||
[.. Enumerable.Range(1, 10).Select(i => new Guid($"00000000-0000-0000-0000-{i:D12}"))];
|
||||
|
||||
private static readonly List<Guid> _groupIds =
|
||||
[.. Enumerable.Range(1, 5).Select(i => new Guid($"11111111-0000-0000-0000-{i:D12}"))];
|
||||
|
||||
private static readonly List<Guid> _userIds =
|
||||
[.. Enumerable.Range(1, 20).Select(i => new Guid($"22222222-0000-0000-0000-{i:D12}"))];
|
||||
|
||||
private static readonly Distribution<PermissionWeight> EvenPermissions = new(
|
||||
(PermissionWeight.ReadOnly, 0.25),
|
||||
(PermissionWeight.ReadWrite, 0.25),
|
||||
(PermissionWeight.Manage, 0.25),
|
||||
(PermissionWeight.HidePasswords, 0.25));
|
||||
|
||||
[Fact]
|
||||
public void ApplyGroupPermissions_EvenSplit_DistributesAllFourTypes()
|
||||
{
|
||||
var assignments = Enumerable.Range(0, 100)
|
||||
.Select(_ => new CollectionGroup { CollectionId = Guid.NewGuid(), GroupId = Guid.NewGuid() })
|
||||
.ToList();
|
||||
|
||||
CreateCollectionsStep.ApplyGroupPermissions(assignments, EvenPermissions);
|
||||
|
||||
Assert.Equal(25, assignments.Count(a => a.ReadOnly));
|
||||
Assert.Equal(25, assignments.Count(a => a.Manage));
|
||||
Assert.Equal(25, assignments.Count(a => a.HidePasswords));
|
||||
Assert.Equal(25, assignments.Count(a => !a.ReadOnly && !a.Manage && !a.HidePasswords));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyGroupPermissions_MutuallyExclusiveFlags()
|
||||
{
|
||||
var assignments = Enumerable.Range(0, 100)
|
||||
.Select(_ => new CollectionGroup { CollectionId = Guid.NewGuid(), GroupId = Guid.NewGuid() })
|
||||
.ToList();
|
||||
|
||||
CreateCollectionsStep.ApplyGroupPermissions(assignments, EvenPermissions);
|
||||
|
||||
Assert.All(assignments, a =>
|
||||
{
|
||||
var flagCount = (a.ReadOnly ? 1 : 0) + (a.HidePasswords ? 1 : 0) + (a.Manage ? 1 : 0);
|
||||
Assert.True(flagCount <= 1, "At most one permission flag should be true");
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyGroupPermissions_ReadOnlyHeavy_MajorityAreReadOnly()
|
||||
{
|
||||
var assignments = Enumerable.Range(0, 100)
|
||||
.Select(_ => new CollectionGroup { CollectionId = Guid.NewGuid(), GroupId = Guid.NewGuid() })
|
||||
.ToList();
|
||||
|
||||
CreateCollectionsStep.ApplyGroupPermissions(assignments, PermissionDistributions.Enterprise);
|
||||
|
||||
var readOnlyCount = assignments.Count(a => a.ReadOnly);
|
||||
Assert.True(readOnlyCount >= 80, $"Expected >= 80 ReadOnly, got {readOnlyCount}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyUserPermissions_EvenSplit_DistributesAllFourTypes()
|
||||
{
|
||||
var assignments = Enumerable.Range(0, 100)
|
||||
.Select(_ => new CollectionUser { CollectionId = Guid.NewGuid(), OrganizationUserId = Guid.NewGuid() })
|
||||
.ToList();
|
||||
|
||||
CreateCollectionsStep.ApplyUserPermissions(assignments, EvenPermissions);
|
||||
|
||||
Assert.Equal(25, assignments.Count(a => a.ReadOnly));
|
||||
Assert.Equal(25, assignments.Count(a => a.Manage));
|
||||
Assert.Equal(25, assignments.Count(a => a.HidePasswords));
|
||||
Assert.Equal(25, assignments.Count(a => !a.ReadOnly && !a.Manage && !a.HidePasswords));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildCollectionGroups_ClampsToAvailableGroups()
|
||||
{
|
||||
var twoGroups = _groupIds.Take(2).ToList();
|
||||
var step = CreateStep(CollectionFanOutShape.Uniform, min: 5, max: 5);
|
||||
|
||||
var result = step.BuildCollectionGroups(_collectionIds, twoGroups);
|
||||
|
||||
Assert.All(result, cg => Assert.Contains(cg.GroupId, twoGroups));
|
||||
Assert.Equal(20, result.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildCollectionGroups_NoDuplicateGroupPerCollection()
|
||||
{
|
||||
var step = CreateStep(CollectionFanOutShape.Uniform, min: 3, max: 3);
|
||||
|
||||
var result = step.BuildCollectionGroups(_collectionIds, _groupIds);
|
||||
|
||||
foreach (var collectionId in _collectionIds)
|
||||
{
|
||||
var groupsForCollection = result.Where(cg => cg.CollectionId == collectionId)
|
||||
.Select(cg => cg.GroupId).ToList();
|
||||
Assert.Equal(groupsForCollection.Count, groupsForCollection.Distinct().Count());
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildCollectionGroups_Uniform_AssignsGroupsToEveryCollection()
|
||||
{
|
||||
var step = CreateStep(CollectionFanOutShape.Uniform, min: 2, max: 2);
|
||||
|
||||
var result = step.BuildCollectionGroups(_collectionIds, _groupIds);
|
||||
|
||||
Assert.Equal(20, result.Count);
|
||||
Assert.All(result, cg => Assert.Contains(cg.GroupId, _groupIds));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildCollectionUsers_AllCollectionIdsAreValid()
|
||||
{
|
||||
var result = CreateCollectionsStep.BuildCollectionUsers(_collectionIds, _userIds, 10);
|
||||
|
||||
Assert.All(result, cu => Assert.Contains(cu.CollectionId, _collectionIds));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildCollectionUsers_AssignsOneToThreeCollectionsPerUser()
|
||||
{
|
||||
var result = CreateCollectionsStep.BuildCollectionUsers(_collectionIds, _userIds, 10);
|
||||
|
||||
var perUser = result.GroupBy(cu => cu.OrganizationUserId).ToList();
|
||||
Assert.All(perUser, group => Assert.InRange(group.Count(), 1, 3));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildCollectionUsers_RespectsDirectUserCount()
|
||||
{
|
||||
var result = CreateCollectionsStep.BuildCollectionUsers(_collectionIds, _userIds, 5);
|
||||
|
||||
var distinctUsers = result.Select(cu => cu.OrganizationUserId).Distinct().ToList();
|
||||
Assert.Equal(5, distinctUsers.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeFanOut_FrontLoaded_FirstTenPercentGetMax()
|
||||
{
|
||||
var step = CreateStep(CollectionFanOutShape.FrontLoaded, min: 1, max: 5);
|
||||
|
||||
Assert.Equal(5, step.ComputeFanOut(0, 100, 1, 5));
|
||||
Assert.Equal(5, step.ComputeFanOut(9, 100, 1, 5));
|
||||
Assert.Equal(1, step.ComputeFanOut(10, 100, 1, 5));
|
||||
Assert.Equal(1, step.ComputeFanOut(99, 100, 1, 5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeFanOut_MinEqualsMax_AlwaysReturnsMin()
|
||||
{
|
||||
var step = CreateStep(CollectionFanOutShape.Uniform, min: 3, max: 3);
|
||||
|
||||
Assert.Equal(3, step.ComputeFanOut(0, 10, 3, 3));
|
||||
Assert.Equal(3, step.ComputeFanOut(5, 10, 3, 3));
|
||||
Assert.Equal(3, step.ComputeFanOut(9, 10, 3, 3));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeFanOut_PowerLaw_FirstCollectionGetsMax()
|
||||
{
|
||||
var step = CreateStep(CollectionFanOutShape.PowerLaw, min: 1, max: 5);
|
||||
|
||||
Assert.Equal(5, step.ComputeFanOut(0, 100, 1, 5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeFanOut_PowerLaw_LaterCollectionsDecay()
|
||||
{
|
||||
var step = CreateStep(CollectionFanOutShape.PowerLaw, min: 1, max: 5);
|
||||
|
||||
var first = step.ComputeFanOut(0, 100, 1, 5);
|
||||
var middle = step.ComputeFanOut(50, 100, 1, 5);
|
||||
var last = step.ComputeFanOut(99, 100, 1, 5);
|
||||
|
||||
Assert.True(first > middle, "First collection should have more fan-out than middle");
|
||||
Assert.True(middle >= last, "Middle should have >= fan-out than last");
|
||||
Assert.True(last >= 1, "Last collection should have at least min fan-out");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeFanOut_Uniform_CyclesThroughRange()
|
||||
{
|
||||
var step = CreateStep(CollectionFanOutShape.Uniform, min: 1, max: 3);
|
||||
|
||||
Assert.Equal(1, step.ComputeFanOut(0, 10, 1, 3));
|
||||
Assert.Equal(2, step.ComputeFanOut(1, 10, 1, 3));
|
||||
Assert.Equal(3, step.ComputeFanOut(2, 10, 1, 3));
|
||||
Assert.Equal(1, step.ComputeFanOut(3, 10, 1, 3));
|
||||
}
|
||||
|
||||
private static CreateCollectionsStep CreateStep(CollectionFanOutShape shape, int min, int max)
|
||||
{
|
||||
var density = new DensityProfile
|
||||
{
|
||||
FanOutShape = shape,
|
||||
CollectionFanOutMin = min,
|
||||
CollectionFanOutMax = max
|
||||
};
|
||||
return CreateCollectionsStep.FromCount(0, density);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
using Bit.Seeder.Data.Enums;
|
||||
using Bit.Seeder.Options;
|
||||
using Bit.Seeder.Steps;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.SeederApi.IntegrationTest.DensityModel;
|
||||
|
||||
public class CreateGroupsStepTests
|
||||
{
|
||||
[Fact]
|
||||
public void ComputeUsersPerGroup_MegaGroup_GroupZeroDoesNotParticipateInRemainder()
|
||||
{
|
||||
var step = CreateStep(MembershipDistributionShape.MegaGroup, skew: 0.5);
|
||||
|
||||
var allocations = step.ComputeUsersPerGroup(5, 100);
|
||||
|
||||
var megaFraction = 0.5 + 0.5 * 0.45; // 0.725
|
||||
var expectedMega = (int)(100 * megaFraction); // 72
|
||||
Assert.Equal(expectedMega, allocations[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeUsersPerGroup_MegaGroup_RemainderGoesToNonMegaGroups()
|
||||
{
|
||||
var step = CreateStep(MembershipDistributionShape.MegaGroup, skew: 0.5);
|
||||
|
||||
var allocations = step.ComputeUsersPerGroup(5, 100);
|
||||
|
||||
var nonMegaTotal = allocations[1] + allocations[2] + allocations[3] + allocations[4];
|
||||
Assert.Equal(100 - allocations[0], nonMegaTotal);
|
||||
Assert.True(allocations[1] > 0, "Non-mega groups should have members");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeUsersPerGroup_MegaGroup_SingleGroup_AllUsersAssigned()
|
||||
{
|
||||
var step = CreateStep(MembershipDistributionShape.MegaGroup, skew: 0.9);
|
||||
|
||||
var allocations = step.ComputeUsersPerGroup(1, 100);
|
||||
|
||||
Assert.Single(allocations);
|
||||
Assert.Equal(100, allocations[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeUsersPerGroup_MegaGroup_SingleUser_SingleGroup()
|
||||
{
|
||||
var step = CreateStep(MembershipDistributionShape.MegaGroup, skew: 1.0);
|
||||
|
||||
var allocations = step.ComputeUsersPerGroup(1, 1);
|
||||
|
||||
Assert.Equal(1, allocations[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeUsersPerGroup_MegaGroup_SumsToUserCount()
|
||||
{
|
||||
var step = CreateStep(MembershipDistributionShape.MegaGroup, skew: 0.8);
|
||||
|
||||
var allocations = step.ComputeUsersPerGroup(10, 100);
|
||||
|
||||
Assert.Equal(100, allocations.Sum());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeUsersPerGroup_PowerLaw_FirstGroupIsLargest()
|
||||
{
|
||||
var step = CreateStep(MembershipDistributionShape.PowerLaw, skew: 0.8);
|
||||
|
||||
var allocations = step.ComputeUsersPerGroup(10, 100);
|
||||
|
||||
Assert.Equal(allocations.Max(), allocations[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeUsersPerGroup_PowerLaw_HighSkewMoreConcentrated()
|
||||
{
|
||||
var gentle = CreateStep(MembershipDistributionShape.PowerLaw, skew: 0.0);
|
||||
var steep = CreateStep(MembershipDistributionShape.PowerLaw, skew: 1.0);
|
||||
|
||||
var gentleAllocations = gentle.ComputeUsersPerGroup(10, 100);
|
||||
var steepAllocations = steep.ComputeUsersPerGroup(10, 100);
|
||||
|
||||
Assert.True(steepAllocations[0] > gentleAllocations[0],
|
||||
$"Steep skew group 0 ({steepAllocations[0]}) should be larger than gentle ({gentleAllocations[0]})");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeUsersPerGroup_PowerLaw_MoreGroupsThanUsers_NoNegativeAllocations()
|
||||
{
|
||||
var step = CreateStep(MembershipDistributionShape.PowerLaw, skew: 1.0);
|
||||
|
||||
var allocations = step.ComputeUsersPerGroup(20, 5);
|
||||
|
||||
Assert.All(allocations, a => Assert.True(a >= 0, $"Allocation should be >= 0, got {a}"));
|
||||
Assert.Equal(5, allocations.Sum());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeUsersPerGroup_PowerLaw_SumsToUserCount()
|
||||
{
|
||||
var step = CreateStep(MembershipDistributionShape.PowerLaw, skew: 0.5);
|
||||
|
||||
var allocations = step.ComputeUsersPerGroup(10, 100);
|
||||
|
||||
Assert.Equal(100, allocations.Sum());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeUsersPerGroup_Uniform_EvenDistribution()
|
||||
{
|
||||
var step = CreateStep(MembershipDistributionShape.Uniform);
|
||||
|
||||
var allocations = step.ComputeUsersPerGroup(5, 100);
|
||||
|
||||
Assert.All(allocations, a => Assert.Equal(20, a));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeUsersPerGroup_Uniform_SumsToUserCount()
|
||||
{
|
||||
var step = CreateStep(MembershipDistributionShape.Uniform);
|
||||
|
||||
var allocations = step.ComputeUsersPerGroup(7, 100);
|
||||
|
||||
Assert.Equal(100, allocations.Sum());
|
||||
}
|
||||
|
||||
private static CreateGroupsStep CreateStep(MembershipDistributionShape shape, double skew = 0.0)
|
||||
{
|
||||
var density = new DensityProfile
|
||||
{
|
||||
MembershipShape = shape,
|
||||
MembershipSkew = skew
|
||||
};
|
||||
return new CreateGroupsStep(0, density);
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,25 @@ Need to create test data?
|
||||
|
||||
See `Pipeline/` folder for implementation.
|
||||
|
||||
## Density Profiles
|
||||
|
||||
Steps accept an optional `DensityProfile` that controls relationship patterns between users, groups, collections, and ciphers. When null, steps use the original round-robin behavior. When present, steps branch into density-aware algorithms.
|
||||
|
||||
**Key files**:
|
||||
|
||||
- `Options/DensityProfile.cs` — strongly-typed options (public class)
|
||||
- `Models/SeedPresetDensity.cs` — JSON preset deserialization targets (internal records)
|
||||
- `Data/Enums/MembershipDistributionShape.cs` — Uniform, PowerLaw, MegaGroup
|
||||
- `Data/Enums/CollectionFanOutShape.cs` — Uniform, PowerLaw, FrontLoaded
|
||||
- `Data/Enums/CipherCollectionSkew.cs` — Uniform, HeavyRight
|
||||
- `Data/Distributions/PermissionDistributions.cs` — 11 named distributions by org tier
|
||||
|
||||
**Backward compatibility contract**: `DensityProfile? == null` MUST produce identical output to the original code. Every step guards this with `if (_density == null) { /* original path */ }`.
|
||||
|
||||
**Preset JSON**: Add an optional `"density": { ... }` block. See `Seeds/schemas/preset.schema.json` for the full schema.
|
||||
|
||||
**Validation presets**: `Seeds/fixtures/presets/validation/` contains presets that verify density algorithms produce correct distributions. See the README in that folder for queries and expected results.
|
||||
|
||||
## The Recipe Contract
|
||||
|
||||
Recipes follow strict rules:
|
||||
|
||||
118
util/Seeder/Data/Distributions/PermissionDistributions.cs
Normal file
118
util/Seeder/Data/Distributions/PermissionDistributions.cs
Normal file
@@ -0,0 +1,118 @@
|
||||
using Bit.Seeder.Data.Enums;
|
||||
|
||||
namespace Bit.Seeder.Data.Distributions;
|
||||
|
||||
/// <summary>
|
||||
/// Pre-configured permission type distributions organized by org size and pendulum position.
|
||||
/// Every distribution guarantees at least 5% Manage and 5% ReadWrite.
|
||||
/// </summary>
|
||||
public static class PermissionDistributions
|
||||
{
|
||||
/// <summary>
|
||||
/// Enterprise, read-heavy. Our production baseline. Pendulum swings hard toward ReadOnly.
|
||||
/// </summary>
|
||||
public static Distribution<PermissionWeight> Enterprise { get; } = new(
|
||||
(PermissionWeight.ReadOnly, 0.82),
|
||||
(PermissionWeight.ReadWrite, 0.09),
|
||||
(PermissionWeight.Manage, 0.05),
|
||||
(PermissionWeight.HidePasswords, 0.04)
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Enterprise, write-heavy. Engineering-driven orgs where most users need to edit shared credentials.
|
||||
/// </summary>
|
||||
public static Distribution<PermissionWeight> EnterpriseWriteHeavy { get; } = new(
|
||||
(PermissionWeight.ReadWrite, 0.55),
|
||||
(PermissionWeight.ReadOnly, 0.25),
|
||||
(PermissionWeight.Manage, 0.10),
|
||||
(PermissionWeight.HidePasswords, 0.10)
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Enterprise, manage-heavy. Decentralized admin model with many collection managers.
|
||||
/// </summary>
|
||||
public static Distribution<PermissionWeight> EnterpriseManageHeavy { get; } = new(
|
||||
(PermissionWeight.Manage, 0.30),
|
||||
(PermissionWeight.ReadWrite, 0.30),
|
||||
(PermissionWeight.ReadOnly, 0.30),
|
||||
(PermissionWeight.HidePasswords, 0.10)
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Mid-market, read-heavy. Structured org where most users consume, leads manage.
|
||||
/// </summary>
|
||||
public static Distribution<PermissionWeight> MidMarket { get; } = new(
|
||||
(PermissionWeight.ReadOnly, 0.55),
|
||||
(PermissionWeight.ReadWrite, 0.20),
|
||||
(PermissionWeight.Manage, 0.15),
|
||||
(PermissionWeight.HidePasswords, 0.10)
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Mid-market, write-heavy. Collaborative teams where most users create and edit.
|
||||
/// </summary>
|
||||
public static Distribution<PermissionWeight> MidMarketWriteHeavy { get; } = new(
|
||||
(PermissionWeight.ReadWrite, 0.50),
|
||||
(PermissionWeight.Manage, 0.20),
|
||||
(PermissionWeight.ReadOnly, 0.20),
|
||||
(PermissionWeight.HidePasswords, 0.10)
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Mid-market, manage-heavy. Flat org where many people own their collections.
|
||||
/// </summary>
|
||||
public static Distribution<PermissionWeight> MidMarketManageHeavy { get; } = new(
|
||||
(PermissionWeight.Manage, 0.40),
|
||||
(PermissionWeight.ReadWrite, 0.30),
|
||||
(PermissionWeight.ReadOnly, 0.20),
|
||||
(PermissionWeight.HidePasswords, 0.10)
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Small business, read-heavy. Tighter controls despite small size — onboarding, contractors.
|
||||
/// </summary>
|
||||
public static Distribution<PermissionWeight> SmallBusiness { get; } = new(
|
||||
(PermissionWeight.ReadOnly, 0.40),
|
||||
(PermissionWeight.ReadWrite, 0.30),
|
||||
(PermissionWeight.Manage, 0.25),
|
||||
(PermissionWeight.HidePasswords, 0.05)
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Small business, write-heavy. High-trust team where most people edit freely.
|
||||
/// </summary>
|
||||
public static Distribution<PermissionWeight> SmallBusinessWriteHeavy { get; } = new(
|
||||
(PermissionWeight.ReadWrite, 0.45),
|
||||
(PermissionWeight.Manage, 0.35),
|
||||
(PermissionWeight.ReadOnly, 0.15),
|
||||
(PermissionWeight.HidePasswords, 0.05)
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Small business, manage-heavy. Founders and senior staff own most collections.
|
||||
/// </summary>
|
||||
public static Distribution<PermissionWeight> SmallBusinessManageHeavy { get; } = new(
|
||||
(PermissionWeight.Manage, 0.50),
|
||||
(PermissionWeight.ReadWrite, 0.30),
|
||||
(PermissionWeight.ReadOnly, 0.15),
|
||||
(PermissionWeight.HidePasswords, 0.05)
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Teams Starter. Tiny high-trust team — heavy Manage, everyone contributes.
|
||||
/// </summary>
|
||||
public static Distribution<PermissionWeight> TeamsStarter { get; } = new(
|
||||
(PermissionWeight.Manage, 0.50),
|
||||
(PermissionWeight.ReadWrite, 0.40),
|
||||
(PermissionWeight.ReadOnly, 0.10)
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Families plan. Shared household — nearly everyone manages everything.
|
||||
/// </summary>
|
||||
public static Distribution<PermissionWeight> Family { get; } = new(
|
||||
(PermissionWeight.Manage, 0.70),
|
||||
(PermissionWeight.ReadWrite, 0.20),
|
||||
(PermissionWeight.ReadOnly, 0.10)
|
||||
);
|
||||
}
|
||||
6
util/Seeder/Data/Enums/CipherCollectionSkew.cs
Normal file
6
util/Seeder/Data/Enums/CipherCollectionSkew.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace Bit.Seeder.Data.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Skew shape for cipher-to-collection assignment distribution.
|
||||
/// </summary>
|
||||
public enum CipherCollectionSkew { Uniform, HeavyRight }
|
||||
6
util/Seeder/Data/Enums/CollectionFanOutShape.cs
Normal file
6
util/Seeder/Data/Enums/CollectionFanOutShape.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace Bit.Seeder.Data.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Distribution shape for group-to-collection fan-out.
|
||||
/// </summary>
|
||||
public enum CollectionFanOutShape { Uniform, PowerLaw, FrontLoaded }
|
||||
6
util/Seeder/Data/Enums/MembershipDistributionShape.cs
Normal file
6
util/Seeder/Data/Enums/MembershipDistributionShape.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace Bit.Seeder.Data.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Distribution shape for user-to-group membership assignment.
|
||||
/// </summary>
|
||||
public enum MembershipDistributionShape { Uniform, PowerLaw, MegaGroup }
|
||||
6
util/Seeder/Data/Enums/PermissionWeight.cs
Normal file
6
util/Seeder/Data/Enums/PermissionWeight.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace Bit.Seeder.Data.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Collection access permission types for distribution weighting.
|
||||
/// </summary>
|
||||
public enum PermissionWeight { ReadWrite, ReadOnly, HidePasswords, Manage }
|
||||
@@ -10,6 +10,7 @@ internal record SeedPreset
|
||||
public bool? Folders { get; init; }
|
||||
public SeedPresetCiphers? Ciphers { get; init; }
|
||||
public SeedPresetPersonalCiphers? PersonalCiphers { get; init; }
|
||||
public SeedPresetDensity? Density { get; init; }
|
||||
}
|
||||
|
||||
internal record SeedPresetOrganization
|
||||
|
||||
65
util/Seeder/Models/SeedPresetDensity.cs
Normal file
65
util/Seeder/Models/SeedPresetDensity.cs
Normal file
@@ -0,0 +1,65 @@
|
||||
namespace Bit.Seeder.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Top-level density block in a preset JSON. Controls relationship patterns between entities.
|
||||
/// </summary>
|
||||
internal record SeedPresetDensity
|
||||
{
|
||||
public SeedPresetMembership? Membership { get; init; }
|
||||
|
||||
public SeedPresetCollectionFanOut? CollectionFanOut { get; init; }
|
||||
|
||||
public double? DirectAccessRatio { get; init; }
|
||||
|
||||
public SeedPresetPermissions? Permissions { get; init; }
|
||||
|
||||
public SeedPresetCipherAssignment? CipherAssignment { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// How users are distributed across groups (uniform, powerLaw, megaGroup) and skew intensity.
|
||||
/// </summary>
|
||||
internal record SeedPresetMembership
|
||||
{
|
||||
public string? Shape { get; init; }
|
||||
|
||||
public double? Skew { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// How collections are assigned to groups: range, distribution shape, and empty group rate.
|
||||
/// </summary>
|
||||
internal record SeedPresetCollectionFanOut
|
||||
{
|
||||
public int? Min { get; init; }
|
||||
|
||||
public int? Max { get; init; }
|
||||
|
||||
public string? Shape { get; init; }
|
||||
|
||||
public double? EmptyGroupRate { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Permission type weights for collection access assignments. Must sum to 1.0.
|
||||
/// </summary>
|
||||
internal record SeedPresetPermissions
|
||||
{
|
||||
public double? Manage { get; init; }
|
||||
|
||||
public double? ReadOnly { get; init; }
|
||||
|
||||
public double? HidePasswords { get; init; }
|
||||
|
||||
public double? ReadWrite { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// How ciphers are distributed across collections: skew shape and orphan rate.
|
||||
/// </summary>
|
||||
internal record SeedPresetCipherAssignment
|
||||
{
|
||||
public string? Skew { get; init; }
|
||||
|
||||
public double? OrphanRate { get; init; }
|
||||
}
|
||||
62
util/Seeder/Options/DensityProfile.cs
Normal file
62
util/Seeder/Options/DensityProfile.cs
Normal file
@@ -0,0 +1,62 @@
|
||||
using Bit.Seeder.Data.Distributions;
|
||||
using Bit.Seeder.Data.Enums;
|
||||
|
||||
namespace Bit.Seeder.Options;
|
||||
|
||||
/// <summary>
|
||||
/// Controls relationship density between users, groups, collections, and ciphers within a seeded organization.
|
||||
/// When null on <see cref="OrganizationVaultOptions"/>, steps use default round-robin behavior.
|
||||
/// </summary>
|
||||
public class DensityProfile
|
||||
{
|
||||
/// <summary>
|
||||
/// User-to-group membership distribution shape. Defaults to Uniform (round-robin).
|
||||
/// </summary>
|
||||
public MembershipDistributionShape MembershipShape { get; init; } = MembershipDistributionShape.Uniform;
|
||||
|
||||
/// <summary>
|
||||
/// Skew intensity for PowerLaw and MegaGroup shapes (0.0-1.0). Ignored for Uniform.
|
||||
/// </summary>
|
||||
public double MembershipSkew { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Minimum collections assigned per non-empty group.
|
||||
/// </summary>
|
||||
public int CollectionFanOutMin { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum collections assigned per non-empty group.
|
||||
/// </summary>
|
||||
public int CollectionFanOutMax { get; init; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Distribution shape for group-to-collection fan-out.
|
||||
/// </summary>
|
||||
public CollectionFanOutShape FanOutShape { get; init; } = CollectionFanOutShape.Uniform;
|
||||
|
||||
/// <summary>
|
||||
/// Fraction of groups with zero members (0.0-1.0).
|
||||
/// </summary>
|
||||
public double EmptyGroupRate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Fraction of access paths that are direct CollectionUser assignments (0.0-1.0).
|
||||
/// 1.0 = all direct (current default), 0.0 = all group-mediated.
|
||||
/// </summary>
|
||||
public double DirectAccessRatio { get; init; } = 1.0;
|
||||
|
||||
/// <summary>
|
||||
/// Permission type weighting for collection access assignments.
|
||||
/// </summary>
|
||||
public Distribution<PermissionWeight> PermissionDistribution { get; init; } = PermissionDistributions.Enterprise;
|
||||
|
||||
/// <summary>
|
||||
/// Cipher-to-collection assignment skew shape.
|
||||
/// </summary>
|
||||
public CipherCollectionSkew CipherSkew { get; init; } = CipherCollectionSkew.Uniform;
|
||||
|
||||
/// <summary>
|
||||
/// Fraction of org ciphers with no collection assignment (0.0-1.0).
|
||||
/// </summary>
|
||||
public double OrphanCipherRate { get; init; }
|
||||
}
|
||||
@@ -78,6 +78,12 @@ public class OrganizationVaultOptions
|
||||
/// </summary>
|
||||
public Distribution<CipherType> CipherTypeDistribution { get; init; } = CipherTypeDistributions.Realistic;
|
||||
|
||||
/// <summary>
|
||||
/// Density profile controlling entity relationship patterns.
|
||||
/// When null, steps use default round-robin behavior.
|
||||
/// </summary>
|
||||
public DensityProfile? Density { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Seed for deterministic data generation. When null, derived from Domain hash.
|
||||
/// </summary>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
using Bit.Seeder.Factories;
|
||||
using Bit.Seeder.Data.Distributions;
|
||||
using Bit.Seeder.Data.Enums;
|
||||
using Bit.Seeder.Factories;
|
||||
using Bit.Seeder.Models;
|
||||
using Bit.Seeder.Options;
|
||||
using Bit.Seeder.Services;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
@@ -83,14 +86,16 @@ internal static class PresetLoader
|
||||
builder.AddUsers(preset.Users.Count, preset.Users.RealisticStatusMix);
|
||||
}
|
||||
|
||||
var density = ParseDensity(preset.Density);
|
||||
|
||||
if (preset.Groups is not null)
|
||||
{
|
||||
builder.AddGroups(preset.Groups.Count);
|
||||
builder.AddGroups(preset.Groups.Count, density);
|
||||
}
|
||||
|
||||
if (preset.Collections is not null)
|
||||
{
|
||||
builder.AddCollections(preset.Collections.Count);
|
||||
builder.AddCollections(preset.Collections.Count, density);
|
||||
}
|
||||
|
||||
if (preset.Folders == true)
|
||||
@@ -104,7 +109,7 @@ internal static class PresetLoader
|
||||
}
|
||||
else if (preset.Ciphers is not null && preset.Ciphers.Count > 0)
|
||||
{
|
||||
builder.AddCiphers(preset.Ciphers.Count, assignFolders: preset.Ciphers.AssignFolders);
|
||||
builder.AddCiphers(preset.Ciphers.Count, assignFolders: preset.Ciphers.AssignFolders, density: density);
|
||||
}
|
||||
|
||||
if (preset.PersonalCiphers is not null && preset.PersonalCiphers.CountPerUser > 0)
|
||||
@@ -114,4 +119,54 @@ internal static class PresetLoader
|
||||
|
||||
builder.Validate();
|
||||
}
|
||||
|
||||
private static DensityProfile? ParseDensity(SeedPresetDensity? preset)
|
||||
{
|
||||
if (preset is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new DensityProfile
|
||||
{
|
||||
MembershipShape = ParseEnum(preset.Membership?.Shape, MembershipDistributionShape.Uniform),
|
||||
MembershipSkew = preset.Membership?.Skew ?? 0,
|
||||
CollectionFanOutMin = preset.CollectionFanOut?.Min ?? 1,
|
||||
CollectionFanOutMax = preset.CollectionFanOut?.Max ?? 3,
|
||||
FanOutShape = ParseEnum(preset.CollectionFanOut?.Shape, CollectionFanOutShape.Uniform),
|
||||
EmptyGroupRate = preset.CollectionFanOut?.EmptyGroupRate ?? 0,
|
||||
DirectAccessRatio = preset.DirectAccessRatio ?? 1.0,
|
||||
PermissionDistribution = ParsePermissions(preset.Permissions),
|
||||
CipherSkew = ParseEnum(preset.CipherAssignment?.Skew, CipherCollectionSkew.Uniform),
|
||||
OrphanCipherRate = preset.CipherAssignment?.OrphanRate ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
private static Distribution<PermissionWeight> ParsePermissions(SeedPresetPermissions? permissions)
|
||||
{
|
||||
if (permissions is null)
|
||||
{
|
||||
return PermissionDistributions.Enterprise;
|
||||
}
|
||||
|
||||
var readOnly = permissions.ReadOnly ?? 0;
|
||||
var readWrite = permissions.ReadWrite ?? 0;
|
||||
var manage = permissions.Manage ?? 0;
|
||||
var hidePasswords = permissions.HidePasswords ?? 0;
|
||||
|
||||
// Empty permissions block (all nulls → zeros) — fall back to Enterprise defaults
|
||||
if (readOnly + readWrite + manage + hidePasswords < 0.001)
|
||||
{
|
||||
return PermissionDistributions.Enterprise;
|
||||
}
|
||||
|
||||
return new Distribution<PermissionWeight>(
|
||||
(PermissionWeight.ReadOnly, readOnly),
|
||||
(PermissionWeight.ReadWrite, readWrite),
|
||||
(PermissionWeight.Manage, manage),
|
||||
(PermissionWeight.HidePasswords, hidePasswords));
|
||||
}
|
||||
|
||||
private static T ParseEnum<T>(string? value, T defaultValue) where T : struct, Enum =>
|
||||
value is not null && Enum.TryParse<T>(value, ignoreCase: true, out var result) ? result : defaultValue;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using Bit.Core.Vault.Enums;
|
||||
using Bit.Seeder.Data.Distributions;
|
||||
using Bit.Seeder.Data.Enums;
|
||||
using Bit.Seeder.Models;
|
||||
using Bit.Seeder.Options;
|
||||
using Bit.Seeder.Services;
|
||||
using Bit.Seeder.Steps;
|
||||
|
||||
@@ -127,7 +128,7 @@ public static class RecipeBuilderExtensions
|
||||
/// <param name="count">Number of groups to generate</param>
|
||||
/// <returns>The builder for fluent chaining</returns>
|
||||
/// <exception cref="InvalidOperationException">Thrown when no users exist</exception>
|
||||
public static RecipeBuilder AddGroups(this RecipeBuilder builder, int count)
|
||||
public static RecipeBuilder AddGroups(this RecipeBuilder builder, int count, DensityProfile? density = null)
|
||||
{
|
||||
if (!builder.HasRosterUsers && !builder.HasGeneratedUsers)
|
||||
{
|
||||
@@ -135,7 +136,7 @@ public static class RecipeBuilderExtensions
|
||||
"Groups require users. Call UseRoster() or AddUsers() first.");
|
||||
}
|
||||
|
||||
builder.AddStep(_ => new CreateGroupsStep(count));
|
||||
builder.AddStep(_ => new CreateGroupsStep(count, density));
|
||||
return builder;
|
||||
}
|
||||
|
||||
@@ -146,7 +147,7 @@ public static class RecipeBuilderExtensions
|
||||
/// <param name="count">Number of collections to generate</param>
|
||||
/// <returns>The builder for fluent chaining</returns>
|
||||
/// <exception cref="InvalidOperationException">Thrown when no users exist</exception>
|
||||
public static RecipeBuilder AddCollections(this RecipeBuilder builder, int count)
|
||||
public static RecipeBuilder AddCollections(this RecipeBuilder builder, int count, DensityProfile? density = null)
|
||||
{
|
||||
if (!builder.HasRosterUsers && !builder.HasGeneratedUsers)
|
||||
{
|
||||
@@ -154,7 +155,7 @@ public static class RecipeBuilderExtensions
|
||||
"Collections require users. Call UseRoster() or AddUsers() first.");
|
||||
}
|
||||
|
||||
builder.AddStep(_ => CreateCollectionsStep.FromCount(count));
|
||||
builder.AddStep(_ => CreateCollectionsStep.FromCount(count, density));
|
||||
return builder;
|
||||
}
|
||||
|
||||
@@ -228,7 +229,8 @@ public static class RecipeBuilderExtensions
|
||||
int count,
|
||||
Distribution<CipherType>? typeDist = null,
|
||||
Distribution<PasswordStrength>? pwDist = null,
|
||||
bool assignFolders = false)
|
||||
bool assignFolders = false,
|
||||
DensityProfile? density = null)
|
||||
{
|
||||
if (builder.HasFixtureCiphers)
|
||||
{
|
||||
@@ -241,7 +243,7 @@ public static class RecipeBuilderExtensions
|
||||
{
|
||||
builder.HasCipherFolderAssignment = true;
|
||||
}
|
||||
builder.AddStep(_ => new GenerateCiphersStep(count, typeDist, pwDist, assignFolders));
|
||||
builder.AddStep(_ => new GenerateCiphersStep(count, typeDist, pwDist, assignFolders, density));
|
||||
return builder;
|
||||
}
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ internal sealed class RecipeOrchestrator(DatabaseContext db, IMapper mapper)
|
||||
|
||||
if (options.Groups > 0)
|
||||
{
|
||||
builder.AddGroups(options.Groups);
|
||||
builder.AddGroups(options.Groups, options.Density);
|
||||
}
|
||||
|
||||
if (options.StructureModel.HasValue)
|
||||
@@ -77,13 +77,13 @@ internal sealed class RecipeOrchestrator(DatabaseContext db, IMapper mapper)
|
||||
}
|
||||
else if (options.Ciphers > 0)
|
||||
{
|
||||
builder.AddCollections(1);
|
||||
builder.AddCollections(1, options.Density);
|
||||
}
|
||||
|
||||
if (options.Ciphers > 0)
|
||||
{
|
||||
builder.AddFolders();
|
||||
builder.AddCiphers(options.Ciphers, options.CipherTypeDistribution, options.PasswordDistribution);
|
||||
builder.AddCiphers(options.Ciphers, options.CipherTypeDistribution, options.PasswordDistribution, density: options.Density);
|
||||
}
|
||||
|
||||
builder.Validate();
|
||||
|
||||
153
util/Seeder/Seeds/fixtures/presets/validation/README.md
Normal file
153
util/Seeder/Seeds/fixtures/presets/validation/README.md
Normal file
@@ -0,0 +1,153 @@
|
||||
# Density Modeling Validation Presets
|
||||
|
||||
These presets validate that the Seeder's density distribution algorithms produce correct relationship patterns. Run them, query the DB, and compare against the expected results below.
|
||||
|
||||
Always use the `--mangle` flag to avoid collisions with existing data.
|
||||
|
||||
## Verification Queries
|
||||
|
||||
Run the first query to get the Organization ID, then paste it into the remaining queries.
|
||||
|
||||
### Find the Organization ID
|
||||
|
||||
```sql
|
||||
SELECT Id, [Name]
|
||||
FROM [dbo].[Organization] WITH (NOLOCK)
|
||||
WHERE [Name] = 'PASTE_ORG_NAME_HERE';
|
||||
```
|
||||
|
||||
### Group Membership Distribution
|
||||
|
||||
```sql
|
||||
DECLARE @OrgId UNIQUEIDENTIFIER = 'PASTE_ORG_ID_HERE';
|
||||
|
||||
SELECT
|
||||
G.[Name],
|
||||
COUNT(GU.OrganizationUserId) AS Members
|
||||
FROM [dbo].[Group] G WITH (NOLOCK)
|
||||
LEFT JOIN [dbo].[GroupUser] GU WITH (NOLOCK) ON G.Id = GU.GroupId
|
||||
WHERE G.OrganizationId = @OrgId
|
||||
GROUP BY G.[Name]
|
||||
ORDER BY Members DESC;
|
||||
```
|
||||
|
||||
### CollectionGroup Count
|
||||
|
||||
```sql
|
||||
DECLARE @OrgId UNIQUEIDENTIFIER = 'PASTE_ORG_ID_HERE';
|
||||
|
||||
SELECT COUNT(*) AS CollectionGroupCount
|
||||
FROM [dbo].[CollectionGroup] CG WITH (NOLOCK)
|
||||
INNER JOIN [dbo].[Collection] C WITH (NOLOCK) ON CG.CollectionId = C.Id
|
||||
WHERE C.OrganizationId = @OrgId;
|
||||
```
|
||||
|
||||
### Permission Distribution
|
||||
|
||||
```sql
|
||||
DECLARE @OrgId UNIQUEIDENTIFIER = 'PASTE_ORG_ID_HERE';
|
||||
|
||||
SELECT
|
||||
'CollectionUser' AS [Source],
|
||||
COUNT(*) AS Total,
|
||||
SUM(CASE WHEN CU.ReadOnly = 1 THEN 1 ELSE 0 END) AS ReadOnly,
|
||||
SUM(CASE WHEN CU.Manage = 1 THEN 1 ELSE 0 END) AS Manage,
|
||||
SUM(CASE WHEN CU.HidePasswords = 1 THEN 1 ELSE 0 END) AS HidePasswords,
|
||||
SUM(CASE WHEN CU.ReadOnly = 0 AND CU.Manage = 0 AND CU.HidePasswords = 0 THEN 1 ELSE 0 END) AS ReadWrite
|
||||
FROM [dbo].[CollectionUser] CU WITH (NOLOCK)
|
||||
INNER JOIN [dbo].[OrganizationUser] OU WITH (NOLOCK) ON CU.OrganizationUserId = OU.Id
|
||||
WHERE OU.OrganizationId = @OrgId
|
||||
UNION ALL
|
||||
SELECT
|
||||
'CollectionGroup',
|
||||
COUNT(*),
|
||||
SUM(CASE WHEN CG.ReadOnly = 1 THEN 1 ELSE 0 END),
|
||||
SUM(CASE WHEN CG.Manage = 1 THEN 1 ELSE 0 END),
|
||||
SUM(CASE WHEN CG.HidePasswords = 1 THEN 1 ELSE 0 END),
|
||||
SUM(CASE WHEN CG.ReadOnly = 0 AND CG.Manage = 0 AND CG.HidePasswords = 0 THEN 1 ELSE 0 END)
|
||||
FROM [dbo].[CollectionGroup] CG WITH (NOLOCK)
|
||||
INNER JOIN [dbo].[Collection] C WITH (NOLOCK) ON CG.CollectionId = C.Id
|
||||
WHERE C.OrganizationId = @OrgId;
|
||||
```
|
||||
|
||||
### Orphan Ciphers
|
||||
|
||||
```sql
|
||||
DECLARE @OrgId UNIQUEIDENTIFIER = 'PASTE_ORG_ID_HERE';
|
||||
|
||||
SELECT
|
||||
COUNT(*) AS TotalCiphers,
|
||||
SUM(CASE WHEN CC.CipherId IS NULL THEN 1 ELSE 0 END) AS Orphans
|
||||
FROM [dbo].[Cipher] CI WITH (NOLOCK)
|
||||
LEFT JOIN (SELECT DISTINCT CipherId FROM [dbo].[CollectionCipher] WITH (NOLOCK)) CC
|
||||
ON CI.Id = CC.CipherId
|
||||
WHERE CI.OrganizationId = @OrgId;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Presets
|
||||
|
||||
### 1. Power-Law Distribution
|
||||
|
||||
Tests skewed group membership, CollectionGroup generation, permission distribution, and cipher orphans.
|
||||
|
||||
```bash
|
||||
cd util/SeederUtility
|
||||
dotnet run -- seed --preset validation.density-modeling-power-law-test --mangle
|
||||
```
|
||||
|
||||
| Check | Expected |
|
||||
| ----------------- | -------------------------------------------------------------------------------------- |
|
||||
| Groups | 10 groups. First has ~50 members, decays to 1. Last 2 have 0 members (20% empty rate). |
|
||||
| CollectionGroups | > 0 records. First collections have more groups assigned (PowerLaw fan-out). |
|
||||
| Permissions | ~50% ReadOnly, ~30% ReadWrite, ~15% Manage, ~5% HidePasswords. |
|
||||
| Orphan ciphers | ~50 of 500 (10% orphan rate). |
|
||||
| DirectAccessRatio | 0.6 — roughly 60% of access paths are direct CollectionUser. |
|
||||
|
||||
### 2. MegaGroup Distribution
|
||||
|
||||
Tests one dominant group with all-group access (no direct CollectionUser).
|
||||
|
||||
```bash
|
||||
cd util/SeederUtility
|
||||
dotnet run -- seed --preset validation.density-modeling-mega-group-test --mangle
|
||||
```
|
||||
|
||||
| Check | Expected |
|
||||
| ---------------- | ------------------------------------------------------------------------ |
|
||||
| Groups | 5 groups. Group 1 has ~90 members (90.5%). Groups 2-5 split ~10 members. |
|
||||
| CollectionUsers | 0 records. DirectAccessRatio is 0.0 — all access via groups. |
|
||||
| CollectionGroups | > 0. First 10 collections get 3 groups (FrontLoaded), rest get 1. |
|
||||
| Permissions | 25% each for ReadOnly, ReadWrite, Manage, HidePasswords (even split). |
|
||||
|
||||
### 3. Empty Groups
|
||||
|
||||
Tests that EmptyGroupRate produces memberless groups excluded from CollectionGroup assignment.
|
||||
|
||||
```bash
|
||||
cd util/SeederUtility
|
||||
dotnet run -- seed --preset validation.density-modeling-empty-groups-test --mangle
|
||||
```
|
||||
|
||||
| Check | Expected |
|
||||
| ----------------- | ---------------------------------------------------------------------------------- |
|
||||
| Groups | 10 groups total. 5 with ~10 members each, 5 with 0 members (50% empty). |
|
||||
| CollectionGroups | Only reference the 5 non-empty groups. Run `SELECT DISTINCT CG.GroupId` to verify. |
|
||||
| DirectAccessRatio | 0.5 — roughly half of users get direct CollectionUser records. |
|
||||
|
||||
### 4. No Density (Baseline)
|
||||
|
||||
Confirms backward compatibility. No `density` block = original round-robin behavior.
|
||||
|
||||
```bash
|
||||
cd util/SeederUtility
|
||||
dotnet run -- seed --preset validation.density-modeling-no-density-test --mangle
|
||||
```
|
||||
|
||||
| Check | Expected |
|
||||
| ---------------- | ---------------------------------------------------------------------------------------- |
|
||||
| Groups | 5 groups with ~10 members each (uniform round-robin). |
|
||||
| CollectionGroups | 0 records. No density = no CollectionGroup generation. |
|
||||
| Permissions | First assignment per user is Manage, subsequent are ReadOnly (original cycling pattern). |
|
||||
| Orphan ciphers | 0. Every cipher assigned to at least one collection. |
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "../../../schemas/preset.schema.json",
|
||||
"organization": {
|
||||
"name": "Density Empty Groups Test",
|
||||
"domain": "density-empty-groups.example",
|
||||
"planType": "enterprise-annually"
|
||||
},
|
||||
"users": { "count": 50 },
|
||||
"groups": { "count": 10 },
|
||||
"collections": { "count": 20 },
|
||||
"density": {
|
||||
"membership": { "shape": "uniform" },
|
||||
"collectionFanOut": {
|
||||
"min": 2,
|
||||
"max": 4,
|
||||
"emptyGroupRate": 0.5
|
||||
},
|
||||
"directAccessRatio": 0.5
|
||||
},
|
||||
"ciphers": { "count": 200 }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"$schema": "../../../schemas/preset.schema.json",
|
||||
"organization": {
|
||||
"name": "Density MegaGroup Test",
|
||||
"domain": "density-megagroup.example",
|
||||
"planType": "enterprise-annually"
|
||||
},
|
||||
"users": { "count": 100 },
|
||||
"groups": { "count": 5 },
|
||||
"collections": { "count": 100 },
|
||||
"density": {
|
||||
"membership": { "shape": "megaGroup", "skew": 0.9 },
|
||||
"collectionFanOut": {
|
||||
"min": 1,
|
||||
"max": 3,
|
||||
"shape": "frontLoaded"
|
||||
},
|
||||
"directAccessRatio": 0.0,
|
||||
"permissions": {
|
||||
"readOnly": 0.25,
|
||||
"readWrite": 0.25,
|
||||
"manage": 0.25,
|
||||
"hidePasswords": 0.25
|
||||
}
|
||||
},
|
||||
"ciphers": { "count": 1000 }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "../../../schemas/preset.schema.json",
|
||||
"organization": {
|
||||
"name": "No Density Baseline Test",
|
||||
"domain": "no-density-baseline.example",
|
||||
"planType": "enterprise-annually"
|
||||
},
|
||||
"users": { "count": 50 },
|
||||
"groups": { "count": 5 },
|
||||
"collections": { "count": 20 },
|
||||
"ciphers": { "count": 200 }
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"$schema": "../../../schemas/preset.schema.json",
|
||||
"organization": {
|
||||
"name": "Density Test Org",
|
||||
"domain": "density-test.example",
|
||||
"planType": "enterprise-annually"
|
||||
},
|
||||
"users": { "count": 100, "realisticStatusMix": true },
|
||||
"groups": { "count": 10 },
|
||||
"collections": { "count": 50 },
|
||||
"density": {
|
||||
"membership": { "shape": "powerLaw", "skew": 0.8 },
|
||||
"collectionFanOut": {
|
||||
"min": 1,
|
||||
"max": 5,
|
||||
"shape": "powerLaw",
|
||||
"emptyGroupRate": 0.2
|
||||
},
|
||||
"directAccessRatio": 0.6,
|
||||
"permissions": {
|
||||
"readOnly": 0.5,
|
||||
"readWrite": 0.3,
|
||||
"manage": 0.15,
|
||||
"hidePasswords": 0.05
|
||||
},
|
||||
"cipherAssignment": { "skew": "heavyRight", "orphanRate": 0.1 }
|
||||
},
|
||||
"ciphers": { "count": 500 }
|
||||
}
|
||||
@@ -71,7 +71,7 @@
|
||||
},
|
||||
"groups": {
|
||||
"type": "object",
|
||||
"description": "Generate random groups with round-robin user assignment.",
|
||||
"description": "Generate random groups with user assignment. Distribution shape is configurable via the density block.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"count": {
|
||||
@@ -84,7 +84,7 @@
|
||||
},
|
||||
"collections": {
|
||||
"type": "object",
|
||||
"description": "Generate random collections with user assignments.",
|
||||
"description": "Generate random collections with user and group assignments. Access patterns are configurable via the density block.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"count": {
|
||||
@@ -176,6 +176,114 @@
|
||||
"description": "Identity provider protocol."
|
||||
}
|
||||
}
|
||||
},
|
||||
"density": {
|
||||
"type": "object",
|
||||
"description": "Density profile controlling how users, groups, collections, and ciphers relate within the seeded organization.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"membership": {
|
||||
"type": "object",
|
||||
"description": "How users are distributed across groups.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"shape": {
|
||||
"type": "string",
|
||||
"enum": ["uniform", "powerLaw", "megaGroup"],
|
||||
"description": "How users spread across groups. 'uniform' = equal sizes, 'powerLaw' = few large groups and many small, 'megaGroup' = one group gets 90%+ of users."
|
||||
},
|
||||
"skew": {
|
||||
"type": "number",
|
||||
"minimum": 0.0,
|
||||
"maximum": 1.0,
|
||||
"description": "Skew intensity for powerLaw and megaGroup shapes. Ignored for uniform."
|
||||
}
|
||||
}
|
||||
},
|
||||
"collectionFanOut": {
|
||||
"type": "object",
|
||||
"description": "How collections are assigned to groups.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"min": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"description": "Minimum collections assigned per non-empty group."
|
||||
},
|
||||
"max": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "Maximum collections assigned per non-empty group."
|
||||
},
|
||||
"shape": {
|
||||
"type": "string",
|
||||
"enum": ["uniform", "powerLaw", "frontLoaded"],
|
||||
"description": "How collections spread across groups. 'uniform' = equal counts, 'powerLaw' = few groups get many collections, 'frontLoaded' = first 10% of collections get max fan-out, rest get min."
|
||||
},
|
||||
"emptyGroupRate": {
|
||||
"type": "number",
|
||||
"minimum": 0.0,
|
||||
"maximum": 1.0,
|
||||
"description": "Fraction of groups that have zero users assigned to them (0.0-1.0)."
|
||||
}
|
||||
}
|
||||
},
|
||||
"directAccessRatio": {
|
||||
"type": "number",
|
||||
"minimum": 0.0,
|
||||
"maximum": 1.0,
|
||||
"description": "Fraction of access paths that are direct CollectionUser assignments. 1.0 = all direct, 0.0 = all group-mediated."
|
||||
},
|
||||
"permissions": {
|
||||
"type": "object",
|
||||
"description": "How Manage, ReadOnly, HidePasswords, and ReadWrite permissions are distributed across collection access assignments.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"manage": {
|
||||
"type": "number",
|
||||
"minimum": 0.0,
|
||||
"maximum": 1.0,
|
||||
"description": "Weight for Manage permission (0.0-1.0). All four weights must sum to 1.0."
|
||||
},
|
||||
"readOnly": {
|
||||
"type": "number",
|
||||
"minimum": 0.0,
|
||||
"maximum": 1.0,
|
||||
"description": "Weight for ReadOnly permission (0.0-1.0). All four weights must sum to 1.0."
|
||||
},
|
||||
"hidePasswords": {
|
||||
"type": "number",
|
||||
"minimum": 0.0,
|
||||
"maximum": 1.0,
|
||||
"description": "Weight for HidePasswords permission (0.0-1.0). All four weights must sum to 1.0."
|
||||
},
|
||||
"readWrite": {
|
||||
"type": "number",
|
||||
"minimum": 0.0,
|
||||
"maximum": 1.0,
|
||||
"description": "Weight for ReadWrite permission (0.0-1.0). All four weights must sum to 1.0."
|
||||
}
|
||||
}
|
||||
},
|
||||
"cipherAssignment": {
|
||||
"type": "object",
|
||||
"description": "How ciphers are distributed across collections.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"skew": {
|
||||
"type": "string",
|
||||
"enum": ["uniform", "heavyRight"],
|
||||
"description": "How ciphers spread across collections. 'uniform' = equal counts, 'heavyRight' = few collections hold most ciphers while most collections are sparse."
|
||||
},
|
||||
"orphanRate": {
|
||||
"type": "number",
|
||||
"minimum": 0.0,
|
||||
"maximum": 1.0,
|
||||
"description": "Fraction of org ciphers with no collection assignment."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Seeder.Data.Distributions;
|
||||
using Bit.Seeder.Data.Enums;
|
||||
using Bit.Seeder.Data.Static;
|
||||
using Bit.Seeder.Factories;
|
||||
using Bit.Seeder.Options;
|
||||
using Bit.Seeder.Pipeline;
|
||||
|
||||
namespace Bit.Seeder.Steps;
|
||||
@@ -10,14 +12,16 @@ internal sealed class CreateCollectionsStep : IStep
|
||||
{
|
||||
private readonly int _count;
|
||||
private readonly OrgStructureModel? _structure;
|
||||
private readonly DensityProfile? _density;
|
||||
|
||||
private CreateCollectionsStep(int count, OrgStructureModel? structure)
|
||||
private CreateCollectionsStep(int count, OrgStructureModel? structure, DensityProfile? density = null)
|
||||
{
|
||||
_count = count;
|
||||
_structure = structure;
|
||||
_density = density;
|
||||
}
|
||||
|
||||
internal static CreateCollectionsStep FromCount(int count) => new(count, null);
|
||||
internal static CreateCollectionsStep FromCount(int count, DensityProfile? density = null) => new(count, null, density);
|
||||
|
||||
internal static CreateCollectionsStep FromStructure(OrgStructureModel structure) => new(0, structure);
|
||||
|
||||
@@ -44,27 +48,159 @@ internal sealed class CreateCollectionsStep : IStep
|
||||
}
|
||||
|
||||
var collectionIds = collections.Select(c => c.Id).ToList();
|
||||
var collectionUsers = new List<CollectionUser>();
|
||||
|
||||
// User assignment: cycling 1-3 collections per user
|
||||
if (collections.Count > 0 && hardenedOrgUserIds.Count > 0)
|
||||
{
|
||||
foreach (var (orgUserId, userIndex) in hardenedOrgUserIds.Select((id, i) => (id, i)))
|
||||
{
|
||||
var maxAssignments = Math.Min((userIndex % 3) + 1, collections.Count);
|
||||
for (var j = 0; j < maxAssignments; j++)
|
||||
{
|
||||
collectionUsers.Add(CollectionUserSeeder.Create(
|
||||
collections[(userIndex + j) % collections.Count].Id,
|
||||
orgUserId,
|
||||
readOnly: j > 0,
|
||||
manage: j == 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
context.Collections.AddRange(collections);
|
||||
context.Registry.CollectionIds.AddRange(collectionIds);
|
||||
context.CollectionUsers.AddRange(collectionUsers);
|
||||
|
||||
if (collections.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_density == null)
|
||||
{
|
||||
var collectionUsers = new List<CollectionUser>();
|
||||
if (hardenedOrgUserIds.Count > 0)
|
||||
{
|
||||
foreach (var (orgUserId, userIndex) in hardenedOrgUserIds.Select((id, i) => (id, i)))
|
||||
{
|
||||
var maxAssignments = Math.Min((userIndex % 3) + 1, collections.Count);
|
||||
for (var j = 0; j < maxAssignments; j++)
|
||||
{
|
||||
collectionUsers.Add(CollectionUserSeeder.Create(
|
||||
collections[(userIndex + j) % collections.Count].Id,
|
||||
orgUserId,
|
||||
readOnly: j > 0,
|
||||
manage: j == 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
context.CollectionUsers.AddRange(collectionUsers);
|
||||
return;
|
||||
}
|
||||
|
||||
var groupIds = context.Registry.GroupIds;
|
||||
|
||||
if (_density.DirectAccessRatio < 1.0 && groupIds.Count > 0)
|
||||
{
|
||||
var collectionGroups = BuildCollectionGroups(collectionIds, groupIds);
|
||||
ApplyGroupPermissions(collectionGroups, _density.PermissionDistribution);
|
||||
context.CollectionGroups.AddRange(collectionGroups);
|
||||
}
|
||||
|
||||
var directUserCount = (int)(hardenedOrgUserIds.Count * _density.DirectAccessRatio);
|
||||
if (directUserCount > 0)
|
||||
{
|
||||
var directUsers = BuildCollectionUsers(collectionIds, hardenedOrgUserIds, directUserCount);
|
||||
ApplyUserPermissions(directUsers, _density.PermissionDistribution);
|
||||
context.CollectionUsers.AddRange(directUsers);
|
||||
}
|
||||
}
|
||||
|
||||
internal List<CollectionGroup> BuildCollectionGroups(List<Guid> collectionIds, List<Guid> groupIds)
|
||||
{
|
||||
var min = _density!.CollectionFanOutMin;
|
||||
var max = _density.CollectionFanOutMax;
|
||||
var result = new List<CollectionGroup>(collectionIds.Count * (min + max + 1) / 2);
|
||||
|
||||
for (var c = 0; c < collectionIds.Count; c++)
|
||||
{
|
||||
var fanOut = ComputeFanOut(c, collectionIds.Count, min, max);
|
||||
fanOut = Math.Min(fanOut, groupIds.Count);
|
||||
|
||||
for (var g = 0; g < fanOut; g++)
|
||||
{
|
||||
result.Add(CollectionGroupSeeder.Create(
|
||||
collectionIds[c],
|
||||
groupIds[(c + g) % groupIds.Count]));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
internal int ComputeFanOut(int collectionIndex, int collectionCount, int min, int max)
|
||||
{
|
||||
var range = max - min + 1;
|
||||
if (range <= 1)
|
||||
{
|
||||
return min;
|
||||
}
|
||||
|
||||
switch (_density!.FanOutShape)
|
||||
{
|
||||
case CollectionFanOutShape.PowerLaw:
|
||||
// Zipf weight normalized against index 0 (where weight = 1.0), scaled to [min, max]
|
||||
var weight = 1.0 / Math.Pow(collectionIndex + 1, 0.8);
|
||||
return min + (int)(weight * (range - 1) + 0.5);
|
||||
|
||||
case CollectionFanOutShape.FrontLoaded:
|
||||
// First 10% of collections get max fan-out, rest get min
|
||||
var topCount = Math.Max(1, collectionCount / 10);
|
||||
return collectionIndex < topCount ? max : min;
|
||||
|
||||
case CollectionFanOutShape.Uniform:
|
||||
return min + (collectionIndex % range);
|
||||
|
||||
default:
|
||||
throw new InvalidOperationException(
|
||||
$"Unhandled CollectionFanOutShape: {_density.FanOutShape}");
|
||||
}
|
||||
}
|
||||
|
||||
internal static List<CollectionUser> BuildCollectionUsers(
|
||||
List<Guid> collectionIds, List<Guid> userIds, int directUserCount)
|
||||
{
|
||||
var result = new List<CollectionUser>(directUserCount * 2);
|
||||
for (var i = 0; i < directUserCount; i++)
|
||||
{
|
||||
var maxAssignments = Math.Min((i % 3) + 1, collectionIds.Count);
|
||||
for (var j = 0; j < maxAssignments; j++)
|
||||
{
|
||||
result.Add(CollectionUserSeeder.Create(
|
||||
collectionIds[(i + j) % collectionIds.Count],
|
||||
userIds[i]));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static (bool ReadOnly, bool HidePasswords, bool Manage) ResolvePermission(
|
||||
Distribution<PermissionWeight> distribution, int index, int total)
|
||||
{
|
||||
var weight = distribution.Select(index, total);
|
||||
return weight switch
|
||||
{
|
||||
PermissionWeight.ReadOnly => (true, false, false),
|
||||
PermissionWeight.HidePasswords => (false, true, false),
|
||||
PermissionWeight.Manage => (false, false, true),
|
||||
PermissionWeight.ReadWrite => (false, false, false),
|
||||
_ => throw new InvalidOperationException(
|
||||
$"Unhandled PermissionWeight: {weight}")
|
||||
};
|
||||
}
|
||||
|
||||
internal static void ApplyGroupPermissions(
|
||||
List<CollectionGroup> assignments, Distribution<PermissionWeight> distribution)
|
||||
{
|
||||
for (var i = 0; i < assignments.Count; i++)
|
||||
{
|
||||
var (readOnly, hidePasswords, manage) = ResolvePermission(distribution, i, assignments.Count);
|
||||
assignments[i].ReadOnly = readOnly;
|
||||
assignments[i].HidePasswords = hidePasswords;
|
||||
assignments[i].Manage = manage;
|
||||
}
|
||||
}
|
||||
|
||||
internal static void ApplyUserPermissions(
|
||||
List<CollectionUser> assignments, Distribution<PermissionWeight> distribution)
|
||||
{
|
||||
for (var i = 0; i < assignments.Count; i++)
|
||||
{
|
||||
var (readOnly, hidePasswords, manage) = ResolvePermission(distribution, i, assignments.Count);
|
||||
assignments[i].ReadOnly = readOnly;
|
||||
assignments[i].HidePasswords = hidePasswords;
|
||||
assignments[i].Manage = manage;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Seeder.Factories;
|
||||
using Bit.Seeder.Options;
|
||||
using Bit.Seeder.Pipeline;
|
||||
|
||||
namespace Bit.Seeder.Steps;
|
||||
|
||||
internal sealed class CreateGroupsStep(int count) : IStep
|
||||
internal sealed class CreateGroupsStep(int count, DensityProfile? density = null) : IStep
|
||||
{
|
||||
private readonly DensityProfile? _density = density;
|
||||
|
||||
public void Execute(SeederContext context)
|
||||
{
|
||||
var orgId = context.RequireOrgId();
|
||||
@@ -13,7 +16,7 @@ internal sealed class CreateGroupsStep(int count) : IStep
|
||||
|
||||
var groups = new List<Group>(count);
|
||||
var groupIds = new List<Guid>(count);
|
||||
var groupUsers = new List<GroupUser>();
|
||||
var groupUsers = new List<GroupUser>(hardenedOrgUserIds.Count);
|
||||
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
@@ -22,18 +25,115 @@ internal sealed class CreateGroupsStep(int count) : IStep
|
||||
groupIds.Add(group.Id);
|
||||
}
|
||||
|
||||
// Round-robin user assignment
|
||||
if (groups.Count > 0 && hardenedOrgUserIds.Count > 0)
|
||||
context.Groups.AddRange(groups);
|
||||
|
||||
if (_density == null)
|
||||
{
|
||||
for (var i = 0; i < hardenedOrgUserIds.Count; i++)
|
||||
if (groups.Count > 0 && hardenedOrgUserIds.Count > 0)
|
||||
{
|
||||
var groupId = groupIds[i % groups.Count];
|
||||
groupUsers.Add(GroupUserSeeder.Create(groupId, hardenedOrgUserIds[i]));
|
||||
for (var i = 0; i < hardenedOrgUserIds.Count; i++)
|
||||
{
|
||||
var groupId = groupIds[i % groups.Count];
|
||||
groupUsers.Add(GroupUserSeeder.Create(groupId, hardenedOrgUserIds[i]));
|
||||
}
|
||||
}
|
||||
|
||||
context.Registry.GroupIds.AddRange(groupIds);
|
||||
}
|
||||
else
|
||||
{
|
||||
var emptyCount = (int)(groups.Count * _density.EmptyGroupRate);
|
||||
var activeGroupIds = groupIds.Take(groups.Count - emptyCount).ToList();
|
||||
|
||||
context.Registry.GroupIds.AddRange(activeGroupIds);
|
||||
|
||||
if (activeGroupIds.Count > 0 && hardenedOrgUserIds.Count > 0)
|
||||
{
|
||||
var allocations = ComputeUsersPerGroup(activeGroupIds.Count, hardenedOrgUserIds.Count);
|
||||
var userIndex = 0;
|
||||
for (var g = 0; g < activeGroupIds.Count; g++)
|
||||
{
|
||||
for (var u = 0; u < allocations[g]; u++)
|
||||
{
|
||||
groupUsers.Add(GroupUserSeeder.Create(activeGroupIds[g], hardenedOrgUserIds[userIndex++]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
context.Groups.AddRange(groups);
|
||||
context.Registry.GroupIds.AddRange(groupIds);
|
||||
context.GroupUsers.AddRange(groupUsers);
|
||||
}
|
||||
|
||||
internal int[] ComputeUsersPerGroup(int groupCount, int userCount)
|
||||
{
|
||||
var allocations = new int[groupCount];
|
||||
|
||||
switch (_density!.MembershipShape)
|
||||
{
|
||||
case Data.Enums.MembershipDistributionShape.Uniform:
|
||||
for (var i = 0; i < userCount; i++)
|
||||
{
|
||||
allocations[i % groupCount]++;
|
||||
}
|
||||
break;
|
||||
|
||||
case Data.Enums.MembershipDistributionShape.PowerLaw:
|
||||
// Maps MembershipSkew [0,1] to Zipf exponent [0.5, 2.0]
|
||||
var exponent = 0.5 + _density.MembershipSkew * 1.5;
|
||||
var fractional = new double[groupCount];
|
||||
var totalWeight = 0.0;
|
||||
for (var i = 0; i < groupCount; i++)
|
||||
{
|
||||
fractional[i] = 1.0 / Math.Pow(i + 1, exponent);
|
||||
totalWeight += fractional[i];
|
||||
}
|
||||
|
||||
var assigned = 0;
|
||||
for (var i = 0; i < groupCount; i++)
|
||||
{
|
||||
fractional[i] = fractional[i] / totalWeight * userCount;
|
||||
allocations[i] = (int)fractional[i];
|
||||
assigned += allocations[i];
|
||||
}
|
||||
|
||||
// Largest-remainder: give +1 to groups that lost the most from truncation
|
||||
var remainder = userCount - assigned;
|
||||
if (remainder > 0)
|
||||
{
|
||||
var indices = Enumerable.Range(0, groupCount)
|
||||
.OrderByDescending(i => fractional[i] - allocations[i])
|
||||
.Take(remainder);
|
||||
foreach (var i in indices)
|
||||
{
|
||||
allocations[i]++;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case Data.Enums.MembershipDistributionShape.MegaGroup:
|
||||
// Maps MembershipSkew [0,1] to mega group share [50%, 95%]
|
||||
var megaFraction = 0.5 + _density.MembershipSkew * 0.45;
|
||||
var megaCount = (int)(userCount * megaFraction);
|
||||
allocations[0] = megaCount;
|
||||
var remaining = userCount - megaCount;
|
||||
if (groupCount > 1)
|
||||
{
|
||||
for (var i = 0; i < remaining; i++)
|
||||
{
|
||||
allocations[1 + (i % (groupCount - 1))]++;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
allocations[0] += remaining;
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new InvalidOperationException(
|
||||
$"Unhandled MembershipDistributionShape: {_density.MembershipShape}");
|
||||
}
|
||||
|
||||
return allocations;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ using Bit.Seeder.Data.Distributions;
|
||||
using Bit.Seeder.Data.Enums;
|
||||
using Bit.Seeder.Data.Static;
|
||||
using Bit.Seeder.Factories;
|
||||
using Bit.Seeder.Options;
|
||||
using Bit.Seeder.Pipeline;
|
||||
|
||||
namespace Bit.Seeder.Steps;
|
||||
@@ -16,7 +17,7 @@ namespace Bit.Seeder.Steps;
|
||||
/// <remarks>
|
||||
/// Requires <see cref="InitGeneratorStep"/> to have run first. Picks cipher types (login, card,
|
||||
/// identity, secureNote, sshKey) from a configurable distribution, delegates to the existing
|
||||
/// cipher factories, and assigns each cipher to collections round-robin. Designed for load
|
||||
/// cipher factories, and assigns ciphers to collections (configurable via density profile). Designed for load
|
||||
/// testing scenarios where you need thousands of realistic vault items.
|
||||
/// </remarks>
|
||||
/// <seealso cref="InitGeneratorStep"/>
|
||||
@@ -25,8 +26,11 @@ internal sealed class GenerateCiphersStep(
|
||||
int count,
|
||||
Distribution<CipherType>? typeDist = null,
|
||||
Distribution<PasswordStrength>? pwDist = null,
|
||||
bool assignFolders = false) : IStep
|
||||
bool assignFolders = false,
|
||||
DensityProfile? density = null) : IStep
|
||||
{
|
||||
private readonly DensityProfile? _density = density;
|
||||
|
||||
public void Execute(SeederContext context)
|
||||
{
|
||||
if (count == 0)
|
||||
@@ -48,7 +52,7 @@ internal sealed class GenerateCiphersStep(
|
||||
|
||||
var ciphers = new List<Cipher>(count);
|
||||
var cipherIds = new List<Guid>(count);
|
||||
var collectionCiphers = new List<CollectionCipher>();
|
||||
var collectionCiphers = new List<CollectionCipher>(count + count / 3);
|
||||
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
@@ -63,27 +67,57 @@ internal sealed class GenerateCiphersStep(
|
||||
|
||||
ciphers.Add(cipher);
|
||||
cipherIds.Add(cipher.Id);
|
||||
}
|
||||
|
||||
// Collection assignment
|
||||
if (collectionIds.Count == 0)
|
||||
if (collectionIds.Count > 0)
|
||||
{
|
||||
if (_density == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
collectionCiphers.Add(new CollectionCipher
|
||||
{
|
||||
CipherId = cipher.Id,
|
||||
CollectionId = collectionIds[i % collectionIds.Count]
|
||||
});
|
||||
|
||||
// Every 3rd cipher gets assigned to an additional collection
|
||||
if (i % 3 == 0 && collectionIds.Count > 1)
|
||||
{
|
||||
collectionCiphers.Add(new CollectionCipher
|
||||
for (var i = 0; i < ciphers.Count; i++)
|
||||
{
|
||||
CipherId = cipher.Id,
|
||||
CollectionId = collectionIds[(i + 1) % collectionIds.Count]
|
||||
});
|
||||
collectionCiphers.Add(new CollectionCipher
|
||||
{
|
||||
CipherId = ciphers[i].Id,
|
||||
CollectionId = collectionIds[i % collectionIds.Count]
|
||||
});
|
||||
|
||||
if (i % 3 == 0 && collectionIds.Count > 1)
|
||||
{
|
||||
collectionCiphers.Add(new CollectionCipher
|
||||
{
|
||||
CipherId = ciphers[i].Id,
|
||||
CollectionId = collectionIds[(i + 1) % collectionIds.Count]
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var orphanCount = (int)(count * _density.OrphanCipherRate);
|
||||
var nonOrphanCount = count - orphanCount;
|
||||
|
||||
for (var i = 0; i < nonOrphanCount; i++)
|
||||
{
|
||||
int collectionIndex;
|
||||
if (_density.CipherSkew == CipherCollectionSkew.HeavyRight)
|
||||
{
|
||||
// Sqrt curve: later collections accumulate more ciphers (right-heavy skew)
|
||||
var normalized = Math.Pow((double)i / nonOrphanCount, 0.5);
|
||||
collectionIndex = Math.Min((int)(normalized * collectionIds.Count), collectionIds.Count - 1);
|
||||
}
|
||||
else
|
||||
{
|
||||
collectionIndex = i % collectionIds.Count;
|
||||
}
|
||||
|
||||
var collectionId = collectionIds[collectionIndex];
|
||||
|
||||
collectionCiphers.Add(new CollectionCipher
|
||||
{
|
||||
CipherId = ciphers[i].Id,
|
||||
CollectionId = collectionId
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user