refinement phase 5
This commit is contained in:
@@ -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).
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+47
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+24
-2
@@ -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)}";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-7
@@ -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");
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+4
-2
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user