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