diff --git a/test/SeederApi.IntegrationTest/Commands/DestroySceneCommandTests.cs b/test/SeederApi.IntegrationTest/Commands/DestroySceneCommandTests.cs new file mode 100644 index 000000000000..2c7f1f67fd02 --- /dev/null +++ b/test/SeederApi.IntegrationTest/Commands/DestroySceneCommandTests.cs @@ -0,0 +1,156 @@ +using AutoMapper; +using Bit.Core.Entities; +using Bit.Core.Settings; +using Bit.Core.Utilities; +using Bit.Infrastructure.EntityFramework.AdminConsole.Repositories; +using Bit.Infrastructure.EntityFramework.Repositories; +using Bit.SeederApi.Commands; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace Bit.SeederApi.IntegrationTest.Commands; + +/// Exercises premium license-file cleanup against in-memory SQLite. +public sealed class DestroySceneCommandTests : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly ServiceProvider _provider; + private readonly string _licenseDirectory; + + public DestroySceneCommandTests() + { + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + + var services = new ServiceCollection(); + services.AddLogging(); + // DatabaseContext.OnModelCreating resolves IDataProtectionProvider for the + // User.Key / User.MasterPassword field converters, so DI must include it. + services.AddDataProtection(); + services.AddDbContext(opts => opts.UseSqlite(_connection)); + services.AddAutoMapper(typeof(UserRepository)); + + _provider = services.BuildServiceProvider(); + _provider.GetRequiredService().Database.EnsureCreated(); + + _licenseDirectory = Path.Combine(Path.GetTempPath(), $"seeder-license-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(Path.Combine(_licenseDirectory, "user")); + } + + public void Dispose() + { + _provider.Dispose(); + _connection.Dispose(); + if (Directory.Exists(_licenseDirectory)) + { + Directory.Delete(_licenseDirectory, recursive: true); + } + } + + [Fact] + public async Task DestroyAsync_SelfHosted_DeletesSeededUserLicenseFile() + { + var playId = Guid.NewGuid().ToString(); + var user = await SeedUserWithPlayItemAsync(playId); + var licenseFile = WriteLicenseFile(user.Id); + + await BuildCommand(selfHosted: true).DestroyAsync(playId); + + Assert.False(File.Exists(licenseFile)); + Assert.False(UserExists(user.Id)); + } + + [Fact] + public async Task DestroyAsync_NotSelfHosted_StillDeletesSeededLicenseFile() + { + var playId = Guid.NewGuid().ToString(); + var user = await SeedUserWithPlayItemAsync(playId); + var licenseFile = WriteLicenseFile(user.Id); + + await BuildCommand(selfHosted: false).DestroyAsync(playId); + + Assert.False(File.Exists(licenseFile)); + Assert.False(UserExists(user.Id)); + } + + [Fact] + public async Task DestroyAsync_SelfHosted_MissingLicenseFile_StillSucceeds() + { + var playId = Guid.NewGuid().ToString(); + var user = await SeedUserWithPlayItemAsync(playId); + + await BuildCommand(selfHosted: true).DestroyAsync(playId); + + Assert.False(UserExists(user.Id)); + } + + [Fact] + public async Task DestroyAsync_SelfHosted_UndeletableLicenseFile_DoesNotAbortDestroy() + { + var playId = Guid.NewGuid().ToString(); + var user = await SeedUserWithPlayItemAsync(playId); + + // A directory at the license file path forces File.Delete to throw; the best-effort cleanup must + // swallow it so the database teardown still succeeds. + var blockingPath = Path.Combine(_licenseDirectory, "user", $"{user.Id}.json"); + Directory.CreateDirectory(blockingPath); + + await BuildCommand(selfHosted: true).DestroyAsync(playId); + + Assert.False(UserExists(user.Id)); + Assert.True(Directory.Exists(blockingPath)); + } + + private DestroySceneCommand BuildCommand(bool selfHosted) + { + var scopeFactory = _provider.GetRequiredService(); + var mapper = _provider.GetRequiredService(); + var globalSettings = new GlobalSettings + { + SelfHosted = selfHosted, + LicenseDirectory = _licenseDirectory, + }; + + return new DestroySceneCommand( + _provider.GetRequiredService(), + _provider.GetRequiredService>(), + new UserRepository(scopeFactory, mapper), + new PlayItemRepository(scopeFactory, mapper), + new ProviderRepository(scopeFactory, mapper), + new OrganizationRepository(scopeFactory, mapper, + _provider.GetRequiredService>()), + globalSettings); + } + + private async Task SeedUserWithPlayItemAsync(string playId) + { + var scopeFactory = _provider.GetRequiredService(); + var mapper = _provider.GetRequiredService(); + + var user = new User + { + Id = CombGuid.Generate(), + Email = $"destroy-{Guid.NewGuid():N}@bw.example", + SecurityStamp = Guid.NewGuid().ToString(), + ApiKey = "test-api-key", + }; + await new UserRepository(scopeFactory, mapper).CreateAsync(user); + + var playItem = PlayItem.Create(user, playId); + playItem.SetNewId(); + await new PlayItemRepository(scopeFactory, mapper).CreateAsync(playItem); + + return user; + } + + private string WriteLicenseFile(Guid userId) + { + var path = Path.Combine(_licenseDirectory, "user", $"{userId}.json"); + File.WriteAllText(path, "{}"); + return path; + } + + private bool UserExists(Guid userId) => + _provider.GetRequiredService().Users.Any(u => u.Id == userId); +} diff --git a/test/SeederApi.IntegrationTest/LicenseTestHelpers.cs b/test/SeederApi.IntegrationTest/LicenseTestHelpers.cs new file mode 100644 index 000000000000..397e0d5e553f --- /dev/null +++ b/test/SeederApi.IntegrationTest/LicenseTestHelpers.cs @@ -0,0 +1,55 @@ +using System.Security.Claims; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Models.Business; +using Bit.Core.Billing.Organizations.Models; +using Bit.Core.Billing.Services; +using Bit.Core.Entities; +using Bit.Core.Models.Business; +using Bit.Seeder.Services; + +namespace Bit.SeederApi.IntegrationTest; + +/// +/// Shared harness for the self-hosted premium license tests: a premium-owner factory plus hand-written +/// licensing stubs. +/// +internal static class LicenseTestHelpers +{ + internal static User NewPremiumOwner() => new() + { + Id = Guid.NewGuid(), + Email = "premium.user@example.com", + Premium = true, + }; + + internal sealed class StubSeederLicenseSigner(Func> behavior) : ISeederLicenseSigner + { + public Task CreateUserTokenAsync(User user) => behavior(user); + } + + /// + /// Captures the licenses passed to and runs + /// to drive the success or failure path. + /// + internal sealed class StubLicensingService(Func onWrite) : ILicensingService + { + public List WrittenLicenses { get; } = []; + + public Task WriteUserLicenseAsync(User user, UserLicense license) + { + WrittenLicenses.Add(license); + return onWrite(user, license); + } + + public Task ValidateOrganizationsAsync() => throw new NotImplementedException(); + public Task ValidateUsersAsync() => throw new NotImplementedException(); + public Task ValidateUserPremiumAsync(User user) => throw new NotImplementedException(); + public bool VerifyLicense(ILicense license) => throw new NotImplementedException(); + public byte[] SignLicense(ILicense license) => throw new NotImplementedException(); + public Task ReadOrganizationLicenseAsync(Organization organization) => throw new NotImplementedException(); + public Task ReadOrganizationLicenseAsync(Guid organizationId) => throw new NotImplementedException(); + public ClaimsPrincipal? GetClaimsPrincipalFromLicense(ILicense license) => throw new NotImplementedException(); + public Task CreateOrganizationTokenAsync(Organization organization, Guid installationId, SubscriptionInfo subscriptionInfo) => throw new NotImplementedException(); + public Task CreateUserTokenAsync(User user, SubscriptionInfo subscriptionInfo) => throw new NotImplementedException(); + } +} diff --git a/test/SeederApi.IntegrationTest/Pipeline/RecipeOrchestratorIntegrationTests.cs b/test/SeederApi.IntegrationTest/Pipeline/RecipeOrchestratorIntegrationTests.cs index d1794754170e..a3d069369c77 100644 --- a/test/SeederApi.IntegrationTest/Pipeline/RecipeOrchestratorIntegrationTests.cs +++ b/test/SeederApi.IntegrationTest/Pipeline/RecipeOrchestratorIntegrationTests.cs @@ -125,6 +125,7 @@ private RecipeOrchestrator NewOrchestrator(IManglerService mangler) new PasswordHasher(), mangler, null!, + null!, null!); return new RecipeOrchestrator(deps); } diff --git a/test/SeederApi.IntegrationTest/RecipeBuilderValidationTests.cs b/test/SeederApi.IntegrationTest/RecipeBuilderValidationTests.cs index d6f7b552cf12..eba148ea7a46 100644 --- a/test/SeederApi.IntegrationTest/RecipeBuilderValidationTests.cs +++ b/test/SeederApi.IntegrationTest/RecipeBuilderValidationTests.cs @@ -183,7 +183,9 @@ public void CreateIndividualUser_ProducesTwoStepsInOrder() var builder = services.AddRecipe("test"); builder.CreateIndividualUser("user@example.com", true, 1, true); + services.AddLogging(); services.AddSingleton(); + services.AddSingleton(); using var provider = services.BuildServiceProvider(); var steps = provider.GetKeyedServices("test") @@ -272,6 +274,12 @@ private sealed class StubLicensingService : ILicensingService public Task WriteUserLicenseAsync(User user, UserLicense license) => throw new NotImplementedException(); } + private sealed class StubSeederLicenseSigner : ISeederLicenseSigner + { + public Task CreateUserTokenAsync(User user) => + Task.FromResult(LicenseSigningResult.Skipped("no signing certificate configured")); + } + private sealed class StubSeedReader(bool hasOwner) : ISeedReader { public T Read(string seedName) => diff --git a/test/SeederApi.IntegrationTest/SeedControllerTests.cs b/test/SeederApi.IntegrationTest/SeedControllerTests.cs index a409741ed1d5..94e47fe395fd 100644 --- a/test/SeederApi.IntegrationTest/SeedControllerTests.cs +++ b/test/SeederApi.IntegrationTest/SeedControllerTests.cs @@ -1,4 +1,5 @@ using System.Net; +using System.Text.Json; using Bit.Seeder.Scenes; using Bit.SeederApi.Models.Request; using Bit.SeederApi.Models.Response; @@ -44,7 +45,7 @@ public async Task SeedEndpoint_WithValidScene_ReturnsOk() var response = await _client.PostAsJsonAsync("/seed", new SeedRequestModel { Template = "SingleUserScene", - Arguments = System.Text.Json.JsonSerializer.SerializeToElement(new SingleUserScene.Request() { Email = testEmail, Password = "asdfasdfasdf" }) + Arguments = JsonSerializer.SerializeToElement(new SingleUserScene.Request() { Email = testEmail, Password = "asdfasdfasdf" }) }, playId); response.EnsureSuccessStatusCode(); @@ -55,13 +56,50 @@ public async Task SeedEndpoint_WithValidScene_ReturnsOk() Assert.NotNull(result.Result); } + [Fact] + public async Task SeedEndpoint_SelfHostedPremiumUser_ReportsPremiumLicenseOutcome() + { + var testEmail = $"premium-selfhost-{Guid.NewGuid()}@bitwarden.com"; + + var response = await _client.PostAsJsonAsync("/seed", new SeedRequestModel + { + Template = "SingleUserScene", + Arguments = JsonSerializer.SerializeToElement(new SingleUserScene.Request + { + Email = testEmail, + Password = "asdfasdfasdf", + Premium = true, + SelfHosted = true + }) + }, Guid.NewGuid().ToString()); + + response.EnsureSuccessStatusCode(); + var result = await response.Content.ReadFromJsonAsync(); + + Assert.NotNull(result); + var written = GetResultProperty(result, "premiumLicenseWritten").GetBoolean(); + var warning = GetResultProperty(result, "premiumLicenseWarning"); + + // The test factory configures no licensing certificate, so signing is skipped and warns. + Assert.False(written); + Assert.False(string.IsNullOrWhiteSpace(warning.GetString())); + } + + private static JsonElement GetResultProperty(SceneResponseModel response, string propertyName) + { + var result = Assert.IsType(response.Result); + Assert.True(result.TryGetProperty(propertyName, out var property), + $"Scene result did not contain '{propertyName}'."); + return property; + } + [Fact] public async Task SeedEndpoint_WithInvalidSceneName_ReturnsNotFound() { var response = await _client.PostAsJsonAsync("/seed", new SeedRequestModel { Template = "NonExistentScene", - Arguments = System.Text.Json.JsonSerializer.SerializeToElement(new SingleUserScene.Request() { Email = "test@example.com", Password = "asdfasdfasdf" }) + Arguments = JsonSerializer.SerializeToElement(new SingleUserScene.Request() { Email = "test@example.com", Password = "asdfasdfasdf" }) }); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); @@ -74,7 +112,7 @@ public async Task SeedEndpoint_WithMissingRequiredField_ReturnsBadRequest() var response = await _client.PostAsJsonAsync("/seed", new SeedRequestModel { Template = "SingleUserScene", - Arguments = System.Text.Json.JsonSerializer.SerializeToElement(new { wrongField = "value" }) + Arguments = JsonSerializer.SerializeToElement(new { wrongField = "value" }) }); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); @@ -89,7 +127,7 @@ public async Task DeleteEndpoint_WithValidPlayId_ReturnsOk() var seedResponse = await _client.PostAsJsonAsync("/seed", new SeedRequestModel { Template = "SingleUserScene", - Arguments = System.Text.Json.JsonSerializer.SerializeToElement(new SingleUserScene.Request() { Email = testEmail, Password = "asdfasdfasdf" }) + Arguments = JsonSerializer.SerializeToElement(new SingleUserScene.Request() { Email = testEmail, Password = "asdfasdfasdf" }) }, playId); seedResponse.EnsureSuccessStatusCode(); @@ -126,7 +164,7 @@ public async Task DeleteBatchEndpoint_WithValidPlayIds_ReturnsOk() var seedResponse = await _client.PostAsJsonAsync("/seed", new SeedRequestModel { Template = "SingleUserScene", - Arguments = System.Text.Json.JsonSerializer.SerializeToElement(new SingleUserScene.Request() { Email = testEmail, Password = "asdfasdfasdf" }) + Arguments = JsonSerializer.SerializeToElement(new SingleUserScene.Request() { Email = testEmail, Password = "asdfasdfasdf" }) }, playId); seedResponse.EnsureSuccessStatusCode(); @@ -158,7 +196,7 @@ public async Task DeleteBatchEndpoint_WithSomeInvalidIds_ReturnsOk() var seedResponse = await _client.PostAsJsonAsync("/seed", new SeedRequestModel { Template = "SingleUserScene", - Arguments = System.Text.Json.JsonSerializer.SerializeToElement(new SingleUserScene.Request() { Email = testEmail, Password = "asdfasdfasdf" }) + Arguments = JsonSerializer.SerializeToElement(new SingleUserScene.Request() { Email = testEmail, Password = "asdfasdfasdf" }) }, validPlayId); seedResponse.EnsureSuccessStatusCode(); @@ -191,7 +229,7 @@ public async Task DeleteAllEndpoint_DeletesAllSeededData() var seedResponse = await _client.PostAsJsonAsync("/seed", new SeedRequestModel { Template = "SingleUserScene", - Arguments = System.Text.Json.JsonSerializer.SerializeToElement(new SingleUserScene.Request() { Email = testEmail, Password = "asdfasdfasdf" }) + Arguments = JsonSerializer.SerializeToElement(new SingleUserScene.Request() { Email = testEmail, Password = "asdfasdfasdf" }) }, playId); seedResponse.EnsureSuccessStatusCode(); @@ -211,7 +249,7 @@ public async Task SeedEndpoint_VerifyResponseContainsMangleMapAndResult() var response = await _client.PostAsJsonAsync("/seed", new SeedRequestModel { Template = "SingleUserScene", - Arguments = System.Text.Json.JsonSerializer.SerializeToElement(new SingleUserScene.Request() { Email = testEmail, Password = "asdfasdfasdf" }) + Arguments = JsonSerializer.SerializeToElement(new SingleUserScene.Request() { Email = testEmail, Password = "asdfasdfasdf" }) }, playId); response.EnsureSuccessStatusCode(); diff --git a/test/SeederApi.IntegrationTest/Services/SeederLicenseSignerTests.cs b/test/SeederApi.IntegrationTest/Services/SeederLicenseSignerTests.cs new file mode 100644 index 000000000000..b53b5902fd59 --- /dev/null +++ b/test/SeederApi.IntegrationTest/Services/SeederLicenseSignerTests.cs @@ -0,0 +1,91 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Bit.Core.Billing.Licenses; +using Bit.Core.Billing.Licenses.Services.Implementations; +using Bit.Core.Entities; +using Bit.Core.Settings; +using Bit.Seeder.Services; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.IdentityModel.Tokens; +using Xunit; + +namespace Bit.SeederApi.IntegrationTest.Services; + +/// Guards JWT parity between and production code LicensingService.GenerateToken. +public sealed class SeederLicenseSignerTests : IDisposable +{ + private const string _password = "test-cert-password"; + + private readonly string _certPath = Path.Join(Path.GetTempPath(), $"seeder-signing-{Guid.NewGuid():N}.pfx"); + private readonly X509Certificate2 _certificate; + + public SeederLicenseSignerTests() + { + using var rsa = RSA.Create(2048); + var request = new CertificateRequest( + "CN=Seeder License Signing Test", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + _certificate = request.CreateSelfSigned( + DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(5)); + File.WriteAllBytes(_certPath, _certificate.Export(X509ContentType.Pfx, _password)); + } + + public void Dispose() + { + _certificate.Dispose(); + if (File.Exists(_certPath)) + { + File.Delete(_certPath); + } + } + + [Fact] + public async Task CreateUserTokenAsync_CertificateConfigured_MintsTokenMatchingLicensingServiceShape() + { + var user = new User { Id = Guid.NewGuid(), Email = "premium.user@example.com", Premium = true }; + using var signer = NewSigner(); + + var result = await signer.CreateUserTokenAsync(user); + + Assert.Null(result.Warning); + Assert.NotNull(result.Token); + + var handler = new JwtSecurityTokenHandler(); + var expectedAudience = $"user:{user.Id}"; + + handler.ValidateToken(result.Token, new TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuer = "bitwarden", + ValidateAudience = true, + ValidAudience = expectedAudience, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + IssuerSigningKey = new X509SecurityKey(_certificate), + }, out var validated); + + var jwt = Assert.IsType(validated); + Assert.Equal("RS256", jwt.Header.Alg); + Assert.Contains(jwt.Claims, c => c.Type == JwtRegisteredClaimNames.Jti); + + Assert.True(jwt.ValidTo >= DateTime.UtcNow.AddYears(1).AddDays(-1)); + Assert.True(jwt.ValidTo <= DateTime.UtcNow.AddYears(1).AddDays(1)); + + Assert.Contains(jwt.Claims, c => c.Type == nameof(UserLicenseConstants.Id) && c.Value == user.Id.ToString()); + Assert.Contains(jwt.Claims, c => c.Type == nameof(UserLicenseConstants.Premium) && c.Value == "True"); + } + + private SeederLicenseSigner NewSigner() + { + var globalSettings = new GlobalSettings + { + LicenseCertificatePath = _certPath, + LicenseCertificatePassword = _password, + }; + + return new SeederLicenseSigner( + globalSettings, + new UserLicenseClaimsFactory(), + NullLogger.Instance); + } +} diff --git a/test/SeederApi.IntegrationTest/Services/SelfHostLicenseServiceTests.cs b/test/SeederApi.IntegrationTest/Services/SelfHostLicenseServiceTests.cs new file mode 100644 index 000000000000..065b85d8abc4 --- /dev/null +++ b/test/SeederApi.IntegrationTest/Services/SelfHostLicenseServiceTests.cs @@ -0,0 +1,71 @@ +using System.Security.Cryptography; +using Bit.Seeder.Services; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; +using static Bit.SeederApi.IntegrationTest.LicenseTestHelpers; + +namespace Bit.SeederApi.IntegrationTest.Services; + +/// +/// Guards : write failures return a warning instead of throwing. +/// +public class SelfHostLicenseServiceTests +{ + [Fact] + public async Task WriteLicenseAsync_SignerConfigured_ReportsWrittenWithoutWarning() + { + var licensing = new StubLicensingService((_, _) => Task.CompletedTask); + var signer = new StubSeederLicenseSigner(_ => Task.FromResult(LicenseSigningResult.Signed("signed.jwt.token"))); + + var outcome = await SelfHostLicenseService.WriteLicenseAsync(licensing, signer, NewPremiumOwner(), NullLogger.Instance); + + Assert.True(outcome.Written); + Assert.Null(outcome.Warning); + Assert.Single(licensing.WrittenLicenses); + } + + [Fact] + public async Task WriteLicenseAsync_SignerNotConfigured_ReportsSignerWarningAndWritesNothing() + { + var licensing = new StubLicensingService((_, _) => Task.CompletedTask); + var signer = new StubSeederLicenseSigner( + _ => Task.FromResult(LicenseSigningResult.Skipped("No signing certificate configured."))); + + var outcome = await SelfHostLicenseService.WriteLicenseAsync(licensing, signer, NewPremiumOwner(), NullLogger.Instance); + + Assert.False(outcome.Written); + Assert.Equal("No signing certificate configured.", outcome.Warning); + Assert.Empty(licensing.WrittenLicenses); + } + + public static TheoryData ExpectedWriteExceptions() => new() + { + new InvalidOperationException("boom"), + new CryptographicException("boom"), + new IOException("disk full"), + new UnauthorizedAccessException("boom"), + }; + + [Theory] + [MemberData(nameof(ExpectedWriteExceptions))] + public async Task WriteLicenseAsync_WriteThrowsExpectedException_ReportsWarningWithoutPropagating(Exception thrown) + { + var licensing = new StubLicensingService((_, _) => throw thrown); + var signer = new StubSeederLicenseSigner(_ => Task.FromResult(LicenseSigningResult.Signed("signed.jwt.token"))); + + var outcome = await SelfHostLicenseService.WriteLicenseAsync(licensing, signer, NewPremiumOwner(), NullLogger.Instance); + + Assert.False(outcome.Written); + Assert.Contains(thrown.Message, outcome.Warning); + } + + [Fact] + public async Task WriteLicenseAsync_WriteThrowsUnexpectedException_Propagates() + { + var licensing = new StubLicensingService((_, _) => throw new NotSupportedException("boom")); + var signer = new StubSeederLicenseSigner(_ => Task.FromResult(LicenseSigningResult.Signed("signed.jwt.token"))); + + await Assert.ThrowsAsync( + () => SelfHostLicenseService.WriteLicenseAsync(licensing, signer, NewPremiumOwner(), NullLogger.Instance)); + } +} diff --git a/test/SeederApi.IntegrationTest/Steps/GenerateSelfHostUserLicenseStepTests.cs b/test/SeederApi.IntegrationTest/Steps/GenerateSelfHostUserLicenseStepTests.cs new file mode 100644 index 000000000000..344d6cf83193 --- /dev/null +++ b/test/SeederApi.IntegrationTest/Steps/GenerateSelfHostUserLicenseStepTests.cs @@ -0,0 +1,57 @@ +using Bit.Core.Entities; +using Bit.Seeder.Pipeline; +using Bit.Seeder.Services; +using Bit.Seeder.Steps; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; +using static Bit.SeederApi.IntegrationTest.LicenseTestHelpers; +using static Bit.SeederApi.IntegrationTest.Steps.SeederStepTestHelpers; + +namespace Bit.SeederApi.IntegrationTest.Steps; + +/// +/// Guards the pipeline self-hosted premium license step: non-premium/no-owner early-return, and a premium +/// owner triggers a license write. +/// +public class GenerateSelfHostUserLicenseStepTests +{ + [Fact] + public async Task ExecuteAsync_PremiumOwner_SignerReturnsToken_WritesOneLicense() + { + var licensing = new StubLicensingService((_, _) => Task.CompletedTask); + var signer = new StubSeederLicenseSigner(_ => Task.FromResult(LicenseSigningResult.Signed("signed.jwt.token"))); + var context = NewContext(new SeederSettings()); + context.Owner = NewPremiumOwner(); + + await new GenerateSelfHostUserLicenseStep(licensing, signer, NullLogger.Instance).ExecuteAsync(context); + + var written = Assert.Single(licensing.WrittenLicenses); + Assert.True(written.Premium); + Assert.False(string.IsNullOrWhiteSpace(written.Token)); + } + + [Fact] + public async Task ExecuteAsync_NoOwner_WritesNothing() + { + var licensing = new StubLicensingService((_, _) => Task.CompletedTask); + var signer = new StubSeederLicenseSigner(_ => Task.FromResult(LicenseSigningResult.Signed("signed.jwt.token"))); + var context = NewContext(new SeederSettings()); + + await new GenerateSelfHostUserLicenseStep(licensing, signer, NullLogger.Instance).ExecuteAsync(context); + + Assert.Empty(licensing.WrittenLicenses); + } + + [Fact] + public async Task ExecuteAsync_OwnerNotPremium_WritesNothing() + { + var licensing = new StubLicensingService((_, _) => Task.CompletedTask); + var signer = new StubSeederLicenseSigner(_ => Task.FromResult(LicenseSigningResult.Signed("signed.jwt.token"))); + var context = NewContext(new SeederSettings()); + context.Owner = new User { Id = Guid.NewGuid(), Email = "free.user@example.com", Premium = false }; + + await new GenerateSelfHostUserLicenseStep(licensing, signer, NullLogger.Instance).ExecuteAsync(context); + + Assert.Empty(licensing.WrittenLicenses); + } +} diff --git a/util/Seeder/CLAUDE.md b/util/Seeder/CLAUDE.md index 140df215cdba..7792e4039e68 100644 --- a/util/Seeder/CLAUDE.md +++ b/util/Seeder/CLAUDE.md @@ -45,7 +45,7 @@ Need to create test data? - **SeederContext**: Shared mutable state bag (NOT thread-safe) - **RecipeExecutor**: Awaits steps sequentially, captures statistics, commits via BulkCommitter, then runs any post-commit steps - **RecipeOrchestrator**: Orchestrates recipe building and execution (from presets or options) -- **SeederDependencies** (`Options/`): Bundles infrastructure services (`DatabaseContext`, `IMapper`, `IPasswordHasher`, `IManglerService`, `ILicensingService`, `IAttachmentStorageService`) into a single record. Recipes and the Orchestrator accept this instead of loose parameters. The CLI utility builds it via `SeederServiceFactory.Create().ToDependencies()`. +- **SeederDependencies** (`Options/`): Bundles infrastructure services (`DatabaseContext`, `IMapper`, `IPasswordHasher`, `IManglerService`, `ILicensingService`, `IAttachmentStorageService`, `ISeederLicenseSigner`) into a single record. Recipes and the Orchestrator accept this instead of loose parameters. The CLI utility builds it via `SeederServiceFactory.Create().ToDependencies()`. **Why two step interfaces, not one async contract?** Deliberate — don't unify. Collapsing to one `Task ExecuteAsync(SeederContext)` costs: rewrite 22 step classes (18 in `Steps/`, 4 test doubles); force 20 `.Execute(context)` sites in `test/SeederApi.IntegrationTest/Steps/` to `await`, their test methods to `async`; and `TreatWarningsAsErrors` is on repo-wide (`Directory.Build.props`), so CS1998 makes `async` without `await` a build error — every sync step needs `return Task.CompletedTask`. Permanent trap. The split costs less: two-arm union in `OrderedStep`, `object`-typed `Inner`, one duplicated `RecipeBuilder` registration. Diverges from `IScene`/`IQuery` — single `Task`-returning, no sync twin. diff --git a/util/Seeder/Options/SeederDependencies.cs b/util/Seeder/Options/SeederDependencies.cs index 3d536ac989cc..61fb8a5f7ab3 100644 --- a/util/Seeder/Options/SeederDependencies.cs +++ b/util/Seeder/Options/SeederDependencies.cs @@ -18,7 +18,8 @@ public sealed record SeederDependencies( IPasswordHasher PasswordHasher, IManglerService ManglerService, ILicensingService LicensingService, - IAttachmentStorageService AttachmentStorageService) + IAttachmentStorageService AttachmentStorageService, + ISeederLicenseSigner LicenseSigner) { /// /// Optional progress reporter. When null, the pipeline runs silently. diff --git a/util/Seeder/Pipeline/RecipeBuilderExtensions.cs b/util/Seeder/Pipeline/RecipeBuilderExtensions.cs index 2bb333215247..1c9224032e5f 100644 --- a/util/Seeder/Pipeline/RecipeBuilderExtensions.cs +++ b/util/Seeder/Pipeline/RecipeBuilderExtensions.cs @@ -9,6 +9,7 @@ using Bit.Seeder.Services; using Bit.Seeder.Steps; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; namespace Bit.Seeder.Pipeline; @@ -172,7 +173,10 @@ public static RecipeBuilder CreateIndividualUser( builder.AddStep(_ => new CreateIndividualUserStep(email, premium, maxStorageGb, true)); if (selfHosted) { - builder.AddAsyncStep(sp => new GenerateSelfHostUserLicenseStep(sp.GetRequiredService())); + builder.AddAsyncStep(sp => new GenerateSelfHostUserLicenseStep( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>())); } return builder; } diff --git a/util/Seeder/Pipeline/RecipeOrchestrator.cs b/util/Seeder/Pipeline/RecipeOrchestrator.cs index c9cea87b5f61..0a8b2fff24a3 100644 --- a/util/Seeder/Pipeline/RecipeOrchestrator.cs +++ b/util/Seeder/Pipeline/RecipeOrchestrator.cs @@ -142,6 +142,7 @@ internal async Task ExecuteAsync(IndividualUserOptions services.AddSingleton(deps.AttachmentStorageService); services.AddSingleton(new SeederSettings(options.Password, options.KdfIterations)); services.AddSingleton(deps.LicensingService); + services.AddSingleton(deps.LicenseSigner); if (deps.Progress is not null) { services.AddSingleton(deps.Progress); diff --git a/util/Seeder/Scenes/SingleUserScene.cs b/util/Seeder/Scenes/SingleUserScene.cs index ffbfa18d57f6..ee065c50273d 100644 --- a/util/Seeder/Scenes/SingleUserScene.cs +++ b/util/Seeder/Scenes/SingleUserScene.cs @@ -1,5 +1,4 @@ using System.ComponentModel.DataAnnotations; -using System.Security.Cryptography; using Bit.Core.Billing.Services; using Bit.Core.Entities; using Bit.Core.Enums; @@ -8,6 +7,7 @@ using Bit.Seeder.Models; using Bit.Seeder.Services; using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Logging; namespace Bit.Seeder.Scenes; @@ -21,6 +21,8 @@ public struct SingleUserSceneResult public string PublicKey { get; init; } public string PrivateKey { get; init; } public string ApiKey { get; init; } + public bool PremiumLicenseWritten { get; init; } + public string? PremiumLicenseWarning { get; init; } } /// @@ -30,7 +32,9 @@ public class SingleUserScene( IPasswordHasher passwordHasher, IUserRepository userRepository, IManglerService manglerService, - ILicensingService licenseService) : IScene + ILicensingService licenseService, + ISeederLicenseSigner licenseSigner, + ILogger logger) : IScene { public class Request { @@ -65,21 +69,17 @@ public async Task> SeedAsync(Request request) await userRepository.CreateAsync(user); + var licenseOutcome = default(LicenseWriteOutcome); if (request.SelfHosted && user.Premium) { - try - { - await SelfHostLicenseService.WriteLicenseAsync(licenseService, user); - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException or CryptographicException) - { - Console.WriteLine($"[SingleUserScene] Non-fatal license write failure for user '{user.Id}': {ex}"); - } + licenseOutcome = await SelfHostLicenseService.WriteLicenseAsync(licenseService, licenseSigner, user, logger); } return new SceneResult( result: new SingleUserSceneResult { + PremiumLicenseWritten = licenseOutcome.Written, + PremiumLicenseWarning = licenseOutcome.Warning, UserId = user.Id, Kdf = user.Kdf.ToString(), KdfIterations = user.KdfIterations, diff --git a/util/Seeder/Services/SeederLicenseSigner.cs b/util/Seeder/Services/SeederLicenseSigner.cs new file mode 100644 index 000000000000..b6d2da1cd85a --- /dev/null +++ b/util/Seeder/Services/SeederLicenseSigner.cs @@ -0,0 +1,151 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Security.Cryptography.X509Certificates; +using Bit.Core.Billing.Licenses.Models; +using Bit.Core.Billing.Licenses.Services; +using Bit.Core.Entities; +using Bit.Core.Models.Business; +using Bit.Core.Settings; +using Bit.Core.Utilities; +using Microsoft.Extensions.Logging; +using Microsoft.IdentityModel.Tokens; + +namespace Bit.Seeder.Services; + +/// Outcome of a signing attempt. +public sealed record LicenseSigningResult(string? Token, string? Warning) +{ + public static LicenseSigningResult Signed(string token) => new(token, null); + + public static LicenseSigningResult Skipped(string warning) => new(null, warning); +} + +/// +/// Signs self-hosted user premium license tokens with a private-key licensing certificate from configuration. +/// +public interface ISeederLicenseSigner +{ + /// + /// Creates a signed license token. Returns a null token with a warning when no usable signing + /// certificate is configured; callers treat that as "skip". Never throws for configuration problems. + /// + Task CreateUserTokenAsync(User user); +} + +/// +/// Caches the loaded signing certificate across runs. +public sealed class SeederLicenseSigner( + IGlobalSettings globalSettings, + ILicenseClaimsFactory userLicenseClaimsFactory, + ILogger logger) : ISeederLicenseSigner, IDisposable +{ + private readonly Lazy _certificate = + new(() => LoadCertificate(globalSettings, logger)); + + public async Task CreateUserTokenAsync(User user) + { + var (certificate, warning) = _certificate.Value; + if (certificate is null) + { + return LicenseSigningResult.Skipped(warning ?? "No signing certificate is available."); + } + + var licenseContext = new LicenseContext { SubscriptionInfo = new SubscriptionInfo() }; + var claims = await userLicenseClaimsFactory.GenerateClaims(user, licenseContext); + var audience = $"user:{user.Id}"; + + return LicenseSigningResult.Signed(GenerateToken(certificate, claims, audience)); + } + + /// + /// Loads the signing certificate. Returns a null certificate plus the reason when it is not + /// usable for signing. Never throws. Invoked once via . + /// + private static CertificateLoad LoadCertificate(IGlobalSettings globalSettings, ILogger logger) + { + if (!CoreHelpers.SettingHasValue(globalSettings.LicenseCertificatePath) || + !CoreHelpers.SettingHasValue(globalSettings.LicenseCertificatePassword)) + { + return Unusable(logger, + "No signing certificate configured (licenseCertificatePath/licenseCertificatePassword). " + + "Skipping premium license generation."); + } + + if (!File.Exists(globalSettings.LicenseCertificatePath)) + { + return Unusable(logger, + "Configured licensing certificate file was not found. Skipping premium license generation."); + } + + X509Certificate2 certificate; + try + { + certificate = CoreHelpers.GetCertificate( + globalSettings.LicenseCertificatePath, globalSettings.LicenseCertificatePassword); + } + catch (Exception ex) + { + logger.LogWarning(ex, + "Failed to load the configured licensing certificate. Skipping premium license generation."); + return new CertificateLoad(null, + $"Failed to load the configured licensing certificate ({ex.Message}). " + + "Skipping premium license generation."); + } + + using var rsa = certificate.GetRSAPrivateKey(); + if (rsa is null) + { + return Unusable(logger, + "Configured licensing certificate has no RSA private key and cannot sign licenses. " + + "Skipping premium license generation."); + } + + var thumbprintPrefix = certificate.Thumbprint is { Length: >= 8 } tp ? tp[..8] : certificate.Thumbprint; + logger.LogInformation("Using licensing certificate with thumbprint {ThumbprintPrefix}… for premium license signing.", + thumbprintPrefix); + + return new CertificateLoad(certificate, null); + } + + private static CertificateLoad Unusable(ILogger logger, string warning) + { + logger.LogWarning("{Warning}", warning); + return new CertificateLoad(null, warning); + } + + /// + /// Mirrors LicensingService.GenerateToken (src/Core/Billing/Services/Implementations/LicensingService.cs); keep issuer, algorithm, and lifetime in sync. + /// + private static string GenerateToken(X509Certificate2 certificate, List claims, string audience) + { + if (claims.All(claim => claim.Type != JwtRegisteredClaimNames.Jti)) + { + claims.Add(new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())); + } + + var securityKey = new RsaSecurityKey(certificate.GetRSAPrivateKey()); + var tokenDescriptor = new SecurityTokenDescriptor + { + Subject = new ClaimsIdentity(claims), + Issuer = "bitwarden", + Audience = audience, + NotBefore = DateTime.UtcNow, + Expires = DateTime.UtcNow.AddYears(1), + SigningCredentials = new SigningCredentials(securityKey, SecurityAlgorithms.RsaSha256Signature) + }; + + var tokenHandler = new JwtSecurityTokenHandler(); + var token = tokenHandler.CreateToken(tokenDescriptor); + return tokenHandler.WriteToken(token); + } + + public void Dispose() + { + if (_certificate.IsValueCreated) + { + _certificate.Value.Certificate?.Dispose(); + } + } + + private readonly record struct CertificateLoad(X509Certificate2? Certificate, string? Warning); +} diff --git a/util/Seeder/Services/SelfHostLicenseService.cs b/util/Seeder/Services/SelfHostLicenseService.cs index 67b9fce176a1..1e75f16799dc 100644 --- a/util/Seeder/Services/SelfHostLicenseService.cs +++ b/util/Seeder/Services/SelfHostLicenseService.cs @@ -1,19 +1,61 @@ -using Bit.Core.Billing.Models.Business; +using System.Security.Cryptography; +using Bit.Core.Billing.Models.Business; using Bit.Core.Billing.Services; using Bit.Core.Entities; using Bit.Core.Enums; -using Bit.Core.Models.Business; +using Microsoft.Extensions.Logging; namespace Bit.Seeder.Services; +/// Result of a premium license write; explains why nothing was written. +internal readonly record struct LicenseWriteOutcome(bool Written, string? Warning) +{ + internal static readonly LicenseWriteOutcome Success = new(true, null); + + internal static LicenseWriteOutcome Skipped(string warning) => new(false, warning); +} + internal static class SelfHostLicenseService { - internal static async Task WriteLicenseAsync(ILicensingService licenseService, User user) + /// + /// Best-effort premium license write. Without a private-key licensing certificate + /// (licenseCertificatePath/licenseCertificatePassword) the signer returns a warning and nothing is + /// written. Write failures are swallowed and returned as a warning so the write never aborts + /// the caller, which has already committed the user row. + /// + internal static async Task WriteLicenseAsync( + ILicensingService licenseService, ISeederLicenseSigner signer, User user, ILogger logger) { - var token = await licenseService.CreateUserTokenAsync(user, new SubscriptionInfo()); - if (string.IsNullOrWhiteSpace(token)) + try + { + return await WriteLicenseCoreAsync(licenseService, signer, user); + } + catch (InvalidOperationException ex) { - return; + return Failed(logger, "invalid operation", ex); + } + catch (CryptographicException ex) + { + return Failed(logger, "cryptographic error", ex); + } + catch (IOException ex) + { + return Failed(logger, "I/O error", ex); + } + catch (UnauthorizedAccessException ex) + { + return Failed(logger, "access error", ex); + } + } + + private static async Task WriteLicenseCoreAsync( + ILicensingService licenseService, ISeederLicenseSigner signer, User user) + { + var signing = await signer.CreateUserTokenAsync(user); + if (string.IsNullOrWhiteSpace(signing.Token)) + { + return LicenseWriteOutcome.Skipped( + signing.Warning ?? "No premium license was signed for this user."); } var license = new UserLicense @@ -27,9 +69,18 @@ internal static async Task WriteLicenseAsync(ILicensingService licenseService, U Issued = DateTime.UtcNow, Expires = user.PremiumExpirationDate?.AddDays(7), Version = 1, - Token = token, + Token = signing.Token, }; await licenseService.WriteUserLicenseAsync(user, license); + + return LicenseWriteOutcome.Success; + } + + private static LicenseWriteOutcome Failed(ILogger logger, string reason, Exception ex) + { + logger.LogWarning(ex, + "Premium user license write failed due to {Reason}. Skipping premium license generation.", reason); + return LicenseWriteOutcome.Skipped($"Premium user license write failed due to {reason}: {ex.Message}"); } } diff --git a/util/Seeder/Steps/GenerateSelfHostUserLicenseStep.cs b/util/Seeder/Steps/GenerateSelfHostUserLicenseStep.cs index 0608f4fc536b..0d6f20dc253b 100644 --- a/util/Seeder/Steps/GenerateSelfHostUserLicenseStep.cs +++ b/util/Seeder/Steps/GenerateSelfHostUserLicenseStep.cs @@ -1,6 +1,7 @@ using Bit.Core.Billing.Services; using Bit.Seeder.Pipeline; using Bit.Seeder.Services; +using Microsoft.Extensions.Logging; namespace Bit.Seeder.Steps; @@ -8,7 +9,10 @@ namespace Bit.Seeder.Steps; /// Writes a user premium license file to the LicenseDirectory. /// Required for self-hosted instances, which validate premium status by reading this file on every login. /// -internal sealed class GenerateSelfHostUserLicenseStep(ILicensingService licenseService) : IAsyncStep +internal sealed class GenerateSelfHostUserLicenseStep( + ILicensingService licenseService, + ISeederLicenseSigner licenseSigner, + ILogger logger) : IAsyncStep { public async Task ExecuteAsync(SeederContext context) { @@ -18,29 +22,7 @@ public async Task ExecuteAsync(SeederContext context) return; } - // Best-effort license write. Self-hosted instances hold only the public licensing - // certificate, so token signing throws there (by design — see LicensingService.SignLicense). - // Don't let that failure abort the pipeline run. The await must stay inside the try — - // returning the task unawaited would make every catch below dead code. - try - { - await SelfHostLicenseService.WriteLicenseAsync(licenseService, user); - } - catch (InvalidOperationException ex) - { - Console.WriteLine($"[GenerateSelfHostUserLicenseStep] Skipping premium user license write due to invalid operation: {ex.Message}"); - } - catch (System.Security.Cryptography.CryptographicException ex) - { - Console.WriteLine($"[GenerateSelfHostUserLicenseStep] Skipping premium user license write due to cryptographic error: {ex.Message}"); - } - catch (IOException ex) - { - Console.WriteLine($"[GenerateSelfHostUserLicenseStep] Skipping premium user license write due to I/O error: {ex.Message}"); - } - catch (UnauthorizedAccessException ex) - { - Console.WriteLine($"[GenerateSelfHostUserLicenseStep] Skipping premium user license write due to access error: {ex.Message}"); - } + // Outcome discarded: any warning is already logged. + _ = await SelfHostLicenseService.WriteLicenseAsync(licenseService, licenseSigner, user, logger); } } diff --git a/util/SeederApi/Commands/DestroySceneCommand.cs b/util/SeederApi/Commands/DestroySceneCommand.cs index 12019282717d..878a132ade99 100644 --- a/util/SeederApi/Commands/DestroySceneCommand.cs +++ b/util/SeederApi/Commands/DestroySceneCommand.cs @@ -1,5 +1,6 @@ using Bit.Core.AdminConsole.Repositories; using Bit.Core.Repositories; +using Bit.Core.Settings; using Bit.Infrastructure.EntityFramework.Repositories; using Bit.SeederApi.Commands.Interfaces; using Bit.SeederApi.Services; @@ -12,7 +13,8 @@ public class DestroySceneCommand( IUserRepository userRepository, IPlayItemRepository playItemRepository, IProviderRepository providerRepository, - IOrganizationRepository organizationRepository) : IDestroySceneCommand + IOrganizationRepository organizationRepository, + IGlobalSettings globalSettings) : IDestroySceneCommand { public async Task DestroyAsync(string playId) { @@ -58,6 +60,8 @@ public class DestroySceneCommand( { var users = databaseContext.Users.Where(u => userIds.Contains(u.Id)); await userRepository.DeleteManyAsync(users); + + DeleteSeededUserLicenseFiles(userIds, playId); } if (organizationIds.Count > 0) @@ -87,4 +91,37 @@ public class DestroySceneCommand( return new { PlayId = playId }; } + + /// + /// Best-effort removal of the premium license files the seeder writes for seeded users. File errors + /// are logged and swallowed so cleanup never aborts the database teardown. + /// + /// + /// Path convention duplicated from LicensingService.WriteUserLicenseAsync + /// ({LicenseDirectory}/user/{userId}.json); keep the two in sync. + /// + private void DeleteSeededUserLicenseFiles(List userIds, string playId) + { + if (string.IsNullOrEmpty(globalSettings.LicenseDirectory)) + { + return; + } + + foreach (var userId in userIds.Where(id => id.HasValue)) + { + var filePath = Path.Combine(globalSettings.LicenseDirectory, "user", $"{userId!.Value}.json"); + try + { + if (File.Exists(filePath)) + { + File.Delete(filePath); + } + } + catch (Exception ex) + { + logger.LogWarning(ex, + "Failed to delete seeded user license file {FilePath} for seed ID {PlayId}", filePath, playId); + } + } + } } diff --git a/util/SeederApi/Extensions/ServiceCollectionExtensions.cs b/util/SeederApi/Extensions/ServiceCollectionExtensions.cs index 6679533a1af7..3f8d4e1f9365 100644 --- a/util/SeederApi/Extensions/ServiceCollectionExtensions.cs +++ b/util/SeederApi/Extensions/ServiceCollectionExtensions.cs @@ -18,6 +18,8 @@ public static class ServiceCollectionExtensions /// public static IServiceCollection AddSeederApiServices(this IServiceCollection services) { + services.TryAddSingleton(); + services.AddScoped(); services.AddScoped(); diff --git a/util/SeederUtility/Configuration/SeederServiceFactory.cs b/util/SeederUtility/Configuration/SeederServiceFactory.cs index 3fd6bb20b129..df1b9359226d 100644 --- a/util/SeederUtility/Configuration/SeederServiceFactory.cs +++ b/util/SeederUtility/Configuration/SeederServiceFactory.cs @@ -41,8 +41,10 @@ internal sealed class SeederServiceScope : IDisposable internal IAttachmentStorageService AttachmentStorageService { get; } + internal ISeederLicenseSigner LicenseSigner { get; } + internal SeederDependencies ToDependencies() - => new(Db, Mapper, PasswordHasher, Mangler, LicensingService, AttachmentStorageService); + => new(Db, Mapper, PasswordHasher, Mangler, LicensingService, AttachmentStorageService, LicenseSigner); private readonly ServiceProvider _provider; @@ -59,6 +61,7 @@ internal SeederServiceScope(ServiceProvider provider, IServiceScope scope) Mangler = sp.GetRequiredService(); LicensingService = sp.GetRequiredService(); AttachmentStorageService = sp.GetRequiredService(); + LicenseSigner = sp.GetRequiredService(); } public void Dispose() diff --git a/util/SeederUtility/Configuration/ServiceCollectionExtension.cs b/util/SeederUtility/Configuration/ServiceCollectionExtension.cs index 870ddb4928fb..8ff707183988 100644 --- a/util/SeederUtility/Configuration/ServiceCollectionExtension.cs +++ b/util/SeederUtility/Configuration/ServiceCollectionExtension.cs @@ -54,6 +54,7 @@ public static void ConfigureServices(ServiceCollection services, bool enableMang services.TryAddSingleton(); services.AddPush(globalSettings); services.TryAddSingleton(); + services.TryAddSingleton(); } }