From 47a646d6165373191a9ff6afa30be951905e7a03 Mon Sep 17 00:00:00 2001 From: Mick Letofsky Date: Sun, 19 Jul 2026 21:11:24 +0200 Subject: [PATCH 01/15] feat(seeder): model the SSO preset block, fixed org id, and local IdP Add the SsoConfigSeeder factory that builds a SAML SsoConfig, the SeedPreset sso block plus an optional fixed organization id, and the LocalSamlIdp constants for the bundled dev IdP. No pipeline wiring yet. --- .../SsoConfigSeederTests.cs | 49 +++++++++++++++++++ util/Seeder/Data/Static/LocalSamlIdp.cs | 15 ++++++ util/Seeder/Factories/SsoConfigSeeder.cs | 40 +++++++++++++++ util/Seeder/Models/SeedPreset.cs | 9 ++++ util/Seeder/Seeds/schemas/preset.schema.json | 5 ++ 5 files changed, 118 insertions(+) create mode 100644 test/SeederApi.IntegrationTest/SsoConfigSeederTests.cs create mode 100644 util/Seeder/Data/Static/LocalSamlIdp.cs create mode 100644 util/Seeder/Factories/SsoConfigSeeder.cs diff --git a/test/SeederApi.IntegrationTest/SsoConfigSeederTests.cs b/test/SeederApi.IntegrationTest/SsoConfigSeederTests.cs new file mode 100644 index 000000000000..0236c8f6e3c1 --- /dev/null +++ b/test/SeederApi.IntegrationTest/SsoConfigSeederTests.cs @@ -0,0 +1,49 @@ +using Bit.Core.Auth.Enums; +using Bit.Seeder.Factories; +using Xunit; + +namespace Bit.SeederApi.IntegrationTest; + +public class SsoConfigSeederTests +{ + private const string _idpEntityId = "http://localhost:8090/simplesaml/saml2/idp/metadata.php"; + private const string _idpSsoUrl = "http://localhost:8090/simplesaml/saml2/idp/SSOService.php"; + private const string _idpCert = "FAKE-TEST-CERT-BASE64=="; + + [Fact] + public void CreateSaml2_ProducesEnabledConfigForOrganization() + { + var orgId = Guid.NewGuid(); + + var config = SsoConfigSeeder.CreateSaml2(orgId, _idpEntityId, _idpSsoUrl, _idpCert); + + Assert.Equal(orgId, config.OrganizationId); + Assert.True(config.Enabled); + } + + [Fact] + public void CreateSaml2_SerializesSamlDataThatRoundTrips() + { + var config = SsoConfigSeeder.CreateSaml2(Guid.NewGuid(), _idpEntityId, _idpSsoUrl, _idpCert); + + var data = config.GetData(); + + Assert.Equal(SsoType.Saml2, data.ConfigType); + Assert.Equal(MemberDecryptionType.MasterPassword, data.MemberDecryptionType); + Assert.Equal(_idpEntityId, data.IdpEntityId); + Assert.Equal(_idpSsoUrl, data.IdpSingleSignOnServiceUrl); + Assert.Equal(_idpCert, data.IdpX509PublicCert); + Assert.Equal(Saml2BindingType.HttpRedirect, data.IdpBindingType); + Assert.True(data.SpUniqueEntityId); + } + + [Fact] + public void CreateSaml2_HonorsMemberDecryptionTypeOverride() + { + var config = SsoConfigSeeder.CreateSaml2( + Guid.NewGuid(), _idpEntityId, _idpSsoUrl, _idpCert, + MemberDecryptionType.TrustedDeviceEncryption); + + Assert.Equal(MemberDecryptionType.TrustedDeviceEncryption, config.GetData().MemberDecryptionType); + } +} diff --git a/util/Seeder/Data/Static/LocalSamlIdp.cs b/util/Seeder/Data/Static/LocalSamlIdp.cs new file mode 100644 index 000000000000..df6dcfec2b88 --- /dev/null +++ b/util/Seeder/Data/Static/LocalSamlIdp.cs @@ -0,0 +1,15 @@ +namespace Bit.Seeder.Data.Static; + +/// +/// Fixed endpoints for the local dev SAML IdP shipped in dev/docker-compose.yml +/// (the idp profile, image kenchan0130/simplesamlphp). The signing certificate is +/// deliberately NOT stored here — it is fetched from the live metadata endpoint at seed time (see +/// CreateSsoConfigStep), so no certificate material lives in source and it always matches the +/// running image. +/// +internal static class LocalSamlIdp +{ + internal const string EntityId = "http://localhost:8090/simplesaml/saml2/idp/metadata.php"; + + internal const string SingleSignOnServiceUrl = "http://localhost:8090/simplesaml/saml2/idp/SSOService.php"; +} diff --git a/util/Seeder/Factories/SsoConfigSeeder.cs b/util/Seeder/Factories/SsoConfigSeeder.cs new file mode 100644 index 000000000000..22a2d19a8f43 --- /dev/null +++ b/util/Seeder/Factories/SsoConfigSeeder.cs @@ -0,0 +1,40 @@ +using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Models.Data; + +namespace Bit.Seeder.Factories; + +internal static class SsoConfigSeeder +{ + internal static SsoConfig CreateSaml2( + Guid organizationId, + string idpEntityId, + string idpSingleSignOnServiceUrl, + string idpX509PublicCert, + MemberDecryptionType memberDecryptionType = MemberDecryptionType.MasterPassword) + { + var data = new SsoConfigurationData + { + ConfigType = SsoType.Saml2, + MemberDecryptionType = memberDecryptionType, + IdpEntityId = idpEntityId, + IdpSingleSignOnServiceUrl = idpSingleSignOnServiceUrl, + IdpX509PublicCert = idpX509PublicCert, + // Saml2BindingType has no 0 member, so the CLR default is invalid — set it explicitly. + IdpBindingType = Saml2BindingType.HttpRedirect, + // Per-org SP entity id (…/saml2/{orgId}), matching the Admin Console default and the + // dev/.env IDP_SP_ENTITY_ID wiring. Without it the SP entity id is the base …/saml2 and + // the IdP rejects the AuthnRequest ("metadata not found"). + SpUniqueEntityId = true, + }; + + var ssoConfig = new SsoConfig + { + OrganizationId = organizationId, + Enabled = true, + }; + ssoConfig.SetData(data); + + return ssoConfig; + } +} diff --git a/util/Seeder/Models/SeedPreset.cs b/util/Seeder/Models/SeedPreset.cs index a399d42ebd23..31d65909f7bf 100644 --- a/util/Seeder/Models/SeedPreset.cs +++ b/util/Seeder/Models/SeedPreset.cs @@ -7,6 +7,7 @@ internal record SeedPreset public SeedPresetUsers? Users { get; init; } public SeedPresetGroups? Groups { get; init; } public SeedPresetCollections? Collections { get; init; } + public SeedPresetSso? Sso { get; init; } public bool? Folders { get; init; } public List? FolderNames { get; init; } public SeedPresetCiphers? Ciphers { get; init; } @@ -22,6 +23,7 @@ internal record SeedPreset internal record SeedPresetOrganization { + public string? Id { get; init; } public string? Fixture { get; init; } public string? Name { get; init; } public string? Domain { get; init; } @@ -35,6 +37,13 @@ internal record SeedPresetOrganization public bool? LimitCollectionDeletion { get; init; } } +internal record SeedPresetSso +{ + public string? Identifier { get; init; } + public string? EncryptionType { get; init; } + public string? Provider { get; init; } +} + internal record SeedPresetRoster { public string? Fixture { get; init; } diff --git a/util/Seeder/Seeds/schemas/preset.schema.json b/util/Seeder/Seeds/schemas/preset.schema.json index d4c6b6f46a2e..27b598b0d8bd 100644 --- a/util/Seeder/Seeds/schemas/preset.schema.json +++ b/util/Seeder/Seeds/schemas/preset.schema.json @@ -18,6 +18,11 @@ "description": "Organization configuration. Use 'fixture' for a named fixture, or 'name'+'domain' for inline creation.", "additionalProperties": false, "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "Optional fixed organization GUID (omit to auto-generate). Lets canonical presets pin downstream wiring such as dev/.env." + }, "fixture": { "type": "string", "description": "Name of an organization fixture file (e.g., 'dunder-mifflin')." From 7fa1baa732af063ce00e616e68befe5e7607cec4 Mon Sep 17 00:00:00 2001 From: Mick Letofsky Date: Sun, 19 Jul 2026 21:14:10 +0200 Subject: [PATCH 02/15] feat(seeder): seed SsoConfig through the recipe pipeline The sso preset block and the Enterprise UseSso flag already existed, but no step ever wrote an SsoConfig row, so seeded SSO orgs were only half-configured and could not broker a login. This wires the SSO factory into the pipeline to close that gap, following the existing OrganizationDomain seed pattern. - CreateSsoConfigStep reads the org from context, sets its Identifier, fetches the IdP signing certificate live from the local IdP metadata endpoint (XXE-safe reader), and stages the SsoConfig. - WithSso(...) on the recipe builder (guarded on an existing org) and PresetLoader consumption of the sso block; an optional fixed org id is threaded through CreateOrganizationStep/OrganizationSeeder so golden orgs reproduce the same GUID. - BulkCommitter commits the single row via EF Add (long identity PK); the SSO identifier is surfaced on the pipeline and organization result models. The certificate is fetched at seed time rather than baked into the repo so a ~1 KB base64 blob never trips secret/SAST scanners, and it auto-tracks the dev IdP image's cert. --- .../CreateSsoConfigStepTests.cs | 56 +++++++++++++ util/Seeder/Factories/OrganizationSeeder.cs | 4 +- util/Seeder/Models/OrganizationSeedResult.cs | 6 +- util/Seeder/Models/PipelineExecutionResult.cs | 3 +- util/Seeder/Pipeline/BulkCommitter.cs | 30 +++++++ util/Seeder/Pipeline/PresetLoader.cs | 30 ++++++- .../Pipeline/RecipeBuilderExtensions.cs | 44 ++++++++-- util/Seeder/Pipeline/RecipeExecutor.cs | 3 +- util/Seeder/Pipeline/SeederContext.cs | 5 ++ util/Seeder/Steps/CreateOrganizationStep.cs | 17 ++-- util/Seeder/Steps/CreateSsoConfigStep.cs | 84 +++++++++++++++++++ 11 files changed, 262 insertions(+), 20 deletions(-) create mode 100644 test/SeederApi.IntegrationTest/CreateSsoConfigStepTests.cs create mode 100644 util/Seeder/Steps/CreateSsoConfigStep.cs diff --git a/test/SeederApi.IntegrationTest/CreateSsoConfigStepTests.cs b/test/SeederApi.IntegrationTest/CreateSsoConfigStepTests.cs new file mode 100644 index 000000000000..fb2543c485e2 --- /dev/null +++ b/test/SeederApi.IntegrationTest/CreateSsoConfigStepTests.cs @@ -0,0 +1,56 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Auth.Enums; +using Bit.Seeder.Pipeline; +using Bit.Seeder.Services; +using Bit.Seeder.Steps; +using Xunit; + +namespace Bit.SeederApi.IntegrationTest; + +public class CreateSsoConfigStepTests +{ + private static SeederContext BuildContext(Organization organization) + { + var services = new ServiceCollection(); + services.AddSingleton(); + services.AddLogging(); + return new SeederContext(services.BuildServiceProvider()) { Organization = organization }; + } + + // The SAML path fetches the cert from the live IdP, so it is exercised end-to-end (M6/M7), + // not here. These cover the guards that don't need a running IdP. + + [Fact] + public void Execute_NonSamlProvider_SkipsWithoutMutating() + { + var org = new Organization { Id = Guid.NewGuid(), Identifier = "unchanged" }; + var context = BuildContext(org); + var step = new CreateSsoConfigStep("local-sso", "oidc", MemberDecryptionType.MasterPassword); + + step.Execute(context); + + Assert.Empty(context.SsoConfigs); + Assert.Equal("unchanged", org.Identifier); + } + + [Fact] + public void WithSso_WithoutOrganization_Throws() + { + var builder = new ServiceCollection().AddRecipe("test"); + + var ex = Assert.Throws( + () => builder.WithSso("local-sso", "saml", MemberDecryptionType.MasterPassword)); + Assert.Contains("requires an organization", ex.Message); + } + + [Fact] + public void WithSso_WithEmptyIdentifier_Throws() + { + var builder = new ServiceCollection().AddRecipe("test"); + builder.HasOrg = true; + + var ex = Assert.Throws( + () => builder.WithSso(" ", "saml", MemberDecryptionType.MasterPassword)); + Assert.Contains("non-empty identifier", ex.Message); + } +} diff --git a/util/Seeder/Factories/OrganizationSeeder.cs b/util/Seeder/Factories/OrganizationSeeder.cs index 84d877285cd2..034a1be3cc2f 100644 --- a/util/Seeder/Factories/OrganizationSeeder.cs +++ b/util/Seeder/Factories/OrganizationSeeder.cs @@ -10,12 +10,12 @@ namespace Bit.Seeder.Factories; internal static class OrganizationSeeder { - internal static Organization Create(string name, string domain, int seats, IManglerService manglerService, string? publicKey = null, string? privateKey = null, PlanType planType = PlanType.EnterpriseAnnually) + internal static Organization Create(string name, string domain, int seats, IManglerService manglerService, string? publicKey = null, string? privateKey = null, PlanType planType = PlanType.EnterpriseAnnually, Guid? id = null) { var billingHash = DeriveShortHash(domain); var org = new Organization { - Id = CoreHelpers.GenerateComb(), + Id = id ?? CoreHelpers.GenerateComb(), Identifier = manglerService.Mangle(domain), Name = manglerService.Mangle(name), BillingEmail = $"billing{billingHash}@{billingHash}.{domain}", diff --git a/util/Seeder/Models/OrganizationSeedResult.cs b/util/Seeder/Models/OrganizationSeedResult.cs index 40563002692a..df764b30d6f3 100644 --- a/util/Seeder/Models/OrganizationSeedResult.cs +++ b/util/Seeder/Models/OrganizationSeedResult.cs @@ -11,7 +11,8 @@ public record OrganizationSeedResult( int UsersCount, int GroupsCount, int CollectionsCount, - int CiphersCount) + int CiphersCount, + string? SsoIdentifier) { internal static OrganizationSeedResult From(PipelineExecutionResult result) => new(result.OrganizationId!.Value, @@ -21,5 +22,6 @@ internal static OrganizationSeedResult From(PipelineExecutionResult result) => result.UsersCount, result.GroupsCount, result.CollectionsCount, - result.CiphersCount); + result.CiphersCount, + result.SsoIdentifier); } diff --git a/util/Seeder/Models/PipelineExecutionResult.cs b/util/Seeder/Models/PipelineExecutionResult.cs index f8fbff4785ca..0b2f3eef2706 100644 --- a/util/Seeder/Models/PipelineExecutionResult.cs +++ b/util/Seeder/Models/PipelineExecutionResult.cs @@ -15,4 +15,5 @@ internal record PipelineExecutionResult( int GroupsCount, int CollectionsCount, int CiphersCount, - int FoldersCount); + int FoldersCount, + string? SsoIdentifier); diff --git a/util/Seeder/Pipeline/BulkCommitter.cs b/util/Seeder/Pipeline/BulkCommitter.cs index 378b3ec73b0b..1f9f7eca339f 100644 --- a/util/Seeder/Pipeline/BulkCommitter.cs +++ b/util/Seeder/Pipeline/BulkCommitter.cs @@ -14,6 +14,7 @@ using EfOrganizationApiKey = Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey; using EfOrganizationDomain = Bit.Infrastructure.EntityFramework.Models.OrganizationDomain; using EfOrganizationUser = Bit.Infrastructure.EntityFramework.Models.OrganizationUser; +using EfSsoConfig = Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig; using EfUser = Bit.Infrastructure.EntityFramework.Models.User; namespace Bit.Seeder.Pipeline; @@ -45,6 +46,8 @@ internal void Commit(SeederContext context) MapAndCopy(context.OrganizationApiKey); + CommitSsoConfigs(context.SsoConfigs); + MapCopyAndClear(context.Users); MapCopyAndClear(context.OrganizationUsers); @@ -120,6 +123,33 @@ private void CopyAndClear(List entities) where T : class entities.Clear(); } + /// + /// Commits SsoConfig rows via EF Add/SaveChanges rather than BulkCopy: SsoConfig.Id is a + /// database identity (long) that the EF change tracker populates on insert. The EF model inherits + /// the Core entity, so only the settable columns are copied (CreationDate/RevisionDate default to UtcNow). + /// Organizations are already flushed (BulkCopy above), so the FK is satisfied. + /// + private void CommitSsoConfigs(List ssoConfigs) + { + if (ssoConfigs.Count is 0) + { + return; + } + + foreach (var config in ssoConfigs) + { + db.SsoConfigs.Add(new EfSsoConfig + { + OrganizationId = config.OrganizationId, + Enabled = config.Enabled, + Data = config.Data, + }); + } + + db.SaveChanges(); + ssoConfigs.Clear(); + } + /// /// Bulk-inserts ciphers via a projection so the /// Reprompt column survives BulkCopy. LinqToDB's bulk path drops CipherRepromptType? diff --git a/util/Seeder/Pipeline/PresetLoader.cs b/util/Seeder/Pipeline/PresetLoader.cs index 3afa8a44ff87..d959e832d337 100644 --- a/util/Seeder/Pipeline/PresetLoader.cs +++ b/util/Seeder/Pipeline/PresetLoader.cs @@ -1,4 +1,5 @@ -using Bit.Core.Vault.Enums; +using Bit.Core.Auth.Enums; +using Bit.Core.Vault.Enums; using Bit.Seeder.Data.Distributions; using Bit.Seeder.Data.Enums; using Bit.Seeder.Factories; @@ -100,7 +101,7 @@ private static void BuildRecipe(string presetName, SeedPreset preset, ISeedReade if (org.Fixture is not null) { - builder.UseOrganization(org.Fixture, org.PlanType, org.Seats, ToOverrides(org)); + builder.UseOrganization(org.Fixture, org.PlanType, org.Seats, ToOverrides(org), ParseOrgId(org.Id)); // If using a fixture and domain not explicitly provided, read it from the fixture if (domain is null) @@ -112,7 +113,7 @@ private static void BuildRecipe(string presetName, SeedPreset preset, ISeedReade else if (org.Name is not null && org.Domain is not null) { var planType = PlanFeatures.Parse(org.PlanType); - builder.CreateOrganization(org.Name, org.Domain, org.Seats, planType, ToOverrides(org)); + builder.CreateOrganization(org.Name, org.Domain, org.Seats, planType, ToOverrides(org), ParseOrgId(org.Id)); domain = org.Domain; } @@ -123,6 +124,16 @@ private static void BuildRecipe(string presetName, SeedPreset preset, ISeedReade builder.WithOrganizationDomain(org.ClaimedDomains); } + if (preset.Sso is not null) + { + builder.WithSso( + preset.Sso.Identifier + ?? throw new InvalidOperationException( + $"Preset '{presetName}' has an 'sso' block without an 'identifier'."), + preset.Sso.Provider, + ParseMemberDecryptionType(preset.Sso.EncryptionType)); + } + if (preset.Roster?.Fixture is not null) { builder.UseRoster(preset.Roster.Fixture, reader); @@ -214,6 +225,19 @@ private static void BuildRecipe(string presetName, SeedPreset preset, ISeedReade LimitCollectionDeletion = org.LimitCollectionDeletion, }; + private static MemberDecryptionType ParseMemberDecryptionType(string? encryptionType) => + encryptionType?.ToLowerInvariant() switch + { + null or "" or "masterpassword" => MemberDecryptionType.MasterPassword, + "trusteddevices" or "trusteddeviceencryption" => MemberDecryptionType.TrustedDeviceEncryption, + "keyconnector" => MemberDecryptionType.KeyConnector, + _ => throw new InvalidOperationException( + $"Unknown SSO encryptionType '{encryptionType}'. Valid values: masterPassword, trustedDevices, keyConnector."), + }; + + private static Guid? ParseOrgId(string? id) => + string.IsNullOrWhiteSpace(id) ? null : Guid.Parse(id); + private static DensityProfile? ParseDensity(SeedPresetDensity? preset) { if (preset is null) diff --git a/util/Seeder/Pipeline/RecipeBuilderExtensions.cs b/util/Seeder/Pipeline/RecipeBuilderExtensions.cs index 39e14d9c6cb2..bdc029b1c1c2 100644 --- a/util/Seeder/Pipeline/RecipeBuilderExtensions.cs +++ b/util/Seeder/Pipeline/RecipeBuilderExtensions.cs @@ -1,4 +1,5 @@ -using Bit.Core.Billing.Enums; +using Bit.Core.Auth.Enums; +using Bit.Core.Billing.Enums; using Bit.Core.Billing.Services; using Bit.Core.Vault.Enums; using Bit.Seeder.Data.Distributions; @@ -31,10 +32,11 @@ public static RecipeBuilder UseOrganization( string fixture, string? planType = null, int? seats = null, - OrganizationOverrides? overrides = null) + OrganizationOverrides? overrides = null, + Guid? id = null) { builder.HasOrg = true; - builder.AddStep(_ => CreateOrganizationStep.FromFixture(fixture, planType, seats, overrides)); + builder.AddStep(_ => CreateOrganizationStep.FromFixture(fixture, planType, seats, overrides, id)); return builder; } @@ -54,10 +56,11 @@ public static RecipeBuilder CreateOrganization( string domain, int? seats = null, PlanType planType = PlanType.EnterpriseAnnually, - OrganizationOverrides? overrides = null) + OrganizationOverrides? overrides = null, + Guid? id = null) { builder.HasOrg = true; - builder.AddStep(_ => CreateOrganizationStep.FromParams(name, domain, seats, planType, overrides)); + builder.AddStep(_ => CreateOrganizationStep.FromParams(name, domain, seats, planType, overrides, id)); return builder; } @@ -111,6 +114,37 @@ public static RecipeBuilder WithOrganizationDomain( return builder; } + /// + /// Attach a SAML 2.0 SSO configuration (wired to the local dev IdP) and set the org's SSO identifier. + /// Only SAML is supported today; other providers are skipped at execution time (see ). + /// + /// The recipe builder + /// Org SSO identifier (the domain_hint typed at login); mangled with the org when --mangle is set + /// Provider from the preset ("saml"/"oidc"); null defaults to saml + /// How members decrypt after SSO auth (MasterPassword by default) + /// The builder for fluent chaining + /// Thrown when no organization exists or the identifier is empty + public static RecipeBuilder WithSso( + this RecipeBuilder builder, + string identifier, + string? provider, + MemberDecryptionType memberDecryptionType) + { + if (!builder.HasOrg) + { + throw new InvalidOperationException( + "SSO configuration requires an organization. Call UseOrganization() or CreateOrganization() first."); + } + + if (string.IsNullOrWhiteSpace(identifier)) + { + throw new InvalidOperationException("SSO configuration requires a non-empty identifier."); + } + + builder.AddStep(_ => new CreateSsoConfigStep(identifier, provider, memberDecryptionType)); + return builder; + } + /// /// Add an organization owner user with admin privileges. /// diff --git a/util/Seeder/Pipeline/RecipeExecutor.cs b/util/Seeder/Pipeline/RecipeExecutor.cs index f7b5d9e59a31..440084056fa6 100644 --- a/util/Seeder/Pipeline/RecipeExecutor.cs +++ b/util/Seeder/Pipeline/RecipeExecutor.cs @@ -54,7 +54,8 @@ internal PipelineExecutionResult Execute() context.Groups.Count, context.Collections.Count, context.Ciphers.Count, - context.Folders.Count); + context.Folders.Count, + context.SsoIdentifier); var progress = context.GetProgress(); progress?.Report(new PhaseStarted(SeederPhases.CommittingToDatabase, null)); diff --git a/util/Seeder/Pipeline/SeederContext.cs b/util/Seeder/Pipeline/SeederContext.cs index d6974898345a..f3886eaaa04c 100644 --- a/util/Seeder/Pipeline/SeederContext.cs +++ b/util/Seeder/Pipeline/SeederContext.cs @@ -1,4 +1,5 @@ using Bit.Core.AdminConsole.Entities; +using Bit.Core.Auth.Entities; using Bit.Core.Entities; using Bit.Core.Vault.Entities; using Bit.RustSDK; @@ -48,6 +49,8 @@ public sealed class SeederContext(IServiceProvider services) internal string? Domain { get; set; } + internal string? SsoIdentifier { get; set; } + internal User? Owner { get; set; } internal OrganizationUser? OwnerOrgUser { get; set; } @@ -58,6 +61,8 @@ public sealed class SeederContext(IServiceProvider services) internal List OrganizationDomains { get; } = []; + internal List SsoConfigs { get; } = []; + internal List Users { get; } = []; internal List OrganizationUsers { get; } = []; diff --git a/util/Seeder/Steps/CreateOrganizationStep.cs b/util/Seeder/Steps/CreateOrganizationStep.cs index 9251040f454c..98abda3e329d 100644 --- a/util/Seeder/Steps/CreateOrganizationStep.cs +++ b/util/Seeder/Steps/CreateOrganizationStep.cs @@ -18,6 +18,7 @@ internal sealed class CreateOrganizationStep : IStep private readonly int? _seats; private readonly PlanType _planType; private readonly OrganizationOverrides? _overrides; + private readonly Guid? _id; private CreateOrganizationStep( string? fixtureName, @@ -25,7 +26,8 @@ private CreateOrganizationStep( string? domain, int? seats, PlanType planType, - OrganizationOverrides? overrides) + OrganizationOverrides? overrides, + Guid? id) { if (fixtureName is null && (name is null || domain is null)) { @@ -39,22 +41,25 @@ private CreateOrganizationStep( _seats = seats; _planType = planType; _overrides = overrides; + _id = id; } internal static CreateOrganizationStep FromFixture( string fixtureName, string? planType = null, int? seats = null, - OrganizationOverrides? overrides = null) => - new(fixtureName, null, null, seats, PlanFeatures.Parse(planType), overrides); + OrganizationOverrides? overrides = null, + Guid? id = null) => + new(fixtureName, null, null, seats, PlanFeatures.Parse(planType), overrides, id); internal static CreateOrganizationStep FromParams( string name, string domain, int? seats = null, PlanType planType = PlanType.EnterpriseAnnually, - OrganizationOverrides? overrides = null) => - new(null, name, domain, seats, planType, overrides); + OrganizationOverrides? overrides = null, + Guid? id = null) => + new(null, name, domain, seats, planType, overrides, id); public void Execute(SeederContext context) { @@ -80,7 +85,7 @@ public void Execute(SeederContext context) var seats = _seats ?? PlanFeatures.GenerateRealisticSeatCount(_planType, domain); var orgKeys = RustSdkService.GenerateOrganizationKeys(); - var organization = OrganizationSeeder.Create(name, domain, seats, context.GetMangler(), orgKeys.PublicKey, orgKeys.PrivateKey, _planType); + var organization = OrganizationSeeder.Create(name, domain, seats, context.GetMangler(), orgKeys.PublicKey, orgKeys.PrivateKey, _planType, _id); PlanFeatures.ApplyOrganizationOverrides(organization, _overrides); diff --git a/util/Seeder/Steps/CreateSsoConfigStep.cs b/util/Seeder/Steps/CreateSsoConfigStep.cs new file mode 100644 index 000000000000..6971361a7dd8 --- /dev/null +++ b/util/Seeder/Steps/CreateSsoConfigStep.cs @@ -0,0 +1,84 @@ +using System.Xml; +using System.Xml.Linq; +using Bit.Core.Auth.Enums; +using Bit.Seeder.Data.Static; +using Bit.Seeder.Factories; +using Bit.Seeder.Pipeline; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Bit.Seeder.Steps; + +/// +/// Attaches a SAML 2.0 SSO configuration (wired to the local dev IdP) to the organization and sets +/// its SSO identifier. Only SAML is supported today; other providers are skipped rather than persisting +/// a config that cannot authenticate against the bundled IdP. The IdP signing certificate is fetched +/// from the live metadata endpoint at execution time, so no certificate is hardcoded in source. +/// +internal sealed class CreateSsoConfigStep( + string identifier, + string? provider, + MemberDecryptionType memberDecryptionType) : IStep +{ + private static readonly XNamespace _ds = "http://www.w3.org/2000/09/xmldsig#"; + + public void Execute(SeederContext context) + { + if (!string.Equals(provider ?? "saml", "saml", StringComparison.OrdinalIgnoreCase)) + { + context.Services.GetService>()? + .LogWarning( + "SSO provider '{Provider}' is not supported by the seeder yet (only 'saml'); skipping SsoConfig.", + provider); + return; + } + + var organization = context.RequireOrganization(); + + // The Admin Console sets Organization.Identifier when SSO is saved; replicate that so the + // identifier typed at login (domain_hint) resolves to this org. Mangle it so --mangle runs stay unique. + organization.Identifier = context.GetMangler().Mangle(identifier); + context.SsoIdentifier = organization.Identifier; + + var ssoConfig = SsoConfigSeeder.CreateSaml2( + organization.Id, + LocalSamlIdp.EntityId, + LocalSamlIdp.SingleSignOnServiceUrl, + FetchIdpSigningCertificate(LocalSamlIdp.EntityId), + memberDecryptionType); + + context.SsoConfigs.Add(ssoConfig); + } + + /// + /// Reads the IdP's public signing certificate from its live metadata document. Keeps the + /// (public, image-specific) cert out of source so nothing trips secret/SAST scanners, and tracks the + /// running image automatically. Requires the local idp container to be running. + /// + private static string FetchIdpSigningCertificate(string metadataUrl) + { + string metadataXml; + try + { + using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(10) }; + metadataXml = client.GetStringAsync(metadataUrl).GetAwaiter().GetResult(); + } + catch (Exception ex) + { + throw new InvalidOperationException( + $"Could not reach the local SAML IdP metadata at {metadataUrl}. " + + "Is the idp container running? docker compose --profile idp up -d", ex); + } + + // Parse with DTDs disabled and no external resolver to avoid XXE. + var settings = new XmlReaderSettings { DtdProcessing = DtdProcessing.Prohibit, XmlResolver = null }; + using var reader = XmlReader.Create(new StringReader(metadataXml), settings); + var metadata = XDocument.Load(reader); + + var certificate = metadata.Descendants(_ds + "X509Certificate").FirstOrDefault()?.Value + ?? throw new InvalidOperationException($"No found in IdP metadata at {metadataUrl}."); + + // The base64 may be wrapped across lines in the XML; strip whitespace before storing. + return new string(certificate.Where(c => !char.IsWhiteSpace(c)).ToArray()); + } +} From f8ab8759c43e6cd9a541a36e03d0b1320a0ca8c7 Mon Sep 17 00:00:00 2001 From: Mick Letofsky Date: Sun, 19 Jul 2026 21:17:58 +0200 Subject: [PATCH 03/15] feat(seeder): print the SSO .env wiring block from the CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After seeding an SSO org the operator still has to point the local IdP at it, and the SP entity id / ACS URL are easy to get subtly wrong by hand — they embed the org GUID and depend on the Sso launch-profile port. Rather than leave that as a manual copy-from-the-Admin-Console step, the CLI now prints the exact wiring. - ConsoleOutput.PrintSsoWiring emits a ready-to-paste dev/.env block (IDP_SP_ENTITY_ID, IDP_SP_ACS_URL) plus the login identifier for the seeded org. - PresetCommand invokes it only when the seed result carries an SSO identifier (set on the SAML path together with the SsoConfig), so non-SSO and OIDC presets print nothing extra. --- util/SeederUtility/Commands/PresetCommand.cs | 5 +++++ util/SeederUtility/Helpers/ConsoleOutput.cs | 11 +++++++++++ 2 files changed, 16 insertions(+) diff --git a/util/SeederUtility/Commands/PresetCommand.cs b/util/SeederUtility/Commands/PresetCommand.cs index ef11e6edaaba..95854c3c3280 100644 --- a/util/SeederUtility/Commands/PresetCommand.cs +++ b/util/SeederUtility/Commands/PresetCommand.cs @@ -64,6 +64,11 @@ private static void RunOrganizationPreset(PresetArgs args) ConsoleOutput.PrintCountRow("Ciphers", result.CiphersCount); ConsoleOutput.PrintMangleMap(deps); + + if (result.SsoIdentifier is not null) + { + ConsoleOutput.PrintSsoWiring(result.OrganizationId, result.SsoIdentifier); + } } private static void RunIndividualPreset(PresetArgs args) diff --git a/util/SeederUtility/Helpers/ConsoleOutput.cs b/util/SeederUtility/Helpers/ConsoleOutput.cs index e69792372982..101a3021d101 100644 --- a/util/SeederUtility/Helpers/ConsoleOutput.cs +++ b/util/SeederUtility/Helpers/ConsoleOutput.cs @@ -38,4 +38,15 @@ internal static void PrintMangleMap(SeederServiceScope deps) Console.Error.WriteLine($" ... and {map.Count - 15} more"); } } + + internal static void PrintSsoWiring(Guid organizationId, string identifier) + { + var sp = $"http://localhost:51822/saml2/{organizationId}"; + Console.Error.WriteLine(); + Console.Error.WriteLine("--- SSO wiring (cloud Sso profile :51822) ---"); + Console.Error.WriteLine($" Login identifier : {identifier}"); + Console.Error.WriteLine(" Add to dev/.env, then restart the IdP: docker compose --profile idp up -d"); + Console.Error.WriteLine($" IDP_SP_ENTITY_ID={sp}"); + Console.Error.WriteLine($" IDP_SP_ACS_URL={sp}/Acs"); + } } From ca20a10296da3ba75e007832b731ebefc3c894f4 Mon Sep 17 00:00:00 2001 From: Mick Letofsky Date: Sun, 19 Jul 2026 21:19:54 +0200 Subject: [PATCH 04/15] feat(seeder): retire the tde/sso-vault fixtures for enterprise-basic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tde-vault (7 login stubs) and sso-vault (a single login) were thin, low-value cipher fixtures that gave their presets almost no content. Consolidating on the shared enterprise-basic fixture — enriched in the following commit — lets those SSO/TDE presets seed a realistic vault and drops two near-empty fixtures. - Delete ciphers/tde-vault.json and ciphers/sso-vault.json. - Re-point sso-enterprise and tde-enterprise to the enterprise-basic cipher fixture. (The golden local-sso preset is added later already pointing at enterprise-basic.) --- .../Seeds/fixtures/ciphers/sso-vault.json | 13 ---- .../Seeds/fixtures/ciphers/tde-vault.json | 60 ------------------- .../presets/features/sso-enterprise.json | 2 +- .../presets/features/tde-enterprise.json | 2 +- 4 files changed, 2 insertions(+), 75 deletions(-) delete mode 100644 util/Seeder/Seeds/fixtures/ciphers/sso-vault.json delete mode 100644 util/Seeder/Seeds/fixtures/ciphers/tde-vault.json diff --git a/util/Seeder/Seeds/fixtures/ciphers/sso-vault.json b/util/Seeder/Seeds/fixtures/ciphers/sso-vault.json deleted file mode 100644 index 706c61d1842d..000000000000 --- a/util/Seeder/Seeds/fixtures/ciphers/sso-vault.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "$schema": "../../schemas/cipher.schema.json", - "items": [ - { - "type": "login", - "name": "Cipher-20606", - "login": { - "username": "Cipher-20606", - "password": "password" - } - } - ] -} diff --git a/util/Seeder/Seeds/fixtures/ciphers/tde-vault.json b/util/Seeder/Seeds/fixtures/ciphers/tde-vault.json deleted file mode 100644 index 83919446e712..000000000000 --- a/util/Seeder/Seeds/fixtures/ciphers/tde-vault.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "$schema": "../../schemas/cipher.schema.json", - "items": [ - { - "type": "login", - "name": "Cipher-1867257", - "login": { - "username": "1867257 Username" - } - }, - { - "type": "login", - "name": "Cipher-1867257-2", - "reprompt": 1, - "login": { - "username": "1867257-2 Username" - } - }, - { - "type": "login", - "name": "Cipher-1867259", - "login": { - "username": "1867259 Username" - } - }, - { - "type": "login", - "name": "Cipher-1867259-2", - "reprompt": 1, - "login": { - "username": "1867259-2 Username" - } - }, - { - "type": "login", - "name": "Cipher-1867261", - "reprompt": 1, - "login": { - "username": "1867261 Username" - } - }, - { - "type": "login", - "name": "Cipher-1867262", - "reprompt": 1, - "login": { - "username": "1867262 Username" - } - }, - { - "type": "login", - "name": "Cipher-19503", - "reprompt": 1, - "login": { - "username": "19503 Username", - "password": "19503 Password" - } - } - ] -} diff --git a/util/Seeder/Seeds/fixtures/presets/features/sso-enterprise.json b/util/Seeder/Seeds/fixtures/presets/features/sso-enterprise.json index ee64d55d19a8..2ace31ae063b 100644 --- a/util/Seeder/Seeds/fixtures/presets/features/sso-enterprise.json +++ b/util/Seeder/Seeds/fixtures/presets/features/sso-enterprise.json @@ -9,7 +9,7 @@ "fixture": "starter-team" }, "ciphers": { - "fixture": "sso-vault" + "fixture": "enterprise-basic" }, "policies": { "enable": ["requireSso"] diff --git a/util/Seeder/Seeds/fixtures/presets/features/tde-enterprise.json b/util/Seeder/Seeds/fixtures/presets/features/tde-enterprise.json index 426f8c15c912..8d1ca7f2c527 100644 --- a/util/Seeder/Seeds/fixtures/presets/features/tde-enterprise.json +++ b/util/Seeder/Seeds/fixtures/presets/features/tde-enterprise.json @@ -9,7 +9,7 @@ "fixture": "starter-team" }, "ciphers": { - "fixture": "tde-vault" + "fixture": "enterprise-basic" }, "policies": { "enable": ["requireSso"] From d36d6291d0277297cd52d82d95807252b02392cd Mon Sep 17 00:00:00 2001 From: Mick Letofsky Date: Sun, 19 Jul 2026 21:21:50 +0200 Subject: [PATCH 05/15] feat(seeder): grow the enterprise-basic roster with department groups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The roster's five members were role-name placeholders (Enterprise Owner, Admin, User, Custom, Custom-Two) under a single All Members group — it didn't resemble a real company. It's also the roster the golden local-sso org now uses, so these members/groups/collections are exactly what a developer sees after an SSO login. - Replace the placeholders with 25 realistically-named members across owner/admin/user/custom roles. - Add Finance, Human Resources, and Information Technology groups with sensible department membership. All Members is retained and repopulated with everyone, since the existing collections grant against it. - Add four named collections: Finance, Human Resources, and Information Technology (each managed by its department group and by the owner directly) plus a Company-Wide collection for All Members. The legacy placeholder collections are retained (the Default collection's owner grant is updated to the renamed owner); the new collections are added alongside, preserving existing access. --- .../fixtures/rosters/enterprise-basic.json | 109 ++++++++++++++++-- 1 file changed, 97 insertions(+), 12 deletions(-) diff --git a/util/Seeder/Seeds/fixtures/rosters/enterprise-basic.json b/util/Seeder/Seeds/fixtures/rosters/enterprise-basic.json index 792170ebddf6..dd4aa7b2983e 100644 --- a/util/Seeder/Seeds/fixtures/rosters/enterprise-basic.json +++ b/util/Seeder/Seeds/fixtures/rosters/enterprise-basic.json @@ -1,21 +1,87 @@ { "$schema": "../../schemas/roster.schema.json", "users": [ - { "firstName": "Enterprise", "lastName": "Owner", "role": "owner" }, - { "firstName": "Enterprise", "lastName": "Admin", "role": "admin" }, - { "firstName": "Enterprise", "lastName": "User", "role": "user" }, - { "firstName": "Enterprise", "lastName": "Custom", "role": "custom" }, - { "firstName": "Enterprise", "lastName": "Custom-Two", "role": "custom" } + { "firstName": "Dana", "lastName": "Whitfield", "role": "owner" }, + { "firstName": "Marcus", "lastName": "Chen", "role": "admin" }, + { "firstName": "Priya", "lastName": "Nair", "role": "admin" }, + { "firstName": "Sofia", "lastName": "Ramirez", "role": "admin" }, + { "firstName": "Trevor", "lastName": "Boyd", "role": "custom" }, + { "firstName": "Alicia", "lastName": "Gomez", "role": "custom" }, + { "firstName": "Nathan", "lastName": "Brooks", "role": "user" }, + { "firstName": "Grace", "lastName": "Sullivan", "role": "user" }, + { "firstName": "Derek", "lastName": "Olsen", "role": "user" }, + { "firstName": "Hannah", "lastName": "Weiss", "role": "user" }, + { "firstName": "Olivia", "lastName": "Bennett", "role": "user" }, + { "firstName": "Ethan", "lastName": "Ward", "role": "user" }, + { "firstName": "Maya", "lastName": "Patel", "role": "user" }, + { "firstName": "Liam", "lastName": "Foster", "role": "user" }, + { "firstName": "Ava", "lastName": "Nguyen", "role": "user" }, + { "firstName": "Noah", "lastName": "Reyes", "role": "user" }, + { "firstName": "Isabella", "lastName": "Rossi", "role": "user" }, + { "firstName": "Julian", "lastName": "Marsh", "role": "user" }, + { "firstName": "Chloe", "lastName": "Adams", "role": "user" }, + { "firstName": "Benjamin", "lastName": "Cole", "role": "user" }, + { "firstName": "Zoe", "lastName": "Carter", "role": "user" }, + { "firstName": "Owen", "lastName": "Fitzgerald", "role": "user" }, + { "firstName": "Lucas", "lastName": "Moreau", "role": "user" }, + { "firstName": "Emma", "lastName": "Donovan", "role": "user" }, + { "firstName": "Caleb", "lastName": "Hughes", "role": "user" } ], "groups": [ { "name": "All Members", "members": [ - "enterprise.owner", - "enterprise.admin", - "enterprise.user", - "enterprise.custom", - "enterprise.custom-two" + "dana.whitfield", + "marcus.chen", + "priya.nair", + "sofia.ramirez", + "trevor.boyd", + "alicia.gomez", + "nathan.brooks", + "grace.sullivan", + "derek.olsen", + "hannah.weiss", + "olivia.bennett", + "ethan.ward", + "maya.patel", + "liam.foster", + "ava.nguyen", + "noah.reyes", + "isabella.rossi", + "julian.marsh", + "chloe.adams", + "benjamin.cole", + "zoe.carter", + "owen.fitzgerald", + "lucas.moreau", + "emma.donovan", + "caleb.hughes" + ] + }, + { + "name": "Finance", + "members": [ + "trevor.boyd", + "nathan.brooks", + "grace.sullivan", + "derek.olsen", + "hannah.weiss" + ] + }, + { + "name": "Human Resources", + "members": ["sofia.ramirez", "olivia.bennett", "ethan.ward", "maya.patel"] + }, + { + "name": "Information Technology", + "members": [ + "marcus.chen", + "priya.nair", + "alicia.gomez", + "liam.foster", + "ava.nguyen", + "noah.reyes", + "isabella.rossi" ] } ], @@ -23,7 +89,7 @@ { "name": "Default collection", "groups": [{ "group": "All Members" }], - "users": [{ "user": "enterprise.owner", "manage": true }] + "users": [{ "user": "dana.whitfield", "manage": true }] }, { "name": "Collection-20360", "groups": [{ "group": "All Members" }] }, { "name": "Collection-20356", "groups": [{ "group": "All Members" }] }, @@ -69,6 +135,25 @@ "groups": [{ "group": "All Members" }] }, { "name": "Collection-3392572", "groups": [{ "group": "All Members" }] }, - { "name": "Collection-3171087", "groups": [{ "group": "All Members" }] } + { "name": "Collection-3171087", "groups": [{ "group": "All Members" }] }, + { + "name": "Finance", + "groups": [{ "group": "Finance", "manage": true }], + "users": [{ "user": "dana.whitfield", "manage": true }] + }, + { + "name": "Human Resources", + "groups": [{ "group": "Human Resources", "manage": true }], + "users": [{ "user": "dana.whitfield", "manage": true }] + }, + { + "name": "Information Technology", + "groups": [{ "group": "Information Technology", "manage": true }], + "users": [{ "user": "dana.whitfield", "manage": true }] + }, + { + "name": "Company-Wide", + "groups": [{ "group": "All Members" }] + } ] } From f374fe1caeeea249c0894217cf67c53f495b1a6a Mon Sep 17 00:00:00 2001 From: Mick Letofsky Date: Sun, 19 Jul 2026 21:23:19 +0200 Subject: [PATCH 06/15] feat(seeder): enrich the enterprise-basic cipher vault MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fixture was 24 near-empty items (login stubs with test/123, several empty login objects, one card, one secure note) covering only three cipher types — not enough to demo the client or exercise the attachment and lifecycle paths. - Rewrite it as 45 realistically-named items covering all eight cipher types (login, card, identity, secureNote, sshKey, bankAccount, driversLicense, passport) with richer fields: notes, uris, custom fields, totp, password history. - Attach bundled sample bodies to 18 items (~40%) across the v0/v1/v2 schemes; every v2 attachment sits on a cipherKey host per the scheme invariant. - Seed 4 archived and 4 soft-deleted items so those states are present. - Extend FixtureParsingTests to assert enterprise-basic covers all eight types, carries archived/deleted items, and references only embedded attachment bodies — bundled here because those assertions only pass against the enriched fixture. All data is fake/mock; no new attachment files are added — existing bundled bodies are reused. --- .../FixtureParsingTests.cs | 16 + .../fixtures/ciphers/enterprise-basic.json | 628 +++++++++++++++--- 2 files changed, 568 insertions(+), 76 deletions(-) diff --git a/test/SeederApi.IntegrationTest/FixtureParsingTests.cs b/test/SeederApi.IntegrationTest/FixtureParsingTests.cs index c0fbfee109b7..5b0aee2d4a5f 100644 --- a/test/SeederApi.IntegrationTest/FixtureParsingTests.cs +++ b/test/SeederApi.IntegrationTest/FixtureParsingTests.cs @@ -65,8 +65,24 @@ public void EncryptionModesFixture_CoversAllCipherTypesAndBothEncryptionModes() Assert.Contains(ciphers.Items, i => i.CipherEncryption is null or "userKey"); } + [Fact] + public void EnterpriseBasicFixture_CoversAllCipherTypesWithLifecycle() + { + var ciphers = _reader.Read("ciphers.enterprise-basic"); + + // The enrichment added the five types that were missing; assert all eight are present. + var types = ciphers.Items.Select(i => i.Type).ToHashSet(); + Assert.Subset( + new HashSet { "login", "card", "identity", "secureNote", "sshKey", "bankAccount", "driversLicense", "passport" }, + types); + + Assert.Contains(ciphers.Items, i => i.Archived == true); + Assert.Contains(ciphers.Items, i => i.Deleted == true); + } + [Theory] [InlineData("encryption-modes")] + [InlineData("enterprise-basic")] public void AttachmentFixtures_ReferenceEmbeddedBodies(string fixture) { var ciphers = _reader.Read($"ciphers.{fixture}"); diff --git a/util/Seeder/Seeds/fixtures/ciphers/enterprise-basic.json b/util/Seeder/Seeds/fixtures/ciphers/enterprise-basic.json index 0eb83b6632a9..3f442bee2908 100644 --- a/util/Seeder/Seeds/fixtures/ciphers/enterprise-basic.json +++ b/util/Seeder/Seeds/fixtures/ciphers/enterprise-basic.json @@ -3,177 +3,653 @@ "items": [ { "type": "login", - "name": "Cipher-997761-2", + "name": "Concur Travel & Expense", + "notes": "Shared Finance login for expense submission. EXAMPLE fake seeder data.", "login": { - "username": "test", - "password": "123" - } + "username": "ap@verdant.example", + "password": "Fake-Concur-Pass-24", + "totp": "JBSWY3DPEHPK3PXP", + "uris": [{ "uri": "https://concursolutions.com", "match": "host" }] + }, + "fields": [{ "name": "Account ID", "value": "VH-88213", "type": "text" }], + "attachments": [ + { + "file": "mock-seeder-data-expense-report-3.pdf", + "fileName": "q3-expense-report.pdf", + "attachmentVersion": "v1" + } + ] }, { "type": "login", - "name": "Cipher-1793363", + "name": "Bill.com — Accounts Payable", + "notes": "EXAMPLE fake seeder data.", "login": { - "username": "test", - "password": "123" + "username": "payables@verdant.example", + "password": "Fake-BillDotCom-91", + "uris": [{ "uri": "https://bill.com" }] } }, { "type": "login", - "name": "Cipher-1867254-1", + "name": "ADP Workforce Now — Payroll", + "notes": "Payroll administration. Master-password reprompt on. EXAMPLE fake seeder data.", "reprompt": 1, "login": { - "username": "1867254 Username" + "username": "payroll.admin@verdant.example", + "password": "Fake-ADP-Pass-77", + "totp": "KRSXG5CTMVRXEZLU", + "uris": [{ "uri": "https://workforcenow.adp.com", "match": "host" }] } }, + { + "type": "bankAccount", + "name": "Operating Account — First National Bank", + "notes": "Primary operating account. EXAMPLE fake seeder data.", + "cipherEncryption": "cipherKey", + "bankAccount": { + "bankName": "First National Bank", + "nameOnAccount": "Verdant Health, Inc.", + "accountType": "Checking", + "accountNumber": "000123456789", + "routingNumber": "110000000", + "swiftCode": "FNBAUS33", + "bankContactPhone": "555-0142" + }, + "attachments": [ + { + "file": "mock-seeder-data-bank-statement-1.pdf", + "fileName": "operating-statement.pdf", + "attachmentVersion": "v2" + } + ] + }, + { + "type": "bankAccount", + "name": "Payroll Account — First National Bank", + "notes": "EXAMPLE fake seeder data.", + "bankAccount": { + "bankName": "First National Bank", + "nameOnAccount": "Verdant Health, Inc.", + "accountType": "Checking", + "accountNumber": "000987654321", + "routingNumber": "110000000", + "swiftCode": "FNBAUS33" + } + }, + { + "type": "card", + "name": "Corporate Amex — Finance", + "notes": "Finance department corporate card. EXAMPLE fake seeder data.", + "cipherEncryption": "cipherKey", + "card": { + "cardholderName": "Verdant Health Finance", + "brand": "Amex", + "number": "371449635398431", + "expMonth": "08", + "expYear": "2029", + "code": "1234" + }, + "attachments": [ + { + "file": "mock-seeder-data-bank-statement-1.pdf", + "fileName": "amex-statement.pdf", + "attachmentVersion": "v2" + } + ] + }, { "type": "login", - "name": "Cipher-997761-1", + "name": "QuickBooks Online", + "notes": "EXAMPLE fake seeder data.", "login": { - "username": "test", - "password": "123" + "username": "books@verdant.example", + "password": "Fake-QBO-Pass-33", + "uris": [{ "uri": "https://quickbooks.intuit.com" }] } }, { "type": "login", - "name": "Cipher-19620", + "name": "Stripe Dashboard", + "notes": "Payment processing. EXAMPLE fake seeder data.", "login": { - "username": "19620 Username" + "username": "billing@verdant.example", + "password": "Fake-Stripe-Pass-55", + "totp": "MFRGGZDFMZTWQ2LK", + "uris": [{ "uri": "https://dashboard.stripe.com", "match": "host" }] + }, + "fields": [ + { + "name": "Restricted API Key", + "value": "rk_test_EXAMPLEfakevalue", + "type": "hidden" + } + ] + }, + { + "type": "login", + "name": "Workday HCM", + "notes": "HR core system. EXAMPLE fake seeder data.", + "login": { + "username": "hr.admin@verdant.example", + "password": "Fake-Workday-Pass-12", + "totp": "NBSWY3DPEB3W64TM", + "uris": [{ "uri": "https://wd5.myworkday.com", "match": "host" }] } }, { "type": "login", - "name": "Cipher-1793362", + "name": "BambooHR", + "notes": "EXAMPLE fake seeder data.", "login": { - "username": "test", - "password": "123" + "username": "people@verdant.example", + "password": "Fake-Bamboo-Pass-64", + "uris": [{ "uri": "https://verdanthealth.bamboohr.com" }] + } + }, + { + "type": "login", + "name": "Greenhouse Recruiting", + "notes": "EXAMPLE fake seeder data.", + "login": { + "username": "recruiting@verdant.example", + "password": "Fake-Greenhouse-Pass-08" + } + }, + { + "type": "identity", + "name": "Employee Record — Dana Whitfield (CEO)", + "notes": "Sensitive employee record. EXAMPLE fake seeder data.", + "cipherEncryption": "cipherKey", + "identity": { + "firstName": "Dana", + "lastName": "Whitfield", + "company": "Verdant Health, Inc.", + "email": "dana.whitfield@verdant.example", + "phone": "555-0110", + "ssn": "000-00-0000", + "address1": "500 Health Plaza", + "city": "Austin", + "state": "TX", + "postalCode": "78701", + "country": "US" + }, + "attachments": [ + { + "file": "mock-seeder-data-contract-5.pdf", + "fileName": "employment-agreement.pdf", + "attachmentVersion": "v2" + } + ] + }, + { + "type": "identity", + "name": "Contractor Onboarding — Availa Consulting", + "notes": "Vendor onboarding record. EXAMPLE fake seeder data.", + "cipherEncryption": "cipherKey", + "identity": { + "firstName": "Jordan", + "lastName": "Availa", + "company": "Availa Consulting LLC", + "email": "jordan@availa.example", + "phone": "555-0188", + "city": "Denver", + "state": "CO", + "country": "US" + }, + "attachments": [ + { + "file": "mock-seeder-data-vendor-w9-7.txt", + "fileName": "availa-w9.txt", + "attachmentVersion": "v2" + } + ] + }, + { + "type": "passport", + "name": "Executive Travel Passport — D. Whitfield", + "notes": "Held for executive business travel. EXAMPLE fake seeder data.", + "cipherEncryption": "cipherKey", + "passport": { + "surname": "Whitfield", + "givenName": "Dana", + "dateOfBirth": "1979-04-12", + "sex": "F", + "nationality": "United States", + "passportNumber": "X1234567", + "passportType": "P", + "issuingCountry": "United States", + "issuingAuthority": "U.S. Department of State", + "issueDate": "2019-06-01", + "expirationDate": "2029-05-31" + }, + "attachments": [ + { + "file": "mock-seeder-data-passport-scan-2.pdf", + "fileName": "passport-scan.pdf", + "attachmentVersion": "v2" + } + ] + }, + { + "type": "driversLicense", + "name": "Fleet Driver License — Operations", + "notes": "On file for company-vehicle drivers. EXAMPLE fake seeder data.", + "driversLicense": { + "firstName": "Marcus", + "lastName": "Chen", + "dateOfBirth": "1988-11-03", + "licenseNumber": "TX-DL-99120", + "issuingCountry": "United States", + "issuingState": "TX", + "issueDate": "2022-02-01", + "issuingAuthority": "Texas DPS", + "expirationDate": "2030-02-01", + "licenseClass": "C" } }, { "type": "secureNote", - "name": "test item" + "name": "Benefits — Open Enrollment Notes", + "notes": "Carrier contacts and enrollment checklist. EXAMPLE fake seeder data.", + "attachments": [ + { + "file": "mock-seeder-data-insurance-card-4.pdf", + "fileName": "group-insurance-card.pdf", + "attachmentVersion": "v1" + } + ] + }, + { + "type": "login", + "name": "Okta Admin Console", + "notes": "Identity provider admin. Master-password reprompt on. EXAMPLE fake seeder data.", + "reprompt": 1, + "login": { + "username": "it.admin@verdant.example", + "password": "Fake-Okta-Admin-19", + "totp": "OZXW2ZLOMRXW6ZDF", + "uris": [{ "uri": "https://verdant-admin.okta.com", "match": "host" }] + }, + "fields": [ + { + "name": "Recovery phrase", + "value": "example-fake-recovery-phrase", + "type": "hidden" + } + ] + }, + { + "type": "login", + "name": "AWS Root Account", + "notes": "Break-glass root credentials. Master-password reprompt on. EXAMPLE fake seeder data.", + "reprompt": 1, + "login": { + "username": "aws-root@verdant.example", + "password": "Fake-AWS-Root-88", + "totp": "PBSWY3DPEHPK3PXQ", + "passwordHistory": [ + { + "password": "Fake-AWS-Root-Prev-01", + "lastUsedDate": "2025-11-02T10:00:00Z" + } + ] + }, + "fields": [ + { "name": "Account #", "value": "1234-5678-9012", "type": "text" }, + { + "name": "Access Key ID", + "value": "AKIAEXAMPLEFAKEKEY", + "type": "hidden" + } + ], + "attachments": [ + { + "file": "mock-seeder-data-recovery-codes-1.txt", + "fileName": "root-mfa-backup-codes.txt", + "attachmentVersion": "v0" + } + ] }, { "type": "login", - "name": "Cipher-317432", + "name": "GitHub Enterprise — verdant-health", + "notes": "EXAMPLE fake seeder data.", "login": { - "username": "test", - "password": "123" + "username": "ops@verdant.example", + "password": "Fake-GH-Pass-42", + "totp": "QFRGGZDFMZTWQ2LL", + "uris": [{ "uri": "https://github.com", "match": "host" }] } }, { "type": "login", - "name": "Cipher-1867254-2", + "name": "Cloudflare Dashboard", + "notes": "EXAMPLE fake seeder data.", "login": { - "username": "1867254 Username" + "username": "netops@verdant.example", + "password": "Fake-CF-Pass-71", + "uris": [{ "uri": "https://dash.cloudflare.com" }] } }, { "type": "login", - "name": "Cipher-842054", + "name": "Datadog Monitoring", + "notes": "EXAMPLE fake seeder data.", "login": { - "username": "User-842054", - "password": "Password-842054" + "username": "observability@verdant.example", + "password": "Fake-DD-Pass-30" } }, { "type": "login", - "name": "Cipher-1892038", - "login": {} + "name": "PagerDuty", + "notes": "EXAMPLE fake seeder data.", + "login": { + "username": "oncall@verdant.example", + "password": "Fake-PD-Pass-15" + } + }, + { + "type": "login", + "name": "Jamf Pro — MDM", + "notes": "Mobile device management. EXAMPLE fake seeder data.", + "login": { + "username": "mdm@verdant.example", + "password": "Fake-Jamf-Pass-60", + "uris": [{ "uri": "https://verdanthealth.jamfcloud.com" }] + } + }, + { + "type": "sshKey", + "name": "Production Bastion Host Key", + "notes": "SSH key for the production bastion. EXAMPLE fake seeder data.", + "cipherEncryption": "cipherKey", + "sshKey": { + "privateKey": "-----BEGIN OPENSSH PRIVATE KEY-----\nFAKE-EXAMPLE-NOT-A-REAL-KEY\n-----END OPENSSH PRIVATE KEY-----", + "publicKey": "ssh-ed25519 AAAAExampleFakeKeyBastionNotRealForSeeding seeder@verdant.example", + "keyFingerprint": "SHA256:ExampleFakeBastionFingerprintForSeedingOnly" + }, + "attachments": [ + { + "file": "mock-seeder-data-ssh-public-key-6.txt", + "fileName": "bastion_ed25519.pub", + "attachmentVersion": "v2" + } + ] + }, + { + "type": "sshKey", + "name": "CI/CD Deploy Key", + "notes": "Deploy key used by the build pipeline. EXAMPLE fake seeder data.", + "cipherEncryption": "cipherKey", + "sshKey": { + "privateKey": "-----BEGIN OPENSSH PRIVATE KEY-----\nFAKE-EXAMPLE-NOT-A-REAL-KEY\n-----END OPENSSH PRIVATE KEY-----", + "publicKey": "ssh-ed25519 AAAAExampleFakeKeyDeployNotRealForSeeding ci@verdant.example", + "keyFingerprint": "SHA256:ExampleFakeDeployFingerprintForSeedingOnly" + }, + "attachments": [ + { + "file": "mock-seeder-data-ssh-public-key-6.txt", + "fileName": "deploy_ed25519.pub", + "attachmentVersion": "v2" + } + ] + }, + { + "type": "secureNote", + "name": "Server Room Wi-Fi + Door Codes", + "notes": "Physical infrastructure access notes. EXAMPLE fake seeder data.", + "attachments": [ + { + "file": "mock-seeder-data-wifi-credentials-2.txt", + "fileName": "serverroom-wifi.txt", + "attachmentVersion": "v1" + } + ] }, { "type": "login", - "name": "Cipher-1892039", - "login": {} + "name": "Terraform Cloud", + "notes": "EXAMPLE fake seeder data.", + "login": { + "username": "infra@verdant.example", + "password": "Fake-TFC-Pass-09", + "uris": [{ "uri": "https://app.terraform.io" }] + } }, { "type": "login", - "name": "Cipher-1892047-No-Access", - "login": {} + "name": "Google Workspace Admin", + "notes": "EXAMPLE fake seeder data.", + "login": { + "username": "admin@verdant.example", + "password": "Fake-GW-Pass-51", + "totp": "RFRGGZDFMZTWQ2LM", + "uris": [{ "uri": "https://admin.google.com", "match": "host" }] + } }, { "type": "login", - "name": "Cipher-1892047-View", - "login": {} + "name": "Slack Workspace", + "notes": "EXAMPLE fake seeder data.", + "login": { + "username": "workspace@verdant.example", + "password": "Fake-Slack-Pass-44", + "uris": [{ "uri": "https://verdanthealth.slack.com" }] + } }, { "type": "login", - "name": "Cipher-1892047-Manage", - "login": {} + "name": "Zoom Company Account", + "notes": "EXAMPLE fake seeder data.", + "login": { + "username": "meetings@verdant.example", + "password": "Fake-Zoom-Pass-83", + "uris": [{ "uri": "https://zoom.us" }] + } }, { "type": "login", - "name": "Cipher-2532597", - "login": {} + "name": "Salesforce CRM", + "notes": "EXAMPLE fake seeder data.", + "login": { + "username": "crm@verdant.example", + "password": "Fake-SFDC-Pass-66", + "totp": "SFRGGZDFMZTWQ2LN", + "uris": [ + { + "uri": "https://verdanthealth.lightning.force.com", + "match": "host" + } + ] + } }, { "type": "login", - "name": "Cipher-1793366", - "login": {} + "name": "Zendesk Support", + "notes": "EXAMPLE fake seeder data.", + "login": { + "username": "support@verdant.example", + "password": "Fake-Zendesk-Pass-72" + } }, { "type": "login", - "name": "Cipher-3925830", - "login": {} + "name": "DocuSign", + "notes": "EXAMPLE fake seeder data.", + "login": { + "username": "esign@verdant.example", + "password": "Fake-DocuSign-Pass-38" + } }, { "type": "login", - "name": "Cipher-3925831", - "login": {} + "name": "LinkedIn — Verdant Health Page", + "notes": "Company social account. EXAMPLE fake seeder data.", + "login": { + "username": "social@verdant.example", + "password": "Fake-LI-Pass-90", + "uris": [{ "uri": "https://linkedin.com" }] + }, + "fields": [ + { + "name": "Backup email", + "value": "social-backup@verdant.example", + "type": "text" + } + ] }, { "type": "card", - "name": "Cipher-ClientEventLog-Card", + "name": "Company Visa — Travel & Facilities", + "notes": "Shared travel card. EXAMPLE fake seeder data.", "card": { - "cardholderName": "QA Testing", + "cardholderName": "Verdant Health", "brand": "Visa", - "number": "4242424242424242", - "expMonth": "1", - "expYear": "2111", + "number": "4111111111111111", + "expMonth": "03", + "expYear": "2028", "code": "123" } }, + { + "type": "secureNote", + "name": "Guest Wi-Fi Credentials", + "notes": "Guest network SSID and rotating passphrase. EXAMPLE fake seeder data.", + "attachments": [ + { + "file": "mock-seeder-data-wifi-credentials-2.txt", + "fileName": "guest-wifi.txt", + "attachmentVersion": "v0" + } + ] + }, + { + "type": "secureNote", + "name": "IT Security Policy", + "notes": "Acceptable-use and incident-response summary. EXAMPLE fake seeder data.", + "attachments": [ + { + "file": "mock-seeder-data-security-policy-5.txt", + "fileName": "security-policy.txt", + "attachmentVersion": "v1" + } + ] + }, { "type": "login", - "name": "Cipher-ClientEventLog-Login", - "fields": [ - { "name": "Hidden-Field", "value": "hiddenfield", "type": "hidden" } - ], + "name": "Legacy VPN — Cisco AnyConnect", + "notes": "Superseded by Cloudflare Access. EXAMPLE fake seeder data.", "login": { - "username": "username", - "password": "password", - "totp": "testing123" - } + "username": "vpn@verdant.example", + "password": "Fake-VPN-Old-01" + }, + "attachments": [ + { + "file": "mock-seeder-data-todo-4.txt", + "fileName": "vpn-decommission-todo.txt", + "attachmentVersion": "v0" + } + ], + "archived": true }, { "type": "login", - "name": "Cipher-5172094", + "name": "Paychex — Former Payroll Provider", + "notes": "Retained for records; migrated to ADP. EXAMPLE fake seeder data.", "login": { - "username": "username", - "password": "password", - "totp": "testing123" - } + "username": "payroll-legacy@verdant.example", + "password": "Fake-Paychex-Old-02" + }, + "attachments": [ + { + "file": "mock-seeder-data-expense-report-3.pdf", + "fileName": "final-payroll-report.pdf", + "attachmentVersion": "v1" + } + ], + "archived": true + }, + { + "type": "secureNote", + "name": "Prior Office Lease & Facilities Docs", + "notes": "Previous HQ lease, kept for reference. EXAMPLE fake seeder data.", + "attachments": [ + { + "file": "mock-seeder-data-contract-5.pdf", + "fileName": "prior-lease.pdf", + "attachmentVersion": "v1" + } + ], + "archived": true + }, + { + "type": "identity", + "name": "Former Vendor — Acme Supplies (W-9)", + "notes": "Terminated vendor; record retained. EXAMPLE fake seeder data.", + "cipherEncryption": "cipherKey", + "identity": { + "firstName": "Acme", + "lastName": "Supplies", + "company": "Acme Supplies Co.", + "email": "ap@acme.example", + "country": "US" + }, + "attachments": [ + { + "file": "mock-seeder-data-vendor-w9-7.txt", + "fileName": "acme-w9.txt", + "attachmentVersion": "v2" + } + ], + "archived": true }, { "type": "login", - "name": "Cipher-3392572", + "name": "Decommissioned Jenkins", + "notes": "Replaced by GitHub Actions. EXAMPLE fake seeder data.", "login": { - "username": "Cipher-3392572", - "password": "abc", - "uris": [ - { "uri": "https://coinbase.com" } - ] - } + "username": "ci-old@verdant.example", + "password": "Fake-Jenkins-Old-03" + }, + "deleted": true }, { "type": "login", - "name": "Cipher-3171087", + "name": "Cancelled T-Mobile Business", + "notes": "Account closed. EXAMPLE fake seeder data.", "login": { - "username": "Cipher-3171087", - "password": "abc", - "uris": [ - { "uri": "webtests.dev" } - ] - } + "username": "telecom@verdant.example", + "password": "Fake-TMO-Old-04" + }, + "deleted": true + }, + { + "type": "secureNote", + "name": "Old Server Room Wi-Fi", + "notes": "Rotated out. EXAMPLE fake seeder data.", + "attachments": [ + { + "file": "mock-seeder-data-wifi-credentials-2.txt", + "fileName": "old-serverroom-wifi.txt", + "attachmentVersion": "v0" + } + ], + "deleted": true + }, + { + "type": "bankAccount", + "name": "Closed Money Market Account", + "notes": "Account closed 2025. EXAMPLE fake seeder data.", + "bankAccount": { + "bankName": "First National Bank", + "nameOnAccount": "Verdant Health, Inc.", + "accountType": "Savings", + "accountNumber": "000555000111", + "routingNumber": "110000000" + }, + "deleted": true } ] } From f35885d6ec45a54df0915147221c81fba1c5bed8 Mon Sep 17 00:00:00 2001 From: Mick Letofsky Date: Sun, 19 Jul 2026 21:25:00 +0200 Subject: [PATCH 07/15] feat(seeder): add the golden local-sso preset and dev IdP wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standing up a SAML SSO org by hand — Enterprise org, Admin Console config, certificate extraction, .env wiring, IdP account mapping — is fiddly and easy to get subtly wrong. This adds a fixed, reproducible golden org so 'preset --name features.local-sso' (seeded without --mangle) yields a login-ready, populated SSO org against the bundled SimpleSAMLphp IdP. - Add the local-sso preset: fixed org GUID, SAML masterPassword, on the enriched enterprise-basic roster and ciphers, with collectionAssignments routing all 45 ciphers into the Finance/HR/IT/Company-Wide collections so the owner logs in to a populated vault. (Explicit routing leaves the legacy placeholder collections empty in this org.) - Pre-wire the committed dev templates: the .env template sets IDP_SP_ENTITY_ID/ACS for the fixed org GUID, and the IdP authsources template maps the 'owner' login to the seeded owner (dana.whitfield@verdant.example) — so a fresh checkout logs in out of the box. The live dev/.env and dev/authsources.php are gitignored; only the .example templates are committed. --- dev/.env.example | 7 +- dev/authsources.php.example | 5 + .../fixtures/presets/features/local-sso.json | 122 ++++++++++++++++++ 3 files changed, 131 insertions(+), 3 deletions(-) create mode 100644 util/Seeder/Seeds/fixtures/presets/features/local-sso.json diff --git a/dev/.env.example b/dev/.env.example index a374a9d784f9..cc1bdc5e2e75 100644 --- a/dev/.env.example +++ b/dev/.env.example @@ -14,9 +14,10 @@ MYSQL_ROOT_PASSWORD=SET_A_PASSWORD_HERE_123 MARIADB_ROOT_PASSWORD=SET_A_PASSWORD_HERE_123 # IdP configuration -# Complete using the values from the Manage SSO page in the web vault -IDP_SP_ENTITY_ID=http://localhost:51822/saml2/yourOrgIdHere -IDP_SP_ACS_URL=http://localhost:51822/saml2/yourOrgIdHere/Acs +# Pre-wired for the `features.local-sso` seeder preset (fixed org GUID; seed it WITHOUT --mangle). +# For any other org, copy the SP Entity ID + ACS URL from the web vault's Manage SSO page instead. +IDP_SP_ENTITY_ID=http://localhost:51822/saml2/a1b2c3d4-0000-4000-8000-000000000001 +IDP_SP_ACS_URL=http://localhost:51822/saml2/a1b2c3d4-0000-4000-8000-000000000001/Acs # Optional reverse proxy configuration # Should match server listen ports in reverse-proxy.conf diff --git a/dev/authsources.php.example b/dev/authsources.php.example index ce7907ba6fb2..4fc465e25bb6 100644 --- a/dev/authsources.php.example +++ b/dev/authsources.php.example @@ -15,6 +15,11 @@ $config = array( 'email' => 'user2@example.com', 'uid' => array('user2'), ), + // Maps to the `features.local-sso` seeder preset's org owner (seed without --mangle). + 'owner:password' => array( + 'email' => 'dana.whitfield@verdant.example', + 'uid' => array('local-sso-owner'), + ), ), ); diff --git a/util/Seeder/Seeds/fixtures/presets/features/local-sso.json b/util/Seeder/Seeds/fixtures/presets/features/local-sso.json new file mode 100644 index 000000000000..ea96e36f58fb --- /dev/null +++ b/util/Seeder/Seeds/fixtures/presets/features/local-sso.json @@ -0,0 +1,122 @@ +{ + "$schema": "../../../schemas/preset.schema.json", + "organization": { + "id": "a1b2c3d4-0000-4000-8000-000000000001", + "fixture": "verdant-health", + "planType": "enterprise-annually", + "seats": 30, + "allowAdminAccessToAllCollectionItems": true + }, + "roster": { + "fixture": "enterprise-basic" + }, + "ciphers": { + "fixture": "enterprise-basic" + }, + "sso": { + "identifier": "local-sso", + "encryptionType": "masterPassword", + "provider": "saml" + }, + "collectionAssignments": [ + { "cipher": "Concur Travel & Expense", "collection": "Finance" }, + { "cipher": "Bill.com — Accounts Payable", "collection": "Finance" }, + { "cipher": "ADP Workforce Now — Payroll", "collection": "Finance" }, + { + "cipher": "Operating Account — First National Bank", + "collection": "Finance" + }, + { + "cipher": "Payroll Account — First National Bank", + "collection": "Finance" + }, + { "cipher": "Corporate Amex — Finance", "collection": "Finance" }, + { "cipher": "QuickBooks Online", "collection": "Finance" }, + { "cipher": "Stripe Dashboard", "collection": "Finance" }, + { "cipher": "Paychex — Former Payroll Provider", "collection": "Finance" }, + { "cipher": "Closed Money Market Account", "collection": "Finance" }, + { "cipher": "Workday HCM", "collection": "Human Resources" }, + { "cipher": "BambooHR", "collection": "Human Resources" }, + { "cipher": "Greenhouse Recruiting", "collection": "Human Resources" }, + { + "cipher": "Employee Record — Dana Whitfield (CEO)", + "collection": "Human Resources" + }, + { + "cipher": "Contractor Onboarding — Availa Consulting", + "collection": "Human Resources" + }, + { + "cipher": "Executive Travel Passport — D. Whitfield", + "collection": "Human Resources" + }, + { + "cipher": "Fleet Driver License — Operations", + "collection": "Human Resources" + }, + { + "cipher": "Benefits — Open Enrollment Notes", + "collection": "Human Resources" + }, + { + "cipher": "Former Vendor — Acme Supplies (W-9)", + "collection": "Human Resources" + }, + { "cipher": "Okta Admin Console", "collection": "Information Technology" }, + { "cipher": "AWS Root Account", "collection": "Information Technology" }, + { + "cipher": "GitHub Enterprise — verdant-health", + "collection": "Information Technology" + }, + { + "cipher": "Cloudflare Dashboard", + "collection": "Information Technology" + }, + { "cipher": "Datadog Monitoring", "collection": "Information Technology" }, + { "cipher": "PagerDuty", "collection": "Information Technology" }, + { "cipher": "Jamf Pro — MDM", "collection": "Information Technology" }, + { + "cipher": "Production Bastion Host Key", + "collection": "Information Technology" + }, + { "cipher": "CI/CD Deploy Key", "collection": "Information Technology" }, + { + "cipher": "Server Room Wi-Fi + Door Codes", + "collection": "Information Technology" + }, + { "cipher": "Terraform Cloud", "collection": "Information Technology" }, + { "cipher": "IT Security Policy", "collection": "Information Technology" }, + { + "cipher": "Legacy VPN — Cisco AnyConnect", + "collection": "Information Technology" + }, + { + "cipher": "Decommissioned Jenkins", + "collection": "Information Technology" + }, + { + "cipher": "Old Server Room Wi-Fi", + "collection": "Information Technology" + }, + { "cipher": "Google Workspace Admin", "collection": "Company-Wide" }, + { "cipher": "Slack Workspace", "collection": "Company-Wide" }, + { "cipher": "Zoom Company Account", "collection": "Company-Wide" }, + { "cipher": "Salesforce CRM", "collection": "Company-Wide" }, + { "cipher": "Zendesk Support", "collection": "Company-Wide" }, + { "cipher": "DocuSign", "collection": "Company-Wide" }, + { + "cipher": "LinkedIn — Verdant Health Page", + "collection": "Company-Wide" + }, + { + "cipher": "Company Visa — Travel & Facilities", + "collection": "Company-Wide" + }, + { "cipher": "Guest Wi-Fi Credentials", "collection": "Company-Wide" }, + { + "cipher": "Prior Office Lease & Facilities Docs", + "collection": "Company-Wide" + }, + { "cipher": "Cancelled T-Mobile Business", "collection": "Company-Wide" } + ] +} From dd448f988a9acf4a657327a7307173f5d0c63ce0 Mon Sep 17 00:00:00 2001 From: Mick Letofsky Date: Sun, 19 Jul 2026 21:26:09 +0200 Subject: [PATCH 08/15] docs(seeder): catalog local-sso and the enterprise-basic refresh Keep the preset catalog in step with the fixture changes so the docs don't drift. - Update the sso-enterprise and tde-enterprise rows to show the enterprise-basic cipher fixture (was sso-vault/tde-vault). - Add a local-sso row and a note: golden fixed-GUID SAML 2.0 org, seed without --mangle, log in as owner/password, and the Azurite requirement now that enterprise-basic carries attachments. The formatter re-aligned the QA table columns as well (whitespace only; no QA content changed). --- util/Seeder/Seeds/docs/presets.md | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/util/Seeder/Seeds/docs/presets.md b/util/Seeder/Seeds/docs/presets.md index 438644a01adf..21a421edf691 100644 --- a/util/Seeder/Seeds/docs/presets.md +++ b/util/Seeder/Seeds/docs/presets.md @@ -18,14 +18,17 @@ Test specific Bitwarden features. Fixture-based data for deterministic results. dotnet run -- preset --name features.{name} --mangle ``` -| Preset | Features Enabled | Org Fixture | Roster | Ciphers | -| ----------------- | -------------------------------------------------- | ---------------- | ------------ | --------- | -| sso-enterprise | SSO (OIDC, masterPassword) + requireSso policy | verdant-health | starter-team | sso-vault | -| tde-enterprise | SSO (OIDC, trustedDevices/TDE) + requireSso policy | obsidian-labs | starter-team | tde-vault | -| policy-enterprise | All policies except requireSso and require2fa | pinnacle-designs | starter-team | — | +| Preset | Features Enabled | Org Fixture | Roster | Ciphers | +| ----------------- | ----------------------------------------------------------- | ---------------- | ---------------- | ---------------- | +| sso-enterprise | SSO (OIDC, masterPassword) + requireSso policy | verdant-health | starter-team | enterprise-basic | +| tde-enterprise | SSO (OIDC, trustedDevices/TDE) + requireSso policy | obsidian-labs | starter-team | enterprise-basic | +| local-sso | SSO (SAML 2.0, masterPassword) — golden local-IdP login org | verdant-health | enterprise-basic | enterprise-basic | +| policy-enterprise | All policies except requireSso and require2fa | pinnacle-designs | starter-team | — | `policy-enterprise` has no ciphers — it exists purely for testing policy enforcement. +`local-sso` is the golden clean-slate SAML 2.0 SSO org (fixed GUID; seed **without** `--mangle`). Log in via the bundled SimpleSAMLphp IdP as `owner` / `password` → `dana.whitfield@verdant.example`, who owns the department collections so the vault is populated on first login. Because the `enterprise-basic` cipher fixture carries attachments, Azurite (or a local attachment dir) must be configured to seed it. Wiring: `dev/authsources.php.example`, `dev/.env.example`. + ## QA Known users, groups, collections, and permissions you can point a client to. @@ -34,15 +37,15 @@ Known users, groups, collections, and permissions you can point a client to. dotnet run -- preset --name qa.{name} --mangle ``` -| Preset | Org Fixture | Roster | Ciphers | Use Case | -| --------------------------------- | -------------------- | ---------------------- | ----------------------- | ------------------------------------------------- | -| enterprise-basic | redwood-analytics | enterprise-basic | enterprise-basic | Standard enterprise org | -| collection-permissions-enterprise | cobalt-logistics | collection-permissions | collection-permissions | Permission edge cases | -| dunder-mifflin-enterprise-full | dunder-mifflin | dunder-mifflin | autofill-testing | Large handcrafted org | -| families-basic | adams-family | family | 150 generated | Families plan with personal vaults + reprompt | -| stark-free-basic | stark-industries | 1 generated user | autofill-testing | Free plan personal vault | -| zero-knowledge-labs-enterprise | zero-knowledge-labs | zero-knowledge-labs | zero-knowledge-labs | Full ZKL org with named folders + favorites | -| paper-trail-partners-team | paper-trail-partners | paper-trail-partners | encryption-modes (34) | Teams org: encryption modes across a shared vault | +| Preset | Org Fixture | Roster | Ciphers | Use Case | +| --------------------------------- | -------------------- | ---------------------- | ---------------------- | ------------------------------------------------- | +| enterprise-basic | redwood-analytics | enterprise-basic | enterprise-basic | Standard enterprise org | +| collection-permissions-enterprise | cobalt-logistics | collection-permissions | collection-permissions | Permission edge cases | +| dunder-mifflin-enterprise-full | dunder-mifflin | dunder-mifflin | autofill-testing | Large handcrafted org | +| families-basic | adams-family | family | 150 generated | Families plan with personal vaults + reprompt | +| stark-free-basic | stark-industries | 1 generated user | autofill-testing | Free plan personal vault | +| zero-knowledge-labs-enterprise | zero-knowledge-labs | zero-knowledge-labs | zero-knowledge-labs | Full ZKL org with named folders + favorites | +| paper-trail-partners-team | paper-trail-partners | paper-trail-partners | encryption-modes (34) | Teams org: encryption modes across a shared vault | `families-basic` and `stark-free-basic` mix fixtures with generated data (ciphers and personal ciphers). From 3d7455ce4baf13e2180ca9c85b44640a75b01e69 Mon Sep 17 00:00:00 2001 From: Mick Letofsky Date: Mon, 20 Jul 2026 09:25:28 +0200 Subject: [PATCH 09/15] chore: resolve code smells --- test/SeederApi.IntegrationTest/FixtureParsingTests.cs | 10 +++++----- util/Seeder/Factories/OrganizationSeeder.cs | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/test/SeederApi.IntegrationTest/FixtureParsingTests.cs b/test/SeederApi.IntegrationTest/FixtureParsingTests.cs index 5b0aee2d4a5f..b02ddb9b2816 100644 --- a/test/SeederApi.IntegrationTest/FixtureParsingTests.cs +++ b/test/SeederApi.IntegrationTest/FixtureParsingTests.cs @@ -11,7 +11,7 @@ namespace Bit.SeederApi.IntegrationTest; /// public sealed class FixtureParsingTests { - private const int ExpectedCipherCount = 34; + private const int _expectedCipherCount = 34; private readonly SeedReader _reader = new(); [Fact] @@ -23,7 +23,7 @@ public void IndividualEncryptionModesPreset_ParsesWithExpectedCiphers() Assert.Equal("encryption-modes", preset.Ciphers?.Fixture); var ciphers = _reader.Read("ciphers.encryption-modes"); - Assert.Equal(ExpectedCipherCount, ciphers.Items.Count); + Assert.Equal(_expectedCipherCount, ciphers.Items.Count); } [Fact] @@ -44,9 +44,9 @@ public void QaPaperTrailPartnersTeamPreset_ParsesWithOrgRosterAndCiphers() Assert.Contains(roster.Users, u => u.Role == "owner"); var ciphers = _reader.Read("ciphers.encryption-modes"); - Assert.Equal(ExpectedCipherCount, ciphers.Items.Count); - Assert.Contains(ciphers.Items, i => i.Archived == true); - Assert.Contains(ciphers.Items, i => i.Deleted == true); + Assert.Equal(_expectedCipherCount, ciphers.Items.Count); + Assert.Contains(ciphers.Items, i => i.Archived is true); + Assert.Contains(ciphers.Items, i => i.Deleted is true); } [Fact] diff --git a/util/Seeder/Factories/OrganizationSeeder.cs b/util/Seeder/Factories/OrganizationSeeder.cs index 034a1be3cc2f..55e6aca45c53 100644 --- a/util/Seeder/Factories/OrganizationSeeder.cs +++ b/util/Seeder/Factories/OrganizationSeeder.cs @@ -15,7 +15,7 @@ internal static Organization Create(string name, string domain, int seats, IMang var billingHash = DeriveShortHash(domain); var org = new Organization { - Id = id ?? CoreHelpers.GenerateComb(), + Id = id ?? CombGuid.Generate(), Identifier = manglerService.Mangle(domain), Name = manglerService.Mangle(name), BillingEmail = $"billing{billingHash}@{billingHash}.{domain}", From 42f15898d5e70c42c270beecbbbdfa03123c57a8 Mon Sep 17 00:00:00 2001 From: Mick Letofsky Date: Mon, 20 Jul 2026 10:38:56 +0200 Subject: [PATCH 10/15] Potential fix for pull request finding 'Unnecessarily complex Boolean expression' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- test/SeederApi.IntegrationTest/FixtureParsingTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/SeederApi.IntegrationTest/FixtureParsingTests.cs b/test/SeederApi.IntegrationTest/FixtureParsingTests.cs index b02ddb9b2816..6d935c8372e5 100644 --- a/test/SeederApi.IntegrationTest/FixtureParsingTests.cs +++ b/test/SeederApi.IntegrationTest/FixtureParsingTests.cs @@ -76,7 +76,7 @@ public void EnterpriseBasicFixture_CoversAllCipherTypesWithLifecycle() new HashSet { "login", "card", "identity", "secureNote", "sshKey", "bankAccount", "driversLicense", "passport" }, types); - Assert.Contains(ciphers.Items, i => i.Archived == true); + Assert.Contains(ciphers.Items, i => i.Archived is true); Assert.Contains(ciphers.Items, i => i.Deleted == true); } From 4dc7ba4a5d7f6f564565c111dc64c9b94ebd18a6 Mon Sep 17 00:00:00 2001 From: Mick Letofsky Date: Mon, 20 Jul 2026 18:16:16 +0200 Subject: [PATCH 11/15] feat(seeder): warn when --owner-email needs a matching authsources.php entry --- dev/authsources.php.example | 4 ++-- util/SeederUtility/Commands/PresetCommand.cs | 2 +- util/SeederUtility/Helpers/ConsoleOutput.cs | 14 +++++++++++++- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/dev/authsources.php.example b/dev/authsources.php.example index 4fc465e25bb6..25b1de02eeb3 100644 --- a/dev/authsources.php.example +++ b/dev/authsources.php.example @@ -16,9 +16,9 @@ $config = array( 'uid' => array('user2'), ), // Maps to the `features.local-sso` seeder preset's org owner (seed without --mangle). - 'owner:password' => array( + 'dana.whitfield:a-password-goes-here' => array( 'email' => 'dana.whitfield@verdant.example', - 'uid' => array('local-sso-owner'), + 'uid' => array('dana.whitfield-local-sso-owner'), ), ), diff --git a/util/SeederUtility/Commands/PresetCommand.cs b/util/SeederUtility/Commands/PresetCommand.cs index 95854c3c3280..c56013853860 100644 --- a/util/SeederUtility/Commands/PresetCommand.cs +++ b/util/SeederUtility/Commands/PresetCommand.cs @@ -67,7 +67,7 @@ private static void RunOrganizationPreset(PresetArgs args) if (result.SsoIdentifier is not null) { - ConsoleOutput.PrintSsoWiring(result.OrganizationId, result.SsoIdentifier); + ConsoleOutput.PrintSsoWiring(result.OrganizationId, result.SsoIdentifier, args.OwnerEmail); } } diff --git a/util/SeederUtility/Helpers/ConsoleOutput.cs b/util/SeederUtility/Helpers/ConsoleOutput.cs index 101a3021d101..cec2964bb20a 100644 --- a/util/SeederUtility/Helpers/ConsoleOutput.cs +++ b/util/SeederUtility/Helpers/ConsoleOutput.cs @@ -39,7 +39,7 @@ internal static void PrintMangleMap(SeederServiceScope deps) } } - internal static void PrintSsoWiring(Guid organizationId, string identifier) + internal static void PrintSsoWiring(Guid organizationId, string identifier, string? ownerEmailOverride) { var sp = $"http://localhost:51822/saml2/{organizationId}"; Console.Error.WriteLine(); @@ -48,5 +48,17 @@ internal static void PrintSsoWiring(Guid organizationId, string identifier) Console.Error.WriteLine(" Add to dev/.env, then restart the IdP: docker compose --profile idp up -d"); Console.Error.WriteLine($" IDP_SP_ENTITY_ID={sp}"); Console.Error.WriteLine($" IDP_SP_ACS_URL={sp}/Acs"); + + if (ownerEmailOverride is not null) + { + Console.Error.WriteLine(); + Console.Error.WriteLine(" --owner-email was set. The local IdP identifies you via dev/authsources.php, not"); + Console.Error.WriteLine(" the database — add or update your login entry there (email AND uid together):"); + Console.Error.WriteLine(" ':' => array("); + Console.Error.WriteLine($" 'email' => '{ownerEmailOverride}',"); + Console.Error.WriteLine(" 'uid' => array(''),"); + Console.Error.WriteLine(" ),"); + Console.Error.WriteLine(" See dev/authsources.php.example for the default (no-override) entry. Live-mounted — no IdP restart needed."); + } } } From 42e556dc581f4383dea9d475b8bb6e045a9acd76 Mon Sep 17 00:00:00 2001 From: Mick Letofsky Date: Mon, 20 Jul 2026 19:08:10 +0200 Subject: [PATCH 12/15] Update test/SeederApi.IntegrationTest/FixtureParsingTests.cs Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> --- test/SeederApi.IntegrationTest/FixtureParsingTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/SeederApi.IntegrationTest/FixtureParsingTests.cs b/test/SeederApi.IntegrationTest/FixtureParsingTests.cs index 6d935c8372e5..95bc8f5d1dc3 100644 --- a/test/SeederApi.IntegrationTest/FixtureParsingTests.cs +++ b/test/SeederApi.IntegrationTest/FixtureParsingTests.cs @@ -72,7 +72,7 @@ public void EnterpriseBasicFixture_CoversAllCipherTypesWithLifecycle() // The enrichment added the five types that were missing; assert all eight are present. var types = ciphers.Items.Select(i => i.Type).ToHashSet(); - Assert.Subset( + Assert.Superset( new HashSet { "login", "card", "identity", "secureNote", "sshKey", "bankAccount", "driversLicense", "passport" }, types); From ca13e1986ce4fdbafc3187cfe0e8e1acb3ab0cff Mon Sep 17 00:00:00 2001 From: Mick Letofsky Date: Wed, 22 Jul 2026 15:15:14 +0200 Subject: [PATCH 13/15] feat(seeder): fail fast when a preset's fixed organization already exists Presets like local-sso seed a golden organization at a literal, fixed GUID so it stays reproducible against the bundled local SAML IdP. Unlike every other preset, --mangle can't rescue a rerun here - mangling only rewrites names/emails, never Organization.Id. Without this guard, rerunning such a preset failed deep inside BulkCommitter with a raw SQL primary-key violation, after cipher and attachment generation had already run, with no indication of what went wrong or how to fix it. This adds a pre-flight existence check that fails immediately, before any of that work starts, naming the offending organization ID and stating that it must be removed before reseeding. --- .../Guards/FixedOrganizationIdGuardTests.cs | 106 ++++++++++++++++++ .../Seeder/Guards/FixedOrganizationIdGuard.cs | 34 ++++++ util/Seeder/Pipeline/PresetLoader.cs | 8 +- util/Seeder/Pipeline/RecipeOrchestrator.cs | 6 +- 4 files changed, 148 insertions(+), 6 deletions(-) create mode 100644 test/SeederApi.IntegrationTest/Guards/FixedOrganizationIdGuardTests.cs create mode 100644 util/Seeder/Guards/FixedOrganizationIdGuard.cs diff --git a/test/SeederApi.IntegrationTest/Guards/FixedOrganizationIdGuardTests.cs b/test/SeederApi.IntegrationTest/Guards/FixedOrganizationIdGuardTests.cs new file mode 100644 index 000000000000..6c9b11dbfba9 --- /dev/null +++ b/test/SeederApi.IntegrationTest/Guards/FixedOrganizationIdGuardTests.cs @@ -0,0 +1,106 @@ +using Bit.Seeder.Guards; +using Bit.Seeder.Models; +using Xunit; + +namespace Bit.SeederApi.IntegrationTest.Guards; + +public sealed class FixedOrganizationIdGuardTests +{ + private static readonly Guid _fixedId = Guid.Parse("a1b2c3d4-0000-4000-8000-000000000001"); + + [Fact] + public void EnsureAvailable_NullOrganization_DoesNotInvokePredicate() + { + var predicateCalled = false; + + FixedOrganizationIdGuard.EnsureAvailable(null, id => + { + predicateCalled = true; + return true; + }); + + Assert.False(predicateCalled); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void EnsureAvailable_NullOrWhitespaceId_DoesNotInvokePredicate(string? id) + { + var predicateCalled = false; + + FixedOrganizationIdGuard.EnsureAvailable(new SeedPresetOrganization { Id = id }, orgId => + { + predicateCalled = true; + return true; + }); + + Assert.False(predicateCalled); + } + + [Fact] + public void EnsureAvailable_NoCollision_DoesNotThrow() + { + FixedOrganizationIdGuard.EnsureAvailable( + new SeedPresetOrganization { Id = _fixedId.ToString() }, + id => false); + } + + [Fact] + public void EnsureAvailable_Collision_ThrowsInvalidOperationException() + { + var ex = Assert.Throws(() => + FixedOrganizationIdGuard.EnsureAvailable( + new SeedPresetOrganization { Id = _fixedId.ToString() }, + id => true)); + + Assert.Contains(_fixedId.ToString(), ex.Message); + } + + [Fact] + public void EnsureAvailable_PassesParsedIdToPredicate() + { + Guid? capturedId = null; + + FixedOrganizationIdGuard.EnsureAvailable( + new SeedPresetOrganization { Id = _fixedId.ToString() }, + id => + { + capturedId = id; + return false; + }); + + Assert.Equal(_fixedId, capturedId); + } + + [Fact] + public void ResolveFixedId_NullOrganization_ReturnsNull() + { + Assert.Null(FixedOrganizationIdGuard.ResolveFixedId(null)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void ResolveFixedId_NullOrWhitespaceId_ReturnsNull(string? id) + { + Assert.Null(FixedOrganizationIdGuard.ResolveFixedId(new SeedPresetOrganization { Id = id })); + } + + [Fact] + public void ResolveFixedId_ValidGuidString_ReturnsParsedGuid() + { + var result = FixedOrganizationIdGuard.ResolveFixedId(new SeedPresetOrganization { Id = _fixedId.ToString() }); + + Assert.Equal(_fixedId, result); + } + + [Fact] + public void ResolveFixedId_MalformedId_ThrowsFormatException() + { + Assert.Throws(() => + FixedOrganizationIdGuard.ResolveFixedId(new SeedPresetOrganization { Id = "not-a-guid" })); + } +} diff --git a/util/Seeder/Guards/FixedOrganizationIdGuard.cs b/util/Seeder/Guards/FixedOrganizationIdGuard.cs new file mode 100644 index 000000000000..a7248da270ea --- /dev/null +++ b/util/Seeder/Guards/FixedOrganizationIdGuard.cs @@ -0,0 +1,34 @@ +using Bit.Seeder.Models; + +namespace Bit.Seeder.Guards; + +/// +/// Fails fast when a preset's fixed, golden organization ID already exists in the database, +/// producing an actionable error instead of a SQL primary-key violation from the BulkCommitter. +/// +internal static class FixedOrganizationIdGuard +{ + internal static void EnsureAvailable(SeedPresetOrganization? organization, Func organizationExists) + { + var id = ResolveFixedId(organization); + if (id is null) + { + return; + } + + if (organizationExists(id.Value)) + { + throw new InvalidOperationException( + $"Organization '{id}' already exists in the database. This preset seeds a fixed organization ID, " + + "so it cannot be reseeded until the existing organization is removed. Delete it manually, or " + + "restore your dev database, before rerunning this preset."); + } + } + + /// + /// Parses a preset's declared organization ID, if any. Presets that omit organization.id + /// get a fresh generated at seed time and are never subject to this guard. + /// + internal static Guid? ResolveFixedId(SeedPresetOrganization? organization) => + string.IsNullOrWhiteSpace(organization?.Id) ? null : Guid.Parse(organization.Id); +} diff --git a/util/Seeder/Pipeline/PresetLoader.cs b/util/Seeder/Pipeline/PresetLoader.cs index d959e832d337..648dfea5afc9 100644 --- a/util/Seeder/Pipeline/PresetLoader.cs +++ b/util/Seeder/Pipeline/PresetLoader.cs @@ -3,6 +3,7 @@ using Bit.Seeder.Data.Distributions; using Bit.Seeder.Data.Enums; using Bit.Seeder.Factories; +using Bit.Seeder.Guards; using Bit.Seeder.Models; using Bit.Seeder.Options; using Bit.Seeder.Services; @@ -101,7 +102,7 @@ private static void BuildRecipe(string presetName, SeedPreset preset, ISeedReade if (org.Fixture is not null) { - builder.UseOrganization(org.Fixture, org.PlanType, org.Seats, ToOverrides(org), ParseOrgId(org.Id)); + builder.UseOrganization(org.Fixture, org.PlanType, org.Seats, ToOverrides(org), FixedOrganizationIdGuard.ResolveFixedId(org)); // If using a fixture and domain not explicitly provided, read it from the fixture if (domain is null) @@ -113,7 +114,7 @@ private static void BuildRecipe(string presetName, SeedPreset preset, ISeedReade else if (org.Name is not null && org.Domain is not null) { var planType = PlanFeatures.Parse(org.PlanType); - builder.CreateOrganization(org.Name, org.Domain, org.Seats, planType, ToOverrides(org), ParseOrgId(org.Id)); + builder.CreateOrganization(org.Name, org.Domain, org.Seats, planType, ToOverrides(org), FixedOrganizationIdGuard.ResolveFixedId(org)); domain = org.Domain; } @@ -235,9 +236,6 @@ private static MemberDecryptionType ParseMemberDecryptionType(string? encryption $"Unknown SSO encryptionType '{encryptionType}'. Valid values: masterPassword, trustedDevices, keyConnector."), }; - private static Guid? ParseOrgId(string? id) => - string.IsNullOrWhiteSpace(id) ? null : Guid.Parse(id); - private static DensityProfile? ParseDensity(SeedPresetDensity? preset) { if (preset is null) diff --git a/util/Seeder/Pipeline/RecipeOrchestrator.cs b/util/Seeder/Pipeline/RecipeOrchestrator.cs index 3bdcd98181dc..0224fa8f9437 100644 --- a/util/Seeder/Pipeline/RecipeOrchestrator.cs +++ b/util/Seeder/Pipeline/RecipeOrchestrator.cs @@ -1,4 +1,5 @@ -using Bit.Seeder.Models; +using Bit.Seeder.Guards; +using Bit.Seeder.Models; using Bit.Seeder.Options; using Bit.Seeder.Services; using Microsoft.Extensions.DependencyInjection; @@ -36,6 +37,9 @@ internal PipelineExecutionResult Execute( // Read preset to extract kdfIterations before building services. // CLI --kdf-iterations takes precedence over the preset value. var preset = reader.Read($"presets.{presetName}"); + + FixedOrganizationIdGuard.EnsureAvailable(preset.Organization, id => deps.Db.Organizations.Any(o => o.Id == id)); + var effectiveKdf = kdfIterations ?? preset.KdfIterations ?? 5_000; var services = new ServiceCollection(); From 875d218ae3692015d189700670a7382dc0f43910 Mon Sep 17 00:00:00 2001 From: Mick Letofsky Date: Wed, 22 Jul 2026 16:49:53 +0200 Subject: [PATCH 14/15] Copilot fixes are legit. --- dev/authsources.php.example | 3 ++- .../Guards/FixedOrganizationIdGuardTests.cs | 7 +++++-- util/Seeder/Guards/FixedOrganizationIdGuard.cs | 17 +++++++++++++++-- util/Seeder/Seeds/schemas/preset.schema.json | 4 ++-- util/SeederUtility/Helpers/ConsoleOutput.cs | 9 ++++++++- 5 files changed, 32 insertions(+), 8 deletions(-) diff --git a/dev/authsources.php.example b/dev/authsources.php.example index 25b1de02eeb3..0ce9e4f07fc8 100644 --- a/dev/authsources.php.example +++ b/dev/authsources.php.example @@ -16,7 +16,8 @@ $config = array( 'uid' => array('user2'), ), // Maps to the `features.local-sso` seeder preset's org owner (seed without --mangle). - 'dana.whitfield:a-password-goes-here' => array( + // Docs (Seeds/docs/presets.md) say to log in as owner/password -- keep this key in sync. + 'owner:password' => array( 'email' => 'dana.whitfield@verdant.example', 'uid' => array('dana.whitfield-local-sso-owner'), ), diff --git a/test/SeederApi.IntegrationTest/Guards/FixedOrganizationIdGuardTests.cs b/test/SeederApi.IntegrationTest/Guards/FixedOrganizationIdGuardTests.cs index 6c9b11dbfba9..3784de21bdb4 100644 --- a/test/SeederApi.IntegrationTest/Guards/FixedOrganizationIdGuardTests.cs +++ b/test/SeederApi.IntegrationTest/Guards/FixedOrganizationIdGuardTests.cs @@ -98,9 +98,12 @@ public void ResolveFixedId_ValidGuidString_ReturnsParsedGuid() } [Fact] - public void ResolveFixedId_MalformedId_ThrowsFormatException() + public void ResolveFixedId_MalformedId_ThrowsInvalidOperationExceptionWithBadValue() { - Assert.Throws(() => + var ex = Assert.Throws(() => FixedOrganizationIdGuard.ResolveFixedId(new SeedPresetOrganization { Id = "not-a-guid" })); + + Assert.Contains("organization.id", ex.Message); + Assert.Contains("not-a-guid", ex.Message); } } diff --git a/util/Seeder/Guards/FixedOrganizationIdGuard.cs b/util/Seeder/Guards/FixedOrganizationIdGuard.cs index a7248da270ea..29a9012b12c4 100644 --- a/util/Seeder/Guards/FixedOrganizationIdGuard.cs +++ b/util/Seeder/Guards/FixedOrganizationIdGuard.cs @@ -29,6 +29,19 @@ internal static void EnsureAvailable(SeedPresetOrganization? organization, Func< /// Parses a preset's declared organization ID, if any. Presets that omit organization.id /// get a fresh generated at seed time and are never subject to this guard. /// - internal static Guid? ResolveFixedId(SeedPresetOrganization? organization) => - string.IsNullOrWhiteSpace(organization?.Id) ? null : Guid.Parse(organization.Id); + internal static Guid? ResolveFixedId(SeedPresetOrganization? organization) + { + if (string.IsNullOrWhiteSpace(organization?.Id)) + { + return null; + } + + if (!Guid.TryParse(organization.Id, out var id)) + { + throw new InvalidOperationException( + $"Preset organization.id '{organization.Id}' is not a valid GUID."); + } + + return id; + } } diff --git a/util/Seeder/Seeds/schemas/preset.schema.json b/util/Seeder/Seeds/schemas/preset.schema.json index 27b598b0d8bd..74669f89a37c 100644 --- a/util/Seeder/Seeds/schemas/preset.schema.json +++ b/util/Seeder/Seeds/schemas/preset.schema.json @@ -228,8 +228,8 @@ }, "encryptionType": { "type": "string", - "enum": ["masterPassword", "trustedDevices"], - "description": "How member keys are protected. 'masterPassword' = standard SSO, 'trustedDevices' = TDE (no master password)." + "enum": ["masterPassword", "trustedDevices", "keyConnector"], + "description": "How member keys are protected. 'masterPassword' = standard SSO, 'trustedDevices' = TDE (no master password), 'keyConnector' = Key Connector." }, "provider": { "type": "string", diff --git a/util/SeederUtility/Helpers/ConsoleOutput.cs b/util/SeederUtility/Helpers/ConsoleOutput.cs index cec2964bb20a..ea6da56c4351 100644 --- a/util/SeederUtility/Helpers/ConsoleOutput.cs +++ b/util/SeederUtility/Helpers/ConsoleOutput.cs @@ -55,10 +55,17 @@ internal static void PrintSsoWiring(Guid organizationId, string identifier, stri Console.Error.WriteLine(" --owner-email was set. The local IdP identifies you via dev/authsources.php, not"); Console.Error.WriteLine(" the database — add or update your login entry there (email AND uid together):"); Console.Error.WriteLine(" ':' => array("); - Console.Error.WriteLine($" 'email' => '{ownerEmailOverride}',"); + Console.Error.WriteLine($" 'email' => '{EscapePhpSingleQuotedString(ownerEmailOverride)}',"); Console.Error.WriteLine(" 'uid' => array(''),"); Console.Error.WriteLine(" ),"); Console.Error.WriteLine(" See dev/authsources.php.example for the default (no-override) entry. Live-mounted — no IdP restart needed."); } } + + /// + /// Escapes backslashes and single quotes so the value stays valid inside a PHP single-quoted + /// string literal when printed as a copy/paste snippet for dev/authsources.php. + /// + private static string EscapePhpSingleQuotedString(string value) => + value.Replace("\\", "\\\\").Replace("'", "\\'"); } From 70db6f44031414164b1ca053d6cb164f6fedcff8 Mon Sep 17 00:00:00 2001 From: Mick Letofsky Date: Wed, 22 Jul 2026 16:58:42 +0200 Subject: [PATCH 15/15] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- util/Seeder/Seeds/docs/presets.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/util/Seeder/Seeds/docs/presets.md b/util/Seeder/Seeds/docs/presets.md index 21a421edf691..31d41308e2a1 100644 --- a/util/Seeder/Seeds/docs/presets.md +++ b/util/Seeder/Seeds/docs/presets.md @@ -20,8 +20,8 @@ dotnet run -- preset --name features.{name} --mangle | Preset | Features Enabled | Org Fixture | Roster | Ciphers | | ----------------- | ----------------------------------------------------------- | ---------------- | ---------------- | ---------------- | -| sso-enterprise | SSO (OIDC, masterPassword) + requireSso policy | verdant-health | starter-team | enterprise-basic | -| tde-enterprise | SSO (OIDC, trustedDevices/TDE) + requireSso policy | obsidian-labs | starter-team | enterprise-basic | +| sso-enterprise | requireSso policy (OIDC SSO config is not seeded by the Seeder yet) | verdant-health | starter-team | enterprise-basic | +| tde-enterprise | requireSso policy (OIDC SSO config is not seeded by the Seeder yet) | obsidian-labs | starter-team | enterprise-basic | | local-sso | SSO (SAML 2.0, masterPassword) — golden local-IdP login org | verdant-health | enterprise-basic | enterprise-basic | | policy-enterprise | All policies except requireSso and require2fa | pinnacle-designs | starter-team | — |