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