Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions dev/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions dev/authsources.php.example
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
),
Comment thread
theMickster marked this conversation as resolved.
),

);
56 changes: 56 additions & 0 deletions test/SeederApi.IntegrationTest/CreateSsoConfigStepTests.cs
Original file line number Diff line number Diff line change
@@ -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<IManglerService, NoOpManglerService>();
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<InvalidOperationException>(
() => 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<InvalidOperationException>(
() => builder.WithSso(" ", "saml", MemberDecryptionType.MasterPassword));
Assert.Contains("non-empty identifier", ex.Message);
}
}
16 changes: 16 additions & 0 deletions test/SeederApi.IntegrationTest/FixtureParsingTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<SeedFile>("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<string> { "login", "card", "identity", "secureNote", "sshKey", "bankAccount", "driversLicense", "passport" },
types);
Comment thread
theMickster marked this conversation as resolved.
Outdated
Comment thread
theMickster marked this conversation as resolved.
Comment on lines +74 to +77

Assert.Contains(ciphers.Items, i => i.Archived == true);
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
Assert.Contains(ciphers.Items, i => i.Deleted == true);
Comment thread
theMickster marked this conversation as resolved.
Dismissed
}

[Theory]
[InlineData("encryption-modes")]
[InlineData("enterprise-basic")]
public void AttachmentFixtures_ReferenceEmbeddedBodies(string fixture)
{
var ciphers = _reader.Read<SeedFile>($"ciphers.{fixture}");
Expand Down
49 changes: 49 additions & 0 deletions test/SeederApi.IntegrationTest/SsoConfigSeederTests.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
15 changes: 15 additions & 0 deletions util/Seeder/Data/Static/LocalSamlIdp.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
ο»Ώnamespace Bit.Seeder.Data.Static;

/// <summary>
/// Fixed endpoints for the local dev SAML IdP shipped in <c>dev/docker-compose.yml</c>
/// (the <c>idp</c> profile, image <c>kenchan0130/simplesamlphp</c>). The signing certificate is
/// deliberately NOT stored here β€” it is fetched from the live metadata endpoint at seed time (see
/// <c>CreateSsoConfigStep</c>), so no certificate material lives in source and it always matches the
/// running image.
/// </summary>
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";
}
4 changes: 2 additions & 2 deletions util/Seeder/Factories/OrganizationSeeder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
Identifier = manglerService.Mangle(domain),
Name = manglerService.Mangle(name),
BillingEmail = $"billing{billingHash}@{billingHash}.{domain}",
Expand Down
40 changes: 40 additions & 0 deletions util/Seeder/Factories/SsoConfigSeeder.cs
Original file line number Diff line number Diff line change
@@ -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;
}
}
6 changes: 4 additions & 2 deletions util/Seeder/Models/OrganizationSeedResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -21,5 +22,6 @@ internal static OrganizationSeedResult From(PipelineExecutionResult result) =>
result.UsersCount,
result.GroupsCount,
result.CollectionsCount,
result.CiphersCount);
result.CiphersCount,
result.SsoIdentifier);
}
3 changes: 2 additions & 1 deletion util/Seeder/Models/PipelineExecutionResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,5 @@ internal record PipelineExecutionResult(
int GroupsCount,
int CollectionsCount,
int CiphersCount,
int FoldersCount);
int FoldersCount,
string? SsoIdentifier);
9 changes: 9 additions & 0 deletions util/Seeder/Models/SeedPreset.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>? FolderNames { get; init; }
public SeedPresetCiphers? Ciphers { get; init; }
Expand All @@ -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; }
Expand All @@ -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; }
Expand Down
30 changes: 30 additions & 0 deletions util/Seeder/Pipeline/BulkCommitter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -45,6 +46,8 @@ internal void Commit(SeederContext context)

MapAndCopy<Core.Entities.OrganizationApiKey, EfOrganizationApiKey>(context.OrganizationApiKey);

CommitSsoConfigs(context.SsoConfigs);

MapCopyAndClear<Core.Entities.User, EfUser>(context.Users);

MapCopyAndClear<Core.Entities.OrganizationUser, EfOrganizationUser>(context.OrganizationUsers);
Expand Down Expand Up @@ -120,6 +123,33 @@ private void CopyAndClear<T>(List<T> entities) where T : class
entities.Clear();
}

/// <summary>
/// Commits SsoConfig rows via EF Add/SaveChanges rather than BulkCopy: <c>SsoConfig.Id</c> 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.
/// </summary>
private void CommitSsoConfigs(List<Core.Auth.Entities.SsoConfig> 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();
}

/// <summary>
/// Bulk-inserts ciphers via a <see cref="CipherBulkRow"/> projection so the
/// <c>Reprompt</c> column survives BulkCopy. LinqToDB's bulk path drops <c>CipherRepromptType?</c>
Expand Down
30 changes: 27 additions & 3 deletions util/Seeder/Pipeline/PresetLoader.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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)
Expand All @@ -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;
}

Expand All @@ -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);
Expand Down Expand Up @@ -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."),
};
Comment thread
theMickster marked this conversation as resolved.

private static Guid? ParseOrgId(string? id) =>
string.IsNullOrWhiteSpace(id) ? null : Guid.Parse(id);

private static DensityProfile? ParseDensity(SeedPresetDensity? preset)
{
if (preset is null)
Expand Down
Loading
Loading