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
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();
}
}