refinement phase 5

This commit is contained in:
hamid
2026-07-13 16:00:34 +03:30
parent 64f6aa45c9
commit d4147342da
16 changed files with 440 additions and 79 deletions
@@ -0,0 +1,67 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
namespace Baya.Web.Api.Configuration;
/// <summary>
/// Fail-fast validation that no load-bearing secret is missing or left at its committed placeholder.
/// A database connection is required in every real environment; the JWE + field-encryption keys are
/// required only in <b>deployed</b> environments (Development keeps working dev-only defaults in
/// <c>appsettings.Development.json</c>, and the "Testing" environment runs on in-memory SQLite with
/// test-injected keys). The effect: a fresh clone with no user-secrets stops at boot with a clear
/// message instead of silently connecting somewhere unintended, and a deployment can never fall back
/// to a committed placeholder key.
/// </summary>
public static class StartupSecretsGuard
{
// Substrings that mark a value as a committed placeholder, never a real secret. Any configured value
// containing one of these is treated as "not provided".
private static readonly string[] PlaceholderMarkers =
[
"SET_VIA_USER_SECRETS_OR_ENV",
"not-for-production",
"change-me",
"ShouldBe-LongerThan-16Char-SecretKey",
"16CharEncryptKey"
];
public static void ValidateRequiredSecrets(this WebApplicationBuilder builder)
{
// Integration tests boot as "Testing" over in-memory SQLite and inject their own crypto keys.
if (builder.Environment.IsEnvironment("Testing"))
return;
var config = builder.Configuration;
var errors = new List<string>();
RequireReal(errors, "ConnectionStrings:SqlServer", config.GetConnectionString("SqlServer"));
RequireReal(errors, "ConnectionStrings:logDb", config.GetConnectionString("logDb"));
// Development supplies working dev-only keys via appsettings.Development.json; only deployed
// environments must inject real per-environment secrets (env vars / Key Vault / KMS).
if (!builder.Environment.IsDevelopment())
{
RequireReal(errors, "IdentitySettings:SecretKey", config["IdentitySettings:SecretKey"]);
RequireReal(errors, "IdentitySettings:Encryptkey", config["IdentitySettings:Encryptkey"]);
RequireReal(errors, "Seams:FieldEncryption:Key", config["Seams:FieldEncryption:Key"]);
RequireReal(errors, "Seams:FieldEncryption:HashKey", config["Seams:FieldEncryption:HashKey"]);
}
if (errors.Count == 0)
return;
throw new InvalidOperationException(
"Refusing to start: required secret configuration is missing or still a committed placeholder. " +
"Provide real values via user-secrets (Development) or environment variables (deployed) — see " +
"dev/post-phase/refinement/RUNBOOK.md.\n - " + string.Join("\n - ", errors));
}
private static void RequireReal(List<string> errors, string key, string? value)
{
if (string.IsNullOrWhiteSpace(value))
errors.Add($"{key} is not set.");
else if (PlaceholderMarkers.Any(marker => value.Contains(marker, StringComparison.OrdinalIgnoreCase)))
errors.Add($"{key} is still a committed placeholder.");
}
}
@@ -25,7 +25,7 @@ namespace Baya.Web.Api.Controllers.V1;
[ApiController]
[Route("api/v{version:apiVersion}/webhooks_bnpl")]
[AllowAnonymous]
[EnableRateLimiting(RateLimitingServiceExtension.SensitivePolicy)]
[EnableRateLimiting(RateLimitingServiceExtension.WebhookPolicy)]
[Display(Description = "BNPL provider callbacks (signature-authenticated, idempotent)")]
public sealed class WebhooksBnplController(ISender sender) : BaseController
{
@@ -7,9 +7,11 @@ using Baya.Application.Features.Payments.Commands.HandlePaymentWebhook;
using Baya.Application.Models.Payments;
using Baya.WebFramework.Attributes;
using Baya.WebFramework.BaseController;
using Baya.WebFramework.ServiceConfiguration;
using Mediator;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
namespace Baya.Web.Api.Controllers.V1;
@@ -23,6 +25,7 @@ namespace Baya.Web.Api.Controllers.V1;
[ApiController]
[Route("api/v{version:apiVersion}/webhooks")]
[AllowAnonymous]
[EnableRateLimiting(RateLimitingServiceExtension.WebhookPolicy)]
[Display(Description = "PSP/BNPL payment callbacks (signature-authenticated, idempotent)")]
public sealed class WebhooksController(ISender sender) : BaseController
{
+22 -4
View File
@@ -12,6 +12,7 @@ using Baya.Infrastructure.Identity.ServiceConfiguration;
using Baya.Infrastructure.Monitoring.Configurations;
using Baya.Infrastructure.Persistence.ServiceConfiguration;
using Baya.SharedKernel.Extensions;
using Baya.Web.Api.Configuration;
using Baya.Web.Plugins.Grpc;
using Baya.WebFramework.Filters;
using Baya.WebFramework.Middlewares;
@@ -29,8 +30,16 @@ builder.Host.UseSerilog(LoggingConfiguration.ConfigureLogger);
var configuration = builder.Configuration;
// Fail fast if a load-bearing secret (DB connection, JWE/field-encryption keys) is missing or still a
// committed placeholder — before any service reaches for it. Skipped in the "Testing" environment.
builder.ValidateRequiredSecrets();
Activity.DefaultIdFormat = ActivityIdFormat.W3C;
// HTTPS metadata is required for the token exchange in deployed environments; relaxed for local
// Development and the Testing host, which run over plain HTTP.
var requireHttpsMetadata = !builder.Environment.IsDevelopment() && !builder.Environment.IsEnvironment("Testing");
builder
.ConfigureHealthChecks()
.SetupOpenTelemetry();
@@ -68,11 +77,12 @@ builder.Services.AddSwagger("v1","v1.1");
builder.Services.AddApplicationServices()
.RegisterIdentityServices(identitySettings)
.RegisterIdentityServices(identitySettings, requireHttpsMetadata)
.AddPersistenceServices(configuration)
.AddCrossCuttingSeams(configuration)
.AddWebFrameworkServices()
.AddCorsPolicies(configuration)
.AddForwardedHeadersConfiguration(configuration)
.AddRateLimitingPolicies();
// Development-only: capture each OTP in-memory so GET /api/v1/dev/last_otp/{phone} can complete a login
@@ -106,12 +116,16 @@ if (!app.Environment.IsEnvironment("Testing"))
{
await app.ApplyMigrationsAsync();
await app.SeedDefaultUsersAsync();
await app.SeedPaymentGatewaysAsync();
// Development-only: populate a demo marketplace (nurses/variants/search rows, customers/patients)
// so the real-path screens aren't empty. Idempotent; never runs in Production/Staging.
// Development-only: a sandbox payment gateway (all-zeros merchant id) and a demo marketplace
// (nurses/variants/search rows, customers/patients) so the real-path screens aren't empty. Neither
// belongs in a deployed DB — a production gateway is an admin action, so this never runs in
// Production/Staging. Both are idempotent.
if (app.Environment.IsDevelopment())
{
await app.SeedPaymentGatewaysAsync();
await app.SeedDemoWorldAsync();
}
}
if (app.Environment.IsDevelopment())
@@ -121,6 +135,10 @@ if (app.Environment.IsDevelopment())
else
app.UseExceptionHandler(_=>{});
// First in the pipeline so the resolved client IP (X-Forwarded-For, from a trusted proxy) is in place
// before anything downstream — notably the rate limiter — reads HttpContext.Connection.RemoteIpAddress.
app.UseForwardedHeaders();
app.UseSwaggerAndUi();
app.UseRouting();
@@ -1,37 +1,15 @@
{
"ConnectionStrings": {
"SqlServer": "Server=localhost,1433;Database=Baya;User Id=sa;Password=SET_VIA_USER_SECRETS_OR_ENV;TrustServerCertificate=True;Encrypt=False;",
"logDb": "Server=localhost,1433;Database=Baya_Logs;User Id=sa;Password=SET_VIA_USER_SECRETS_OR_ENV;TrustServerCertificate=True;Encrypt=False;"
},
"IdentitySettings": {
"SecretKey": "ShouldBe-LongerThan-16Char-SecretKey",
"Encryptkey": "16CharEncryptKey",
"Issuer": "MyWebsite",
"Audience": "MyWebsite",
"NotBeforeMinutes": "0",
"ExpirationMinutes": "10000"
"SecretKey": "dev-only-jwe-signing-key-not-for-production-0123456789abcdef",
"Encryptkey": "dev-only-16bytes"
},
"Seams": {
"FieldEncryption": {
"Key": "local-dev-field-encryption-key-change-me",
"HashKey": "local-dev-field-hash-key-change-me"
},
"ObjectStorage": {
"RootPath": ""
},
"Geocoding": {
"ReturnNullCoordinates": false,
"LowConfidenceMarker": "NO_GEO",
"ResolvedConfidence": 0.9
"Key": "local-dev-field-encryption-key-not-for-production",
"HashKey": "local-dev-field-hash-key-not-for-production"
}
},
"Cors": {
"AllowedOrigins": [ "http://localhost:3000" ]
},
"AllowedHosts": "*",
"Kestrel": {
"EndpointDefaults": {
"Protocols": "Http2"
}
}
}
+12 -8
View File
@@ -4,17 +4,17 @@
"logDb": "Server=localhost,1433;Database=Baya_Logs;User Id=sa;Password=SET_VIA_USER_SECRETS_OR_ENV;TrustServerCertificate=True;Encrypt=False;"
},
"IdentitySettings": {
"SecretKey": "ShouldBe-LongerThan-16Char-SecretKey",
"Encryptkey": "16CharEncryptKey",
"Issuer": "MyWebsite",
"Audience": "MyWebsite",
"SecretKey": "SET_VIA_USER_SECRETS_OR_ENV",
"Encryptkey": "SET_VIA_USER_SECRETS_OR_ENV",
"Issuer": "Balinyaar",
"Audience": "BalinyaarClient",
"NotBeforeMinutes": "0",
"ExpirationMinutes": "10000"
"ExpirationMinutes": "60"
},
"Seams": {
"FieldEncryption": {
"Key": "local-dev-field-encryption-key-change-me",
"HashKey": "local-dev-field-hash-key-change-me"
"Key": "SET_VIA_USER_SECRETS_OR_ENV",
"HashKey": "SET_VIA_USER_SECRETS_OR_ENV"
},
"ObjectStorage": {
"RootPath": ""
@@ -28,10 +28,14 @@
"Cors": {
"AllowedOrigins": []
},
"ForwardedHeaders": {
"KnownProxies": [],
"KnownNetworks": []
},
"AllowedHosts": "*",
"Kestrel": {
"EndpointDefaults": {
"Protocols": "Http2"
"Protocols": "Http1AndHttp2"
}
}
}
@@ -0,0 +1,47 @@
using System.Net;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace Baya.WebFramework.ServiceConfiguration;
public static class ForwardedHeadersServiceExtension
{
/// <summary>Configuration section holding the trusted reverse-proxy addresses/networks.</summary>
public const string ConfigSection = "ForwardedHeaders";
/// <summary>
/// Configures the forwarded-headers middleware so that, behind a reverse proxy, the resolved client
/// address (from <c>X-Forwarded-For</c>) — not the proxy's address — is what
/// <c>HttpContext.Connection.RemoteIpAddress</c> reports. The rate limiter partitions on that address,
/// so without this every client behind the proxy shares a single bucket (self-DoS). Only proxies listed
/// in <c>ForwardedHeaders:KnownProxies</c> / <c>:KnownNetworks</c> (plus loopback) are trusted; an
/// untrusted hop's forwarded header is ignored, so a client can't spoof its address. Pair with
/// <c>app.UseForwardedHeaders()</c> placed first in the pipeline (before anything reads the client IP).
/// </summary>
public static IServiceCollection AddForwardedHeadersConfiguration(this IServiceCollection services, IConfiguration configuration)
{
var knownProxies = configuration.GetSection($"{ConfigSection}:KnownProxies").Get<string[]>() ?? [];
var knownNetworks = configuration.GetSection($"{ConfigSection}:KnownNetworks").Get<string[]>() ?? [];
services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
// The framework defaults trust only loopback; extend that to the deployment's real proxy hop(s).
foreach (var proxy in knownProxies)
if (IPAddress.TryParse(proxy, out var address))
options.KnownProxies.Add(address);
foreach (var network in knownNetworks)
{
var parts = network.Split('/', 2);
if (parts.Length == 2 && IPAddress.TryParse(parts[0], out var prefix) && int.TryParse(parts[1], out var prefixLength))
options.KnownIPNetworks.Add(new System.Net.IPNetwork(prefix, prefixLength));
}
});
return services;
}
}
@@ -20,6 +20,13 @@ public static class RateLimitingServiceExtension
/// <summary>Limit for money-sensitive actions (refund/payout) applied in later phases.</summary>
public const string SensitivePolicy = "sensitive";
/// <summary>
/// The single deliberate policy for inbound PSP/BNPL webhooks. PSP callbacks are bursty (retries,
/// batched settlements), so this is more permissive than <see cref="SensitivePolicy"/> and partitions
/// per-provider (not just per-IP) so one provider's burst can't starve another.
/// </summary>
public const string WebhookPolicy = "webhook";
/// <summary>
/// Registers the built-in rate limiter with a per-IP global limit plus named policies that
/// auth/OTP/sensitive endpoints opt into via <c>[EnableRateLimiting(name)]</c>. Over-limit
@@ -46,6 +53,9 @@ public static class RateLimitingServiceExtension
AddFixedWindowPolicy(options, AuthPolicy, permitLimit: 10, windowSeconds: 60);
AddFixedWindowPolicy(options, SensitivePolicy, permitLimit: 20, windowSeconds: 60);
// Bursty PSP/BNPL callbacks, partitioned per-provider (see WebhookPartitionKey).
AddFixedWindowPolicy(options, WebhookPolicy, permitLimit: 120, windowSeconds: 60, keyResolver: WebhookPartitionKey);
// A deliberately tiny policy used by the phase-0 ping endpoint to demonstrate 429s.
AddFixedWindowPolicy(options, GlobalPolicy, permitLimit: 5, windowSeconds: 10);
});
@@ -53,11 +63,13 @@ public static class RateLimitingServiceExtension
return services;
}
private static void AddFixedWindowPolicy(RateLimiterOptions options, string name, int permitLimit, int windowSeconds)
private static void AddFixedWindowPolicy(RateLimiterOptions options, string name, int permitLimit, int windowSeconds,
Func<HttpContext, string>? keyResolver = null)
{
var resolvePartition = keyResolver ?? PartitionKey;
options.AddPolicy(name, context =>
RateLimitPartition.GetFixedWindowLimiter(
PartitionKey(context),
resolvePartition(context),
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = permitLimit,
@@ -66,6 +78,16 @@ public static class RateLimitingServiceExtension
}));
}
// The client IP as resolved by the forwarded-headers middleware (see ForwardedHeadersServiceExtension) —
// behind a trusted proxy this is the real client, not the proxy, so each client gets its own bucket.
private static string PartitionKey(HttpContext context) =>
context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
// Per-provider partition so a single PSP's burst can't exhaust the shared webhook budget; still bounded
// by the resolved client IP so a spoofed provider segment can't fan out unboundedly.
private static string WebhookPartitionKey(HttpContext context)
{
var provider = context.Request.RouteValues.TryGetValue("provider", out var value) ? value?.ToString() : null;
return $"webhook:{(string.IsNullOrWhiteSpace(provider) ? "unknown" : provider)}:{PartitionKey(context)}";
}
}
@@ -1,6 +1,7 @@
using Baya.Domain.Entities.User;
using Baya.Infrastructure.Identity.Identity.Manager;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
namespace Baya.Infrastructure.Identity.Identity.SeedDatabaseService;
@@ -13,11 +14,13 @@ public class SeedDataBase : ISeedDataBase
{
private readonly AppUserManager _userManager;
private readonly AppRoleManager _roleManager;
private readonly IConfiguration _configuration;
public SeedDataBase(AppUserManager userManager, AppRoleManager roleManager)
public SeedDataBase(AppUserManager userManager, AppRoleManager roleManager, IConfiguration configuration)
{
_userManager = userManager;
_roleManager = roleManager;
_configuration = configuration;
}
public async Task Seed()
@@ -35,18 +38,35 @@ public class SeedDataBase : ISeedDataBase
}
}
if (!_userManager.Users.AsNoTracking().Any(u => u.UserName.Equals("admin")))
{
var user = new User
{
UserName = "admin",
Email = "admin@site.com",
PhoneNumberConfirmed = true,
IsActive = true
};
await SeedBootstrapAdminAsync();
}
await _userManager.CreateAsync(user, "qw123321");
await _userManager.AddToRoleAsync(user,"admin");
}
// The bootstrap admin is config-driven, never a committed credential: it is created only when both
// Seed:AdminUsername and Seed:AdminPassword are supplied (via user-secrets in Development, environment
// variables in a deployment). With neither configured — the default for Testing and any fresh boot —
// no admin account is created, so no well-known password ever lands in a real database. Day-to-day
// admins reach the backoffice through the phone-OTP demo seeds (Development) or are provisioned
// out-of-band; this account is a break-glass bootstrap only.
private async Task SeedBootstrapAdminAsync()
{
var username = _configuration["Seed:AdminUsername"];
var password = _configuration["Seed:AdminPassword"];
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
return;
if (_userManager.Users.AsNoTracking().Any(u => u.UserName.Equals(username)))
return;
var user = new User
{
UserName = username,
Email = _configuration["Seed:AdminEmail"] ?? "admin@balinyaar.local",
PhoneNumberConfirmed = true,
IsActive = true
};
await _userManager.CreateAsync(user, password);
await _userManager.AddToRoleAsync(user, "admin");
}
}
@@ -29,7 +29,7 @@ namespace Baya.Infrastructure.Identity.ServiceConfiguration;
public static class ServiceCollectionExtension
{
public static IServiceCollection RegisterIdentityServices(this IServiceCollection services,IdentitySettings identitySettings)
public static IServiceCollection RegisterIdentityServices(this IServiceCollection services,IdentitySettings identitySettings, bool requireHttpsMetadata)
{
services.AddHttpContextAccessor();
services.AddScoped<ICurrentUser, HttpContextCurrentUser>();
@@ -136,7 +136,9 @@ public static class ServiceCollectionExtension
};
options.RequireHttpsMetadata = false;
// HTTPS is required for the token/metadata exchange in deployed environments; relaxed only for
// local Development / the Testing host, which run over plain HTTP.
options.RequireHttpsMetadata = requireHttpsMetadata;
options.SaveToken = true;
options.TokenValidationParameters = validationParameters;
options.Events = new JwtBearerEvents
@@ -33,6 +33,14 @@ public sealed class BayaApiFactory : WebApplicationFactory<Program>
{
_keepAlive = new SqliteConnection(_connectionString);
_keepAlive.Open();
// The committed appsettings.json ships placeholder JWE keys (real ones come from user-secrets /
// env in Development / deploy). The Testing host has neither, so supply working test keys via
// environment variables — they sit after appsettings.json in the default config chain, so they
// reliably override the placeholders. The Encrypt key must be exactly 16 bytes for the AES-128
// JWE; field-encryption keeps the mock seam's placeholder (it derives a key from any string).
Environment.SetEnvironmentVariable("IdentitySettings__SecretKey", "testing-only-jwe-signing-key-0123456789abcdef");
Environment.SetEnvironmentVariable("IdentitySettings__Encryptkey", "testing-16-bytes");
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
@@ -0,0 +1,67 @@
using Baya.Web.Api.Configuration;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Configuration;
namespace Baya.Test.Api;
/// <summary>
/// Refinement Phase 5 — proves the fail-fast secret guard: a deployed environment left on committed
/// placeholders refuses to boot with a clear message, and boots once real values are supplied. (The
/// integration host runs as "Testing", where the guard is intentionally skipped, so this exercises it
/// directly.)
/// </summary>
public class StartupSecretsGuardTests
{
private static WebApplicationBuilder ProductionBuilder(Dictionary<string, string?> settings)
{
var builder = WebApplication.CreateBuilder(new WebApplicationOptions { EnvironmentName = "Production" });
builder.Configuration.AddInMemoryCollection(settings);
return builder;
}
private static Dictionary<string, string?> RealSecrets() => new()
{
["ConnectionStrings:SqlServer"] = "Server=db;Database=Baya;User Id=app;Password=real-value;",
["ConnectionStrings:logDb"] = "Server=db;Database=Baya_Logs;User Id=app;Password=real-value;",
["IdentitySettings:SecretKey"] = "a-real-production-jwe-signing-key-0123456789",
["IdentitySettings:Encryptkey"] = "real-16-byte-key",
["Seams:FieldEncryption:Key"] = "a-real-production-field-key",
["Seams:FieldEncryption:HashKey"] = "a-real-production-hash-key"
};
[Fact]
public void PlaceholderConnectionString_InDeployedEnvironment_RefusesToStart()
{
var settings = RealSecrets();
settings["ConnectionStrings:SqlServer"] = "Server=localhost;Password=SET_VIA_USER_SECRETS_OR_ENV;";
var ex = Assert.Throws<InvalidOperationException>(() => ProductionBuilder(settings).ValidateRequiredSecrets());
Assert.Contains("ConnectionStrings:SqlServer", ex.Message);
}
[Fact]
public void PlaceholderJweKey_InDeployedEnvironment_RefusesToStart()
{
var settings = RealSecrets();
settings["IdentitySettings:SecretKey"] = "SET_VIA_USER_SECRETS_OR_ENV";
var ex = Assert.Throws<InvalidOperationException>(() => ProductionBuilder(settings).ValidateRequiredSecrets());
Assert.Contains("IdentitySettings:SecretKey", ex.Message);
}
[Fact]
public void DevOnlyKeyLeakedToDeployedEnvironment_RefusesToStart()
{
var settings = RealSecrets();
settings["Seams:FieldEncryption:Key"] = "local-dev-field-encryption-key-not-for-production";
Assert.Throws<InvalidOperationException>(() => ProductionBuilder(settings).ValidateRequiredSecrets());
}
[Fact]
public void RealSecrets_InDeployedEnvironment_StartUpSucceeds()
{
var exception = Record.Exception(() => ProductionBuilder(RealSecrets()).ValidateRequiredSecrets());
Assert.Null(exception);
}
}