diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..21644aad --- /dev/null +++ b/.env.example @@ -0,0 +1,18 @@ +# Copy to .env to override defaults before running `docker compose up`. +# .env is gitignored — never commit real credentials. + +# Passwords for the seeded accounts. Both are OPTIONAL: when unset they fall back +# to the compiled-in defaults (Admin123! / User123!), which are what the login +# page's quick-login buttons use. +# +# Those defaults are published in this repository — set these before exposing the +# deployment to anyone else, and turn off the "Show Test Accounts" setting. +SEED_ADMIN_PASSWORD= +SEED_USER_PASSWORD= + +# PostgreSQL password for the bundled database container. +# Defaults to "simplemodule" when unset — override for anything exposed. +POSTGRES_PASSWORD=simplemodule + +# Public base URL, used by OpenIddict to register redirect URIs. +APP_BASE_URL=http://localhost:8080 diff --git a/.gitignore b/.gitignore index f3c169dd..6f1bff6d 100644 --- a/.gitignore +++ b/.gitignore @@ -451,3 +451,9 @@ baseline/ # Verification artifacts .verify/ .qa/ + +# Local environment files — docker-compose reads SEED_ADMIN_PASSWORD and +# POSTGRES_PASSWORD from here, so these must never be committed. +.env +.env.* +!.env.example diff --git a/Dockerfile.worker b/Dockerfile.worker index 875649fd..1ec6f487 100644 --- a/Dockerfile.worker +++ b/Dockerfile.worker @@ -94,7 +94,12 @@ RUN dotnet publish template/SimpleModule.Worker/SimpleModule.Worker.csproj \ --no-restore \ -p:ErrorOnDuplicatePublishOutputFiles=false -FROM mcr.microsoft.com/dotnet/runtime:10.0 AS runtime +# The worker hosts the same modules as the api, and every module carries a +# . The plain runtime +# image does not ship that shared framework, so the worker started and then died +# with "No frameworks were found" on every boot. This must stay in sync with the +# api image in ./Dockerfile. +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime WORKDIR /app RUN apt-get update \ diff --git a/README.md b/README.md index 6ce88149..70af1485 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,44 @@ dotnet run --project template/SimpleModule.Host # https://localhost:5001 docker compose up # http://localhost:8080 with PostgreSQL 16 ``` +#### Seed credentials + +Two accounts are seeded on first start, and the login page's quick-login buttons use these exact +credentials: + +| Account | Default password | Override with | +|---------|------------------|---------------| +| `admin@simplemodule.dev` | `Admin123!` | `Seed__AdminPassword` | +| `user@simplemodule.dev` | `User123!` | `Seed__UserPassword` | + +The defaults apply in every environment so the app boots and is usable out of the box. Outside +`Development` and `Testing` the app logs a warning when it falls back to them. + +> **These passwords are published in this repository.** Before exposing a deployment to anyone else, +> set `Seed__AdminPassword` (or change the password after first login) and turn off the +> **Show Test Accounts** setting, which renders the quick-login buttons and defaults to on. + +```bash +docker build -t simplemodule . +docker run -e Seed__AdminPassword='' -p 8080:8080 simplemodule +``` + +`Seed__AdminPassword` is the environment-variable spelling of the `Seed:AdminPassword` configuration +key — use the latter in `appsettings.*.json`. Accounts are seeded only when they don't already +exist, so changing these values later will **not** rotate an existing password; change it from the +account page instead. + +> **Upgrading an existing deployment.** Previously the demo `user@simplemodule.dev` account was +> skipped outside `Development` and `Testing` when `Seed__UserPassword` was unset, so deployments that set only +> `Seed__AdminPassword` have no such account today. They will gain one, seeded with `User123!`, on +> the next restart. Set `Seed__UserPassword`, or delete the account after it appears, if you don't +> want it. The admin account is unaffected — it already exists, and existing accounts are never +> re-seeded or rotated. + +Note that `Production` additionally requires OpenIddict signing and encryption certificates +(`OpenIddict__SigningCertificatePath` / `OpenIddict__EncryptionCertificatePath`, PKCS#12), and +refuses to start without them. + ### Development ```bash diff --git a/docker-compose.yml b/docker-compose.yml index 84d4af01..e4906fc8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,11 +22,12 @@ services: # combined with seeded credentials it would hand out fully-privileged # tokens with a single POST /connect/token. OpenIddict__AllowPasswordGrant: "false" - # Required: password for the seeded admin account. There is deliberately - # no default — put SEED_ADMIN_PASSWORD in your .env file. - Seed__AdminPassword: ${SEED_ADMIN_PASSWORD:?Set SEED_ADMIN_PASSWORD in .env} - # Optional: password for the seeded demo user (outside Development the - # demo user is skipped entirely when this is unset). + # Optional: passwords for the seeded accounts. When unset, both fall back to + # the compiled-in defaults (Admin123! / User123!) so the stack comes up + # usable — those are the same credentials the login page's quick-login + # buttons use. They are published in this repository, so set these (or + # change the passwords after first login) before exposing the deployment. + Seed__AdminPassword: ${SEED_ADMIN_PASSWORD:-} Seed__UserPassword: ${SEED_USER_PASSWORD:-} # api stays in Producer mode — it enqueues jobs but never runs them. # All IModuleJob execution lives in the worker service below. @@ -56,11 +57,10 @@ services: Database__Provider: PostgreSQL BackgroundJobs__WorkerMode: Consumer # The worker runs the same module set as the api, including the user - # seeder. It must see the SAME seed passwords — otherwise whichever - # process wins the startup race seeds the admin account, and a worker - # without these would silently seed the compiled-in default password, - # defeating the api's required SEED_ADMIN_PASSWORD. - Seed__AdminPassword: ${SEED_ADMIN_PASSWORD:?Set SEED_ADMIN_PASSWORD in .env} + # seeder. It must see the SAME seed passwords as the api — whichever + # process wins the startup race is the one that creates the accounts, so a + # mismatch here makes the seeded password depend on that race. + Seed__AdminPassword: ${SEED_ADMIN_PASSWORD:-} Seed__UserPassword: ${SEED_USER_PASSWORD:-} volumes: - storage_data:/app/storage diff --git a/modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditWriterService.cs b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditWriterService.cs index 301f1fba..e10ab378 100644 --- a/modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditWriterService.cs +++ b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditWriterService.cs @@ -81,7 +81,18 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) #pragma warning restore CA1031 { LogFlushError(logger, ex); - await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken); + + // This backoff runs inside a catch block, so the enclosing try + // cannot observe its cancellation — guard it here or a shutdown + // during the backoff escapes ExecuteAsync as a spurious crash. + try + { + await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken); + } + catch (OperationCanceledException) + { + break; + } } } @@ -93,7 +104,21 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) } if (batch.Count > 0) { - await FlushBatchAsync(batch); + // The service provider is frequently already disposed by the time a + // failed startup unwinds to here, so this final flush can throw. It + // runs outside the loop's try, so letting it escape would surface a + // normal shutdown as a crashed BackgroundService — the exact noise + // this class of fix exists to remove — and skip LogStopped too. + try + { + await FlushBatchAsync(batch); + } +#pragma warning disable CA1031 + catch (Exception ex) +#pragma warning restore CA1031 + { + LogFlushError(logger, ex); + } } LogStopped(logger); diff --git a/modules/AuditLogs/src/SimpleModule.AuditLogs/Retention/AuditRetentionService.cs b/modules/AuditLogs/src/SimpleModule.AuditLogs/Retention/AuditRetentionService.cs index a6d5c4e0..05c34496 100644 --- a/modules/AuditLogs/src/SimpleModule.AuditLogs/Retention/AuditRetentionService.cs +++ b/modules/AuditLogs/src/SimpleModule.AuditLogs/Retention/AuditRetentionService.cs @@ -16,29 +16,41 @@ ILogger logger { protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken); + try + { + await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken); - var checkInterval = moduleOptions.Value.RetentionCheckInterval; + var checkInterval = moduleOptions.Value.RetentionCheckInterval; - while (!stoppingToken.IsCancellationRequested) - { - try - { - await RunCleanupAsync(stoppingToken); - } - catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + while (!stoppingToken.IsCancellationRequested) { - break; - } + try + { + await RunCleanupAsync(stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } #pragma warning disable CA1031 - catch (Exception ex) + catch (Exception ex) #pragma warning restore CA1031 - { - LogError(logger, ex); - } + { + LogError(logger, ex); + } - await Task.Delay(checkInterval, stoppingToken); + await Task.Delay(checkInterval, stoppingToken); + } + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + // Normal shutdown while waiting on the startup delay or the interval + // delay. Returning quietly keeps this off the BackgroundService failure + // path, which would otherwise log a critical "BackgroundService failed" + // on top of — and obscuring — whatever actually stopped the host. } + + LogStopped(logger); } private async Task RunCleanupAsync(CancellationToken ct) @@ -87,4 +99,7 @@ private async Task RunCleanupAsync(CancellationToken ct) [LoggerMessage(Level = LogLevel.Error, Message = "Retention cleanup failed")] private static partial void LogError(ILogger logger, Exception ex); + + [LoggerMessage(Level = LogLevel.Information, Message = "AuditRetentionService stopped")] + private static partial void LogStopped(ILogger logger); } diff --git a/modules/BackgroundJobs/src/SimpleModule.BackgroundJobs/Services/ProgressFlushService.cs b/modules/BackgroundJobs/src/SimpleModule.BackgroundJobs/Services/ProgressFlushService.cs index 7adee6fe..50f05911 100644 --- a/modules/BackgroundJobs/src/SimpleModule.BackgroundJobs/Services/ProgressFlushService.cs +++ b/modules/BackgroundJobs/src/SimpleModule.BackgroundJobs/Services/ProgressFlushService.cs @@ -57,7 +57,18 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) #pragma warning restore CA1031 { LogFlushError(logger, ex); - await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken); + + // This backoff runs inside a catch block, so the enclosing try + // cannot observe its cancellation — guard it here or a shutdown + // during the backoff escapes ExecuteAsync as a spurious crash. + try + { + await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken); + } + catch (OperationCanceledException) + { + break; + } } } @@ -69,7 +80,25 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) } if (batch.Count > 0) { - await FlushBatchAsync(batch, moduleOptions.Value.MaxLogEntries, CancellationToken.None); + // The service provider is frequently already disposed by the time a + // failed startup unwinds to here, so this final flush can throw. It + // runs outside the loop's try, so letting it escape would surface a + // normal shutdown as a crashed BackgroundService — the exact noise + // this class of fix exists to remove — and skip LogStopped too. + try + { + await FlushBatchAsync( + batch, + moduleOptions.Value.MaxLogEntries, + CancellationToken.None + ); + } +#pragma warning disable CA1031 + catch (Exception ex) +#pragma warning restore CA1031 + { + LogFlushError(logger, ex); + } } LogStopped(logger); diff --git a/modules/Users/src/SimpleModule.Users/Services/SeedConfigurationException.cs b/modules/Users/src/SimpleModule.Users/Services/SeedConfigurationException.cs deleted file mode 100644 index 48665495..00000000 --- a/modules/Users/src/SimpleModule.Users/Services/SeedConfigurationException.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace SimpleModule.Users.Services; - -/// -/// Thrown when seeding cannot proceed safely — e.g. a required seed password is -/// missing outside Development. Unlike transient database errors (which the seed -/// service logs and tolerates), this exception is allowed to escape -/// so host startup fails loudly instead -/// of seeding a publicly known default credential. -/// -public sealed class SeedConfigurationException : InvalidOperationException -{ - public SeedConfigurationException() { } - - public SeedConfigurationException(string message) - : base(message) { } - - public SeedConfigurationException(string message, Exception innerException) - : base(message, innerException) { } -} diff --git a/modules/Users/src/SimpleModule.Users/Services/SeedPasswordOutcome.cs b/modules/Users/src/SimpleModule.Users/Services/SeedPasswordOutcome.cs deleted file mode 100644 index 1f1fdec2..00000000 --- a/modules/Users/src/SimpleModule.Users/Services/SeedPasswordOutcome.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace SimpleModule.Users.Services; - -/// -/// The decision made by for a -/// single seed account. -/// -internal enum SeedPasswordOutcome -{ - /// Seed the account with the resolved password. - Seed, - - /// Skip the (optional) account — unconfigured in a real deployment. - Skip, - - /// - /// Fail host startup — a required account is unconfigured in a real - /// deployment and must not fall back to the compiled-in default. - /// - Fail, -} diff --git a/modules/Users/src/SimpleModule.Users/Services/UserSeedService.cs b/modules/Users/src/SimpleModule.Users/Services/UserSeedService.cs index 3b201271..6a8c8e04 100644 --- a/modules/Users/src/SimpleModule.Users/Services/UserSeedService.cs +++ b/modules/Users/src/SimpleModule.Users/Services/UserSeedService.cs @@ -44,8 +44,7 @@ await SeedUserAsync( SeedConstants.AdminDisplayName, ConfigKeys.SeedAdminPassword, SeedConstants.DefaultAdminPassword, - SeedConstants.AdminRole, - requiredOutsideDevelopment: true + SeedConstants.AdminRole ); await SeedUserAsync( userManager, @@ -53,12 +52,11 @@ await SeedUserAsync( SeedConstants.UserDisplayName, ConfigKeys.SeedUserPassword, SeedConstants.DefaultUserPassword, - SeedConstants.UserRole, - requiredOutsideDevelopment: false + SeedConstants.UserRole ); } #pragma warning disable CA1031 // Seed service must not crash the host on database errors - catch (Exception ex) when (ex is not SeedConfigurationException) + catch (Exception ex) #pragma warning restore CA1031 { LogSeedError(logger, ex.Message); @@ -102,32 +100,18 @@ private async Task SeedUserAsync( string displayName, string passwordConfigKey, string defaultPassword, - string role, - bool requiredOutsideDevelopment + string role ) { if (await userManager.FindByEmailAsync(email) is not null) return; - var outcome = ResolveSeedPassword( - configuration[passwordConfigKey], - defaultPassword, - environment.IsLocalOrTest(), - requiredOutsideDevelopment, - out var password - ); + var configuredPassword = configuration[passwordConfigKey]; + var password = ResolveSeedPassword(configuredPassword, defaultPassword); - switch (outcome) + if (string.IsNullOrEmpty(configuredPassword) && !environment.IsLocalOrTest()) { - case SeedPasswordOutcome.Fail: - throw new SeedConfigurationException( - $"'{passwordConfigKey}' must be configured in the '{environment.EnvironmentName}' " - + $"environment. Refusing to create '{email}' with the compiled-in default " - + "password." - ); - case SeedPasswordOutcome.Skip: - LogSkippingSeedUser(logger, email, passwordConfigKey); - return; + LogDefaultPasswordWarning(logger, email, passwordConfigKey); } LogSeedingUser(logger, email); @@ -141,9 +125,7 @@ out var password CreatedAt = DateTime.UtcNow, }; - // Only SeedPasswordOutcome.Seed falls through the switch above, and it - // always yields a non-null password. - var result = await userManager.CreateAsync(user, password!); + var result = await userManager.CreateAsync(user, password); if (result.Succeeded) { await userManager.AddToRoleAsync(user, role); @@ -158,45 +140,28 @@ out var password } /// - /// Decides how to seed a user's password. Extracted as a pure function so the - /// security-critical branching — never seed a real deployment with the - /// compiled-in default — is unit-testable without an Identity stack. + /// Resolves the password to seed an account with: the configured value when one + /// is present, otherwise the compiled-in default. Extracted as a pure function + /// so the fallback is unit-testable without an Identity stack. /// + /// + /// Both seeded accounts always fall back to their compiled-in defaults so the + /// app boots and is usable out of the box in every environment — the login page + /// advertises these same credentials via its quick-login buttons, so seeding + /// them is what makes those buttons work. The defaults are published in this + /// repository: any deployment that is reachable by anyone else must set the + /// Seed:AdminPassword configuration key (or rotate the password after + /// first login), and turn off the "Show Test Accounts" system setting + /// (auth.show_test_accounts) from the Settings UI — that one is a + /// SettingDefinition read through ISettingsContracts, not an + /// IConfiguration key, so it cannot be set via an environment variable. + /// /// The password from configuration, if any. - /// The compiled-in fallback (local/CI only). - /// True for Development/Testing environments. - /// - /// True for the admin account (must fail closed); false for the optional demo - /// user (skipped when unconfigured in a real deployment). - /// - /// The password to use when the outcome is Seed. - internal static SeedPasswordOutcome ResolveSeedPassword( + /// The compiled-in fallback. + internal static string ResolveSeedPassword( string? configuredPassword, - string defaultPassword, - bool isLocalOrTest, - bool requiredOutsideLocal, - out string? password - ) - { - if (!string.IsNullOrEmpty(configuredPassword)) - { - password = configuredPassword; - return SeedPasswordOutcome.Seed; - } - - // The compiled-in default passwords are a local/CI convenience only. In a - // real deployment (anything but Development/Testing) the configured - // password is mandatory: seeding the admin with a published default would - // leave it one POST /connect/token away from a fully-privileged token. - if (isLocalOrTest) - { - password = defaultPassword; - return SeedPasswordOutcome.Seed; - } - - password = null; - return requiredOutsideLocal ? SeedPasswordOutcome.Fail : SeedPasswordOutcome.Skip; - } + string defaultPassword + ) => string.IsNullOrEmpty(configuredPassword) ? defaultPassword : configuredPassword; [LoggerMessage(Level = LogLevel.Information, Message = "Seeding role: {RoleName}")] private static partial void LogSeedingRole(ILogger logger, string roleName); @@ -205,10 +170,14 @@ out string? password private static partial void LogSeedingUser(ILogger logger, string email); [LoggerMessage( - Level = LogLevel.Information, - Message = "Skipping seed user {Email}: '{ConfigKey}' is not configured and default passwords are disabled outside Development." + Level = LogLevel.Warning, + Message = "Seeded {Email} with the compiled-in default password because '{ConfigKey}' is not configured. This password is published in the SimpleModule repository — set '{ConfigKey}' or change the password after first login before exposing this deployment." )] - private static partial void LogSkippingSeedUser(ILogger logger, string email, string configKey); + private static partial void LogDefaultPasswordWarning( + ILogger logger, + string email, + string configKey + ); [LoggerMessage(Level = LogLevel.Error, Message = "Seed error: {ErrorDescription}")] private static partial void LogSeedError(ILogger logger, string errorDescription); diff --git a/modules/Users/tests/SimpleModule.Users.Tests/Unit/UserSeedServiceTests.cs b/modules/Users/tests/SimpleModule.Users.Tests/Unit/UserSeedServiceTests.cs index e349e9e7..8d920363 100644 --- a/modules/Users/tests/SimpleModule.Users.Tests/Unit/UserSeedServiceTests.cs +++ b/modules/Users/tests/SimpleModule.Users.Tests/Unit/UserSeedServiceTests.cs @@ -8,88 +8,26 @@ public class UserSeedServiceTests private const string Default = "Default123!"; private const string Configured = "Configured123!"; - [Theory] - [InlineData(true, true)] // local, required (admin) - [InlineData(true, false)] // local, optional (demo) - [InlineData(false, true)] // real deployment, required - [InlineData(false, false)] // real deployment, optional - public void ResolveSeedPassword_ConfiguredPassword_AlwaysUsed( - bool isLocalOrTest, - bool requiredOutsideLocal - ) - { - var outcome = UserSeedService.ResolveSeedPassword( - Configured, - Default, - isLocalOrTest, - requiredOutsideLocal, - out var password - ); - - outcome.Should().Be(SeedPasswordOutcome.Seed); - password.Should().Be(Configured); - } - - [Theory] - [InlineData(true)] // required (admin) - [InlineData(false)] // optional (demo) - public void ResolveSeedPassword_LocalOrTest_NoConfig_UsesDefault(bool requiredOutsideLocal) - { - var outcome = UserSeedService.ResolveSeedPassword( - configuredPassword: null, - Default, - isLocalOrTest: true, - requiredOutsideLocal, - out var password - ); - - outcome.Should().Be(SeedPasswordOutcome.Seed); - password.Should().Be(Default); - } - [Fact] - public void ResolveSeedPassword_RealDeployment_RequiredAndUnconfigured_Fails() + public void ResolveSeedPassword_ConfiguredPassword_IsUsed() { - // The security-critical path: the admin account must never fall back to - // the compiled-in default outside a local/test environment. - var outcome = UserSeedService.ResolveSeedPassword( - configuredPassword: null, - Default, - isLocalOrTest: false, - requiredOutsideLocal: true, - out var password - ); - - outcome.Should().Be(SeedPasswordOutcome.Fail); - password.Should().BeNull(); + UserSeedService.ResolveSeedPassword(Configured, Default).Should().Be(Configured); } + /// + /// Both seeded accounts fall back to their compiled-in defaults in every + /// environment so the app is usable out of the box — the login page's + /// quick-login buttons advertise these exact credentials. + /// [Fact] - public void ResolveSeedPassword_RealDeployment_OptionalAndUnconfigured_Skips() + public void ResolveSeedPassword_NoConfiguredPassword_FallsBackToDefault() { - var outcome = UserSeedService.ResolveSeedPassword( - configuredPassword: null, - Default, - isLocalOrTest: false, - requiredOutsideLocal: false, - out var password - ); - - outcome.Should().Be(SeedPasswordOutcome.Skip); - password.Should().BeNull(); + UserSeedService.ResolveSeedPassword(null, Default).Should().Be(Default); } [Fact] public void ResolveSeedPassword_EmptyConfiguredPassword_TreatedAsUnconfigured() { - var outcome = UserSeedService.ResolveSeedPassword( - configuredPassword: "", - Default, - isLocalOrTest: false, - requiredOutsideLocal: true, - out _ - ); - - outcome.Should().Be(SeedPasswordOutcome.Fail); + UserSeedService.ResolveSeedPassword("", Default).Should().Be(Default); } }