7.9 KiB
Seeder - Claude Code Context
Ubiquitous Language
The Seeder follows six core patterns:
-
Factories - Create ONE entity with encryption. Named
{Entity}SeederwithCreate{Type}{Entity}()methods. Do not interact with database. -
Recipes - Orchestrate MANY entities. Named
{DomainConcept}Recipe. MUST haveSeed()method as primary interface, notAddToOrganization()or similar. Use parameters for variations, not separate methods. Compose Factories internally. -
Models - DTOs bridging SDK ↔ Server format. Named
{Entity}ViewDto(plaintext),Encrypted{Entity}Dto(SDK format). Pure data, no logic. -
Scenes - Complete test scenarios with ID mangling. Implement
IScene<TReques, TResult>. Async, returnsSceneResult<TResult>with MangleMap and result property populated withTResult. Named{Scenario}Scene. -
Queries - Read-only data retrieval. Implement
IQuery<TRequest, TResult>. Synchronous, no DB modifications. Named{DataToRetrieve}Query. -
Data - Static, filterable test data collections (Companies, Passwords, Names, OrgStructures). Deterministic, composable. Enums provide public API.
The Recipe Contract
Recipes follow strict rules (like a cooking recipe that you follow completely):
- A Recipe SHALL have exactly one public method named
Seed() - A Recipe MUST produce one cohesive result (like baking one complete cake)
- A Recipe MAY have overloaded
Seed()methods with different parameters - A Recipe SHALL use private helper methods for internal steps
- A Recipe SHALL use BulkCopy for performance when creating multiple entities
- A Recipe SHALL compose Factories for individual entity creation
- A Recipe SHALL NOT expose implementation details as public methods
Current violations (to be refactored):
CiphersRecipe- UsesAddLoginCiphersToOrganization()instead ofSeed()CollectionsRecipe- UsesAddFromStructure()andAddToOrganization()instead ofSeed()GroupsRecipe- UsesAddToOrganization()instead ofSeed()OrganizationDomainRecipe- UsesAddVerifiedDomainToOrganization()instead ofSeed()
Pattern Decision Tree
Need to create test data?
├─ ONE entity with encryption? → Factory
├─ MANY entities as cohesive operation? → Recipe
├─ Complete test scenario with ID mangling to be used by the Seeder API? → Scene
├─ READ existing seeded data? → Query
└─ Data transformation SDK ↔ Server? → Model
When to Use the Seeder
✅ Use for:
- Local development database setup
- Integration test data creation
- Performance testing with realistic encrypted data
❌ Do NOT use for:
- Production data
- Copying real user vaults (use backup/restore instead)
Zero-Knowledge Architecture
Critical Principle: Unencrypted vault data never leaves the client. The server never sees plaintext.
Why Seeder Uses the Rust SDK
The Seeder must behave exactly like any other Bitwarden client. Since the server:
- Never receives plaintext
- Cannot perform encryption (doesn't have keys)
- Only stores/retrieves encrypted blobs
...the Seeder cannot simply write plaintext to the database. It must:
- Generate encryption keys (like a client does during account setup)
- Encrypt vault data client-side (using the same SDK the real clients use)
- Store only the encrypted result
This is why we use the Rust SDK via FFI - it's the same cryptographic implementation used by the official clients.
Cipher Encryption Architecture
The Two-State Pattern
Bitwarden uses a clean separation between encrypted and decrypted data:
| State | SDK Type | Description | Stored in DB? |
|---|---|---|---|
| Plaintext | CipherView |
Decrypted, human-readable | Never |
| Encrypted | Cipher |
EncString values | Yes |
Encryption flow:
CipherView (plaintext) → encrypt_composite() → Cipher (encrypted)
Decryption flow:
Cipher (encrypted) → decrypt() → CipherView (plaintext)
SDK vs Server Format Difference
Critical: The SDK and server use different JSON structures.
SDK Cipher (nested):
{
"name": "2.abc...",
"login": {
"username": "2.def...",
"password": "2.ghi..."
}
}
Server Cipher.Data (flat CipherLoginData):
{
"Name": "2.abc...",
"Username": "2.def...",
"Password": "2.ghi..."
}
Data Flow in Seeder
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────────┐
│ CipherViewDto │────▶│ Rust SDK │────▶│ EncryptedCipherDto │
│ (plaintext) │ │ encrypt_cipher │ │ (SDK Cipher) │
└─────────────────┘ └──────────────────┘ └─────────────────────┘
│
▼
┌───────────────────────┐
│ TransformToServer │
│ (flatten nested → │
│ flat structure) │
└───────────────────────┘
│
▼
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────────┐
│ Server Cipher │◀────│ CipherLoginData │◀────│ Flattened JSON │
│ Entity │ │ (serialized) │ │ │
└─────────────────┘ └──────────────────┘ └─────────────────────┘
Key Hierarchy
Bitwarden uses a two-level encryption hierarchy:
- User/Organization Key - Encrypts the cipher's individual key
- Cipher Key (optional) - Encrypts the actual cipher data
For seeding, we use the organization's symmetric key directly (no per-cipher key).
Rust SDK FFI
Error Handling
SDK functions return JSON with an "error" field on failure:
{ "error": "Failed to parse CipherView JSON" }
Always check for "error" in the response before parsing.
Testing
Integration tests in test/SeederApi.IntegrationTest verify:
- Roundtrip encryption - Encrypt then decrypt preserves plaintext
- Server format compatibility - Output matches CipherLoginData structure
- Field encryption - Custom fields are properly encrypted
- Security - Plaintext never appears in encrypted output
Common Patterns
Creating a Cipher
var sdk = new RustSdkService();
var seeder = new CipherSeeder(sdk);
var cipher = seeder.CreateOrganizationLoginCipher(
organizationId,
orgKey, // Base64-encoded symmetric key
name: "My Login",
username: "user@example.com",
password: "secret123");
Bulk Cipher Creation
var recipe = new CiphersRecipe(dbContext, sdkService);
var cipherIds = recipe.AddLoginCiphersToOrganization(
organizationId,
orgKey,
collectionIds,
count: 100);
Security Reminders
- Generated test passwords are intentionally weak (
asdfasdfasdf) - Never commit database dumps containing seeded data to version control
- Seeded keys are for testing only - regenerate for each test run