1
0
mirror of https://github.com/bitwarden/server synced 2026-02-19 19:03:30 +00:00

Enhance seeder allowing for a user-defined password (#7021)

This commit is contained in:
Mick Letofsky
2026-02-18 06:48:05 +01:00
committed by GitHub
parent 3ed9be1384
commit 81120bd24e
14 changed files with 78 additions and 85 deletions

View File

@@ -113,7 +113,7 @@ public class Program
var stopwatch = Stopwatch.StartNew();
Console.WriteLine($"Seeding organization from preset '{args.Preset}'...");
var result = recipe.Seed(args.Preset!);
var result = recipe.Seed(args.Preset!, args.Password);
stopwatch.Stop();
PrintSeedResult(result, stopwatch.Elapsed);

View File

@@ -11,7 +11,7 @@ dotnet build
dotnet run -- <command> [options]
```
**Login Credentials:** All seeded users use password `asdfasdfasdf`. The owner email is `owner@<domain>`.
**Login Credentials:** All seeded users use password `asdfasdfasdf` by default (override with `--password`). The owner email is `owner@<domain>`.
## Commands
@@ -29,6 +29,8 @@ dotnet run -- seed --preset dunder-mifflin-full --mangle
# Large enterprise preset for performance testing
dotnet run -- seed --preset large-enterprise
dotnet run -- seed --preset dunder-mifflin-full --password "MyTestPassword1" --mangle
```
### `organization` - Users Only (No Vault Data)
@@ -65,4 +67,7 @@ dotnet run -- vault-organization -n ApacOrg -d apac.test -u 17 -c 600 -g 12 --re
# With ID mangling for test isolation (prevents collisions with existing data)
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"
```

View File

@@ -8,7 +8,7 @@ namespace Bit.DbSeederUtility;
/// </summary>
public class SeedArgs : IArgumentModel
{
[Option('p', "preset", Description = "Name of embedded preset to load (e.g., 'dunder-mifflin-full')")]
[Option("preset", Description = "Name of embedded preset to load")]
public string? Preset { get; set; }
[Option('l', "list", Description = "List all available presets and fixtures")]
@@ -17,6 +17,9 @@ public class SeedArgs : IArgumentModel
[Option("mangle", Description = "Enable mangling for test isolation")]
public bool Mangle { get; set; }
[Option("password", Description = "Password for all seeded accounts (default: asdfasdfasdf)")]
public string? Password { get; set; }
public void Validate()
{
// List mode is standalone

View File

@@ -37,6 +37,9 @@ public class VaultOrganizationArgs : IArgumentModel
[Option("mangle", Description = "Enable mangling for test isolation")]
public bool Mangle { get; set; } = false;
[Option("password", Description = "Password for all seeded accounts (default: asdfasdfasdf)")]
public string? Password { get; set; }
public void Validate()
{
if (Users < 1)
@@ -74,7 +77,8 @@ public class VaultOrganizationArgs : IArgumentModel
Groups = Groups,
RealisticStatusMix = MixStatuses,
StructureModel = ParseOrgStructure(Structure),
Region = ParseGeographicRegion(Region)
Region = ParseGeographicRegion(Region),
Password = Password
};
private static OrgStructureModel? ParseOrgStructure(string? structure)

View File

@@ -98,6 +98,6 @@ _seed = options.Seed ?? StableHash.ToInt32(options.Domain);
## Security Reminders
- Test password: `asdfasdfasdf`
- Default test password: `asdfasdfasdf` (overridable via `--password` CLI flag or `SeederSettings`)
- Never commit database dumps with seeded data
- Seeded keys are for testing only

View File

@@ -83,4 +83,9 @@ public class OrganizationVaultOptions
/// Seed for deterministic data generation. When null, derived from Domain hash.
/// </summary>
public int? Seed { get; init; }
/// <summary>
/// Password for all seeded accounts. Defaults to "asdfasdfasdf" if not specified.
/// </summary>
public string? Password { get; init; }
}

View File

@@ -18,11 +18,13 @@ internal sealed class PresetExecutor(DatabaseContext db, IMapper mapper)
/// <param name="presetName">Name of the embedded preset (e.g., "dunder-mifflin-full")</param>
/// <param name="passwordHasher">Password hasher for user creation</param>
/// <param name="manglerService">Mangler service for test isolation</param>
/// <param name="password">Optional password for all seeded accounts</param>
/// <returns>Execution result with organization ID and entity counts</returns>
internal ExecutionResult Execute(
string presetName,
IPasswordHasher<User> passwordHasher,
IManglerService manglerService)
IManglerService manglerService,
string? password = null)
{
var reader = new SeedReader();
@@ -30,6 +32,7 @@ internal sealed class PresetExecutor(DatabaseContext db, IMapper mapper)
services.AddSingleton(passwordHasher);
services.AddSingleton(manglerService);
services.AddSingleton<ISeedReader>(reader);
services.AddSingleton(new SeederSettings(password));
services.AddSingleton(db);
PresetLoader.RegisterRecipe(presetName, reader, services);

View File

@@ -19,4 +19,15 @@ internal static class SeederContextExtensions
internal static ISeedReader GetSeedReader(this SeederContext context) =>
context.Services.GetRequiredService<ISeedReader>();
internal static SeederSettings GetSettings(this SeederContext context) =>
context.Services.GetRequiredService<SeederSettings>();
internal static string GetPassword(this SeederContext context) =>
context.GetSettings().Password ?? Factories.UserSeeder.DefaultPassword;
}
/// <summary>
/// Runtime settings for a seeding operation, registered in DI.
/// </summary>
internal sealed record SeederSettings(string? Password = null);

View File

@@ -27,10 +27,11 @@ public class OrganizationFromPresetRecipe(
/// Seeds an organization from an embedded preset.
/// </summary>
/// <param name="presetName">Name of the embedded preset (e.g., "dunder-mifflin-full")</param>
/// <param name="password">Optional password for all seeded accounts</param>
/// <returns>The organization ID and summary statistics.</returns>
public SeedResult Seed(string presetName)
public SeedResult Seed(string presetName, string? password = null)
{
var result = _executor.Execute(presetName, passwordHasher, manglerService);
var result = _executor.Execute(presetName, passwordHasher, manglerService, password);
return new SeedResult(
result.OrganizationId,

View File

@@ -54,6 +54,7 @@ public class OrganizationWithVaultRecipe(
public Guid Seed(OrganizationVaultOptions options)
{
_ctx = GeneratorContext.FromOptions(options);
var password = options.Password ?? UserSeeder.DefaultPassword;
var seats = Math.Max(options.Users + 1, _minimumOrgSeats);
var orgKeys = RustSdkService.GenerateOrganizationKeys();
@@ -63,7 +64,11 @@ public class OrganizationWithVaultRecipe(
options.Name, options.Domain, seats, orgKeys.PublicKey, orgKeys.PrivateKey);
// Create owner user via factory
var ownerUser = UserSeeder.Create($"owner@{options.Domain}", passwordHasher, manglerService);
var ownerEmail = $"owner@{options.Domain}";
var mangledOwnerEmail = manglerService.Mangle(ownerEmail);
var ownerKeys = RustSdkService.GenerateUserKeys(mangledOwnerEmail, password);
var ownerUser = UserSeeder.Create(mangledOwnerEmail, passwordHasher, manglerService, keys: ownerKeys, password: password);
var ownerOrgKey = RustSdkService.GenerateUserOrganizationKey(ownerUser.PublicKey!, orgKeys.Key);
var ownerOrgUser = organization.CreateOrganizationUserWithKey(
ownerUser, OrganizationUserType.Owner, OrganizationUserStatusType.Confirmed, ownerOrgKey);
@@ -77,8 +82,8 @@ public class OrganizationWithVaultRecipe(
{
var email = $"user{i}@{options.Domain}";
var mangledEmail = manglerService.Mangle(email);
var userKeys = RustSdkService.GenerateUserKeys(mangledEmail, UserSeeder.DefaultPassword);
var memberUser = UserSeeder.Create(mangledEmail, passwordHasher, manglerService, keys: userKeys);
var userKeys = RustSdkService.GenerateUserKeys(mangledEmail, password);
var memberUser = UserSeeder.Create(mangledEmail, passwordHasher, manglerService, keys: userKeys, password: password);
memberUsersWithKeys.Add(new UserWithKey(memberUser, userKeys.Key));
var status = useRealisticMix

View File

@@ -35,73 +35,40 @@ Vault items - logins, cards, identities, secure notes.
| `card` | `card` | Payment card details |
| `identity` | `identity` | Personal identity info |
| `secureNote` | — | Uses `notes` field only |
| `sshKey` | `sshKey` | SSH key credentials |
**Example** (`fixtures/ciphers/banking-logins.json`):
```json
{
"$schema": "../../schemas/cipher.schema.json",
"items": [
{
"type": "login",
"name": "Chase Bank",
"login": {
"username": "myuser",
"password": "MyP@ssw0rd",
"uris": [{ "uri": "https://chase.com", "match": "domain" }]
}
}
]
}
```
**Schema**: `schemas/cipher.schema.json`
### Organizations
Organization definitions with name, domain, and seat count.
Organization identity definitions with name and domain. Plan type and seats are defined in presets, not org fixtures.
```json
{
"$schema": "../../schemas/organization.schema.json",
"name": "Acme Corp",
"domain": "acme.com",
"seats": 100
}
```
**Required fields**: `name`, `domain`
**Schema**: `schemas/organization.schema.json`
### Rosters
Complete user/group/collection structures with permissions. User emails auto-generated as `firstName.lastName@domain`.
**User roles**: `owner`, `admin`, `user`, `custom`
**Collection permissions**: `readOnly`, `hidePasswords`, `manage`
See `rosters/dunder-mifflin.json` for a complete 58-user example.
**Schema**: `schemas/roster.schema.json`
**Example**: See `fixtures/rosters/dunder-mifflin.json` for a complete 58-user example
### Presets
Combine organization, roster, and ciphers into complete scenarios.
Combine organization, roster, and ciphers into complete scenarios. Presets can reference fixtures, generate data programmatically, or mix both approaches.
**From fixtures**:
**Key features**:
```json
{
"$schema": "../../schemas/preset.schema.json",
"organization": { "fixture": "acme-corp" },
"roster": { "fixture": "acme-roster" },
"ciphers": { "fixture": "banking-logins" }
}
```
- Reference existing fixtures by name
- Generate users, groups, collections, and ciphers with count parameters
- Add personal ciphers (user-owned, encrypted with user key, not in collections)
- Mix fixture references and generated data
**Mixed approach**:
```json
{
"organization": { "fixture": "acme-corp" },
"users": { "count": 50 },
"ciphers": { "count": 500 }
}
```
**Schema**: `schemas/preset.schema.json`
**Examples**: See `fixtures/presets/` for complete examples including fixture-based, generated, and hybrid approaches
## Validation
@@ -113,19 +80,6 @@ Build errors catch schema violations:
dotnet build util/Seeder/Seeder.csproj
```
## Testing
Add integration test in `test/SeederApi.IntegrationTest/SeedReaderTests.cs`:
```csharp
[Fact]
public void Read_YourFixture_Success()
{
var result = _reader.Read<SeedFile>("ciphers.your-fixture");
Assert.NotEmpty(result.Items);
}
```
## Naming Conventions
| Element | Pattern | Example |
@@ -137,13 +91,7 @@ public void Read_YourFixture_Success()
## Security
- Test password: `asdfasdfasdf`
- Test password: See `UserSeeder.DefaultPassword` constant
- Use fictional names/addresses
- Never commit real passwords or PII
- Never seed production databases
## Examples
- **Small org**: `presets/dunder-mifflin-full.json` (58 users, realistic structure)
- **Browser testing**: `ciphers/autofill-testing.json` (18 specialized items)
- **Real websites**: `ciphers/public-site-logins.json` (90+ website examples)

View File

@@ -13,7 +13,11 @@ internal sealed class CreateOwnerStep : IStep
public void Execute(SeederContext context)
{
var org = context.RequireOrganization();
var owner = UserSeeder.Create($"owner@{context.RequireDomain()}", context.GetPasswordHasher(), context.GetMangler());
var password = context.GetPassword();
var ownerEmail = context.GetMangler().Mangle($"owner@{context.RequireDomain()}");
var userKeys = RustSdkService.GenerateUserKeys(ownerEmail, password);
var owner = UserSeeder.Create(ownerEmail, context.GetPasswordHasher(), context.GetMangler(), keys: userKeys, password: password);
var ownerOrgKey = RustSdkService.GenerateUserOrganizationKey(owner.PublicKey!, context.RequireOrgKey());
var ownerOrgUser = org.CreateOrganizationUserWithKey(
@@ -25,5 +29,6 @@ internal sealed class CreateOwnerStep : IStep
context.Users.Add(owner);
context.OrganizationUsers.Add(ownerOrgUser);
context.Registry.HardenedOrgUserIds.Add(ownerOrgUser.Id);
context.Registry.UserDigests.Add(new EntityRegistry.UserDigest(owner.Id, ownerOrgUser.Id, userKeys.Key));
}
}

View File

@@ -34,10 +34,12 @@ internal sealed class CreateRosterStep(string fixtureName) : IStep
"Each user must have a unique FirstName.LastName combination.");
}
var email = $"{emailPrefix}@{domain}";
var mangledEmail = context.GetMangler().Mangle(email);
var userKeys = RustSdkService.GenerateUserKeys(mangledEmail, UserSeeder.DefaultPassword);
var user = UserSeeder.Create(mangledEmail, context.GetPasswordHasher(), context.GetMangler(), keys: userKeys);
var password = context.GetPassword();
var userKeys = RustSdkService.GenerateUserKeys(mangledEmail, password);
var user = UserSeeder.Create(mangledEmail, context.GetPasswordHasher(), context.GetMangler(), keys: userKeys, password: password);
var userOrgKey = RustSdkService.GenerateUserOrganizationKey(user.PublicKey!, orgKey);
var orgUserType = ParseRole(rosterUser.Role);
var orgUser = org.CreateOrganizationUserWithKey(

View File

@@ -28,13 +28,14 @@ internal sealed class CreateUsersStep(int count, bool realisticStatusMix = false
var organizationUsers = new List<OrganizationUser>(count);
var hardenedOrgUserIds = new List<Guid>();
var userDigests = new List<EntityRegistry.UserDigest>();
var password = context.GetPassword();
for (var i = 0; i < count; i++)
{
var email = $"user{i}@{domain}";
var mangledEmail = context.GetMangler().Mangle(email);
var userKeys = RustSdkService.GenerateUserKeys(mangledEmail, UserSeeder.DefaultPassword);
var user = UserSeeder.Create(mangledEmail, context.GetPasswordHasher(), context.GetMangler(), keys: userKeys);
var userKeys = RustSdkService.GenerateUserKeys(mangledEmail, password);
var user = UserSeeder.Create(mangledEmail, context.GetPasswordHasher(), context.GetMangler(), keys: userKeys, password: password);
var status = statusDistribution.Select(i, count);