refinement phase 5
This commit is contained in:
@@ -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
|
||||
{
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user