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
+26
View File
@@ -0,0 +1,26 @@
# Git hooks
Repo-managed git hooks (they live in version control, unlike `.git/hooks`).
## Enable (once per clone)
```bash
git config core.hooksPath .githooks
```
## `pre-commit` — secret scan
A fast, dependency-free backstop for the root `CLAUDE.md` rule **"Never commit secrets"**
(refinement-phase-5). It rejects a commit that stages:
- the historically-leaked SQL Server host `87.107.152.16`,
- the retired hardcoded admin password `qw123321`,
- a **real** connection-string password in any `appsettings*.json` (only the `SET_VIA_USER_SECRETS_OR_ENV`
placeholder is allowed — real values belong in user-secrets / environment variables),
- private-key material or an AWS access-key id, anywhere.
It scans only staged additions, so it is quick. It is **not** a replacement for a full scanner
(gitleaks / trufflehog) in CI — it is the local first line of defence.
Bypass a false positive with `git commit --no-verify` (use sparingly, and only when you are certain the
flagged line is not a secret).
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env bash
#
# Balinyaar secret-scanning pre-commit hook (refinement-phase-5).
# Blocks a commit that stages an obvious credential. This is a fast, dependency-free backstop for the
# root CLAUDE.md rule "Never commit secrets" — not a replacement for gitleaks/trufflehog in CI.
#
# Enable once per clone: git config core.hooksPath .githooks
# Bypass a false positive: git commit --no-verify (use sparingly, and only when you are certain)
#
set -euo pipefail
# Committed placeholders are allowed — real values are not. Keep in sync with StartupSecretsGuard.
PLACEHOLDER='SET_VIA_USER_SECRETS_OR_ENV'
# Only scan added/changed lines in text files that are staged.
staged=$(git diff --cached --name-only --diff-filter=ACM)
[ -z "$staged" ] && exit 0
violations=0
report() { printf ' ✖ %s\n' "$1"; violations=$((violations + 1)); }
while IFS= read -r file; do
# Skip this hook, lockfiles, and binaries.
case "$file" in
.githooks/*) continue ;;
*.png|*.jpg|*.jpeg|*.gif|*.ico|*.pdf|*.dll|*.exe|*.snk) continue ;;
esac
[ -f "$file" ] || continue
added=$(git diff --cached -U0 -- "$file" | grep '^+' | grep -v '^+++' || true)
[ -z "$added" ] && continue
# The historically-leaked SQL Server host — must never reappear.
echo "$added" | grep -Eq '87\.107\.152\.16' && report "$file: leaked SQL Server host 87.107.152.16"
# The retired hardcoded admin password.
echo "$added" | grep -Eq 'qw123321' && report "$file: hardcoded admin password 'qw123321'"
# A real (non-placeholder) connection-string password in a committed appsettings file.
case "$file" in
*appsettings*.json)
echo "$added" \
| grep -Ei 'Password=[^;"'"'"' ]+' \
| grep -viq "Password=${PLACEHOLDER}" \
&& report "$file: connection-string password must be '${PLACEHOLDER}' (real value belongs in user-secrets/env)"
;;
esac
# Private keys and common cloud tokens, anywhere.
echo "$added" | grep -Eq -- '-----BEGIN (RSA|EC|OPENSSH|PRIVATE) .*PRIVATE KEY-----' && report "$file: private key material"
echo "$added" | grep -Eq 'AKIA[0-9A-Z]{16}' && report "$file: AWS access key id"
done <<< "$staged"
if [ "$violations" -gt 0 ]; then
echo ""
echo "Commit blocked: $violations potential secret(s) staged. Move the real value to user-secrets"
echo "(Development) or an environment variable (deploy) and commit only the '${PLACEHOLDER}' placeholder."
echo "See dev/post-phase/refinement/RUNBOOK.md. To override a false positive: git commit --no-verify"
exit 1
fi
exit 0
+26 -6
View File
@@ -75,9 +75,11 @@ Development environment, so this never affects a deployed build.
dotnet run --project src/API/Baya.Web.Api/Baya.Web.Api.csproj dotnet run --project src/API/Baya.Web.Api/Baya.Web.Api.csproj
``` ```
On boot the API applies all EF migrations and seeds roles + an admin user + a sandbox payment gateway On boot the API applies all EF migrations and seeds roles against the (empty) local DB, then listens on
against the (empty) local DB, then listens on **`https://localhost:5002`** — Swagger at **`https://localhost:5002`** — Swagger at `https://localhost:5002/swagger`. In **Development** it also seeds a
`https://localhost:5002/swagger`. sandbox payment gateway and the demo world (below). It does **not** seed the old `admin`/`qw123321` account
anymore (refinement-phase-5); a break-glass admin is created only if you set `Seed:AdminUsername` /
`Seed:AdminPassword` (see below), and the day-to-day admin path is the phone-OTP demo admins.
In **Development** it additionally runs the **demo-world seeder** (Refinement Phase 1): verified/unverified In **Development** it additionally runs the **demo-world seeder** (Refinement Phase 1): verified/unverified
demo nurses with priced variants + Tehran coverage (and therefore real `nurse_search_index` rows), plus demo demo nurses with priced variants + Tehran coverage (and therefore real `nurse_search_index` rows), plus demo
@@ -112,7 +114,15 @@ endpoint works). Use them to see the real path populated:
| `09120000011` | customer | رضا حسینی (male) | 1 patient, 1 Tehran address | | `09120000011` | customer | رضا حسینی (male) | 1 patient, 1 Tehran address |
| `09120000020` | admin (`super_admin`) | نگار مدیری (female) | full backoffice — **lands on `/admin`**, sees every console incl. RBAC | | `09120000020` | admin (`super_admin`) | نگار مدیری (female) | full backoffice — **lands on `/admin`**, sees every console incl. RBAC |
| `09120000021` | admin (`finance`) | کامران مالی (male) | scoped backoffice — lands on `/admin`, sidebar shows only the money consoles (`useAdminCapabilities` gating) | | `09120000021` | admin (`finance`) | کامران مالی (male) | scoped backoffice — lands on `/admin`, sidebar shows only the money consoles (`useAdminCapabilities` gating) |
| `admin` / `qw123321` | admin | reference super-admin (username+password) | not a phone-OTP login — the frontend uses the phone admins above |
> The old username+password `admin`/`qw123321` account is **no longer auto-seeded** (refinement-phase-5 —
> no committed credential). To bootstrap a break-glass username+password admin, set both secrets before boot,
> then log in via the API (not the web UI, which is phone-OTP only):
> ```bash
> cd server/src/API/Baya.Web.Api
> dotnet user-secrets set "Seed:AdminUsername" "admin"
> dotnet user-secrets set "Seed:AdminPassword" "<a-strong-password>"
> ```
The **phone-OTP admins** (`09120000020` / `09120000021`, refinement-phase-2) are how you reach the `/admin` The **phone-OTP admins** (`09120000020` / `09120000021`, refinement-phase-2) are how you reach the `/admin`
console through the same web login flow as everyone else — admin sub-roles are server-granted, never console through the same web login flow as everyone else — admin sub-roles are server-granted, never
@@ -148,8 +158,17 @@ Prove search works without the frontend: open Swagger →
## Good to know ## Good to know
- **The API speaks HTTP/2** (Kestrel `Protocols: Http2`, for gRPC). Browsers negotiate h2-over-TLS - **The API speaks HTTP/1.1 and HTTP/2** (Kestrel `Protocols: Http1AndHttp2`, refinement-phase-5 — the
automatically, so `fetch` just works; for `curl` add `--http2`. previous HTTP/2-only default broke non-TLS HTTP/1.1 hops). Over TLS the client negotiates h2 via ALPN, so
gRPC and `fetch` both work; plain-HTTP hops fall back to HTTP/1.1.
- **Secrets fail fast.** On a fresh clone with no user-secrets the API refuses to start with
`Refusing to start: required secret configuration is missing…` — set the connection-string user-secret
(step 3) and boot again. Deployed environments must additionally supply real `IdentitySettings` JWE keys
and `Seams:FieldEncryption` keys (Development uses dev-only defaults from `appsettings.Development.json`).
- **Enable the secret-scan pre-commit hook** once per clone so a stray credential can't be committed:
`git config core.hooksPath .githooks` (see [`.githooks/README.md`](../../../.githooks/README.md)).
- **Behind a reverse proxy**, list its address in `ForwardedHeaders:KnownProxies` (or a CIDR in
`:KnownNetworks`) so the rate limiter partitions on the real client IP, not the proxy's.
- **Only `auth` is real by default.** 21 of 22 client service domains default to an in-browser mock - **Only `auth` is real by default.** 21 of 22 client service domains default to an in-browser mock
(`USE_*_MOCK = true`); the home, search, bookings, etc. are fake in-memory data until Refinement Phase 4. (`USE_*_MOCK = true`); the home, search, bookings, etc. are fake in-memory data until Refinement Phase 4.
- **The DB self-migrates + self-seeds**, so pointing at an empty local instance is enough — including the - **The DB self-migrates + self-seeds**, so pointing at an empty local instance is enough — including the
@@ -183,6 +202,7 @@ world, wipe the volume (`docker compose down -v`) and boot again.
| Symptom | Fix | | Symptom | Fix |
| --- | --- | | --- | --- |
| Browser: `net::ERR_CERT_AUTHORITY_INVALID` on `:5002` | Run `dotnet dev-certs https --trust` (setup step 1). | | Browser: `net::ERR_CERT_AUTHORITY_INVALID` on `:5002` | Run `dotnet dev-certs https --trust` (setup step 1). |
| API startup: `Refusing to start: required secret configuration is missing…` | The connection-string user-secret isn't set (or still the placeholder). Do setup step 3. |
| API startup: `Login failed for user 'sa'` / connect timeout | DB not up or wrong password — check `docker compose ps` and that the user-secrets password matches `docker-compose.yml`. | | API startup: `Login failed for user 'sa'` / connect timeout | DB not up or wrong password — check `docker compose ps` and that the user-secrets password matches `docker-compose.yml`. |
| Console: `...has been blocked by CORS policy` | `UseCors` missing/mis-ordered, or the browser origin isn't in `Cors:AllowedOrigins`. It must sit after `UseRouting` and before the rate limiter. | | Console: `...has been blocked by CORS policy` | `UseCors` missing/mis-ordered, or the browser origin isn't in `Cors:AllowedOrigins`. It must sit after `UseRouting` and before the rate limiter. |
| `dotnet user-secrets` errors with "could not find UserSecretsId" | Run it from `server/src/API/Baya.Web.Api` (the project with `<UserSecretsId>`). | | `dotnet user-secrets` errors with "could not find UserSecretsId" | Run it from `server/src/API/Baya.Web.Api` (the project with `<UserSecretsId>`). |
+34 -17
View File
@@ -56,9 +56,14 @@ You are a **senior .NET software engineer** working on this codebase. That means
| Update DB | `dotnet ef database update --project src/Infrastructure/Baya.Infrastructure.Persistence --startup-project src/API/Baya.Web.Api` | | Update DB | `dotnet ef database update --project src/Infrastructure/Baya.Infrastructure.Persistence --startup-project src/API/Baya.Web.Api` |
**Default URL:** `https://localhost:5002` — Swagger at `/swagger`. **Default URL:** `https://localhost:5002` — Swagger at `/swagger`.
On boot, `Program.cs` calls `ApplyMigrationsAsync()`, `SeedDefaultUsersAsync()`, `SeedPaymentGatewaysAsync()` On boot (non-Testing), `Program.cs` calls `ApplyMigrationsAsync()` + `SeedDefaultUsersAsync()` (roles always;
— and, **only in Development**, `SeedDemoWorldAsync()` (the demo marketplace seeder, see Persistence below). a bootstrap admin **only if `Seed:AdminUsername`/`Seed:AdminPassword` are configured** — never a committed
A reachable SQL Server is required to start. credential), and **only in Development** `SeedPaymentGatewaysAsync()` (the sandbox gateway) + `SeedDemoWorldAsync()`
(the demo marketplace, see Persistence below). A reachable SQL Server is required to start. Startup **fails fast**
(`StartupSecretsGuard`) if a load-bearing secret — the DB connection strings, and in deployed environments the
JWE + field-encryption keys — is missing or left at its committed `SET_VIA_USER_SECRETS_OR_ENV` placeholder
(refinement-phase-5). Development supplies working dev-only crypto keys via `appsettings.Development.json`; only
the connection string must come from user-secrets (see [RUNBOOK](../dev/post-phase/refinement/RUNBOOK.md)).
--- ---
@@ -510,25 +515,29 @@ only canonical if it stays accurate.
Service registration is composed from per-layer extension methods (each project's `ServiceConfiguration/`): Service registration is composed from per-layer extension methods (each project's `ServiceConfiguration/`):
``` ```
builder.ValidateRequiredSecrets() // refinement-phase-5: fail fast on missing/placeholder DB + crypto secrets
ConfigureHealthChecks() · SetupOpenTelemetry() ConfigureHealthChecks() · SetupOpenTelemetry()
AddApplicationServices() // Mediator + pipeline behaviors (Logging → Metrics → Validate) AddApplicationServices() // Mediator + pipeline behaviors (Logging → Metrics → Validate)
RegisterIdentityServices(...) // Identity, JWT/JWE, authorization policies, ICurrentUser + IHttpContextAccessor RegisterIdentityServices(…, requireHttpsMetadata) // Identity, JWT/JWE (RequireHttpsMetadata on outside Dev/Testing), ICurrentUser
AddPersistenceServices(...) // DbContext (+ AuditFieldInterceptor), UnitOfWork, repositories AddPersistenceServices(...) // DbContext (+ AuditFieldInterceptor), UnitOfWork, repositories
AddCrossCuttingSeams(config) // IDateTimeProvider, IFieldEncryptor, ICacheService, IObjectStorage, INotificationDispatcher (mocks) AddCrossCuttingSeams(config) // IDateTimeProvider, IFieldEncryptor, ICacheService, IObjectStorage, INotificationDispatcher (mocks)
AddWebFrameworkServices() // API versioning + snake_case routing AddWebFrameworkServices() // API versioning + snake_case routing
AddCorsPolicies(config) // browser CORS policy from Cors:AllowedOrigins (refinement-phase-0; default http://localhost:3000 in Dev) AddCorsPolicies(config) // browser CORS policy from Cors:AllowedOrigins (refinement-phase-0; default http://localhost:3000 in Dev)
AddRateLimitingPolicies() // built-in rate limiter: per-IP global + named (otp/auth/sensitive) AddForwardedHeadersConfiguration(config) // refinement-phase-5: trust ForwardedHeaders:KnownProxies/KnownNetworks so the rate limiter sees the real client IP behind a proxy
AddRateLimitingPolicies() // built-in rate limiter: per-resolved-IP global + named (otp/auth/sensitive/webhook)
AddSwagger("v1", "v1.1") · RegisterValidatorsAsServices() · AddMapster() AddSwagger("v1", "v1.1") · RegisterValidatorsAsServices() · AddMapster()
ConfigureGrpcPluginServices() ConfigureGrpcPluginServices()
// Development-only: AddDevelopmentOtpCapture() (refinement-phase-0) decorates ISmsSender to capture each // Development-only: AddDevelopmentOtpCapture() (refinement-phase-0) decorates ISmsSender to capture each
// OTP in-memory for the GET /api/v1/dev/last_otp/{phone} helper — never wired outside Development. // OTP in-memory for the GET /api/v1/dev/last_otp/{phone} helper — never wired outside Development.
``` ```
Pipeline order: exception handler → Swagger → routing → **CORS → rate limiter → authentication → Pipeline order: **forwarded headers** → exception handler → Swagger → routing → **CORS → rate limiter →
authorization** → controllers → metrics → health checks → gRPC. `UseCors(...)` (refinement-phase-0) sits authentication → authorization** → controllers → metrics → health checks → gRPC. `UseForwardedHeaders()`
**after `UseRouting()` and before `UseRateLimiter()`** so a pre-flight `OPTIONS` is answered before the (refinement-phase-5) is **first** so the resolved client IP (`X-Forwarded-For` from a trusted proxy) is in
limiter/auth run; `UseRateLimiter()` is placed **before** `UseAuthentication()` so over-limit auth/OTP place before the rate limiter partitions on it. `UseCors(...)` (refinement-phase-0) sits **after
attempts are rejected (`429`) before hitting the auth stack. `UseRouting()` and before `UseRateLimiter()`** so a pre-flight `OPTIONS` is answered before the limiter/auth
run; `UseRateLimiter()` is placed **before** `UseAuthentication()` so over-limit auth/OTP attempts are
rejected (`429`) before hitting the auth stack.
When adding new infrastructure, expose it as an extension method and call it from `Program.cs` — When adding new infrastructure, expose it as an extension method and call it from `Program.cs` —
never inline registrations there directly. never inline registrations there directly.
@@ -606,18 +615,26 @@ action to `sender.Send(...)`. Full conventions are in [CONVENTIONS.md](CONVENTIO
process-wide singleton because EF caches the model). Equality lookups go through the deterministic process-wide singleton because EF caches the model). Equality lookups go through the deterministic
`PhoneHash` column (UNIQUE, synced on SaveChanges — which also resets `ShahkarVerifiedAt` when the `PhoneHash` column (UNIQUE, synced on SaveChanges — which also resets `ShahkarVerifiedAt` when the
phone actually changes). Never query `PhoneNumber == x`. phone actually changes). Never query `PhoneNumber == x`.
- **Roles:** full vocabulary in `Domain/Entities/User/RoleNames` (seeded by `SeedDataBase`). - **Roles:** full vocabulary in `Domain/Entities/User/RoleNames`; `SeedDataBase` always seeds the roles,
`customer`/`nurse` are self-selectable via `POST me/select_role` (audited and seeds a **bootstrap admin only when `Seed:AdminUsername`/`Seed:AdminPassword` are configured**
`granted_by`/`granted_at`, idempotent, both can be held); admin sub-roles are internal-only and (refinement-phase-5 — no more committed `admin`/`qw123321`; break-glass only, day-to-day admins come from
return 403 there. `user_roles.revoked_at` has a global query filter, so revoked grants disappear the phone-OTP demo seeds or are provisioned out-of-band). `customer`/`nurse` are self-selectable via
from every role read automatically. Auth knobs (`auth_otp_resend_seconds`, `auth_otp_max_attempts`, `POST me/select_role` (audited `granted_by`/`granted_at`, idempotent, both can be held); admin sub-roles are
internal-only and return 403 there. `user_roles.revoked_at` has a global query filter, so revoked grants
disappear from every role read automatically. Auth knobs (`auth_otp_resend_seconds`, `auth_otp_max_attempts`,
`auth_session_ttl_days`) are `platform_configs` rows read via `IPlatformConfig`. `auth_session_ttl_days`) are `platform_configs` rows read via `IPlatformConfig`.
- Dynamic permission system: `DynamicPermissionHandler` reads `[controller]` + `[action]` route - Dynamic permission system: `DynamicPermissionHandler` reads `[controller]` + `[action]` route
values and checks role claims. Always use `[controller]`/`[action]` tokens so the keys stay values and checks role claims. Always use `[controller]`/`[action]` tokens so the keys stay
consistent (see CONVENTIONS.md §1 Routing). consistent (see CONVENTIONS.md §1 Routing).
- Settings bound from `appsettings.json` → `IdentitySettings`. - Settings bound from `appsettings.json` → `IdentitySettings`. **JWE keys are never committed**: the
committed values are `SET_VIA_USER_SECRETS_OR_ENV` placeholders (real ones via user-secrets/env; Development
uses dev-only keys in `appsettings.Development.json`). `RequireHttpsMetadata` is **on outside Dev/Testing**
(passed into `RegisterIdentityServices`), the access-token lifetime is `ExpirationMinutes: 60`, and
`Issuer`/`Audience` are real (`Balinyaar`/`BalinyaarClient`) — refinement-phase-5.
- Auth and OTP endpoints must be rate-limited (CONVENTIONS.md §11) — `request_otp`/`verify_otp` use - Auth and OTP endpoints must be rate-limited (CONVENTIONS.md §11) — `request_otp`/`verify_otp` use
the `otp` policy, `refresh` the `auth` policy; plus a per-phone resend window via `ICacheService`. the `otp` policy, `refresh` the `auth` policy; plus a per-phone resend window via `ICacheService`. The two
PSP/BNPL webhooks share the single deliberate **`webhook`** policy (bursty-tolerant, partitioned per-provider);
behind a reverse proxy the limiter partitions on the forwarded client IP (see Startup wiring).
--- ---
@@ -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] [ApiController]
[Route("api/v{version:apiVersion}/webhooks_bnpl")] [Route("api/v{version:apiVersion}/webhooks_bnpl")]
[AllowAnonymous] [AllowAnonymous]
[EnableRateLimiting(RateLimitingServiceExtension.SensitivePolicy)] [EnableRateLimiting(RateLimitingServiceExtension.WebhookPolicy)]
[Display(Description = "BNPL provider callbacks (signature-authenticated, idempotent)")] [Display(Description = "BNPL provider callbacks (signature-authenticated, idempotent)")]
public sealed class WebhooksBnplController(ISender sender) : BaseController 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.Application.Models.Payments;
using Baya.WebFramework.Attributes; using Baya.WebFramework.Attributes;
using Baya.WebFramework.BaseController; using Baya.WebFramework.BaseController;
using Baya.WebFramework.ServiceConfiguration;
using Mediator; using Mediator;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
namespace Baya.Web.Api.Controllers.V1; namespace Baya.Web.Api.Controllers.V1;
@@ -23,6 +25,7 @@ namespace Baya.Web.Api.Controllers.V1;
[ApiController] [ApiController]
[Route("api/v{version:apiVersion}/webhooks")] [Route("api/v{version:apiVersion}/webhooks")]
[AllowAnonymous] [AllowAnonymous]
[EnableRateLimiting(RateLimitingServiceExtension.WebhookPolicy)]
[Display(Description = "PSP/BNPL payment callbacks (signature-authenticated, idempotent)")] [Display(Description = "PSP/BNPL payment callbacks (signature-authenticated, idempotent)")]
public sealed class WebhooksController(ISender sender) : BaseController 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.Monitoring.Configurations;
using Baya.Infrastructure.Persistence.ServiceConfiguration; using Baya.Infrastructure.Persistence.ServiceConfiguration;
using Baya.SharedKernel.Extensions; using Baya.SharedKernel.Extensions;
using Baya.Web.Api.Configuration;
using Baya.Web.Plugins.Grpc; using Baya.Web.Plugins.Grpc;
using Baya.WebFramework.Filters; using Baya.WebFramework.Filters;
using Baya.WebFramework.Middlewares; using Baya.WebFramework.Middlewares;
@@ -29,8 +30,16 @@ builder.Host.UseSerilog(LoggingConfiguration.ConfigureLogger);
var configuration = builder.Configuration; 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; 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 builder
.ConfigureHealthChecks() .ConfigureHealthChecks()
.SetupOpenTelemetry(); .SetupOpenTelemetry();
@@ -68,11 +77,12 @@ builder.Services.AddSwagger("v1","v1.1");
builder.Services.AddApplicationServices() builder.Services.AddApplicationServices()
.RegisterIdentityServices(identitySettings) .RegisterIdentityServices(identitySettings, requireHttpsMetadata)
.AddPersistenceServices(configuration) .AddPersistenceServices(configuration)
.AddCrossCuttingSeams(configuration) .AddCrossCuttingSeams(configuration)
.AddWebFrameworkServices() .AddWebFrameworkServices()
.AddCorsPolicies(configuration) .AddCorsPolicies(configuration)
.AddForwardedHeadersConfiguration(configuration)
.AddRateLimitingPolicies(); .AddRateLimitingPolicies();
// Development-only: capture each OTP in-memory so GET /api/v1/dev/last_otp/{phone} can complete a login // 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.ApplyMigrationsAsync();
await app.SeedDefaultUsersAsync(); await app.SeedDefaultUsersAsync();
await app.SeedPaymentGatewaysAsync();
// Development-only: populate a demo marketplace (nurses/variants/search rows, customers/patients) // Development-only: a sandbox payment gateway (all-zeros merchant id) and a demo marketplace
// so the real-path screens aren't empty. Idempotent; never runs in Production/Staging. // (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()) if (app.Environment.IsDevelopment())
{
await app.SeedPaymentGatewaysAsync();
await app.SeedDemoWorldAsync(); await app.SeedDemoWorldAsync();
}
} }
if (app.Environment.IsDevelopment()) if (app.Environment.IsDevelopment())
@@ -121,6 +135,10 @@ if (app.Environment.IsDevelopment())
else else
app.UseExceptionHandler(_=>{}); 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.UseSwaggerAndUi();
app.UseRouting(); 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": { "IdentitySettings": {
"SecretKey": "ShouldBe-LongerThan-16Char-SecretKey", "SecretKey": "dev-only-jwe-signing-key-not-for-production-0123456789abcdef",
"Encryptkey": "16CharEncryptKey", "Encryptkey": "dev-only-16bytes"
"Issuer": "MyWebsite",
"Audience": "MyWebsite",
"NotBeforeMinutes": "0",
"ExpirationMinutes": "10000"
}, },
"Seams": { "Seams": {
"FieldEncryption": { "FieldEncryption": {
"Key": "local-dev-field-encryption-key-change-me", "Key": "local-dev-field-encryption-key-not-for-production",
"HashKey": "local-dev-field-hash-key-change-me" "HashKey": "local-dev-field-hash-key-not-for-production"
},
"ObjectStorage": {
"RootPath": ""
},
"Geocoding": {
"ReturnNullCoordinates": false,
"LowConfidenceMarker": "NO_GEO",
"ResolvedConfidence": 0.9
} }
}, },
"Cors": { "Cors": {
"AllowedOrigins": [ "http://localhost:3000" ] "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;" "logDb": "Server=localhost,1433;Database=Baya_Logs;User Id=sa;Password=SET_VIA_USER_SECRETS_OR_ENV;TrustServerCertificate=True;Encrypt=False;"
}, },
"IdentitySettings": { "IdentitySettings": {
"SecretKey": "ShouldBe-LongerThan-16Char-SecretKey", "SecretKey": "SET_VIA_USER_SECRETS_OR_ENV",
"Encryptkey": "16CharEncryptKey", "Encryptkey": "SET_VIA_USER_SECRETS_OR_ENV",
"Issuer": "MyWebsite", "Issuer": "Balinyaar",
"Audience": "MyWebsite", "Audience": "BalinyaarClient",
"NotBeforeMinutes": "0", "NotBeforeMinutes": "0",
"ExpirationMinutes": "10000" "ExpirationMinutes": "60"
}, },
"Seams": { "Seams": {
"FieldEncryption": { "FieldEncryption": {
"Key": "local-dev-field-encryption-key-change-me", "Key": "SET_VIA_USER_SECRETS_OR_ENV",
"HashKey": "local-dev-field-hash-key-change-me" "HashKey": "SET_VIA_USER_SECRETS_OR_ENV"
}, },
"ObjectStorage": { "ObjectStorage": {
"RootPath": "" "RootPath": ""
@@ -28,10 +28,14 @@
"Cors": { "Cors": {
"AllowedOrigins": [] "AllowedOrigins": []
}, },
"ForwardedHeaders": {
"KnownProxies": [],
"KnownNetworks": []
},
"AllowedHosts": "*", "AllowedHosts": "*",
"Kestrel": { "Kestrel": {
"EndpointDefaults": { "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> /// <summary>Limit for money-sensitive actions (refund/payout) applied in later phases.</summary>
public const string SensitivePolicy = "sensitive"; 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> /// <summary>
/// Registers the built-in rate limiter with a per-IP global limit plus named policies that /// 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 /// 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, AuthPolicy, permitLimit: 10, windowSeconds: 60);
AddFixedWindowPolicy(options, SensitivePolicy, permitLimit: 20, 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. // A deliberately tiny policy used by the phase-0 ping endpoint to demonstrate 429s.
AddFixedWindowPolicy(options, GlobalPolicy, permitLimit: 5, windowSeconds: 10); AddFixedWindowPolicy(options, GlobalPolicy, permitLimit: 5, windowSeconds: 10);
}); });
@@ -53,11 +63,13 @@ public static class RateLimitingServiceExtension
return services; 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 => options.AddPolicy(name, context =>
RateLimitPartition.GetFixedWindowLimiter( RateLimitPartition.GetFixedWindowLimiter(
PartitionKey(context), resolvePartition(context),
_ => new FixedWindowRateLimiterOptions _ => new FixedWindowRateLimiterOptions
{ {
PermitLimit = permitLimit, 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) => private static string PartitionKey(HttpContext context) =>
context.Connection.RemoteIpAddress?.ToString() ?? "unknown"; 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.Domain.Entities.User;
using Baya.Infrastructure.Identity.Identity.Manager; using Baya.Infrastructure.Identity.Identity.Manager;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
namespace Baya.Infrastructure.Identity.Identity.SeedDatabaseService; namespace Baya.Infrastructure.Identity.Identity.SeedDatabaseService;
@@ -13,11 +14,13 @@ public class SeedDataBase : ISeedDataBase
{ {
private readonly AppUserManager _userManager; private readonly AppUserManager _userManager;
private readonly AppRoleManager _roleManager; private readonly AppRoleManager _roleManager;
private readonly IConfiguration _configuration;
public SeedDataBase(AppUserManager userManager, AppRoleManager roleManager) public SeedDataBase(AppUserManager userManager, AppRoleManager roleManager, IConfiguration configuration)
{ {
_userManager = userManager; _userManager = userManager;
_roleManager = roleManager; _roleManager = roleManager;
_configuration = configuration;
} }
public async Task Seed() public async Task Seed()
@@ -35,18 +38,35 @@ public class SeedDataBase : ISeedDataBase
} }
} }
if (!_userManager.Users.AsNoTracking().Any(u => u.UserName.Equals("admin"))) await SeedBootstrapAdminAsync();
}
// 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 var user = new User
{ {
UserName = "admin", UserName = username,
Email = "admin@site.com", Email = _configuration["Seed:AdminEmail"] ?? "admin@balinyaar.local",
PhoneNumberConfirmed = true, PhoneNumberConfirmed = true,
IsActive = true IsActive = true
}; };
await _userManager.CreateAsync(user, "qw123321"); await _userManager.CreateAsync(user, password);
await _userManager.AddToRoleAsync(user,"admin"); await _userManager.AddToRoleAsync(user, "admin");
}
} }
} }
@@ -29,7 +29,7 @@ namespace Baya.Infrastructure.Identity.ServiceConfiguration;
public static class ServiceCollectionExtension 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.AddHttpContextAccessor();
services.AddScoped<ICurrentUser, HttpContextCurrentUser>(); 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.SaveToken = true;
options.TokenValidationParameters = validationParameters; options.TokenValidationParameters = validationParameters;
options.Events = new JwtBearerEvents options.Events = new JwtBearerEvents
@@ -33,6 +33,14 @@ public sealed class BayaApiFactory : WebApplicationFactory<Program>
{ {
_keepAlive = new SqliteConnection(_connectionString); _keepAlive = new SqliteConnection(_connectionString);
_keepAlive.Open(); _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) 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);
}
}