Skip to content

Commit d5b3b5b

Browse files
committed
security(seeding): require explicit admin password outside dev/test
1 parent ad4d76c commit d5b3b5b

5 files changed

Lines changed: 120 additions & 4 deletions

File tree

docs/guides/authentication-guide.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ graph TB
5959
- Tenant admin seeding now requires an explicit password outside Development/Test contexts.
6060
- Configure a default seed password with `Seeding:AdminPassword` (or the environment variable `Seeding__AdminPassword`) when startup seeding is enabled.
6161
- In Development/Test only, if no explicit password is provided, the legacy fallback `Admin123!` is still used to keep local and automated test flows deterministic.
62+
- For CI, staging, and production environments, always provide `Seeding__AdminPassword` through your secret store or deployment pipeline variables.
6263

6364
Example configuration:
6465

src/BookStore.ApiService/Handlers/TenantAdminHandler.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
using BookStore.ApiService.Models;
55
using Marten;
66
using Microsoft.AspNetCore.Identity;
7+
using Microsoft.Extensions.Configuration;
8+
using Microsoft.Extensions.Hosting;
79
using Microsoft.Extensions.Logging;
810
using Wolverine;
911

@@ -16,10 +18,14 @@ public static async Task Handle(
1618
IDocumentSession session,
1719
UserManager<ApplicationUser> userManager,
1820
IMessageBus bus,
21+
IConfiguration configuration,
22+
IHostEnvironment environment,
1923
ILogger logger,
2024
CancellationToken ct)
2125
{
2226
Log.Tenants.SeedingAdminUser(logger, command.TenantId, session.TenantId);
27+
var defaultAdminPassword = configuration["Seeding:AdminPassword"];
28+
var allowInsecureDevelopmentFallback = environment.IsDevelopment() || environment.IsEnvironment("Testing") || environment.IsEnvironment("Test");
2329

2430
// Seed the admin user using the tenant-scoped session
2531
// confirmEmail is false if verification is required
@@ -28,6 +34,8 @@ public static async Task Handle(
2834
command.TenantId,
2935
command.Email,
3036
command.Password,
37+
defaultPassword: defaultAdminPassword,
38+
allowInsecureDevelopmentFallback: allowInsecureDevelopmentFallback,
3139
confirmEmail: !command.VerificationRequired);
3240

3341
if (adminUser != null)

src/BookStore.ApiService/Infrastructure/DatabaseSeeder.cs

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ public class DatabaseSeeder(
2323
IMessageBus bus,
2424
ILogger<DatabaseSeeder> logger)
2525
{
26+
const string DevelopmentFallbackAdminPassword = "Admin123!";
27+
2628

2729
public async Task SeedTenantsAsync(string[] tenantIds)
2830
{
@@ -72,15 +74,24 @@ public async Task SeedTenantsAsync(string[] tenantIds)
7274
_ => ("BookStore", "Discover your next great read from our curated collection", "#594AE2")
7375
};
7476

75-
public async Task SeedAsync(string tenantId)
77+
public async Task SeedAsync(
78+
string tenantId,
79+
string? defaultAdminPassword = null,
80+
bool allowInsecureDevelopmentFallback = false)
7681
{
7782
// Since we use "*DEFAULT*" as the default tenant ID, we can pass it directly
7883
await using var session = store.LightweightSession(tenantId);
7984

8085
// Seed tenant-specific admin user using the tenant-scoped session
8186
// This is outside the 'existingBooks' guard to ensure admins are always present
8287
// even if data was previously partially seeded. The method itself handles idempotency.
83-
_ = await SeedAdminUserAsync(session, tenantId, logger: logger);
88+
_ = await SeedAdminUserAsync(
89+
session,
90+
tenantId,
91+
password: null,
92+
defaultPassword: defaultAdminPassword,
93+
allowInsecureDevelopmentFallback: allowInsecureDevelopmentFallback,
94+
logger: logger);
8495

8596
// Check if already seeded with books
8697
var existingBooks = await session.Query<BookSearchProjection>().AnyAsync();
@@ -116,6 +127,8 @@ public async Task SeedAsync(string tenantId)
116127
string tenantId,
117128
string? email = null,
118129
string? password = null,
130+
string? defaultPassword = null,
131+
bool allowInsecureDevelopmentFallback = false,
119132
bool confirmEmail = true,
120133
ILogger? logger = null)
121134
{
@@ -125,7 +138,10 @@ public async Task SeedAsync(string tenantId)
125138
: tenantId;
126139
var adminEmail = email ?? $"admin@{tenantAlias}.com";
127140

128-
var adminPassword = password ?? "Admin123!";
141+
var adminPassword = ResolveAdminSeedPassword(
142+
password,
143+
defaultPassword,
144+
allowInsecureDevelopmentFallback);
129145

130146
// Check if admin user already exists in THIS tenant
131147
var existingAdmin = await session.Query<Models.ApplicationUser>()
@@ -200,6 +216,31 @@ public async Task SeedAsync(string tenantId)
200216
return adminUser;
201217
}
202218

219+
internal static string ResolveAdminSeedPassword(
220+
string? password,
221+
string? defaultPassword,
222+
bool allowInsecureDevelopmentFallback)
223+
{
224+
if (!string.IsNullOrWhiteSpace(password))
225+
{
226+
return password;
227+
}
228+
229+
if (!string.IsNullOrWhiteSpace(defaultPassword))
230+
{
231+
return defaultPassword;
232+
}
233+
234+
if (allowInsecureDevelopmentFallback)
235+
{
236+
return DevelopmentFallbackAdminPassword;
237+
}
238+
239+
throw new InvalidOperationException(
240+
"Tenant admin seeding requires an explicit password outside Development/Test. " +
241+
"Provide the command password or configure Seeding:AdminPassword.");
242+
}
243+
203244
static Dictionary<string, PublisherAdded> SeedPublishers(IDocumentSession session, ILogger logger)
204245
{
205246
Log.Seeding.SeedingPublishers(logger);

src/BookStore.ApiService/Infrastructure/Extensions/DatabaseExtensions.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ public static void RunDatabaseSeedingAsync(this WebApplication app)
3030
var logger = app.Services.GetRequiredService<ILogger<Program>>();
3131
var env = app.Services.GetRequiredService<IHostEnvironment>();
3232
var seedingEnabled = app.Configuration.GetValue("Seeding:Enabled", true);
33+
var seedingAdminPassword = app.Configuration["Seeding:AdminPassword"];
34+
var allowInsecureDevelopmentFallback = env.IsDevelopment() || env.IsEnvironment("Testing") || env.IsEnvironment("Test");
3335

3436
Log.Infrastructure.StartupTaskRunning(logger, env.EnvironmentName, seedingEnabled);
3537

@@ -65,7 +67,10 @@ public static void RunDatabaseSeedingAsync(this WebApplication app)
6567
{
6668
Log.Infrastructure.SeedingTenant(logger, tenantId);
6769

68-
await seeder.SeedAsync(tenantId);
70+
await seeder.SeedAsync(
71+
tenantId,
72+
seedingAdminPassword,
73+
allowInsecureDevelopmentFallback);
6974

7075
// Wait for async projections to process the seeded events for this tenant
7176
await WaitForProjectionsAsync(store, logger, tenantId);
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
using BookStore.ApiService.Infrastructure;
2+
3+
namespace BookStore.ApiService.UnitTests.Infrastructure;
4+
5+
public class DatabaseSeederPasswordTests
6+
{
7+
[Test]
8+
[Category("Unit")]
9+
public async Task ResolveAdminSeedPassword_WithExplicitPassword_ShouldReturnExplicitPassword()
10+
{
11+
// Act
12+
var resolved = DatabaseSeeder.ResolveAdminSeedPassword(
13+
password: "Explicit-Password-123!",
14+
defaultPassword: "Configured-Password-123!",
15+
allowInsecureDevelopmentFallback: false);
16+
17+
// Assert
18+
_ = await Assert.That(resolved).IsEqualTo("Explicit-Password-123!");
19+
}
20+
21+
[Test]
22+
[Category("Unit")]
23+
public async Task ResolveAdminSeedPassword_WithoutExplicitPassword_WithConfiguredDefault_ShouldReturnConfiguredPassword()
24+
{
25+
// Act
26+
var resolved = DatabaseSeeder.ResolveAdminSeedPassword(
27+
password: null,
28+
defaultPassword: "Configured-Password-123!",
29+
allowInsecureDevelopmentFallback: false);
30+
31+
// Assert
32+
_ = await Assert.That(resolved).IsEqualTo("Configured-Password-123!");
33+
}
34+
35+
[Test]
36+
[Category("Unit")]
37+
public async Task ResolveAdminSeedPassword_WithoutPasswords_InDevelopment_ShouldReturnLegacyFallback()
38+
{
39+
// Act
40+
var resolved = DatabaseSeeder.ResolveAdminSeedPassword(
41+
password: null,
42+
defaultPassword: null,
43+
allowInsecureDevelopmentFallback: true);
44+
45+
// Assert
46+
_ = await Assert.That(resolved).IsEqualTo("Admin123!");
47+
}
48+
49+
[Test]
50+
[Category("Unit")]
51+
public async Task ResolveAdminSeedPassword_WithoutPasswords_OutsideDevelopment_ShouldThrow()
52+
{
53+
// Act + Assert
54+
_ = await Assert.That(() => DatabaseSeeder.ResolveAdminSeedPassword(
55+
password: null,
56+
defaultPassword: null,
57+
allowInsecureDevelopmentFallback: false))
58+
.Throws<InvalidOperationException>()
59+
.WithMessage("Tenant admin seeding requires an explicit password outside Development/Test. Provide the command password or configure Seeding:AdminPassword.");
60+
}
61+
}

0 commit comments

Comments
 (0)