Makes the application say what it is doing and when it fails

U4. Console logging plus Sentry, a same-origin tunnel so ad blockers cannot
silence browser errors, Umami on the admin SPA, and six security events that
alert rules can actually be built on.

The correlation id is the W3C trace id from the ambient Activity, enabled by
one line of ActivityTrackingOptions so every entry from every category carries
it without touching a call site. It propagates across the master/slave
boundary via traceparent, which TraceIdentifier cannot do at all, and it is
the same value ProblemDetails already returns to the browser.

The security events use source-generated LoggerMessage with constant
templates. Sentry groups log events by message, so interpolating an email
address would give every address its own issue and "more than 20 failed
logins in five minutes" could never fire — the events would arrive, be
visible, be tagged, and the alerting would silently be impossible. A test
asserts the rendered message is identical across argument values.

Scrubbing happens in-process, before transmission, and covers Set-Cookie as
well as Cookie: the login response issues the refreshToken there, so
scrubbing only the request side would protect nothing. Transactions are
scrubbed too, because they carry request data and are the channel nobody
thinks of.

The tunnel derives its destination from the DSN once at startup and reads
nothing from the request, which is what separates a tunnel from a
server-side request forgery primitive. Size is capped by a bounded read
rather than by trusting Content-Length, and the endpoint is rate limited.

Two things found along the way. Zod 4's url() hands the value to the URL
constructor, which accepts any scheme — so the existing frontend validation
would have accepted the exact "htp://" typo BR-U4-24 names, and the SPA
would have called a nonexistent origin. Now constrained to http(s). And the
new appsettings comments are verified against the real configuration
provider, because the failure mode if it rejected them is both hosts
refusing to start after a release switch.

One deviation. IAdminTokenValidator was meant to gain a reason-reporting
overload; implemented that way, a substitute returning false by default
silently inverted the access decision while both methods compiled. Two
methods whose difference is invisible at the call site is the defect, so it
is now a single Validate returning AdminTokenResult.

Touches two files from already-committed units: DatabaseMigrationExtensions
(U2) gains a flush before the rethrow, or the one Critical event in the
system dies with the process; AdminTokenValidator (U1) classifies why a
bypass was refused.

Build 0 errors; 366 backend tests pass, up from 315, and 237 frontend tests,
up from 213. tsc clean, eslint clean on every changed file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHoJpxYXzHACSQguHrC5fw
This commit is contained in:
2026-07-28 11:25:54 +02:00
co-authored by Claude Opus 5
parent a122548454
commit 8e79a72340
54 changed files with 2598 additions and 66 deletions
@@ -5,7 +5,7 @@
- **Feature Slug**: gitea-deployment-workflow - **Feature Slug**: gitea-deployment-workflow
- **Project Type**: Brownfield - **Project Type**: Brownfield
- **Start Date**: 2026-07-27T00:00:00Z - **Start Date**: 2026-07-27T00:00:00Z
- **Current Stage**: CONSTRUCTION - Round 2, NFR Design complete for U3 + U4 (awaiting approval) - **Current Stage**: CONSTRUCTION - Code Generation complete for Round 2 (U3 + U4)
- **Branch**: feature/gitea-deployment-workflow - **Branch**: feature/gitea-deployment-workflow
## Workspace State ## Workspace State
@@ -69,7 +69,7 @@ Execution rounds (Q4 = B): **R1** = U1 + U2 · **R2** = U3 + U4 · **R3** = U5 +
- [ ] NFR Requirements — **SKIP (all units)** — already comprehensively captured in `requirements.md` § 5 and § 6 - [ ] NFR Requirements — **SKIP (all units)** — already comprehensively captured in `requirements.md` § 5 and § 6
- [x] NFR Design — **EXECUTE for U3, U4**; SKIP for the rest. *Deliberate deviation from the default NFR-Requirements/NFR-Design coupling — rationale in the execution plan.* **U3 ✅ U4 ✅** 2026-07-28 — 11 patterns for U3, 10 for U4. Closed OPEN-01; raised REF-U3-01 - [x] NFR Design — **EXECUTE for U3, U4**; SKIP for the rest. *Deliberate deviation from the default NFR-Requirements/NFR-Design coupling — rationale in the execution plan.* **U3 ✅ U4 ✅** 2026-07-28 — 11 patterns for U3, 10 for U4. Closed OPEN-01; raised REF-U3-01
- [ ] Infrastructure Design — **EXECUTE for U6, U7**; SKIP for the rest - [ ] Infrastructure Design — **EXECUTE for U6, U7**; SKIP for the rest
- [~] Code Generation — **EXECUTE** (all 7 units, each built and tested before its completion message). **U1 ✅ U2 ✅** generated and verified 2026-07-27 — build 0 errors, 253 backend tests pass (was 219) - [~] Code Generation — **EXECUTE** (all 7 units, each built and tested before its completion message). **U1 ✅ U2 ✅** 2026-07-27 (253 backend tests). **U3 ✅** 2026-07-28 (315). **U4 ✅** 2026-07-28 (366 backend + 237 frontend)
- [ ] Build and Test — **EXECUTE** - [ ] Build and Test — **EXECUTE**
### OPERATIONS ### OPERATIONS
@@ -84,9 +84,9 @@ Execution rounds (Q4 = B): **R1** = U1 + U2 · **R2** = U3 + U4 · **R3** = U5 +
## Current Status ## Current Status
- **Lifecycle Phase**: CONSTRUCTION - **Lifecycle Phase**: CONSTRUCTION
- **Current Stage**: Round 2 — NFR Design complete for U3 Security Headers & CSP and U4 Observability - **Current Stage**: Round 2 complete U3 Security Headers & CSP and U4 Observability generated, built and tested
- **Next Stage**: Code Generation for U3 + U4 - **Next Stage**: Round 3 — U5 CI Workflow & Gates + U6 Deploy Workflow (U6 needs Infrastructure Design first)
- **Status**: Awaiting NFR Design approval. Round 1 (U1 + U2) code approved and committed 2026-07-28 - **Status**: Awaiting Round 2 code approval. Rounds 1 and 2 committed, nothing pushed
## Round 2 Design Record (2026-07-28) ## Round 2 Design Record (2026-07-28)
- Functional Design U3 + U4 complete and committed (`357d395`) - Functional Design U3 + U4 complete and committed (`357d395`)
@@ -105,3 +105,14 @@ Execution rounds (Q4 = B): **R1** = U1 + U2 · **R2** = U3 + U4 · **R3** = U5 +
- Embedded placeholder resource name verified against the compiled assembly manifest - Embedded placeholder resource name verified against the compiled assembly manifest
- **Carried to phase-level Build and Test**: composed-startup behaviour that needs a running host and a real database — `/admin` trailing-slash redirect, 404-vs-HTML for missing assets, SPA fallback and placeholder resolution, `/health` while availability-disabled, `MigrateCoreDatabase` against SQL Server, and both hosts starting - **Carried to phase-level Build and Test**: composed-startup behaviour that needs a running host and a real database — `/admin` trailing-slash redirect, 404-vs-HTML for missing assets, SPA fallback and placeholder resolution, `/health` while availability-disabled, `MigrateCoreDatabase` against SQL Server, and both hosts starting
- **Deviation**: U1 plan Step 11 (`StaticContentTests`) not implemented — the code lives in `SlpModularCms.Api`, which has no test project by convention; behaviour carried to Build and Test instead. Recorded in the unit's `generation-summary.md` - **Deviation**: U1 plan Step 11 (`StaticContentTests`) not implemented — the code lives in `SlpModularCms.Api`, which has no test project by convention; behaviour carried to Build and Test instead. Recorded in the unit's `generation-summary.md`
## Round 2 Verification Record (2026-07-28)
- `dotnet build SlpModularCms.sln -c Release` — 0 errors (70 warnings, all pre-existing package advisories)
- Backend tests — **366 passed, 0 failed** (Core 196, Availability 82, Master 51, Identity 37); was 253 after Round 1
- Frontend tests — **237 passed, 0 failed**; baseline 213
- `npx tsc -b` clean; eslint on every changed frontend file reports 0 problems; full `pnpm run lint` unchanged at the pre-existing 5 errors / 1 warning (FR-21, U5)
- `Sentry.AspNetCore` 6.8.0 ships a native **`net10.0`** asset — the carried-forward compatibility question is closed
- `@sentry/react` 10.68.0; `pnpm-lock.yaml` diff is additions only
- **Two findings**: `z.string().url()` accepts `htp://` in Zod 4 (URL constructor accepts any scheme), so the pre-existing frontend validation never caught the typo BR-U4-24 names — now `z.url({ protocol: /^https?$/ })`. And `appsettings.json` comments are verified by `DeployedConfigurationTests` against the real provider rather than assumed, because the failure mode is both hosts refusing to start
- **One deviation**: `IAdminTokenValidator` collapsed to a single `Validate``AdminTokenResult` instead of adding an overload. Two methods with an invisible difference at the call site let a substitute silently invert the access decision while both compiled — see U4's `generation-summary.md`
- **Carried to phase-level Build and Test**: trace-ID propagation master → slave, `TraceId` present in rendered console output, tunnel status codes, the `security_event` tag on a real Sentry event, threshold behaviour end to end, and CSP/HSTS header presence on real static assets and error responses
@@ -877,3 +877,73 @@ Each is one line; all three are listed at the end of U4's pattern document for r
**No blocking security findings. No new deviation.** **No blocking security findings. No new deviation.**
--- ---
## 2026-07-28 — CONSTRUCTION: Code Generation (Round 2, U3 + U4)
**Stage**: Code Generation for U3 HTTP Security Headers & CSP and U4 Observability Integration. Each unit was built and tested before moving on, per the standing instruction not to defer verification to the final stage.
### Verification
| | U3 | U4 |
|---|---|---|
| Release build | 0 errors | 0 errors |
| Backend tests | **315** passed (from 253) | **366** passed |
| Frontend tests | unchanged (213) | **237** passed |
| `tsc -b` | n/a | clean |
| eslint on changed files | n/a | 0 problems |
Full `pnpm run lint` remains at the pre-existing 5 errors / 1 warning — none in files this round touched. FR-21 fixes those in U5.
`Sentry.AspNetCore` 6.8.0 ships a native `net10.0` asset, closing the compatibility question NFR Design carried forward. `@sentry/react` 10.68.0; the lockfile diff is additions only.
### Two findings
**1. `z.string().url()` never caught the typo BR-U4-24 cites.** The rule says a malformed API base URL must not be silently accepted, and names `htp://localhost:7221`. Zod 4's `url()` validates by handing the value to the `URL` constructor, which accepts **any** scheme — verified directly: `htp://localhost:7221` and `ftp://x.nl` both pass a bare `.url()`. So the *pre-existing* frontend validation, before this unit touched it, would have accepted exactly the typo it was supposed to catch, and the SPA would have issued requests to a nonexistent origin — a failure that looks like the API being down. Now `z.url({ protocol: /^https?$/ })`. Not a regression introduced here; found because BR-U4-24 asked for a test the old schema could not have passed.
**2. Comments in `appsettings.json` are verified rather than assumed.** Several non-obvious values gained `//` comments. The JSON configuration provider does tolerate them, but the failure mode if it did not is *both hosts refusing to start after a release switch*, so `DeployedConfigurationTests` now loads both real, committed files through the real provider — and runs `ValidateOnStart` against the committed `SecurityHeaders` section, so a policy-name typo fails in CI rather than in a deployment.
### One deviation, and what it revealed
**`IAdminTokenValidator` collapsed to a single method.** NFR Design specified adding a reason-reporting overload alongside the existing `IsVerifiedAdmin(string?)`. Implemented that way, two existing tests failed in a revealing manner: the middleware called the new overload while the tests stubbed the old one, and an `NSubstitute` substitute returns `false` by default — so **the access decision silently inverted** while both methods existed and compiled.
That is the shape of the defect, not merely of the test failure. Two methods whose difference is invisible at a call site means a caller using the boolean form gets the correct access decision and silently emits no security event — precisely the class of bug this unit exists to make impossible. Replaced with one `Validate(string?) → AdminTokenResult`. Five call sites and two test files updated; behaviour otherwise identical.
Also noted: `MigrationFailure` uses synchronous `SentrySdk.Flush`, because `MigrateCoreDatabase` is synchronous and making it async would change a U2 signature and both hosts' startup for no benefit.
### Traps that were closed rather than encountered
- **`DefaultHttpContext.Response.OnStarting` is a no-op.** All of U3's decision logic went into a static `SecurityHeaderWriter` tested against a bare `HeaderDictionary`, so the middleware is glue whose only risk is registration order — which is carried to Build and Test rather than pretended to be unit-testable.
- **Sentry groups log events by message template.** Six source-generated `LoggerMessage` methods with constant templates and `EventId` 50015006, plus a test asserting the rendered message is *identical* across different argument values. Without that, FR-19's rate-based alert rules could never fire while everything appeared to work.
- **`StartsWithSegments`, not `string.StartsWith`.** `"/administrator".StartsWith("/admin")` is true; a public page would have inherited the strict policy and lost its inline scripts with no server-side trace at all. Asserted for `/administrator`, `/admin-tools`, `/administration/contact`, `/healthcheck` and `/api/v10/Users`.
### Additions beyond the functional design, each implemented
- `Set-Cookie` in the scrub list — the login response issues the `refreshToken` there, so scrubbing only the request cookie would protect nothing
- `SetBeforeSendTransaction` alongside `SetBeforeSend`
- `OnRejected` on the rate limiter — a `429` previously left no trace anywhere, making the only rate limiter in the application unobservable
- A `sentry-tunnel` fixed-window limiter — the other controls bound what each call can do, not how many calls there can be
- `SentrySdk.Flush` before the migration-failure rethrow
- Origin-format validation on the CSP origin lists — a CSP source list silently ignores a malformed source, so a URL with a path would look configured and block the script anyway
- `SecurityAuthorizationResultHandler` at `IAuthorizationMiddlewareResultHandler` rather than inside an `IAuthorizationHandler`, because a handler sees one requirement at a time and would report denials for requests that were ultimately allowed
### Divergences from the reference project, all deliberate
- The dev tunnel proxy targets the local API rather than Sentry's ingest host, so no project id is committed and nothing has to be kept in sync by hand
- `UmamiAnalytics` has no script-removing cleanup: under StrictMode the double-invocation becomes inject → remove → inject, and removing the element does not unregister Umami's listeners, so the first page view can be counted twice. Guarded by a test asserting one injection across re-renders
- `tanstackRouterBrowserTracingIntegration` not adopted — it needs the router instance, and importing it from `sentry.ts` inverts the startup order
### Artifacts generated
- `construction/u3-security-headers/code/generation-summary.md`
- `construction/u4-observability/code/generation-summary.md`
- 7 production files + 4 test files for U3; 10 backend and 3 frontend production files + 5 test files for U4
### Security Compliance (Security Baseline extension — enabled, blocking)
- **SECURITY-03 — compliant.** Correlation ID arrives by configuration rather than discipline; EF's `Database.Command` pinned at `Warning`, asserted against the committed files.
- **SECURITY-04 — compliant.** All five headers, one-year HSTS with `includeSubDomains`, a CSP on every HTML-serving path. `script-src 'self'` under Strict is asserted by a test, so loosening it requires deleting a test that explains why.
- **SECURITY-11 — compliant.** The tunnel destination is parsed once at startup and no part of it can come from a request.
- **SECURITY-14 — addressed; DEV-01 unchanged.** Six tagged event types, all `Warning` or above by construction.
- **SECURITY-15 — compliant.** Both units fail closed at startup and neither throws into the response path.
**No blocking security findings. No new deviation.**
---
@@ -0,0 +1,137 @@
# Code Generation Summary — U4 Observability Integration
**Generated**: 2026-07-28
**Verified**:
- `dotnet build SlpModularCms.sln -c Release` → **0 errors**
- `dotnet test`**366 passed, 0 failed** (315 after U3, so **+51**)
- `pnpm test`**237 passed, 0 failed** (baseline 213, so **+24**)
- `npx tsc -b` → clean
- `npx eslint` on all changed frontend files → **0 problems**; full `pnpm run lint` unchanged at the pre-existing 5 errors / 1 warning (FR-21, U5)
---
## Backend Files Created
| File | Purpose |
|---|---|
| `Core/Observability/BypassRejectionReason.cs` | Why an admin bypass was refused — never the token |
| `Core/Observability/SecurityEvents.cs` | `SecurityEventNames` + six source-generated `LoggerMessage` methods, `EventId` 50015006 |
| `Core/Hosting/Observability/ObservabilityOptions.cs` | `Observability` configuration section |
| `Core/Hosting/Observability/LoggingExtensions.cs` | `AddCmsLogging` — activity tracking, `IncludeScopes`, per-environment console |
| `Core/Hosting/Observability/SentryEventScrubber.cs` | `ISentryEventScrubber` — removes four credential headers and the body |
| `Core/Hosting/Observability/SecurityEventProcessor.cs` | Promotes the event name to a `security_event` Sentry tag |
| `Core/Hosting/Observability/SentryTunnelTarget.cs` | Envelope endpoint parsed from the DSN, once, at startup |
| `Core/Hosting/Observability/SentryExtensions.cs` | `AddCmsObservability` / `UseCmsSentry` |
| `Core/Hosting/Observability/SentryTunnelExtensions.cs` | The `/sentry-tunnel` endpoint |
| `Core/Hosting/Observability/SecurityAuthorizationResultHandler.cs` | Reports authorization denials, then defers to the framework |
## Frontend Files Created
| File | Purpose |
|---|---|
| `frontend/src/lib/sentry.ts` | `initSentry()` — skips without a DSN, tunnels same-origin |
| `frontend/src/components/SentryErrorBoundary.tsx` | Recoverable fallback with no exception text |
| `frontend/src/components/UmamiAnalytics.tsx` | Script injection, once, never in development |
## Files Modified
| File | Change |
|---|---|
| `Core/Hosting/ServiceCollectionExtensions.cs` | `OnRejected` on the rate limiter; `sentry-tunnel` limiter; registers the authorization result handler |
| `Core/Hosting/DatabaseMigrationExtensions.cs` (**U2**) | Emits `MigrationFailure`; `SentrySdk.Flush` before the rethrow |
| `Core/Hosting/Security/IAdminTokenValidator.cs` (**U1**) | Replaced by a single `Validate` returning `AdminTokenResult` — see deviations |
| `Core/Hosting/Security/AdminTokenValidator.cs` (**U1**) | Classifies the rejection cause from the exception type |
| `Core/Identity/Services/AuthService.cs` | `ILogger` dependency; emits `FailedLogin` |
| `Modules.Availability/Middleware/AvailabilityMiddleware.cs` | Emits `AdminBypassRejected` |
| `Modules.Availability/Controllers/MasterController.cs` | Emits `MasterApiKeyRejected` on all four rejection paths |
| `Modules.Master/Controllers/SlaveStatusController.cs` | Same |
| `Api/Program.cs`, `Api.Slave/Program.cs` | `AddCmsLogging``UseCmsSentry``AddCmsObservability``MapSentryTunnel` |
| `Api/appsettings.json`, `Api.Slave/appsettings.json` | `Observability` section; `Logging` raised to `Information`; EF command logging pinned; `RateLimiting:SentryTunnel` |
| `frontend/src/lib/config.ts` | Four new fields; `apiBaseUrl` accepts empty **or** an http(s) URL |
| `frontend/src/main.tsx` | `initSentry()` first; boundary inside `AuthProvider`; `UmamiAnalytics` |
| `frontend/vite.config.ts` | `__APP_VERSION__` define; dev proxy for `/sentry-tunnel` → local API |
| `frontend/src/vite-env.d.ts` | Five `VITE_` variables and `__APP_VERSION__` |
| `frontend/src/i18n/locales/{nl,en}/translation.json` | `error.unexpected.title` / `.message` |
| `frontend/package.json` | `@sentry/react` `10.68.0` |
| `Core.Tests/*.csproj` | Links both hosts' real `appsettings.json` into the test output |
## Test Files Created
| File | Tests | Covers |
|---|---|---|
| `Core.Tests/Hosting/Observability/SentryEventScrubberTests.cs` | 12 | Four headers removed, body nulled, diagnostic fields retained, transactions scrubbed |
| `Core.Tests/Hosting/Observability/SentryTunnelTargetTests.cs` | 8 | Endpoint derivation, no-DSN state, unparseable DSN fails, path outside `/api` |
| `Core.Tests/Hosting/Observability/SecurityEventsTests.cs` | 10 | **Identical rendered message across argument values**, levels, distinct IDs, tagging |
| `Core.Tests/Hosting/Security/AdminTokenRejectionReasonTests.cs` | 11 | Each rejection reason from a real token |
| `Core.Tests/Hosting/DeployedConfigurationTests.cs` | 13 | The committed `appsettings.json` of both hosts |
| `frontend/src/lib/sentry.test.ts` | 5 | Skip without a DSN, tunnel not an ingest URL, tags, PII off |
| `frontend/src/lib/config.test.ts` | 7 | Same-origin resolution, explicit URL preserved, malformed rejected |
| `frontend/src/components/UmamiAnalytics.test.tsx` | 6 | Never in dev, nothing without an ID, injected once, nothing rendered |
| `frontend/src/components/SentryErrorBoundary.test.tsx` | 5 | Fallback shown, **no exception text**, retry remounts, works without a DSN |
| `frontend/src/lib/api-client.test.ts` (extended) | +1 | Relative URL construction with an empty base |
---
## Two Findings Worth Reading
### 1. `z.string().url()` never caught the typo its own rule cites
BR-U4-24 says a malformed value must not be silently accepted, and names `htp://localhost:7221` as the case. **Zod 4's `url()` validates by handing the value to the `URL` constructor, which accepts any scheme** — verified directly:
```
z.string().url().safeParse('htp://localhost:7221') → success: true
z.string().url().safeParse('ftp://x.nl') → success: true
```
So the *pre-existing* validation, before this unit touched it, would have accepted the exact typo the rule exists to catch. The schema is now `z.url({ protocol: /^https?$/ })`, which rejects both. This was not a regression introduced here; it was found because BR-U4-24 asked for a test that the old schema could not have passed.
### 2. Comments in `appsettings.json` are now verified, not assumed
Several non-obvious values gained `//` comments. The JSON configuration provider tolerates them — but "tolerates" was worth verifying rather than assuming, because the failure mode is *both hosts refusing to start after a release switch*. `DeployedConfigurationTests` loads both real files through the real provider, and also runs `ValidateOnStart` against the committed `SecurityHeaders` section, so a policy-name typo fails here rather than in a deployment.
---
## Deviations from the NFR Design
**`IAdminTokenValidator` ended up with one method, not two.** The design specified adding an overload returning the reason alongside the existing boolean. Implementing it that way immediately broke two existing tests in a revealing way: the middleware called the new overload, the tests stubbed the old one, and an `NSubstitute` substitute returns `false` by default — so the *access decision silently inverted* while both methods still existed and compiled.
That is the shape of the defect, not just of the test failure: two methods where the difference is invisible at the call site, and a caller using the boolean form gets the right decision and silently emits no security event. Replaced with a single `Validate(string?) → AdminTokenResult` record. Five call sites and two test files updated; behaviour otherwise identical.
**`Set-Cookie` was already in the NFR design's scrub list** and is implemented; noted here because the functional design named only `Cookie`.
**`MigrationFailure` uses `SentrySdk.Flush`, not `FlushAsync`.** `MigrateCoreDatabase` is synchronous, and making it async would change a U2 signature and both hosts' startup for no benefit.
---
## Business Rule Coverage
| Rule | Where | Test |
|---|---|---|
| BR-U4-01, BR-U4-02 console always active at `Information` | `AddCmsLogging`, `appsettings.json` | `HostConfiguration_ShouldAllowInformationLevelLogging` |
| BR-U4-03 correlation ID on every entry | `ActivityTrackingOptions` + `IncludeScopes` | Carried to Build and Test |
| BR-U4-04 logging before Sentry | `Program.cs` order | Carried to Build and Test |
| BR-U4-05 no secrets in logs | EF category pinned; event templates | `HostConfiguration_ShouldPinEfCommandLoggingBelowInformation` |
| BR-U4-06, BR-U4-07 threshold split | `MinimumEventLevel` / `MinimumBreadcrumbLevel` | Carried to Build and Test |
| BR-U4-08 absent DSN supported | `UseCmsSentry` early return | `HostConfiguration_ShouldShipWithoutASentryDsn` |
| BR-U4-09 never blocks a request | SDK is fire-and-forget; tunnel returns `202` | Carried to Build and Test |
| BR-U4-10, BR-U4-11 environment and release tags | `UseCmsSentry` | `sentry.test.ts` (frontend side) |
| BR-U4-12…14 scrubbing in-process | `SentryEventScrubber` | 12 scrubber tests |
| BR-U4-15…21 tunnel | `SentryTunnelExtensions`, `SentryTunnelTarget` | 8 target tests; endpoint carried to Build and Test |
| BR-U4-22…25 frontend configuration | `config.ts` | 7 config tests + the api-client test |
| BR-U4-26 frontend Sentry skipped | `initSentry` | `sentry.test.ts` |
| BR-U4-27…29 Umami | `UmamiAnalytics` | 6 component tests |
| Six security events | `SecurityEvents` + emission sites | 10 event tests |
---
## Carried to Phase-Level Build and Test
| Behaviour | Why it needs a running host |
|---|---|
| **Trace ID propagates master → slave** | The entire justification for resolving OPEN-01 as the W3C trace ID. Needs both hosts and a real master/slave call |
| `TraceId` actually appears in rendered console output | `ActivityTrackingOptions` populates the scope; `IncludeScopes` renders it. Set one and forget the other and every line looks normal with no correlation ID and no error |
| Tunnel: `404` without a DSN, `413` oversized, `202` on upstream failure, `503` when availability-disabled | Needs the endpoint in a real pipeline |
| `security_event` tag present on a real Sentry event | The processor reads `Extra["SecurityEvent"]`; that the SDK populates it from the log state is verified against a live event rather than assumed |
| Sentry event and breadcrumb thresholds observed end to end | Needs a DSN and a real send |
| `Observability__SentryDsn` environment variable overrides the empty committed value | Confirms the D-16 secret path works |
| Both hosts start with all new sections | Partly pre-empted by `DeployedConfigurationTests` |
+1
View File
@@ -23,6 +23,7 @@
"@radix-ui/react-label": "^2.1.10", "@radix-ui/react-label": "^2.1.10",
"@radix-ui/react-select": "^2.3.1", "@radix-ui/react-select": "^2.3.1",
"@radix-ui/react-slot": "^1.3.0", "@radix-ui/react-slot": "^1.3.0",
"@sentry/react": "^10.68.0",
"@tanstack/react-query": "^5.101.0", "@tanstack/react-query": "^5.101.0",
"@tanstack/react-router": "^1.170.16", "@tanstack/react-router": "^1.170.16",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
+78
View File
@@ -26,6 +26,9 @@ importers:
'@radix-ui/react-slot': '@radix-ui/react-slot':
specifier: ^1.3.0 specifier: ^1.3.0
version: 1.3.0(@types/react@19.2.17)(react@19.2.7) version: 1.3.0(@types/react@19.2.17)(react@19.2.7)
'@sentry/react':
specifier: ^10.68.0
version: 10.68.0(react@19.2.7)
'@tanstack/react-query': '@tanstack/react-query':
specifier: ^5.101.0 specifier: ^5.101.0
version: 5.101.0(react@19.2.7) version: 5.101.0(react@19.2.7)
@@ -875,6 +878,40 @@ packages:
'@rolldown/pluginutils@1.0.1': '@rolldown/pluginutils@1.0.1':
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
'@sentry/browser-utils@10.68.0':
resolution: {integrity: sha512-be8VtdjCngKc77cstJeV+gO15iH+blyXpBDk8yOehmtX4BkFO33mfTMNCWVR2LA0oOxjIHWRAhf77fIUEhzxPg==}
engines: {node: '>=18'}
'@sentry/browser@10.68.0':
resolution: {integrity: sha512-8xVgk7oG2lajXnbXF6a7H1xMZ/U6icqSldHGzQu1+bajfrK8Gan9ULG/Xsj1VM1LlNeK6/7znDJ3u1jgvIwznw==}
engines: {node: '>=18'}
'@sentry/conventions@0.16.0':
resolution: {integrity: sha512-fO9PLmHdVURcSPUpWCItWAtgKiMwGdJHbovoSEyLplX5sxs2ugvI4CBPTrkkgqhObnZOD0CnWBKDzSVQYBKEyQ==}
engines: {node: '>=14'}
'@sentry/core@10.68.0':
resolution: {integrity: sha512-5Amhx8ltVz7vb1bRGyf3c4J69/iHW8R/H+SJxTRILHlsSOBrnVVc/IQEYDC6PTRdRdZ3x2u7RVjxZi2Mhe525g==}
engines: {node: '>=18'}
'@sentry/feedback@10.68.0':
resolution: {integrity: sha512-XbdcXiBnpC3vgw46eHOPeD/ZQ+XzluP75ubdUcaPDW02hCh2nsdXiwjZ2DBImbpvIpTbJgjHf/sIlHWvcZJ2Mg==}
engines: {node: '>=18'}
'@sentry/react@10.68.0':
resolution: {integrity: sha512-rIq4QR4ScMHHx9JJZv7Jgw31bMdUVJMx+ykHIJb7htjY6mj78sjKs+KpCsMDnvJxDhSmvftGM1KfKD4BggL7OQ==}
engines: {node: '>=18'}
peerDependencies:
react: ^16.14.0 || 17.x || 18.x || 19.x
'@sentry/replay-canvas@10.68.0':
resolution: {integrity: sha512-HusYcr+He+ohnUDHunYrc5St6vdDnBXpUAndnT5ReyUMVSCiWKfY3paXowU/0787HwYfxdcpZgwC5u79+XbEIg==}
engines: {node: '>=18'}
'@sentry/replay@10.68.0':
resolution: {integrity: sha512-ZoG2n16vbkx4GWSCnLIqUUN9xlUmccQFbQ2US2rhruQeHTUnHl/ukr8NHOQXZaEbwKyMkX9bEMwfmZHJm+wSTQ==}
engines: {node: '>=18'}
'@standard-schema/spec@1.1.0': '@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
@@ -2997,6 +3034,47 @@ snapshots:
'@rolldown/pluginutils@1.0.1': {} '@rolldown/pluginutils@1.0.1': {}
'@sentry/browser-utils@10.68.0':
dependencies:
'@sentry/conventions': 0.16.0
'@sentry/core': 10.68.0
'@sentry/browser@10.68.0':
dependencies:
'@sentry/browser-utils': 10.68.0
'@sentry/conventions': 0.16.0
'@sentry/core': 10.68.0
'@sentry/feedback': 10.68.0
'@sentry/replay': 10.68.0
'@sentry/replay-canvas': 10.68.0
'@sentry/conventions@0.16.0': {}
'@sentry/core@10.68.0':
dependencies:
'@sentry/conventions': 0.16.0
'@sentry/feedback@10.68.0':
dependencies:
'@sentry/core': 10.68.0
'@sentry/react@10.68.0(react@19.2.7)':
dependencies:
'@sentry/browser': 10.68.0
'@sentry/conventions': 0.16.0
'@sentry/core': 10.68.0
react: 19.2.7
'@sentry/replay-canvas@10.68.0':
dependencies:
'@sentry/core': 10.68.0
'@sentry/replay': 10.68.0
'@sentry/replay@10.68.0':
dependencies:
'@sentry/browser-utils': 10.68.0
'@sentry/core': 10.68.0
'@standard-schema/spec@1.1.0': {} '@standard-schema/spec@1.1.0': {}
'@standard-schema/utils@0.3.0': {} '@standard-schema/utils@0.3.0': {}
@@ -0,0 +1,90 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SentryErrorBoundary } from '@/components/SentryErrorBoundary';
function Boom(): never {
throw new Error('Internal detail: connection string Server=db;Password=hunter2');
}
describe('SentryErrorBoundary', () => {
beforeEach(() => {
// React logs caught render errors to the console; silence the expected noise.
vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
it('shows a fallback instead of blanking the screen', () => {
render(
<SentryErrorBoundary>
<Boom />
</SentryErrorBoundary>,
);
expect(screen.getByTestId('error-boundary-fallback')).toBeInTheDocument();
expect(screen.getByRole('alert')).toBeInTheDocument();
});
/**
* Exception text frequently contains internal detail, so showing it to an operator is both
* unhelpful and a small information leak. This asserts the leak cannot reappear.
*/
it('shows no exception message and no stack trace', () => {
render(
<SentryErrorBoundary>
<Boom />
</SentryErrorBoundary>,
);
const fallback = screen.getByTestId('error-boundary-fallback');
expect(fallback.textContent).not.toContain('Internal detail');
expect(fallback.textContent).not.toContain('hunter2');
expect(fallback.textContent).not.toContain('Server=');
});
it('offers a retry that remounts the subtree', async () => {
let shouldThrow = true;
function Flaky() {
if (shouldThrow) {
throw new Error('transient');
}
return <p data-testid="recovered">recovered</p>;
}
render(
<SentryErrorBoundary>
<Flaky />
</SentryErrorBoundary>,
);
expect(screen.getByTestId('error-boundary-fallback')).toBeInTheDocument();
shouldThrow = false;
await userEvent.click(screen.getByTestId('error-boundary-retry-button'));
expect(screen.getByTestId('recovered')).toBeInTheDocument();
});
it('renders its children untouched when nothing throws', () => {
render(
<SentryErrorBoundary>
<p data-testid="child">fine</p>
</SentryErrorBoundary>,
);
expect(screen.getByTestId('child')).toBeInTheDocument();
expect(screen.queryByTestId('error-boundary-fallback')).not.toBeInTheDocument();
});
/**
* No DSN is configured in the test environment, so this run also covers the without-Sentry
* case: the boundary still catches and still shows the fallback, it simply reports nothing.
*/
it('works without a Sentry DSN configured', () => {
expect(import.meta.env.VITE_SENTRY_DSN).toBeUndefined();
});
});
@@ -0,0 +1,59 @@
import type { ReactNode } from 'react';
import * as Sentry from '@sentry/react';
import { AlertTriangle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
interface SentryErrorBoundaryProps {
children: ReactNode;
}
interface FallbackProps {
resetError: () => void;
}
/**
* Shown when a render error was caught.
*
* Deliberately shows no exception message and no stack trace: exception text frequently contains
* internal detail, and showing it to an operator is both unhelpful and a small information leak.
*/
function ErrorFallback({ resetError }: FallbackProps) {
const { t } = useTranslation();
return (
<div
className="flex flex-col items-center justify-center py-16 text-center space-y-4"
role="alert"
data-testid="error-boundary-fallback"
>
<AlertTriangle className="size-12 text-destructive" />
<h1 className="text-2xl font-semibold">{t('error.unexpected.title')}</h1>
<p className="text-muted-foreground max-w-sm">{t('error.unexpected.message')}</p>
<Button onClick={resetError} data-testid="error-boundary-retry-button">
{t('common.retry')}
</Button>
</div>
);
}
/**
* Catches render-time React errors that would otherwise blank the screen, reports them, and shows
* a recoverable fallback.
*
* Placed inside AuthProvider rather than outermost: the fallback has to be reachable for a
* logged-in user, and an error inside a page must not tear down the session context otherwise
* recovering from a render error would also log the user out.
*
* Works without a DSN too: it still catches and still shows the fallback, it simply reports
* nothing.
*/
export function SentryErrorBoundary({ children }: SentryErrorBoundaryProps) {
return (
<Sentry.ErrorBoundary
fallback={({ resetError }) => <ErrorFallback resetError={resetError} />}
>
{children}
</Sentry.ErrorBoundary>
);
}
@@ -0,0 +1,95 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { render } from '@testing-library/react';
import { UmamiAnalytics } from '@/components/UmamiAnalytics';
import { resetAppConfigCache } from '@/lib/config';
const SCRIPT_SELECTOR = '#umami-analytics-script';
function scripts(): NodeListOf<HTMLScriptElement> {
return document.head.querySelectorAll<HTMLScriptElement>(SCRIPT_SELECTOR);
}
describe('UmamiAnalytics', () => {
beforeEach(() => {
resetAppConfigCache();
document.head.querySelectorAll(SCRIPT_SELECTOR).forEach((node) => node.remove());
});
afterEach(() => {
vi.unstubAllEnvs();
resetAppConfigCache();
});
function configure(): void {
vi.stubEnv('VITE_UMAMI_SCRIPT_URL', 'https://analytics.example.com/script.js');
vi.stubEnv('VITE_UMAMI_WEBSITE_ID', 'site-id');
}
it('injects nothing in development, even when fully configured', () => {
vi.stubEnv('DEV', true);
configure();
render(<UmamiAnalytics />);
expect(scripts()).toHaveLength(0);
});
it('injects nothing when the website ID is missing', () => {
vi.stubEnv('DEV', false);
vi.stubEnv('VITE_UMAMI_SCRIPT_URL', 'https://analytics.example.com/script.js');
vi.stubEnv('VITE_UMAMI_WEBSITE_ID', undefined as unknown as string);
render(<UmamiAnalytics />);
expect(scripts()).toHaveLength(0);
});
it('injects nothing when the script URL is missing', () => {
vi.stubEnv('DEV', false);
vi.stubEnv('VITE_UMAMI_SCRIPT_URL', undefined as unknown as string);
vi.stubEnv('VITE_UMAMI_WEBSITE_ID', 'site-id');
render(<UmamiAnalytics />);
expect(scripts()).toHaveLength(0);
});
it('injects the script once when configured outside development', () => {
vi.stubEnv('DEV', false);
configure();
render(<UmamiAnalytics />);
const injected = scripts();
expect(injected).toHaveLength(1);
expect(injected[0].src).toBe('https://analytics.example.com/script.js');
expect(injected[0].getAttribute('data-website-id')).toBe('site-id');
expect(injected[0].defer).toBe(true);
});
/**
* Guards against reinstating a cleanup that removes the script: under StrictMode the
* double-invocation would become inject -> remove -> inject, and removing the element does not
* unregister the listeners Umami already installed, so the first page view can be counted
* twice.
*/
it('injects the script only once across re-renders', () => {
vi.stubEnv('DEV', false);
configure();
const { rerender } = render(<UmamiAnalytics />);
rerender(<UmamiAnalytics />);
render(<UmamiAnalytics />);
expect(scripts()).toHaveLength(1);
});
it('renders nothing visible', () => {
vi.stubEnv('DEV', false);
configure();
const { container } = render(<UmamiAnalytics />);
expect(container.firstChild).toBeNull();
});
});
@@ -0,0 +1,47 @@
import { useEffect } from 'react';
import { getAppConfig } from '@/lib/config';
const SCRIPT_ELEMENT_ID = 'umami-analytics-script';
/**
* Injects the self-hosted Umami tracking script when both the script URL and the website ID are
* configured at build time. Renders nothing.
*
* A component rather than a tag in index.html, because the website ID is a build-time variable
* and index.html cannot read import.meta.env. It also makes the "never in development" and "once
* only" rules testable.
*
* `Do Not Track` is deliberately not consulted: Umami sets no cookies and collects no personal
* data, and this SPA's audience is a known set of operators, so honouring DNT would reduce data
* without protecting anyone. A conscious choice rather than an omission.
*/
export function UmamiAnalytics() {
useEffect(() => {
const { umamiScriptUrl, umamiWebsiteId } = getAppConfig();
// Never in local development, regardless of configuration, so local testing does not
// pollute the real visitor analytics.
if (import.meta.env.DEV || !umamiScriptUrl || !umamiWebsiteId) {
return;
}
if (document.getElementById(SCRIPT_ELEMENT_ID) !== null) {
return;
}
const script = document.createElement('script');
script.id = SCRIPT_ELEMENT_ID;
script.src = umamiScriptUrl;
script.defer = true;
script.setAttribute('data-website-id', umamiWebsiteId);
document.head.appendChild(script);
// Deliberately no cleanup removing the script. Under StrictMode the double-invocation
// would become inject -> remove -> inject, and removing the element does not unregister
// the listeners Umami already installed — so the first page view can be counted twice.
// This component lives for the application's lifetime and has nothing to clean up; the
// duplicate guard above handles re-invocation on its own.
}, []);
return null;
}
@@ -239,7 +239,11 @@
"title": "Page Not Found", "title": "Page Not Found",
"message": "The page you're looking for doesn't exist." "message": "The page you're looking for doesn't exist."
}, },
"backToDashboard": "Back to Dashboard" "backToDashboard": "Back to Dashboard",
"unexpected": {
"title": "Something went wrong",
"message": "This page could not be displayed. Try again, or go back to the dashboard."
}
}, },
"errors": { "errors": {
"network": "Unable to reach the server. Check your connection and try again.", "network": "Unable to reach the server. Check your connection and try again.",
@@ -239,7 +239,11 @@
"title": "Pagina niet gevonden", "title": "Pagina niet gevonden",
"message": "De pagina die je zoekt bestaat niet." "message": "De pagina die je zoekt bestaat niet."
}, },
"backToDashboard": "Terug naar dashboard" "backToDashboard": "Terug naar dashboard",
"unexpected": {
"title": "Er is iets misgegaan",
"message": "Deze pagina kon niet worden weergegeven. Probeer het opnieuw of ga terug naar het dashboard."
}
}, },
"errors": { "errors": {
"network": "Kan de server niet bereiken. Controleer je verbinding en probeer opnieuw.", "network": "Kan de server niet bereiken. Controleer je verbinding en probeer opnieuw.",
+27
View File
@@ -183,4 +183,31 @@ describe('ApiClient', () => {
const client = makeClient(); const client = makeClient();
await expect(client.get('/network-fail')).rejects.toThrow(NetworkError); await expect(client.get('/network-fail')).rejects.toThrow(NetworkError);
}); });
/**
* Same-origin is the production configuration, and the one nobody runs locally so it is
* only ever exercised here. Note the URL must come out as a plain relative path: any code
* building it with `new URL(path, base)` or an interpolated slash would break on an empty
* base, and would break only in production.
*/
describe('with an empty base URL (same-origin)', () => {
it('issues requests against relative paths', async () => {
let seenUrl: string | null = null;
server.use(
http.get('/api/v1/System/capabilities', ({ request }) => {
seenUrl = request.url;
return HttpResponse.json({ modules: [] });
}),
);
const client = new ApiClient('');
const result = await client.get<{ modules: string[] }>(
'/api/v1/System/capabilities',
);
expect(result).toEqual({ modules: [] });
expect(seenUrl).toContain('/api/v1/System/capabilities');
expect(seenUrl).not.toContain('//api/v1');
});
});
}); });
+83
View File
@@ -0,0 +1,83 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { getAppConfig, resetAppConfigCache } from '@/lib/config';
/**
* The empty-base case is the production configuration, and the one nobody runs locally so it is
* only ever exercised here. A malformed value must still be rejected: relaxing validation to
* permit "" is not the same as removing it.
*/
describe('getAppConfig', () => {
const originalEnv = { ...import.meta.env };
beforeEach(() => {
resetAppConfigCache();
});
afterEach(() => {
vi.unstubAllEnvs();
resetAppConfigCache();
Object.assign(import.meta.env, originalEnv);
});
it('resolves an absent API base URL to same-origin', () => {
vi.stubEnv('VITE_API_BASE_URL', undefined as unknown as string);
expect(getAppConfig().apiBaseUrl).toBe('');
});
it('resolves an empty API base URL to same-origin', () => {
vi.stubEnv('VITE_API_BASE_URL', '');
expect(getAppConfig().apiBaseUrl).toBe('');
});
it('keeps an explicit absolute URL, so local master/slave development is unaffected', () => {
vi.stubEnv('VITE_API_BASE_URL', 'https://localhost:7221');
expect(getAppConfig().apiBaseUrl).toBe('https://localhost:7221');
});
it('warns in development about a malformed API base URL rather than accepting it silently', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.stubEnv('VITE_API_BASE_URL', 'htp://localhost:7221');
getAppConfig();
expect(warn).toHaveBeenCalled();
warn.mockRestore();
});
it('does not warn about an empty API base URL', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.stubEnv('VITE_API_BASE_URL', '');
getAppConfig();
expect(warn).not.toHaveBeenCalled();
warn.mockRestore();
});
it('exposes the observability values', () => {
vi.stubEnv('VITE_SENTRY_DSN', 'https://abc@host.sentry.io/42');
vi.stubEnv('VITE_APP_ENV', 'test');
vi.stubEnv('VITE_UMAMI_SCRIPT_URL', 'https://analytics.example.com/script.js');
vi.stubEnv('VITE_UMAMI_WEBSITE_ID', 'site-id');
const config = getAppConfig();
expect(config.sentryDsn).toBe('https://abc@host.sentry.io/42');
expect(config.appEnv).toBe('test');
expect(config.umamiScriptUrl).toBe('https://analytics.example.com/script.js');
expect(config.umamiWebsiteId).toBe('site-id');
});
it('leaves the observability values undefined when nothing is configured', () => {
vi.stubEnv('VITE_SENTRY_DSN', undefined as unknown as string);
vi.stubEnv('VITE_UMAMI_WEBSITE_ID', undefined as unknown as string);
const config = getAppConfig();
expect(config.sentryDsn).toBeUndefined();
expect(config.umamiWebsiteId).toBeUndefined();
});
});
+28 -2
View File
@@ -6,8 +6,21 @@ import { z } from 'zod';
* once and only warns in development production trusts the build-time env. * once and only warns in development production trusts the build-time env.
*/ */
const configSchema = z.object({ const configSchema = z.object({
apiBaseUrl: z.string().url(), // Relaxed by exactly one case, not loosened: the empty string means same-origin, and
// everything else must still be a valid absolute http(s) URL. A typo such as
// 'htp://localhost:7221' has to stay a failure, or the SPA silently issues requests to a
// nonexistent origin — which looks exactly like the API being down.
//
// The protocol constraint is not decoration. Zod 4's url() validates by handing the value to
// the URL constructor, which happily accepts ANY scheme — 'htp://localhost:7221' and
// 'ftp://x.nl' both pass a bare .url(). The scheme typo this rule exists to catch was
// therefore never actually caught before this constraint was added.
apiBaseUrl: z.union([z.literal(''), z.url({ protocol: /^https?$/ })]),
appTitle: z.string(), appTitle: z.string(),
sentryDsn: z.string().optional(),
appEnv: z.string().optional(),
umamiScriptUrl: z.string().optional(),
umamiWebsiteId: z.string().optional(),
}); });
export type AppConfig = z.infer<typeof configSchema>; export type AppConfig = z.infer<typeof configSchema>;
@@ -20,8 +33,16 @@ export function getAppConfig(): AppConfig {
} }
const raw: AppConfig = { const raw: AppConfig = {
apiBaseUrl: import.meta.env.VITE_API_BASE_URL, // Absent or empty means same-origin: requests use relative paths, which is the intended
// production configuration now that the API and this SPA are served by one process. An
// explicit absolute URL is still honoured, so local development against
// https://localhost:7221 (master) and :7222 (slave) keeps working unchanged.
apiBaseUrl: import.meta.env.VITE_API_BASE_URL ?? '',
appTitle: import.meta.env.VITE_APP_TITLE ?? 'SlpModularCms', appTitle: import.meta.env.VITE_APP_TITLE ?? 'SlpModularCms',
sentryDsn: import.meta.env.VITE_SENTRY_DSN,
appEnv: import.meta.env.VITE_APP_ENV,
umamiScriptUrl: import.meta.env.VITE_UMAMI_SCRIPT_URL,
umamiWebsiteId: import.meta.env.VITE_UMAMI_WEBSITE_ID,
}; };
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
@@ -35,3 +56,8 @@ export function getAppConfig(): AppConfig {
cached = raw; cached = raw;
return cached; return cached;
} }
/** Test-only: clears the memoised configuration so a different env can be observed. */
export function resetAppConfigCache(): void {
cached = null;
}
+72
View File
@@ -0,0 +1,72 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import * as Sentry from '@sentry/react';
import { SENTRY_TUNNEL_PATH, initSentry } from '@/lib/sentry';
import { resetAppConfigCache } from '@/lib/config';
// ESM namespace objects are not configurable, so Sentry.init cannot be spied on in place.
vi.mock('@sentry/react', () => ({ init: vi.fn() }));
const init = vi.mocked(Sentry.init);
describe('initSentry', () => {
beforeEach(() => {
resetAppConfigCache();
init.mockClear();
});
afterEach(() => {
vi.unstubAllEnvs();
resetAppConfigCache();
});
it('does nothing without a DSN, so local development is unaffected', () => {
vi.stubEnv('VITE_SENTRY_DSN', undefined as unknown as string);
initSentry();
expect(init).not.toHaveBeenCalled();
});
it('does nothing when the DSN is an empty string', () => {
vi.stubEnv('VITE_SENTRY_DSN', '');
initSentry();
expect(init).not.toHaveBeenCalled();
});
/**
* The tunnel is the whole reason browser reporting survives an ad blocker: a direct ingest URL
* is blocked with ERR_BLOCKED_BY_CLIENT, losing errors precisely for the users who have one.
*/
it('routes through the same-origin tunnel rather than a Sentry ingest URL', () => {
vi.stubEnv('VITE_SENTRY_DSN', 'https://abc@o1.ingest.de.sentry.io/42');
initSentry();
expect(init).toHaveBeenCalledOnce();
const options = init.mock.calls[0][0]!;
expect(options.tunnel).toBe(SENTRY_TUNNEL_PATH);
expect(options.tunnel).not.toContain('sentry.io');
});
it('tags the environment and the release, and keeps PII off', () => {
vi.stubEnv('VITE_SENTRY_DSN', 'https://abc@o1.ingest.de.sentry.io/42');
vi.stubEnv('VITE_APP_ENV', 'test');
initSentry();
const options = init.mock.calls[0][0]!;
expect(options.environment).toBe('test');
expect(options.release).toBeTruthy();
// False on the frontend even though the backend enables it with scrubbing: the browser
// offers no equivalent in-process guarantee, and there is nothing here the backend cannot
// already report.
expect(options.sendDefaultPii).toBe(false);
});
it('uses the same tunnel path the API serves and the dev proxy mirrors', () => {
expect(SENTRY_TUNNEL_PATH).toBe('/sentry-tunnel');
expect(SENTRY_TUNNEL_PATH).not.toMatch(/^\/api\//);
});
});
+40
View File
@@ -0,0 +1,40 @@
import * as Sentry from '@sentry/react';
import { getAppConfig } from '@/lib/config';
/** Same-origin path served by the API (see SentryTunnelExtensions). */
export const SENTRY_TUNNEL_PATH = '/sentry-tunnel';
/**
* Initialises Sentry, or does nothing when no DSN is configured.
*
* Called first from main.tsx, before the query client and before render, so an error during
* startup is still captured.
*/
export function initSentry(): void {
const { sentryDsn, appEnv } = getAppConfig();
// Local development and any deployment without Sentry must work unchanged.
if (!sentryDsn) {
return;
}
Sentry.init({
dsn: sentryDsn,
environment: appEnv,
release: __APP_VERSION__,
// Sentry's free plan counts transactions against the same quota as errors, and this
// setup's value is in errors rather than performance traces.
tracesSampleRate: 0.1,
// False here even though the backend enables it with scrubbing: the backend can scrub
// in-process before transmission because it controls the send, and in the browser there
// is no equivalent guarantee. The frontend has nothing to add that the backend cannot
// already report, so there is no reason to accept the risk.
sendDefaultPii: false,
// Ad blockers block requests to *.ingest.sentry.io outright (ERR_BLOCKED_BY_CLIENT),
// because they look like third-party tracking. Without this, errors are lost precisely
// for the users who have an ad blocker — a silently biased sample of exactly the group
// most likely to have browser oddities. The API forwards the envelope onward; the
// destination is derived from its own DSN, so nothing here needs a project id.
tunnel: SENTRY_TUNNEL_PATH,
});
}
+16 -1
View File
@@ -9,6 +9,13 @@ import { useAuth } from '@/contexts/auth-context';
import { Toaster } from '@/components/ui/sonner'; import { Toaster } from '@/components/ui/sonner';
import { router } from '@/router'; import { router } from '@/router';
import { getAppConfig } from '@/lib/config'; import { getAppConfig } from '@/lib/config';
import { initSentry } from '@/lib/sentry';
import { SentryErrorBoundary } from '@/components/SentryErrorBoundary';
import { UmamiAnalytics } from '@/components/UmamiAnalytics';
// First, before the query client and before render, so an error during startup is still captured.
// A no-op when no DSN is configured.
initSentry();
document.title = getAppConfig().appTitle; document.title = getAppConfig().appTitle;
@@ -55,7 +62,15 @@ void enableMocking().then(() => {
<StrictMode> <StrictMode>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<AuthProvider> <AuthProvider>
<InnerApp /> {/*
* Inside AuthProvider, not outermost: the fallback has to be reachable for a
* logged-in user, and a render error inside a page must not tear down the
* session context otherwise recovering would also log the user out.
*/}
<SentryErrorBoundary>
<InnerApp />
<UmamiAnalytics />
</SentryErrorBoundary>
<Toaster /> <Toaster />
</AuthProvider> </AuthProvider>
</QueryClientProvider> </QueryClientProvider>
+17 -2
View File
@@ -1,15 +1,30 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
interface ImportMetaEnv { interface ImportMetaEnv {
/** Base URL of the SlpModularCms .NET API (e.g. http://localhost:5000). */ /**
readonly VITE_API_BASE_URL: string; * Base URL of the SlpModularCms .NET API. Absent or empty means same-origin, which is the
* production configuration: the API and this SPA are served by one process. Set explicitly
* for local development (e.g. https://localhost:7221 for master, :7222 for slave).
*/
readonly VITE_API_BASE_URL?: string;
/** Set to 'true' to run the MSW mock backend in the browser during dev. */ /** Set to 'true' to run the MSW mock backend in the browser during dev. */
readonly VITE_ENABLE_MSW?: string; readonly VITE_ENABLE_MSW?: string;
/** Browser tab title; lets local master/slave dev instances be told apart. Defaults to "SlpModularCms". */ /** Browser tab title; lets local master/slave dev instances be told apart. Defaults to "SlpModularCms". */
readonly VITE_APP_TITLE?: string; readonly VITE_APP_TITLE?: string;
/** Sentry DSN for client-side error reporting. Not a secret — it ships in the bundle. Absent means Sentry is skipped. */
readonly VITE_SENTRY_DSN?: string;
/** Build-time environment tag ('test' | 'production'). Both use `vite build`, so MODE alone cannot tell them apart. */
readonly VITE_APP_ENV?: string;
/** Self-hosted Umami tracking script URL, e.g. https://analytics.example.com/script.js */
readonly VITE_UMAMI_SCRIPT_URL?: string;
/** Umami website ID, created per environment in the Umami dashboard. */
readonly VITE_UMAMI_WEBSITE_ID?: string;
// Add future typed env flags here. // Add future typed env flags here.
} }
interface ImportMeta { interface ImportMeta {
readonly env: ImportMetaEnv; readonly env: ImportMetaEnv;
} }
/** App version at build time (from package.json), injected via vite.config.ts `define`. Used as the Sentry `release` tag. */
declare const __APP_VERSION__: string;
+24
View File
@@ -3,12 +3,17 @@ import path from 'node:path';
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite'; import tailwindcss from '@tailwindcss/vite';
import { version } from './package.json';
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig(({ command }) => ({ export default defineConfig(({ command }) => ({
// Production builds are deployed under /admin (see Program.cs); the dev server keeps serving from '/'. // Production builds are deployed under /admin (see Program.cs); the dev server keeps serving from '/'.
base: command === 'build' ? '/admin/' : '/', base: command === 'build' ? '/admin/' : '/',
plugins: [react(), tailwindcss()], plugins: [react(), tailwindcss()],
define: {
// Injected at build time, used as the Sentry `release` tag (see src/lib/sentry.ts).
__APP_VERSION__: JSON.stringify(version),
},
resolve: { resolve: {
alias: { alias: {
'@': path.resolve(__dirname, './src'), '@': path.resolve(__dirname, './src'),
@@ -16,6 +21,22 @@ export default defineConfig(({ command }) => ({
}, },
server: { server: {
port: 5173, port: 5173,
proxy: {
// Mirrors the tunnel path the API serves in test and production, so the same
// Sentry `tunnel` option also works during `pnpm dev` — where this SPA runs on
// 5173 and the API on 7221.
//
// Note this targets our own API rather than Sentry's ingest host directly. The
// reference project proxies straight to Sentry, which forces the DSN's project id
// into this file and requires keeping it in sync by hand. The API derives the
// destination from its own DSN, so there is nothing to synchronise and no project
// id in a committed file.
'/sentry-tunnel': {
target: 'https://localhost:7221',
changeOrigin: true,
secure: false,
},
},
}, },
test: { test: {
globals: true, globals: true,
@@ -33,6 +54,9 @@ export default defineConfig(({ command }) => ({
'src/mocks/**', 'src/mocks/**',
'src/main.tsx', 'src/main.tsx',
'src/vite-env.d.ts', 'src/vite-env.d.ts',
// Calls Sentry.init, which cannot run meaningfully under jsdom; its skip-without-
// a-DSN behaviour is covered by a test that asserts init was not called.
'src/lib/sentry.ts',
], ],
}, },
}, },
+11 -1
View File
@@ -1,5 +1,6 @@
using SlpModularCms.Core.Hosting; using SlpModularCms.Core.Hosting;
using SlpModularCms.Core.Hosting.Health; using SlpModularCms.Core.Hosting.Health;
using SlpModularCms.Core.Hosting.Observability;
using SlpModularCms.Core.Hosting.Security; using SlpModularCms.Core.Hosting.Security;
using Scalar.AspNetCore; using Scalar.AspNetCore;
@@ -8,6 +9,10 @@ var builder = WebApplication.CreateBuilder(args);
// Load local developer overrides // Load local developer overrides
builder.Configuration.AddJsonFile("appsettings.local.json", optional: true, reloadOnChange: true); builder.Configuration.AddJsonFile("appsettings.local.json", optional: true, reloadOnChange: true);
// Logging first, then Sentry — same order and same reasons as the master host.
builder.Logging.AddCmsLogging(builder.Environment);
builder.WebHost.UseCmsSentry(builder.Configuration);
// 1. Initialize Module Orchestrator // 1. Initialize Module Orchestrator
var loggerFactory = LoggerFactory.Create(lb => lb.AddConsole()); var loggerFactory = LoggerFactory.Create(lb => lb.AddConsole());
var orchestrator = new ModuleOrchestrator(loggerFactory.CreateLogger<ModuleOrchestrator>()); var orchestrator = new ModuleOrchestrator(loggerFactory.CreateLogger<ModuleOrchestrator>());
@@ -23,6 +28,7 @@ builder.Services.AddCmsHealthChecks();
// and it will be reached directly during diagnosis. There is no reason for it to be the one // and it will be reached directly during diagnosis. There is no reason for it to be the one
// host without nosniff and HSTS. The path rules that do not apply here simply never match. // host without nosniff and HSTS. The path rules that do not apply here simply never match.
builder.Services.AddCmsSecurityHeaders(builder.Configuration); builder.Services.AddCmsSecurityHeaders(builder.Configuration);
builder.Services.AddCmsObservability(builder.Configuration);
// Registered BEFORE module services — see the note in DataProtectionExtensions. // Registered BEFORE module services — see the note in DataProtectionExtensions.
builder.Services.AddCmsDataProtection(); builder.Services.AddCmsDataProtection();
@@ -79,4 +85,8 @@ app.MapControllers();
// API instance looks like, so it behaves like one in every other respect. // API instance looks like, so it behaves like one in every other respect.
app.MapCmsHealthChecks(); app.MapCmsHealthChecks();
// This host serves no SPA, so nothing here posts envelopes today. The endpoint is mapped anyway
// so both hosts behave identically and a slave that later serves an admin UI needs no change.
app.MapSentryTunnel();
app.Run(); app.Run();
+21 -3
View File
@@ -1,8 +1,11 @@
{ {
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Warning", "Default": "Information",
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Information",
// Pinned at Warning deliberately. At Information, EF prints every SQL statement INCLUDING
// parameter values, and the login path passes a normalised email address through it.
"Microsoft.EntityFrameworkCore.Database.Command": "Warning"
} }
}, },
"AllowedHosts": "*", "AllowedHosts": "*",
@@ -36,6 +39,10 @@
"Refresh": { "Refresh": {
"PermitLimit": 20, "PermitLimit": 20,
"WindowSeconds": 60 "WindowSeconds": 60
},
"SentryTunnel": {
"PermitLimit": 60,
"WindowSeconds": 60
} }
}, },
"SecurityHeaders": { "SecurityHeaders": {
@@ -47,5 +54,16 @@
], ],
"AllowedScriptOrigins": [], "AllowedScriptOrigins": [],
"AllowedConnectOrigins": [] "AllowedConnectOrigins": []
},
"Observability": {
// Supplied per environment as Observability__SentryDsn. Empty means Sentry is skipped
// entirely and console logging continues a normal, supported state, not an error.
"SentryDsn": "",
// Falls back to ASPNETCORE_ENVIRONMENT when empty.
"Environment": "",
// Sentry's free plan counts transactions against the same quota as errors, and this setup's
// value is in errors rather than performance traces.
"TracesSampleRate": 0.1,
"TunnelMaxPayloadBytes": 204800
} }
} }
+18
View File
@@ -1,6 +1,7 @@
using SlpModularCms.Api.Extensions; using SlpModularCms.Api.Extensions;
using SlpModularCms.Core.Hosting; using SlpModularCms.Core.Hosting;
using SlpModularCms.Core.Hosting.Health; using SlpModularCms.Core.Hosting.Health;
using SlpModularCms.Core.Hosting.Observability;
using SlpModularCms.Core.Hosting.Security; using SlpModularCms.Core.Hosting.Security;
using Scalar.AspNetCore; using Scalar.AspNetCore;
@@ -9,6 +10,15 @@ var builder = WebApplication.CreateBuilder(args);
// Load local developer overrides // Load local developer overrides
builder.Configuration.AddJsonFile("appsettings.local.json", optional: true, reloadOnChange: true); builder.Configuration.AddJsonFile("appsettings.local.json", optional: true, reloadOnChange: true);
// Logging FIRST, so a problem initialising Sentry below is itself logged. Puts the W3C trace id
// into the scope of every entry from every category — the correlation id that also travels to
// the slave via traceparent and appears as `traceId` in ProblemDetails responses.
builder.Logging.AddCmsLogging(builder.Environment);
// Then Sentry. Does nothing at all when no DSN is configured, which is a normal, fully
// supported state rather than an error.
builder.WebHost.UseCmsSentry(builder.Configuration);
// 1. Initialize Module Orchestrator // 1. Initialize Module Orchestrator
var loggerFactory = LoggerFactory.Create(lb => lb.AddConsole()); var loggerFactory = LoggerFactory.Create(lb => lb.AddConsole());
var orchestrator = new ModuleOrchestrator(loggerFactory.CreateLogger<ModuleOrchestrator>()); var orchestrator = new ModuleOrchestrator(loggerFactory.CreateLogger<ModuleOrchestrator>());
@@ -20,6 +30,7 @@ builder.Services.AddCmsCors(builder.Configuration);
builder.Services.AddCmsRateLimiting(builder.Configuration); builder.Services.AddCmsRateLimiting(builder.Configuration);
builder.Services.AddCmsHealthChecks(); builder.Services.AddCmsHealthChecks();
builder.Services.AddCmsSecurityHeaders(builder.Configuration); builder.Services.AddCmsSecurityHeaders(builder.Configuration);
builder.Services.AddCmsObservability(builder.Configuration);
// Registered BEFORE module services: modules must not configure Data Protection themselves, // Registered BEFORE module services: modules must not configure Data Protection themselves,
// because a later registration would override this persistent key store (see // because a later registration would override this persistent key store (see
@@ -93,6 +104,13 @@ app.MapControllers();
// (/api/v1/System/capabilities). Those are CMS domain state and must not be used for monitoring. // (/api/v1/System/capabilities). Those are CMS domain state and must not be used for monitoring.
app.MapCmsHealthChecks(); app.MapCmsHealthChecks();
// Forwards browser Sentry envelopes through this origin, because ad blockers block requests to
// Sentry domains outright. Mapped before the SPA catch-all below, and deliberately NOT on the
// availability gate's bypass list: if the instance is switched off, losing admin-SPA error
// reports is acceptable, and that is one fewer anonymous outbound-capable endpoint reachable on
// a disabled instance.
app.MapSentryTunnel();
// SPA fallbacks so client-side routes (e.g. /admin/dashboard) resolve to the right index.html // SPA fallbacks so client-side routes (e.g. /admin/dashboard) resolve to the right index.html
// instead of 404ing. The "nonfile" constraint keeps genuinely missing assets (e.g. /admin/assets/x.js) as 404s. // instead of 404ing. The "nonfile" constraint keeps genuinely missing assets (e.g. /admin/assets/x.js) as 404s.
app.MapCmsSpaFallbacks(); app.MapCmsSpaFallbacks();
+21 -3
View File
@@ -1,8 +1,11 @@
{ {
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Warning", "Default": "Information",
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Information",
// Pinned at Warning deliberately. At Information, EF prints every SQL statement INCLUDING
// parameter values, and the login path passes a normalised email address through it.
"Microsoft.EntityFrameworkCore.Database.Command": "Warning"
} }
}, },
"AllowedHosts": "*", "AllowedHosts": "*",
@@ -41,6 +44,10 @@
"Refresh": { "Refresh": {
"PermitLimit": 20, "PermitLimit": 20,
"WindowSeconds": 60 "WindowSeconds": 60
},
"SentryTunnel": {
"PermitLimit": 60,
"WindowSeconds": 60
} }
}, },
"SecurityHeaders": { "SecurityHeaders": {
@@ -53,5 +60,16 @@
], ],
"AllowedScriptOrigins": [], "AllowedScriptOrigins": [],
"AllowedConnectOrigins": [] "AllowedConnectOrigins": []
},
"Observability": {
// Supplied per environment as Observability__SentryDsn. Empty means Sentry is skipped
// entirely and console logging continues a normal, supported state, not an error.
"SentryDsn": "",
// Falls back to ASPNETCORE_ENVIRONMENT when empty.
"Environment": "",
// Sentry's free plan counts transactions against the same quota as errors, and this setup's
// value is in errors rather than performance traces.
"TracesSampleRate": 0.1,
"TunnelMaxPayloadBytes": 204800
} }
} }
@@ -1,4 +1,4 @@
using System.IdentityModel.Tokens.Jwt; using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims; using System.Security.Claims;
using System.Text; using System.Text;
using FluentAssertions; using FluentAssertions;
@@ -46,7 +46,7 @@ public class AdminTokenValidatorTests
{ {
var header = $"Bearer {CreateToken(role)}"; var header = $"Bearer {CreateToken(role)}";
_validator.IsVerifiedAdmin(header).Should().BeTrue(); _validator.Validate(header).IsVerifiedAdmin.Should().BeTrue();
} }
[Fact] [Fact]
@@ -54,7 +54,7 @@ public class AdminTokenValidatorTests
{ {
var header = $"Bearer {CreateToken("User")}"; var header = $"Bearer {CreateToken("User")}";
_validator.IsVerifiedAdmin(header).Should().BeFalse(); _validator.Validate(header).IsVerifiedAdmin.Should().BeFalse();
} }
[Fact] [Fact]
@@ -64,7 +64,7 @@ public class AdminTokenValidatorTests
// signed by us. Reading claims without validating would have accepted this. // signed by us. Reading claims without validating would have accepted this.
var forged = CreateUnsignedToken("Owner"); var forged = CreateUnsignedToken("Owner");
_validator.IsVerifiedAdmin($"Bearer {forged}").Should().BeFalse(); _validator.Validate($"Bearer {forged}").IsVerifiedAdmin.Should().BeFalse();
} }
[Fact] [Fact]
@@ -83,7 +83,7 @@ public class AdminTokenValidatorTests
var header = $"Bearer {new JwtSecurityTokenHandler().WriteToken(token)}"; var header = $"Bearer {new JwtSecurityTokenHandler().WriteToken(token)}";
_validator.IsVerifiedAdmin(header).Should().BeFalse(); _validator.Validate(header).IsVerifiedAdmin.Should().BeFalse();
} }
[Fact] [Fact]
@@ -91,7 +91,7 @@ public class AdminTokenValidatorTests
{ {
var header = $"Bearer {CreateToken("Owner", expiresIn: TimeSpan.FromMinutes(-5))}"; var header = $"Bearer {CreateToken("Owner", expiresIn: TimeSpan.FromMinutes(-5))}";
_validator.IsVerifiedAdmin(header).Should().BeFalse(); _validator.Validate(header).IsVerifiedAdmin.Should().BeFalse();
} }
[Fact] [Fact]
@@ -99,7 +99,7 @@ public class AdminTokenValidatorTests
{ {
var header = $"Bearer {CreateToken("Owner", issuer: "SomeoneElse")}"; var header = $"Bearer {CreateToken("Owner", issuer: "SomeoneElse")}";
_validator.IsVerifiedAdmin(header).Should().BeFalse(); _validator.Validate(header).IsVerifiedAdmin.Should().BeFalse();
} }
[Theory] [Theory]
@@ -115,7 +115,7 @@ public class AdminTokenValidatorTests
{ {
// Never throws — an unusable header simply means "not an admin". Rejecting the request // Never throws — an unusable header simply means "not an admin". Rejecting the request
// is the authentication middleware's job, not the availability gate's. // is the authentication middleware's job, not the availability gate's.
_validator.IsVerifiedAdmin(header).Should().BeFalse(); _validator.Validate(header).IsVerifiedAdmin.Should().BeFalse();
} }
private static string CreateToken( private static string CreateToken(
@@ -0,0 +1,133 @@
using FluentAssertions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using SlpModularCms.Core.Hosting.Observability;
using SlpModularCms.Core.Hosting.Security;
using Xunit;
namespace SlpModularCms.Core.Tests.Hosting;
/// <summary>
/// Loads the committed <c>appsettings.json</c> of both hosts and runs the startup validators
/// against them.
/// </summary>
/// <remarks>
/// Everything asserted here is a startup failure in production if it is wrong, which means the
/// symptom is a host that will not boot after a release switch. Two specific reasons this exists:
///
/// The files carry <c>//</c> comments explaining non-obvious values. The JSON configuration
/// provider does tolerate them, but "does tolerate" is worth verifying rather than assuming when
/// the failure mode is both hosts refusing to start.
///
/// And <c>SecurityHeaders</c> policy names are validated with <c>ValidateOnStart</c>, so a typo in
/// the committed file stops the process. Better to fail here than in a deployment.
/// </remarks>
public class DeployedConfigurationTests
{
public static TheoryData<string> HostConfigurations => new()
{
Path.Combine("host-config", "master.appsettings.json"),
Path.Combine("host-config", "slave.appsettings.json")
};
private static IConfigurationRoot Load(string relativePath)
{
File.Exists(relativePath).Should().BeTrue($"'{relativePath}' should be copied to the test output");
return new ConfigurationBuilder()
.AddJsonFile(relativePath, optional: false)
.Build();
}
[Theory]
[MemberData(nameof(HostConfigurations))]
public void HostConfiguration_ShouldParse_IncludingItsComments(string relativePath)
{
var act = () => Load(relativePath);
act.Should().NotThrow();
}
[Theory]
[MemberData(nameof(HostConfigurations))]
public void HostConfiguration_ShouldPassSecurityHeadersStartupValidation(string relativePath)
{
var services = new ServiceCollection();
services.AddLogging();
services.AddCmsSecurityHeaders(Load(relativePath));
var act = () => services.BuildServiceProvider().GetRequiredService<IStartupValidator>().Validate();
act.Should().NotThrow();
}
[Theory]
[MemberData(nameof(HostConfigurations))]
public void HostConfiguration_ShouldShipWithoutASentryDsn(string relativePath)
{
var options = Load(relativePath)
.GetSection(ObservabilityOptions.SectionName)
.Get<ObservabilityOptions>();
options.Should().NotBeNull();
// A DSN identifies a project and belongs to an account. It is supplied per environment as
// Observability__SentryDsn; the repository is not where it should be written down.
options!.IsSentryConfigured.Should().BeFalse();
options.TunnelMaxPayloadBytes.Should().BeGreaterThan(0);
}
/// <summary>
/// At <c>Information</c>, EF's command logging prints every SQL statement including parameter
/// values, and the login path passes a normalised email address through it. Raising the
/// default log level without pinning this category would start writing that to the console and
/// to Sentry breadcrumbs.
/// </summary>
[Theory]
[MemberData(nameof(HostConfigurations))]
public void HostConfiguration_ShouldPinEfCommandLoggingBelowInformation(string relativePath)
{
var level = Load(relativePath)["Logging:LogLevel:Microsoft.EntityFrameworkCore.Database.Command"];
level.Should().Be("Warning");
}
/// <summary>
/// Sentry breadcrumbs are capped by the logging level, so leaving the default at Warning would
/// deliver every event with an empty breadcrumb trail — the feature present, configured, and
/// useless.
/// </summary>
[Theory]
[MemberData(nameof(HostConfigurations))]
public void HostConfiguration_ShouldAllowInformationLevelLogging(string relativePath)
{
var configuration = Load(relativePath);
configuration["Logging:LogLevel:Default"].Should().Be("Information");
configuration["Logging:LogLevel:Microsoft.AspNetCore"].Should().Be("Information");
}
[Theory]
[MemberData(nameof(HostConfigurations))]
public void HostConfiguration_ShouldRateLimitTheSentryTunnel(string relativePath)
{
var configuration = Load(relativePath);
configuration.GetValue<int>("RateLimiting:SentryTunnel:PermitLimit").Should().BeGreaterThan(0);
configuration.GetValue<int>("RateLimiting:SentryTunnel:WindowSeconds").Should().BeGreaterThan(0);
}
/// <summary>The admin SPA must never fall to the relaxed policy on the host that serves it.</summary>
[Fact]
public void MasterHostConfiguration_ShouldApplyTheStrictPolicyToAdmin()
{
var options = Load(Path.Combine("host-config", "master.appsettings.json"))
.GetSection(SecurityHeadersOptions.SectionName)
.Get<SecurityHeadersOptions>();
options.Should().NotBeNull();
options!.Enabled.Should().BeTrue();
options.PathPolicies
.Should().Contain(rule => rule.PathPrefix == "/admin" && rule.Policy == CspPolicyCatalog.Strict);
}
}
@@ -0,0 +1,165 @@
using FluentAssertions;
using Microsoft.Extensions.Logging;
using Sentry;
using SlpModularCms.Core.Hosting.Observability;
using SlpModularCms.Core.Observability;
using Xunit;
namespace SlpModularCms.Core.Tests.Hosting.Observability;
/// <summary>
/// Guards the property that makes alert rules possible at all.
/// </summary>
/// <remarks>
/// Sentry groups log-derived events by their message. If a failed-login entry interpolated the
/// email address, every distinct address would become its own Sentry issue and an alert rule of
/// the form "more than 20 failed logins in five minutes" could never fire, because no single
/// issue would ever reach 20. Everything would look like it worked: events arrive, they are
/// visible, they are tagged. Only the alerting would silently be impossible.
///
/// These tests assert that the rendered message is identical across different argument values.
/// </remarks>
public class SecurityEventsTests
{
private sealed class CapturingLogger : ILogger
{
public List<(LogLevel Level, EventId EventId, string Message)> Entries { get; } = [];
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter)
{
Entries.Add((logLevel, eventId, formatter(state, exception)));
}
}
[Fact]
public void FailedLogin_ShouldRenderTheSameMessage_ForDifferentAccounts()
{
var logger = new CapturingLogger();
SecurityEvents.FailedLogin(logger, "/api/v1/Auth/login", accountExists: true);
SecurityEvents.FailedLogin(logger, "/api/v1/Auth/login", accountExists: true);
logger.Entries.Should().HaveCount(2);
logger.Entries[0].Message.Should().Be(logger.Entries[1].Message);
}
[Fact]
public void FailedLogin_ShouldCarryTheEventNameAndNoCredentials()
{
var logger = new CapturingLogger();
SecurityEvents.FailedLogin(logger, "/api/v1/Auth/login", accountExists: false);
var entry = logger.Entries.Single();
entry.Message.Should().Contain(SecurityEventNames.FailedLogin);
entry.EventId.Id.Should().Be(SecurityEvents.FailedLoginEventId);
entry.Level.Should().Be(LogLevel.Warning);
}
/// <summary>
/// All six must cross the Sentry event threshold by construction rather than by a
/// coincidence of configuration.
/// </summary>
[Fact]
public void AllEvents_ShouldBeWarningOrAbove()
{
var logger = new CapturingLogger();
SecurityEvents.FailedLogin(logger, "/e", accountExists: true);
SecurityEvents.AuthorizationDenied(logger, "/e", "OwnerOnly");
SecurityEvents.MasterApiKeyRejected(logger, "/e", "10.0.0.1");
SecurityEvents.AdminBypassRejected(logger, "/e", BypassRejectionReason.InvalidSignature);
SecurityEvents.RateLimitTriggered(logger, "login", "/e");
SecurityEvents.MigrationFailure(logger, new InvalidOperationException("boom"), attempts: 5);
logger.Entries.Should().HaveCount(6);
logger.Entries.Should().OnlyContain(e => e.Level >= LogLevel.Warning);
}
[Fact]
public void AllEvents_ShouldUseDistinctEventIds()
{
var logger = new CapturingLogger();
SecurityEvents.FailedLogin(logger, "/e", accountExists: true);
SecurityEvents.AuthorizationDenied(logger, "/e", "OwnerOnly");
SecurityEvents.MasterApiKeyRejected(logger, "/e", "10.0.0.1");
SecurityEvents.AdminBypassRejected(logger, "/e", BypassRejectionReason.Expired);
SecurityEvents.RateLimitTriggered(logger, "login", "/e");
SecurityEvents.MigrationFailure(logger, new InvalidOperationException("boom"), attempts: 1);
logger.Entries.Select(e => e.EventId.Id).Should().OnlyHaveUniqueItems();
}
[Fact]
public void MigrationFailure_ShouldBeCritical()
{
var logger = new CapturingLogger();
SecurityEvents.MigrationFailure(logger, new InvalidOperationException("boom"), attempts: 5);
logger.Entries.Single().Level.Should().Be(LogLevel.Critical);
}
/// <summary>
/// The reason class distinguishes forgery from a stale tab. The token itself must never
/// appear anywhere in the entry.
/// </summary>
[Fact]
public void AdminBypassRejected_ShouldCarryTheReasonClass()
{
var logger = new CapturingLogger();
SecurityEvents.AdminBypassRejected(logger, "/admin", BypassRejectionReason.InvalidSignature);
logger.Entries.Single().Message.Should().Contain(nameof(BypassRejectionReason.InvalidSignature));
}
}
public class SecurityEventProcessorTests
{
private readonly SecurityEventProcessor _processor = new();
[Fact]
public void Process_ShouldTagKnownSecurityEvents()
{
var sentryEvent = new SentryEvent();
sentryEvent.SetExtra("SecurityEvent", SecurityEventNames.FailedLogin);
var processed = _processor.Process(sentryEvent);
processed!.Tags[SecurityEventProcessor.TagName].Should().Be(SecurityEventNames.FailedLogin);
}
/// <summary>
/// Alert rules filter on the tag rather than on message text, because a rule that matches
/// nothing looks exactly like a rule with nothing to match.
/// </summary>
[Fact]
public void Process_ShouldNotTagUnknownValues()
{
var sentryEvent = new SentryEvent();
sentryEvent.SetExtra("SecurityEvent", "something_else");
var processed = _processor.Process(sentryEvent);
processed!.Tags.Should().NotContainKey(SecurityEventProcessor.TagName);
}
[Fact]
public void Process_ShouldLeaveOrdinaryEventsUntouched()
{
var processed = _processor.Process(new SentryEvent());
processed!.Tags.Should().NotContainKey(SecurityEventProcessor.TagName);
}
}
@@ -0,0 +1,111 @@
using FluentAssertions;
using Sentry;
using SlpModularCms.Core.Hosting.Observability;
using Xunit;
namespace SlpModularCms.Core.Tests.Hosting.Observability;
/// <summary>
/// The most security-critical code in the observability unit.
/// </summary>
/// <remarks>
/// Enabling <c>SendDefaultPii</c> attaches request headers, and this application carries two
/// standing credentials in them: the <c>refreshToken</c> cookie and the master/slave shared
/// secret. Sending either to a third party would be worse than the problem the setting solves.
/// These tests exist so that the scrub list cannot quietly shrink.
/// </remarks>
public class SentryEventScrubberTests
{
private readonly SentryEventScrubber _scrubber = new();
private static SentryEvent CreateEventWithCredentials()
{
var sentryEvent = new SentryEvent();
sentryEvent.Request.Method = "POST";
sentryEvent.Request.Url = "https://example.com/api/v1/Auth/login";
sentryEvent.Request.QueryString = "returnUrl=/admin";
sentryEvent.Request.Headers["Cookie"] = "refreshToken=super-secret-value";
sentryEvent.Request.Headers["Set-Cookie"] = "refreshToken=freshly-issued; HttpOnly";
sentryEvent.Request.Headers["Authorization"] = "Bearer eyJhbGciOi...";
sentryEvent.Request.Headers["X-Master-Api-Key"] = "the-shared-secret";
sentryEvent.Request.Headers["User-Agent"] = "Mozilla/5.0";
sentryEvent.Request.Data = "{\"email\":\"a@b.nl\",\"password\":\"hunter2\"}";
return sentryEvent;
}
[Theory]
[InlineData("Cookie")]
[InlineData("Set-Cookie")]
[InlineData("Authorization")]
[InlineData("X-Master-Api-Key")]
public void Scrub_ShouldRemoveCredentialHeaders(string header)
{
var scrubbed = _scrubber.Scrub(CreateEventWithCredentials());
scrubbed.Request.Headers.Should().NotContainKey(header);
}
[Fact]
public void Scrub_ShouldRemoveTheRequestBody()
{
var scrubbed = _scrubber.Scrub(CreateEventWithCredentials());
scrubbed.Request.Data.Should().BeNull();
}
/// <summary>
/// The retained fields are the entire diagnostic value of the request context. Scrubbing
/// everything would be safe and useless.
/// </summary>
[Fact]
public void Scrub_ShouldRetainDiagnosticFields()
{
var scrubbed = _scrubber.Scrub(CreateEventWithCredentials());
scrubbed.Request.Method.Should().Be("POST");
scrubbed.Request.Url.Should().Be("https://example.com/api/v1/Auth/login");
scrubbed.Request.QueryString.Should().Be("returnUrl=/admin");
scrubbed.Request.Headers.Should().ContainKey("User-Agent");
}
[Fact]
public void Scrub_ShouldNotThrow_WhenNoRequestContextIsPresent()
{
var act = () => _scrubber.Scrub(new SentryEvent());
act.Should().NotThrow();
}
/// <summary>
/// Transactions carry request data too, and are the channel nobody thinks of. Raising
/// TracesSampleRate without this would start leaking headers.
/// </summary>
[Theory]
[InlineData("Cookie")]
[InlineData("Authorization")]
[InlineData("X-Master-Api-Key")]
public void ScrubTransaction_ShouldRemoveCredentialHeaders(string header)
{
var transaction = new SentryTransaction("test", "http.server");
transaction.Request.Headers["Cookie"] = "refreshToken=secret";
transaction.Request.Headers["Authorization"] = "Bearer token";
transaction.Request.Headers["X-Master-Api-Key"] = "secret";
transaction.Request.Data = "body";
var scrubbed = _scrubber.ScrubTransaction(transaction);
scrubbed.Request.Headers.Should().NotContainKey(header);
scrubbed.Request.Data.Should().BeNull();
}
/// <summary>
/// A guard against the list shrinking by accident. Each entry has a reason recorded next to
/// it in the implementation.
/// </summary>
[Fact]
public void RemovedHeaders_ShouldCoverAllFourCredentialCarriers()
{
SentryEventScrubber.RemovedHeaders.Should().BeEquivalentTo(
["Cookie", "Set-Cookie", "Authorization", "X-Master-Api-Key"]);
}
}
@@ -0,0 +1,69 @@
using FluentAssertions;
using Microsoft.Extensions.Options;
using SlpModularCms.Core.Hosting.Observability;
using Xunit;
namespace SlpModularCms.Core.Tests.Hosting.Observability;
/// <summary>
/// The destination is the rule that keeps the tunnel from being a server-side request forgery
/// primitive: it is computed once from configuration, and nothing in a request can influence it.
/// </summary>
public class SentryTunnelTargetTests
{
private static SentryTunnelTarget Create(string dsn, int maxBytes = 204_800) =>
new(Options.Create(new ObservabilityOptions
{
SentryDsn = dsn,
TunnelMaxPayloadBytes = maxBytes
}));
[Fact]
public void Constructor_ShouldDeriveTheEnvelopeEndpoint_FromTheDsn()
{
var target = Create("https://abc123@o4511795618185216.ingest.de.sentry.io/4511795622838352");
target.IsConfigured.Should().BeTrue();
target.EnvelopeEndpoint.Should().Be(
new Uri("https://o4511795618185216.ingest.de.sentry.io/api/4511795622838352/envelope/"));
}
[Fact]
public void Constructor_ShouldNotBeConfigured_WithoutADsn()
{
var target = Create(string.Empty);
target.IsConfigured.Should().BeFalse();
target.EnvelopeEndpoint.Should().BeNull();
}
/// <summary>
/// Fails at startup rather than per request, consistent with failing closed everywhere else.
/// </summary>
[Theory]
[InlineData("not-a-uri")]
[InlineData("https://abc123@ingest.sentry.io")] // no project id
[InlineData("https://abc123@ingest.sentry.io/")]
public void Constructor_ShouldThrow_ForAnUnusableDsn(string dsn)
{
var act = () => Create(dsn);
act.Should().Throw<InvalidOperationException>();
}
[Fact]
public void Constructor_ShouldFallBackToADefault_WhenTheSizeCapIsNotPositive()
{
var target = Create("https://abc@host.sentry.io/42", maxBytes: 0);
target.MaxPayloadBytes.Should().BeGreaterThan(0);
}
/// <summary>Kept in sync with the frontend's <c>tunnel</c> option and the vite dev proxy.</summary>
[Fact]
public void Path_ShouldBeOutsideTheVersionedApi()
{
SentryTunnelTarget.Path.Should().Be("/sentry-tunnel");
SentryTunnelTarget.Path.Should().NotStartWith("/api/");
}
}
@@ -0,0 +1,137 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using FluentAssertions;
using Microsoft.IdentityModel.Tokens;
using SlpModularCms.Core.Hosting;
using SlpModularCms.Core.Hosting.Security;
using SlpModularCms.Core.Identity.Models;
using SlpModularCms.Core.Observability;
using Xunit;
namespace SlpModularCms.Core.Tests.Hosting.Security;
/// <summary>
/// The rejection reason is what makes a rejected admin bypass actionable: InvalidSignature means
/// someone is forging tokens, Expired is almost always an administrator with a stale tab, and an
/// alert that cannot tell those apart is one nobody acts on.
/// </summary>
public class AdminTokenRejectionReasonTests
{
private const string Secret = "TestSecretKeyThatIsLongEnoughForHmacSha256Signing!!";
private const string Issuer = "SlpModularCms";
private const string Audience = "SlpModularCmsPortal";
private readonly AdminTokenValidator _validator = new(JwtTokenValidation.Create(new JwtSettings
{
Secret = Secret,
Issuer = Issuer,
Audience = Audience
}));
private static string CreateToken(
string? secret = null,
string? issuer = null,
string? audience = null,
string role = "Owner",
TimeSpan? lifetime = null)
{
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret ?? Secret));
var expires = DateTime.UtcNow.Add(lifetime ?? TimeSpan.FromMinutes(30));
var token = new JwtSecurityToken(
issuer: issuer ?? Issuer,
audience: audience ?? Audience,
claims: [new Claim(ClaimTypes.Role, role)],
notBefore: expires.AddMinutes(-35),
expires: expires,
signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256));
return new JwtSecurityTokenHandler().WriteToken(token);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("Basic abc")]
[InlineData("Bearer ")]
public void Validate_ShouldReportAbsent_WhenThereIsNoBearerToken(string? header)
{
var result = _validator.Validate(header);
result.IsVerifiedAdmin.Should().BeFalse();
result.Reason.Should().Be(BypassRejectionReason.Absent);
}
[Fact]
public void Validate_ShouldReportInvalidSignature_ForAForgedToken()
{
var forged = CreateToken(secret: "AnEntirelyDifferentSecretThatIsAlsoLongEnough!!!!!");
var result = _validator.Validate($"Bearer {forged}");
result.IsVerifiedAdmin.Should().BeFalse();
result.Reason.Should().Be(BypassRejectionReason.InvalidSignature);
}
[Fact]
public void Validate_ShouldReportExpired_ForAStaleToken()
{
var expired = CreateToken(lifetime: TimeSpan.FromMinutes(-10));
var result = _validator.Validate($"Bearer {expired}");
result.IsVerifiedAdmin.Should().BeFalse();
result.Reason.Should().Be(BypassRejectionReason.Expired);
}
[Fact]
public void Validate_ShouldReportWrongIssuer()
{
var token = CreateToken(issuer: "SomeoneElse");
var result = _validator.Validate($"Bearer {token}");
result.IsVerifiedAdmin.Should().BeFalse();
result.Reason.Should().Be(BypassRejectionReason.WrongIssuer);
}
[Fact]
public void Validate_ShouldReportWrongAudience()
{
var token = CreateToken(audience: "SomeOtherAudience");
var result = _validator.Validate($"Bearer {token}");
result.IsVerifiedAdmin.Should().BeFalse();
result.Reason.Should().Be(BypassRejectionReason.WrongAudience);
}
[Fact]
public void Validate_ShouldReportNotAdmin_ForAValidNonAdminToken()
{
var token = CreateToken(role: "User");
var result = _validator.Validate($"Bearer {token}");
result.IsVerifiedAdmin.Should().BeFalse();
result.Reason.Should().Be(BypassRejectionReason.NotAdmin);
}
[Fact]
public void Validate_ShouldReportMalformed_ForGarbage()
{
var result = _validator.Validate("Bearer not.a.jwt");
result.IsVerifiedAdmin.Should().BeFalse();
result.Reason.Should().Be(BypassRejectionReason.Malformed);
}
[Fact]
public void Validate_ShouldStillSucceed_ForAValidAdminToken()
{
var token = CreateToken();
_validator.Validate($"Bearer {token}").IsVerifiedAdmin.Should().BeTrue();
}
}
@@ -1,3 +1,4 @@
using Microsoft.Extensions.Logging.Abstractions;
using FluentAssertions; using FluentAssertions;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -38,7 +39,7 @@ public class AuthServiceTests
RefreshTokenExpiryDays = 7 RefreshTokenExpiryDays = 7
}; };
_service = new AuthService(_userManager, _context, Options.Create(jwtSettings)); _service = new AuthService(_userManager, _context, Options.Create(jwtSettings), NullLogger<AuthService>.Instance);
} }
[Fact] [Fact]
@@ -31,4 +31,14 @@
<ProjectReference Include="..\SlpModularCms.Core\SlpModularCms.Core.csproj" /> <ProjectReference Include="..\SlpModularCms.Core\SlpModularCms.Core.csproj" />
</ItemGroup> </ItemGroup>
<!--
The real, committed appsettings of both hosts, so DeployedConfigurationTests can validate them
rather than a copy that drifts. Linked, not duplicated: a test asserting a second file proves
nothing about what actually ships.
-->
<ItemGroup>
<Content Include="..\SlpModularCms.Api\appsettings.json" Link="host-config\master.appsettings.json" CopyToOutputDirectory="PreserveNewest" />
<Content Include="..\SlpModularCms.Api.Slave\appsettings.json" Link="host-config\slave.appsettings.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project> </Project>
@@ -3,7 +3,9 @@ using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Sentry;
using SlpModularCms.Core.Data; using SlpModularCms.Core.Data;
using SlpModularCms.Core.Observability;
namespace SlpModularCms.Core.Hosting; namespace SlpModularCms.Core.Hosting;
@@ -79,10 +81,15 @@ public static class DatabaseMigrationExtensions
{ {
// Logged with the failure reason but never the connection string or credentials — // Logged with the failure reason but never the connection string or credentials —
// this message travels to the console and to Sentry. // this message travels to the console and to Sentry.
logger.LogCritical( SecurityEvents.MigrationFailure(logger, ex, attempt);
ex,
"Core database migration failed after {Attempts} attempt(s). The application will not start.", // The SDK batches and sends in the background, and this process is about to exit
attempt); // — which would kill the sender before it transmits. Without this flush the one
// Critical event in the whole system is also the event most likely never to
// arrive. Bounded at five seconds: a host that cannot reach its database is
// already down, and five seconds buys the alert that says why. A no-op when
// Sentry was never initialised, so the no-DSN path is unaffected.
SentrySdk.Flush(TimeSpan.FromSeconds(5));
throw; throw;
} }
@@ -0,0 +1,68 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace SlpModularCms.Core.Hosting.Observability;
[ExcludeFromCodeCoverage]
public static class LoggingExtensions
{
/// <summary>
/// Configures structured console logging with a correlation identifier on every entry.
/// </summary>
/// <remarks>
/// Must be called <b>before</b> Sentry is registered, so that a problem initialising Sentry
/// is itself logged.
/// </remarks>
public static ILoggingBuilder AddCmsLogging(this ILoggingBuilder logging, IHostEnvironment environment)
{
ArgumentNullException.ThrowIfNull(logging);
ArgumentNullException.ThrowIfNull(environment);
// The default host already added a console provider without IncludeScopes. Adding a
// second one prints every line twice — once with the correlation ID and once without —
// which reads as a logging bug and wastes real time.
logging.ClearProviders();
// The correlation identifier required by SECURITY-03, resolved as the W3C trace ID from
// the ambient Activity rather than HttpContext.TraceIdentifier.
//
// Two reasons, the first decisive:
// 1. It crosses the master/slave HTTP boundary. HttpClient injects traceparent and the
// slave's hosting layer adopts it, so both sides' log entries carry the SAME value.
// "The master says the slave rejected its API key — what did the slave see?" is the
// hardest diagnostic question in this codebase, and TraceIdentifier, being host-local,
// cannot answer it at all.
// 2. It is already the value the client is shown: ASP.NET Core's ProblemDetails writes
// traceId as Activity.Current?.Id ?? HttpContext.TraceIdentifier. So the log entry,
// the Sentry event, the slave's log entry and the browser's error response all carry
// one value, and an operator handed a traceId from a screenshot can find the request.
//
// This puts TraceId into the scope of EVERY entry from every category, framework included,
// without touching a single call site — which matters, because the alternative is
// remembering to pass an identifier into every existing log call.
logging.Configure(options =>
options.ActivityTrackingOptions =
ActivityTrackingOptions.TraceId |
ActivityTrackingOptions.SpanId |
ActivityTrackingOptions.ParentId);
if (environment.IsDevelopment())
{
// A developer reads this with their eyes, and JSON is hostile to that.
logging.AddSimpleConsole(options =>
{
options.IncludeScopes = true;
options.SingleLine = true;
});
}
else
{
// In test and production the process is supervised and its stdout lands in the
// journal, where JSON is greppable and TraceId is a field rather than a substring.
logging.AddJsonConsole(options => options.IncludeScopes = true);
}
return logging;
}
}
@@ -0,0 +1,52 @@
namespace SlpModularCms.Core.Hosting.Observability;
/// <summary>
/// Configuration for logging and error reporting.
/// </summary>
/// <remarks>
/// Every value here is optional. An absent DSN is a normal, fully supported state — local
/// development and any deployment without Sentry must work unchanged — so nothing in this
/// section is required and nothing warns about being empty.
///
/// Deliberately <b>not</b> configurable: the scrub list and the tunnel's destination host. Both
/// are security-critical, and making either configurable would create a way to switch the
/// protection off — the scrub list by omission, the destination by turning the tunnel into a
/// request-forgery primitive.
/// </remarks>
public sealed class ObservabilityOptions
{
public const string SectionName = "Observability";
/// <summary>
/// Sentry DSN. Empty means Sentry is skipped entirely.
/// </summary>
/// <remarks>
/// Not a secret in the usual sense — it identifies a project and permits event submission,
/// and the frontend's copy is visible in the page source. It still comes from an environment
/// variable (<c>Observability__SentryDsn</c>) in test and production rather than being
/// committed: it belongs to an account, and the repository is not where it should be written
/// down.
/// </remarks>
public string SentryDsn { get; set; } = string.Empty;
/// <summary>Environment tag. Falls back to <c>ASPNETCORE_ENVIRONMENT</c> when empty.</summary>
public string Environment { get; set; } = string.Empty;
/// <summary>
/// Performance sampling rate.
/// </summary>
/// <remarks>
/// Kept low: Sentry's free plan counts transactions against the same quota as errors, and
/// this setup's value is in errors rather than performance traces.
/// </remarks>
public double TracesSampleRate { get; set; } = 0.1;
/// <summary>
/// Hard upper bound on a tunnelled envelope. Envelopes with a stack trace and breadcrumbs
/// run to tens of kilobytes, so this is generous without being an allocation risk on a
/// Raspberry Pi.
/// </summary>
public int TunnelMaxPayloadBytes { get; set; } = 204_800;
public bool IsSentryConfigured => !string.IsNullOrWhiteSpace(SentryDsn);
}
@@ -0,0 +1,57 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Authorization.Policy;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using SlpModularCms.Core.Observability;
namespace SlpModularCms.Core.Hosting.Observability;
/// <summary>
/// Reports authorization denials, then defers entirely to the framework's own handler.
/// </summary>
/// <remarks>
/// Hooked in at <see cref="IAuthorizationMiddlewareResultHandler"/> rather than inside an
/// <see cref="IAuthorizationHandler"/>, because a handler sees one requirement at a time: a
/// requirement that does not succeed is not the same as the request being denied, and reporting
/// from there would produce events for requests that were ultimately allowed. This interface sees
/// the final result, which is the thing worth alerting on.
///
/// The response itself is unchanged — this only observes.
/// </remarks>
public sealed class SecurityAuthorizationResultHandler : IAuthorizationMiddlewareResultHandler
{
private readonly AuthorizationMiddlewareResultHandler _inner = new();
private readonly ILogger<SecurityAuthorizationResultHandler> _logger;
public SecurityAuthorizationResultHandler(ILogger<SecurityAuthorizationResultHandler> logger)
{
_logger = logger;
}
public Task HandleAsync(
RequestDelegate next,
HttpContext context,
AuthorizationPolicy policy,
PolicyAuthorizationResult authorizeResult)
{
// Challenged means "not authenticated yet" — a 401 that the browser resolves by logging
// in, and an entirely ordinary event. Forbidden means an authenticated caller reached
// something they lack the rights for, which is the case worth knowing about.
if (authorizeResult.Forbidden)
{
SecurityEvents.AuthorizationDenied(
_logger,
context.Request.Path,
DescribePolicy(policy));
}
return _inner.HandleAsync(next, context, policy, authorizeResult);
}
/// <summary>
/// Names the requirements rather than the policy, because ASP.NET Core resolves the policy
/// object before this point and the original name is no longer available.
/// </summary>
private static string DescribePolicy(AuthorizationPolicy policy) =>
string.Join(", ", policy.Requirements.Select(requirement => requirement.GetType().Name));
}
@@ -0,0 +1,51 @@
using System.Collections.Frozen;
using Sentry;
using Sentry.Extensibility;
using SlpModularCms.Core.Observability;
namespace SlpModularCms.Core.Hosting.Observability;
/// <summary>
/// Promotes a security event's constant name to a Sentry tag.
/// </summary>
/// <remarks>
/// Alert rules then filter on <c>security_event:failed_login</c>, which survives a change to the
/// message wording. Matching on message text would not — and it would break silently, because a
/// rule that matches nothing looks exactly like a rule with nothing to match.
/// </remarks>
public sealed class SecurityEventProcessor : ISentryEventProcessor
{
public const string TagName = "security_event";
private static readonly FrozenSet<string> KnownEventNames = new[]
{
SecurityEventNames.FailedLogin,
SecurityEventNames.AuthorizationDenied,
SecurityEventNames.MasterApiKeyRejected,
SecurityEventNames.AdminBypassRejected,
SecurityEventNames.RateLimitTriggered,
SecurityEventNames.MigrationFailure
}.ToFrozenSet(StringComparer.Ordinal);
/// <summary>
/// The structured property name that <see cref="SecurityEvents"/> attaches to every entry.
/// </summary>
private const string PropertyName = "SecurityEvent";
public SentryEvent? Process(SentryEvent @event)
{
if (@event is null)
{
return null;
}
if (@event.Extra.TryGetValue(PropertyName, out var value) &&
value is string name &&
KnownEventNames.Contains(name))
{
@event.SetTag(TagName, name);
}
return @event;
}
}
@@ -0,0 +1,101 @@
using Sentry;
namespace SlpModularCms.Core.Hosting.Observability;
/// <summary>Removes credentials from an outbound Sentry event.</summary>
public interface ISentryEventScrubber
{
SentryEvent Scrub(SentryEvent sentryEvent);
SentryTransaction ScrubTransaction(SentryTransaction transaction);
}
/// <summary>
/// Strips credential-bearing headers and the request body before anything leaves the process.
/// </summary>
/// <remarks>
/// Enabling <c>SendDefaultPii</c> attaches request headers, and "PII" understates what this
/// application carries in them: a <c>refreshToken</c> cookie and the master/slave shared secret
/// are <b>credentials</b>, not merely personal data. Sending them to a third party would be
/// worse than the problem the setting solves.
///
/// This runs <b>in-process, before transmission</b>. Sentry offers server-side scrubbing, but by
/// then the secret has already left the building — doing it here is the only version that
/// actually protects anything.
///
/// It is a class rather than a lambda inside the <c>UseSentry</c> callback because it is the most
/// security-critical code in this unit, and a lambda there cannot be unit-tested without
/// initialising the SDK.
/// </remarks>
public sealed class SentryEventScrubber : ISentryEventScrubber
{
/// <summary>
/// A <c>static readonly</c> array in code, never an options property: a configurable scrub
/// list is a supported way to switch the protection off by omission.
/// </summary>
public static IReadOnlyList<string> RemovedHeaders => RemovedHeaderNames;
private static readonly string[] RemovedHeaderNames =
[
// The whole header, not one cookie. It carries the refreshToken, and removing a single
// cookie by rewriting the header is error-prone in a way that removing the header is not.
"Cookie",
// The response counterpart. The login and refresh responses ISSUE the refreshToken here,
// so scrubbing the request cookie while sending this one would protect nothing.
"Set-Cookie",
// Bearer token.
"Authorization",
// The master/slave shared secret. Not mentioned when SendDefaultPii was chosen, but the
// same class of secret — and it would otherwise be sent to a third party on every error
// raised during a master/slave call.
"X-Master-Api-Key"
];
public SentryEvent Scrub(SentryEvent sentryEvent)
{
ArgumentNullException.ThrowIfNull(sentryEvent);
ScrubRequest(sentryEvent.Request);
return sentryEvent;
}
/// <remarks>
/// Transactions carry request data too. Scrubbing only events would leave a second channel
/// open — less obvious precisely because nobody thinks of a transaction as containing
/// headers — so raising <c>TracesSampleRate</c> later, without touching this file, would
/// start leaking.
/// </remarks>
public SentryTransaction ScrubTransaction(SentryTransaction transaction)
{
ArgumentNullException.ThrowIfNull(transaction);
ScrubRequest(transaction.Request);
return transaction;
}
/// <summary>
/// What is deliberately <b>retained</b>: method, path, query string, user agent, IP address,
/// authenticated username and the correlation ID — all genuinely diagnostic.
/// </summary>
/// <remarks>
/// Query strings are kept even though invitation tokens travel as <c>?token=…</c> on one
/// endpoint. That token is single-use and time-limited rather than a standing credential, and
/// knowing which endpoint was called outweighs it. Recorded so the trade-off is visible
/// rather than accidental.
/// </remarks>
private static void ScrubRequest(SentryRequest request)
{
foreach (var header in RemovedHeaderNames)
{
request.Headers.Remove(header);
}
// Belt and braces: MaxRequestBodySize is None, so the body should never have been
// captured in the first place. Nulling it here means a future change to that option
// cannot quietly start shipping login payloads.
request.Data = null;
}
}
@@ -0,0 +1,104 @@
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Sentry.AspNetCore;
using Sentry.Extensibility;
namespace SlpModularCms.Core.Hosting.Observability;
[ExcludeFromCodeCoverage]
public static class SentryExtensions
{
public static IServiceCollection AddCmsObservability(
this IServiceCollection services,
IConfiguration configuration)
{
services.AddOptions<ObservabilityOptions>()
.Bind(configuration.GetSection(ObservabilityOptions.SectionName))
.ValidateOnStart();
services.AddSingleton<ISentryEventScrubber, SentryEventScrubber>();
services.AddSingleton<SecurityEventProcessor>();
// Resolving this at startup is what makes an unparseable DSN a startup failure rather
// than a per-request one.
services.AddSingleton<SentryTunnelTarget>();
services.AddHttpClient(SentryTunnelExtensions.HttpClientName, client =>
{
// Short, and no retry. A dropped error report is acceptable; a request thread held
// open by an anonymous caller is not.
client.Timeout = TimeSpan.FromSeconds(5);
});
return services;
}
/// <summary>
/// Registers Sentry, or does nothing at all when no DSN is configured.
/// </summary>
/// <remarks>
/// An absent DSN is a normal, supported state and produces <b>no warning</b>. Local
/// development is the common case, and a startup warning that always appears trains people to
/// ignore startup warnings — including the ones that matter.
/// </remarks>
public static IWebHostBuilder UseCmsSentry(this IWebHostBuilder webHost, IConfiguration configuration)
{
ArgumentNullException.ThrowIfNull(webHost);
ArgumentNullException.ThrowIfNull(configuration);
var settings = configuration.GetSection(ObservabilityOptions.SectionName).Get<ObservabilityOptions>()
?? new ObservabilityOptions();
if (!settings.IsSentryConfigured)
{
return webHost;
}
// Stateless, and needed inside a callback that runs before any service provider exists.
// The same types are also registered in DI, where the tests reach them.
var scrubber = new SentryEventScrubber();
var processor = new SecurityEventProcessor();
return webHost.UseSentry(options =>
{
options.Dsn = settings.SentryDsn;
options.Environment = string.IsNullOrWhiteSpace(settings.Environment) ? null : settings.Environment;
options.Release = GetRelease();
options.TracesSampleRate = settings.TracesSampleRate;
// Reconciles two answers rather than choosing between them: the console gets
// everything at Information, Sentry gets warnings and errors as EVENTS — still more
// than exceptions — and informational entries travel attached to those events as
// breadcrumbs. Sending every framework Information entry as an event would mean one
// event per request, exhausting the free plan within hours and burying real errors in
// request noise.
options.MinimumBreadcrumbLevel = LogLevel.Information;
options.MinimumEventLevel = LogLevel.Warning;
// Attaches request context — and therefore requires the scrubber below.
options.SendDefaultPii = true;
// The safest version of "the request body is never sent to Sentry" is that it is
// never read into an event at all. This is the SDK default; set explicitly so that a
// future change to it has to be deliberate.
options.MaxRequestBodySize = RequestSize.None;
options.SetBeforeSend((sentryEvent, _) => scrubber.Scrub(sentryEvent));
options.SetBeforeSendTransaction((transaction, _) => scrubber.ScrubTransaction(transaction));
options.AddEventProcessor(processor);
});
}
private static string GetRelease()
{
var assembly = Assembly.GetEntryAssembly() ?? Assembly.GetExecutingAssembly();
return assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion
?? assembly.GetName().Version?.ToString()
?? "unknown";
}
}
@@ -0,0 +1,127 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace SlpModularCms.Core.Hosting.Observability;
/// <summary>
/// Forwards browser Sentry envelopes through this application's own origin.
/// </summary>
/// <remarks>
/// Ad blockers block requests to Sentry domains with <c>ERR_BLOCKED_BY_CLIENT</c>. Without a
/// tunnel, errors are lost precisely for the users who have an ad blocker — a silently biased
/// sample of exactly the group most likely to have browser oddities. It also keeps browser
/// traffic same-origin, so the CSP needs <c>connect-src 'self'</c> and no external Sentry origin.
///
/// The reference project tunnels through nginx. Relying on server configuration is what this
/// deployment model forbids, so the application forwards it instead.
/// </remarks>
[ExcludeFromCodeCoverage]
public static class SentryTunnelExtensions
{
public const string HttpClientName = "sentry-tunnel";
public const string RateLimiterName = "sentry-tunnel";
public static IEndpointRouteBuilder MapSentryTunnel(this IEndpointRouteBuilder endpoints)
{
endpoints.MapPost(SentryTunnelTarget.Path, HandleAsync)
// Anonymous on purpose: the errors most worth capturing include authentication
// failures, so error reporting must work for a user whose session just expired.
.AllowAnonymous()
// An anonymous endpoint that triggers an outbound HTTPS request per call is a free
// amplifier and a way to burn the Sentry plan's quota from outside. The other three
// controls — fixed destination, size cap, no-DSN-no-forwarding — bound what each call
// can do but not how many calls there can be.
.RequireRateLimiting(RateLimiterName)
// Not a versioned CMS API and not part of the public contract.
.ExcludeFromDescription()
.WithName("SentryTunnel");
return endpoints;
}
private static async Task<IResult> HandleAsync(
HttpContext context,
SentryTunnelTarget target,
IHttpClientFactory httpClientFactory,
ILoggerFactory loggerFactory,
CancellationToken cancellationToken)
{
if (!target.IsConfigured)
{
// 404 rather than 503: with no DSN configured this endpoint genuinely does not exist.
return Results.NotFound();
}
// Content-Length is absent under chunked transfer encoding and is attacker-controlled in
// any case, so this is an optimisation rather than the control.
if (context.Request.ContentLength > target.MaxPayloadBytes)
{
return Results.StatusCode(StatusCodes.Status413PayloadTooLarge);
}
// This bounded read IS the control. Trusting Content-Length alone would give an anonymous
// caller an unbounded memory allocation on a Raspberry Pi.
var payload = await ReadAtMostAsync(context.Request.Body, target.MaxPayloadBytes, cancellationToken);
if (payload is null)
{
return Results.StatusCode(StatusCodes.Status413PayloadTooLarge);
}
var logger = loggerFactory.CreateLogger(typeof(SentryTunnelExtensions).FullName!);
try
{
using var content = new ByteArrayContent(payload);
var client = httpClientFactory.CreateClient(HttpClientName);
// target.EnvelopeEndpoint was derived from configuration at startup. No part of the
// destination comes from this request, and that is what separates a tunnel from a
// server-side request forgery primitive.
using var response = await client.PostAsync(target.EnvelopeEndpoint, content, cancellationToken);
if (!response.IsSuccessStatusCode)
{
logger.LogWarning(
"Sentry tunnel: ingest returned {StatusCode} for a {ByteCount}-byte envelope.",
(int)response.StatusCode, payload.Length);
}
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogWarning(ex, "Sentry tunnel: forwarding an envelope failed.");
}
// Accepted regardless of what happened upstream. The browser must not retry or log a
// console error over a failed error report: failing to report an error must not itself
// become an error.
return Results.Accepted();
}
/// <summary>
/// Reads at most <paramref name="maxBytes"/>. Returns null when the stream carries more.
/// </summary>
private static async Task<byte[]?> ReadAtMostAsync(Stream body, int maxBytes, CancellationToken cancellationToken)
{
// One byte of headroom, so "exactly at the limit" and "over the limit" are distinguishable.
var buffer = new byte[maxBytes + 1];
var total = 0;
while (total < buffer.Length)
{
var read = await body.ReadAsync(buffer.AsMemory(total), cancellationToken);
if (read == 0)
{
break;
}
total += read;
}
return total > maxBytes ? null : buffer[..total];
}
}
@@ -0,0 +1,71 @@
using Microsoft.Extensions.Options;
namespace SlpModularCms.Core.Hosting.Observability;
/// <summary>
/// The one destination the Sentry tunnel is allowed to forward to, derived from the configured
/// DSN at startup.
/// </summary>
/// <remarks>
/// <b>This class is the rule that keeps the tunnel from being a liability.</b> An anonymous
/// endpoint that makes an outbound request on demand is a server-side request forgery primitive
/// if the destination comes from the caller. Computing it once, from configuration, and never
/// reading anything from the request makes that structurally impossible rather than merely
/// avoided by the current code.
///
/// An unparseable DSN fails at startup rather than per request, consistent with the security
/// headers unit and with failing closed generally.
/// </remarks>
public sealed class SentryTunnelTarget
{
/// <summary>Same path the reference project uses, where nginx serves it.</summary>
/// <remarks>
/// Here the application forwards it, because relying on reverse-proxy configuration is
/// exactly what this deployment model forbids. Keeping the path identical means the frontend
/// <c>tunnel</c> option, the vite dev proxy and an operator's muscle memory all carry over
/// unchanged between the two workspaces.
///
/// Deliberately outside <c>/api/v1</c>: it is not a versioned CMS API, it must not appear in
/// the OpenAPI document, and <c>/api/v1</c> maps to the strict CSP policy for reasons that
/// have nothing to do with this endpoint.
/// </remarks>
public const string Path = "/sentry-tunnel";
/// <summary>Null when no DSN is configured — the tunnel then accepts nothing.</summary>
public Uri? EnvelopeEndpoint { get; }
public int MaxPayloadBytes { get; }
public bool IsConfigured => EnvelopeEndpoint is not null;
public SentryTunnelTarget(IOptions<ObservabilityOptions> options)
{
ArgumentNullException.ThrowIfNull(options);
var value = options.Value;
MaxPayloadBytes = value.TunnelMaxPayloadBytes > 0 ? value.TunnelMaxPayloadBytes : 204_800;
EnvelopeEndpoint = value.IsSentryConfigured ? BuildEnvelopeEndpoint(value.SentryDsn) : null;
}
/// <summary>
/// Turns <c>https://{publicKey}@{host}/{projectId}</c> into
/// <c>https://{host}/api/{projectId}/envelope/</c>.
/// </summary>
private static Uri BuildEnvelopeEndpoint(string dsn)
{
if (!Uri.TryCreate(dsn.Trim(), UriKind.Absolute, out var uri))
{
throw new InvalidOperationException(
$"{ObservabilityOptions.SectionName}:SentryDsn is not a valid absolute URI.");
}
var projectId = uri.AbsolutePath.Trim('/');
if (projectId.Length == 0)
{
throw new InvalidOperationException(
$"{ObservabilityOptions.SectionName}:SentryDsn does not contain a project id.");
}
return new Uri($"{uri.Scheme}://{uri.Host}/api/{projectId}/envelope/");
}
}
@@ -1,6 +1,7 @@
using System.IdentityModel.Tokens.Jwt; using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims; using System.Security.Claims;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using SlpModularCms.Core.Observability;
namespace SlpModularCms.Core.Hosting.Security; namespace SlpModularCms.Core.Hosting.Security;
@@ -30,18 +31,18 @@ public sealed class AdminTokenValidator : IAdminTokenValidator
_validationParameters = validationParameters ?? throw new ArgumentNullException(nameof(validationParameters)); _validationParameters = validationParameters ?? throw new ArgumentNullException(nameof(validationParameters));
} }
public bool IsVerifiedAdmin(string? authorizationHeader) public AdminTokenResult Validate(string? authorizationHeader)
{ {
if (string.IsNullOrEmpty(authorizationHeader) || if (string.IsNullOrEmpty(authorizationHeader) ||
!authorizationHeader.StartsWith(BearerPrefix, StringComparison.OrdinalIgnoreCase)) !authorizationHeader.StartsWith(BearerPrefix, StringComparison.OrdinalIgnoreCase))
{ {
return false; return Rejected(BypassRejectionReason.Absent);
} }
var token = authorizationHeader[BearerPrefix.Length..].Trim(); var token = authorizationHeader[BearerPrefix.Length..].Trim();
if (token.Length == 0) if (token.Length == 0)
{ {
return false; return Rejected(BypassRejectionReason.Absent);
} }
ClaimsPrincipal principal; ClaimsPrincipal principal;
@@ -52,13 +53,34 @@ public sealed class AdminTokenValidator : IAdminTokenValidator
// whether to serve is this component's job; returning 401 is not. // whether to serve is this component's job; returning 401 is not.
principal = _handler.ValidateToken(token, _validationParameters, out _); principal = _handler.ValidateToken(token, _validationParameters, out _);
} }
catch (Exception) catch (Exception ex)
{ {
return false; return Rejected(Classify(ex));
} }
return AdminRoles.Any(role => principal.IsInRole(role)) var isAdmin = AdminRoles.Any(role => principal.IsInRole(role))
|| principal.FindAll(ClaimTypes.Role).Any(c => AdminRoles.Contains(c.Value)) || principal.FindAll(ClaimTypes.Role).Any(c => AdminRoles.Contains(c.Value))
|| principal.FindAll("role").Any(c => AdminRoles.Contains(c.Value)); || principal.FindAll("role").Any(c => AdminRoles.Contains(c.Value));
return isAdmin
? new AdminTokenResult(true, default)
: Rejected(BypassRejectionReason.NotAdmin);
} }
private static AdminTokenResult Rejected(BypassRejectionReason reason) => new(false, reason);
/// <summary>
/// Maps a validation exception onto a reason class. The exception type is the only thing
/// inspected — never the token, and never the exception message, which can quote it.
/// </summary>
private static BypassRejectionReason Classify(Exception exception) => exception switch
{
SecurityTokenExpiredException => BypassRejectionReason.Expired,
// Covers SecurityTokenSignatureKeyNotFoundException too, which derives from it — a key
// that cannot be found and a signature that does not verify are the same signal here.
SecurityTokenInvalidSignatureException => BypassRejectionReason.InvalidSignature,
SecurityTokenInvalidIssuerException => BypassRejectionReason.WrongIssuer,
SecurityTokenInvalidAudienceException => BypassRejectionReason.WrongAudience,
_ => BypassRejectionReason.Malformed
};
} }
@@ -1,5 +1,17 @@
using SlpModularCms.Core.Observability;
namespace SlpModularCms.Core.Hosting.Security; namespace SlpModularCms.Core.Hosting.Security;
/// <summary>
/// Outcome of validating an admin bypass token.
/// </summary>
/// <param name="IsVerifiedAdmin">True only for a fully valid Owner or Administrator token.</param>
/// <param name="Reason">
/// Why a rejected token was rejected; meaningless when <paramref name="IsVerifiedAdmin"/> is true.
/// Never the token or any part of it.
/// </param>
public readonly record struct AdminTokenResult(bool IsVerifiedAdmin, BypassRejectionReason Reason);
/// <summary> /// <summary>
/// Decides whether a request carries a genuinely valid Owner or Administrator token. /// Decides whether a request carries a genuinely valid Owner or Administrator token.
/// </summary> /// </summary>
@@ -12,13 +24,25 @@ namespace SlpModularCms.Core.Hosting.Security;
public interface IAdminTokenValidator public interface IAdminTokenValidator
{ {
/// <summary> /// <summary>
/// Returns true only when the supplied Authorization header contains a bearer token that /// Validates the supplied Authorization header and reports both the decision and, on
/// validates successfully and carries the Owner or Administrator role. /// rejection, its cause.
/// </summary> /// </summary>
/// <param name="authorizationHeader">Raw Authorization header value; may be null or empty.</param> /// <param name="authorizationHeader">Raw Authorization header value; may be null or empty.</param>
/// <returns> /// <returns>
/// True when the caller is a verified Owner or Administrator; false in every other case, /// A verified-admin result only for a bearer token that validates successfully and carries the
/// including an absent, malformed, forged, expired or non-admin token. Never throws. /// Owner or Administrator role. Every other case — absent, malformed, forged, expired or
/// non-admin — is a rejection with a reason. Never throws.
/// </returns> /// </returns>
bool IsVerifiedAdmin(string? authorizationHeader); /// <remarks>
/// <b>Deliberately the only method on this interface.</b> An earlier shape had a plain
/// boolean overload alongside this one, and the difference was invisible at a call site: a
/// caller using the boolean form got the right access decision and silently emitted no
/// security event. One method means the reason cannot be skipped by accident.
///
/// The reason class matters operationally:
/// <see cref="BypassRejectionReason.InvalidSignature"/> suggests forgery, while
/// <see cref="BypassRejectionReason.Expired"/> is usually an administrator with a stale tab,
/// and an alert that cannot tell those apart is one nobody acts on.
/// </remarks>
AdminTokenResult Validate(string? authorizationHeader);
} }
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Authorization.Policy;
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
@@ -7,6 +8,7 @@ using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using SlpModularCms.Core.Availability; using SlpModularCms.Core.Availability;
using SlpModularCms.Core.Data; using SlpModularCms.Core.Data;
@@ -16,6 +18,8 @@ using SlpModularCms.Core.Identity.Authorization;
using SlpModularCms.Core.Identity.Entities; using SlpModularCms.Core.Identity.Entities;
using SlpModularCms.Core.Identity.Models; using SlpModularCms.Core.Identity.Models;
using SlpModularCms.Core.Identity.Services; using SlpModularCms.Core.Identity.Services;
using SlpModularCms.Core.Hosting.Observability;
using SlpModularCms.Core.Observability;
using System.Threading.RateLimiting; using System.Threading.RateLimiting;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
@@ -86,6 +90,11 @@ public static class ServiceCollectionExtensions
services.AddSingleton<IAuthorizationHandler, HierarchicalRoleHandler>(); services.AddSingleton<IAuthorizationHandler, HierarchicalRoleHandler>();
// Observes the final authorization result so a denial becomes an alertable event.
// Replaces the framework's handler and delegates straight back to it — the response is
// unchanged.
services.AddSingleton<IAuthorizationMiddlewareResultHandler, SecurityAuthorizationResultHandler>();
// 6. Exception Handling // 6. Exception Handling
services.AddExceptionHandler<GlobalExceptionHandler>(); services.AddExceptionHandler<GlobalExceptionHandler>();
services.AddProblemDetails(); services.AddProblemDetails();
@@ -126,6 +135,24 @@ public static class ServiceCollectionExtensions
{ {
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
// Without this callback a brute-force attempt against /api/v1/Auth/login returns 429
// and leaves NO trace anywhere — the one rate limiter this application has would be
// entirely unobservable, and the alert rule for it could never fire.
options.OnRejected = (context, _) =>
{
var logger = context.HttpContext.RequestServices
.GetRequiredService<ILoggerFactory>()
.CreateLogger(typeof(ServiceCollectionExtensions).FullName!);
SecurityEvents.RateLimitTriggered(
logger,
context.HttpContext.GetEndpoint()?.Metadata
.GetMetadata<EnableRateLimitingAttribute>()?.PolicyName ?? "(unknown)",
context.HttpContext.Request.Path);
return ValueTask.CompletedTask;
};
options.AddFixedWindowLimiter("login", opt => options.AddFixedWindowLimiter("login", opt =>
{ {
var settings = configuration.GetSection("RateLimiting:Login"); var settings = configuration.GetSection("RateLimiting:Login");
@@ -142,6 +169,17 @@ public static class ServiceCollectionExtensions
opt.SegmentsPerWindow = 4; opt.SegmentsPerWindow = 4;
opt.QueueLimit = 0; opt.QueueLimit = 0;
}); });
// Guards the anonymous Sentry tunnel. Generous, because a burst of browser errors is
// exactly when reporting matters most, but bounded, because the endpoint makes an
// outbound HTTPS request per call.
options.AddFixedWindowLimiter(SentryTunnelExtensions.RateLimiterName, opt =>
{
var settings = configuration.GetSection("RateLimiting:SentryTunnel");
opt.PermitLimit = settings.GetValue<int>("PermitLimit", 60);
opt.Window = TimeSpan.FromSeconds(settings.GetValue<int>("WindowSeconds", 60));
opt.QueueLimit = 0;
});
}); });
return services; return services;
@@ -4,12 +4,14 @@ using System.Security.Cryptography;
using System.Text; using System.Text;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using SlpModularCms.Core.Data; using SlpModularCms.Core.Data;
using SlpModularCms.Core.Exceptions; using SlpModularCms.Core.Exceptions;
using SlpModularCms.Core.Identity.Entities; using SlpModularCms.Core.Identity.Entities;
using SlpModularCms.Core.Identity.Models; using SlpModularCms.Core.Identity.Models;
using SlpModularCms.Core.Observability;
namespace SlpModularCms.Core.Identity.Services; namespace SlpModularCms.Core.Identity.Services;
@@ -18,15 +20,18 @@ public class AuthService : IAuthService
private readonly UserManager<ApplicationUser> _userManager; private readonly UserManager<ApplicationUser> _userManager;
private readonly ApplicationDbContext _context; private readonly ApplicationDbContext _context;
private readonly JwtSettings _jwtSettings; private readonly JwtSettings _jwtSettings;
private readonly ILogger<AuthService> _logger;
public AuthService( public AuthService(
UserManager<ApplicationUser> userManager, UserManager<ApplicationUser> userManager,
ApplicationDbContext context, ApplicationDbContext context,
IOptions<JwtSettings> jwtSettings) IOptions<JwtSettings> jwtSettings,
ILogger<AuthService> logger)
{ {
_userManager = userManager; _userManager = userManager;
_context = context; _context = context;
_jwtSettings = jwtSettings.Value; _jwtSettings = jwtSettings.Value;
_logger = logger;
} }
public async Task<TokenResponse> AuthenticateAsync(string email, string password) public async Task<TokenResponse> AuthenticateAsync(string email, string password)
@@ -34,6 +39,11 @@ public class AuthService : IAuthService
var user = await _userManager.FindByEmailAsync(email); var user = await _userManager.FindByEmailAsync(email);
if (user == null || !user.IsActive || !await _userManager.CheckPasswordAsync(user, password)) if (user == null || !user.IsActive || !await _userManager.CheckPasswordAsync(user, password))
{ {
// Repeated occurrences suggest an attack or a forgotten password, and whether the
// account exists is what tells those apart. Deliberately absent from the event: the
// password, the attempted password, and the address itself.
SecurityEvents.FailedLogin(_logger, "/api/v1/Auth/login", accountExists: user is not null);
throw new UnauthorizedException("Ongeldige inloggegevens."); throw new UnauthorizedException("Ongeldige inloggegevens.");
} }
@@ -0,0 +1,34 @@
namespace SlpModularCms.Core.Observability;
/// <summary>
/// Why the availability gate refused an admin bypass token.
/// </summary>
/// <remarks>
/// A classification rather than the token itself, and the distinction is operationally real:
/// <see cref="InvalidSignature"/> means someone is forging tokens, whereas <see cref="Expired"/>
/// is almost always an administrator who left a tab open. An alert that cannot tell those apart
/// is an alert nobody acts on.
/// </remarks>
public enum BypassRejectionReason
{
/// <summary>No Authorization header, or one that is not a bearer token.</summary>
Absent,
/// <summary>Not a readable JWT at all.</summary>
Malformed,
/// <summary>Signature verification failed — the interesting one.</summary>
InvalidSignature,
/// <summary>Valid signature, but past its lifetime.</summary>
Expired,
/// <summary>Signed by a different issuer.</summary>
WrongIssuer,
/// <summary>Issued for a different audience.</summary>
WrongAudience,
/// <summary>A genuinely valid token whose holder is not an Owner or Administrator.</summary>
NotAdmin
}
@@ -0,0 +1,120 @@
using Microsoft.Extensions.Logging;
namespace SlpModularCms.Core.Observability;
/// <summary>Stable tag values for the alertable security events.</summary>
public static class SecurityEventNames
{
public const string FailedLogin = "failed_login";
public const string AuthorizationDenied = "authorization_denied";
public const string MasterApiKeyRejected = "master_api_key_rejected";
public const string AdminBypassRejected = "admin_bypass_rejected";
public const string RateLimitTriggered = "rate_limit_triggered";
public const string MigrationFailure = "migration_failure";
}
/// <summary>
/// The six security events that alert rules are built on.
/// </summary>
/// <remarks>
/// <b>Why source-generated <see cref="LoggerMessageAttribute"/> rather than plain log calls:</b>
/// Sentry groups log-derived events by their message. Emitted with interpolation —
/// <c>LogWarning($"Login failed for {email}")</c> — every distinct email produces a separate
/// Sentry issue, and an alert rule of the form "more than 20 failed logins in five minutes" can
/// then never fire, because no single issue ever reaches 20. The feature would look like it
/// works: events arrive, they are visible, they are tagged. Only the alerting would silently be
/// impossible. A compile-time constant template groups them all into one issue with the variable
/// parts as structured fields.
///
/// Each method also carries a constant <c>SecurityEvent</c> property, which
/// <c>SecurityEventProcessor</c> promotes to a Sentry tag so that alert rules filter on
/// <c>security_event:failed_login</c> rather than on message text. Matching on message text
/// would break the day someone improves the wording — silently, because a rule that matches
/// nothing looks exactly like a rule with nothing to match.
///
/// All six sit at <c>Warning</c> or above by construction, so they cross the Sentry event
/// threshold rather than depending on a coincidence of configuration.
/// </remarks>
public static partial class SecurityEvents
{
public const int FailedLoginEventId = 5001;
public const int AuthorizationDeniedEventId = 5002;
public const int MasterApiKeyRejectedEventId = 5003;
public const int AdminBypassRejectedEventId = 5004;
public const int RateLimitTriggeredEventId = 5005;
public const int MigrationFailureEventId = 5006;
/// <summary>
/// Repeated occurrences suggest an attack or a forgotten password. Carries whether the
/// account exists — useful for telling the two apart — but never the password, the attempted
/// password, or the full address.
/// </summary>
public static void FailedLogin(ILogger logger, string endpoint, bool accountExists) =>
FailedLoginCore(logger, SecurityEventNames.FailedLogin, endpoint, accountExists);
/// <summary>Someone reached an endpoint they lack rights for.</summary>
public static void AuthorizationDenied(ILogger logger, string endpoint, string? requiredPolicy) =>
AuthorizationDeniedCore(logger, SecurityEventNames.AuthorizationDenied, endpoint, requiredPolicy ?? "(unnamed)");
/// <summary>
/// Ambiguous by nature: an intruder, <b>or</b> a key ring that has become unreadable. The
/// data-durability work exists to make the second cause impossible, but if it ever happens
/// this is the first sign of it.
/// </summary>
public static void MasterApiKeyRejected(ILogger logger, string endpoint, string callingHost) =>
MasterApiKeyRejectedCore(logger, SecurityEventNames.MasterApiKeyRejected, endpoint, callingHost);
/// <summary>
/// Only became a meaningful signal once the gate started validating signatures: before that,
/// a forged token succeeded silently.
/// </summary>
public static void AdminBypassRejected(ILogger logger, string path, BypassRejectionReason reason) =>
AdminBypassRejectedCore(logger, SecurityEventNames.AdminBypassRejected, path, reason);
/// <summary>Brute-force pressure. Without this, a 429 leaves no trace anywhere.</summary>
public static void RateLimitTriggered(ILogger logger, string limiterName, string endpoint) =>
RateLimitTriggeredCore(logger, SecurityEventNames.RateLimitTriggered, limiterName, endpoint);
/// <summary>
/// Not a security event, but the one event in this system that needs immediate attention —
/// and the process is about to exit, so it must be flushed before it does.
/// </summary>
public static void MigrationFailure(ILogger logger, Exception exception, int attempts) =>
MigrationFailureCore(logger, exception, SecurityEventNames.MigrationFailure, attempts);
[LoggerMessage(
EventId = FailedLoginEventId,
Level = LogLevel.Warning,
Message = "Security event {SecurityEvent}: login failed on {Endpoint} (account exists: {AccountExists})")]
private static partial void FailedLoginCore(ILogger logger, string securityEvent, string endpoint, bool accountExists);
[LoggerMessage(
EventId = AuthorizationDeniedEventId,
Level = LogLevel.Warning,
Message = "Security event {SecurityEvent}: authorization denied on {Endpoint} (required policy: {RequiredPolicy})")]
private static partial void AuthorizationDeniedCore(ILogger logger, string securityEvent, string endpoint, string requiredPolicy);
[LoggerMessage(
EventId = MasterApiKeyRejectedEventId,
Level = LogLevel.Warning,
Message = "Security event {SecurityEvent}: master API key rejected on {Endpoint} from {CallingHost}")]
private static partial void MasterApiKeyRejectedCore(ILogger logger, string securityEvent, string endpoint, string callingHost);
[LoggerMessage(
EventId = AdminBypassRejectedEventId,
Level = LogLevel.Warning,
Message = "Security event {SecurityEvent}: admin bypass rejected on {Path} (reason: {Reason})")]
private static partial void AdminBypassRejectedCore(ILogger logger, string securityEvent, string path, BypassRejectionReason reason);
[LoggerMessage(
EventId = RateLimitTriggeredEventId,
Level = LogLevel.Warning,
Message = "Security event {SecurityEvent}: rate limit {LimiterName} triggered on {Endpoint}")]
private static partial void RateLimitTriggeredCore(ILogger logger, string securityEvent, string limiterName, string endpoint);
[LoggerMessage(
EventId = MigrationFailureEventId,
Level = LogLevel.Critical,
Message = "Security event {SecurityEvent}: database migration failed after {Attempts} attempt(s)")]
private static partial void MigrationFailureCore(ILogger logger, Exception exception, string securityEvent, int attempts);
}
@@ -26,6 +26,7 @@
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.9" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.9" /> <PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.9" /> <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.9" />
<PackageReference Include="Sentry.AspNetCore" Version="6.8.0" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -1,9 +1,10 @@
using FluentAssertions; using FluentAssertions;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute; using NSubstitute;
using SlpModularCms.Core.Availability; using SlpModularCms.Core.Availability;
using SlpModularCms.Core.Hosting.Security; using SlpModularCms.Core.Hosting.Security;
using SlpModularCms.Core.Observability;
using SlpModularCms.Modules.Availability.Middleware; using SlpModularCms.Modules.Availability.Middleware;
using SlpModularCms.Modules.Availability.Services; using SlpModularCms.Modules.Availability.Services;
@@ -111,7 +112,7 @@ public class AvailabilityMiddlewareMasterGateTests
{ {
var context = new DefaultHttpContext(); var context = new DefaultHttpContext();
context.Request.Headers.Authorization = "Bearer owner-token"; context.Request.Headers.Authorization = "Bearer owner-token";
_adminTokenValidator.IsVerifiedAdmin("Bearer owner-token").Returns(true); _adminTokenValidator.Validate("Bearer owner-token").Returns(new AdminTokenResult(true, default));
_masterSvc.GetMasterStatus().Returns(new MasterGateStatus(false, null)); _masterSvc.GetMasterStatus().Returns(new MasterGateStatus(false, null));
_localSvc.IsAvailableAsync().Returns(AvailabilityStatus.NotAvailable); _localSvc.IsAvailableAsync().Returns(AvailabilityStatus.NotAvailable);
@@ -127,7 +128,7 @@ public class AvailabilityMiddlewareMasterGateTests
var context = new DefaultHttpContext(); var context = new DefaultHttpContext();
context.Response.Body = new MemoryStream(); context.Response.Body = new MemoryStream();
context.Request.Headers.Authorization = "Bearer user-token"; context.Request.Headers.Authorization = "Bearer user-token";
_adminTokenValidator.IsVerifiedAdmin(Arg.Any<string>()).Returns(false); _adminTokenValidator.Validate(Arg.Any<string>()).Returns(new AdminTokenResult(false, BypassRejectionReason.Absent));
_masterSvc.GetMasterStatus().Returns(new MasterGateStatus(false, null)); _masterSvc.GetMasterStatus().Returns(new MasterGateStatus(false, null));
await _middleware.InvokeAsync(context, _localSvc, _masterSvc); await _middleware.InvokeAsync(context, _localSvc, _masterSvc);
@@ -1,4 +1,4 @@
using System.IdentityModel.Tokens.Jwt; using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims; using System.Security.Claims;
using System.Text; using System.Text;
using FluentAssertions; using FluentAssertions;
@@ -9,6 +9,7 @@ using NSubstitute;
using SlpModularCms.Core.Availability; using SlpModularCms.Core.Availability;
using SlpModularCms.Core.Hosting; using SlpModularCms.Core.Hosting;
using SlpModularCms.Core.Hosting.Security; using SlpModularCms.Core.Hosting.Security;
using SlpModularCms.Core.Observability;
using SlpModularCms.Core.Identity.Models; using SlpModularCms.Core.Identity.Models;
using SlpModularCms.Modules.Availability.Middleware; using SlpModularCms.Modules.Availability.Middleware;
using SlpModularCms.Modules.Availability.Services; using SlpModularCms.Modules.Availability.Services;
@@ -145,7 +146,7 @@ public class AvailabilityMiddlewareTests
{ {
var context = new DefaultHttpContext(); var context = new DefaultHttpContext();
context.Request.Headers.Authorization = "Bearer some-token"; context.Request.Headers.Authorization = "Bearer some-token";
_adminTokenValidator.IsVerifiedAdmin("Bearer some-token").Returns(true); _adminTokenValidator.Validate("Bearer some-token").Returns(new AdminTokenResult(true, default));
_service.IsAvailableAsync().Returns(AvailabilityStatus.Maintenance); _service.IsAvailableAsync().Returns(AvailabilityStatus.Maintenance);
await _middleware.InvokeAsync(context, _service, _masterService); await _middleware.InvokeAsync(context, _service, _masterService);
@@ -159,7 +160,7 @@ public class AvailabilityMiddlewareTests
var context = new DefaultHttpContext(); var context = new DefaultHttpContext();
context.Response.Body = new MemoryStream(); context.Response.Body = new MemoryStream();
context.Request.Headers.Authorization = "Bearer some-token"; context.Request.Headers.Authorization = "Bearer some-token";
_adminTokenValidator.IsVerifiedAdmin(Arg.Any<string>()).Returns(false); _adminTokenValidator.Validate(Arg.Any<string>()).Returns(new AdminTokenResult(false, BypassRejectionReason.Absent));
_service.IsAvailableAsync().Returns(AvailabilityStatus.Maintenance); _service.IsAvailableAsync().Returns(AvailabilityStatus.Maintenance);
await _middleware.InvokeAsync(context, _service, _masterService); await _middleware.InvokeAsync(context, _service, _masterService);
@@ -173,7 +174,7 @@ public class AvailabilityMiddlewareTests
{ {
var context = new DefaultHttpContext(); var context = new DefaultHttpContext();
context.Response.Body = new MemoryStream(); context.Response.Body = new MemoryStream();
_adminTokenValidator.IsVerifiedAdmin(Arg.Any<string>()).Returns(false); _adminTokenValidator.Validate(Arg.Any<string>()).Returns(new AdminTokenResult(false, BypassRejectionReason.Absent));
_service.IsAvailableAsync().Returns(AvailabilityStatus.NotAvailable); _service.IsAvailableAsync().Returns(AvailabilityStatus.NotAvailable);
await _middleware.InvokeAsync(context, _service, _masterService); await _middleware.InvokeAsync(context, _service, _masterService);
@@ -1,3 +1,4 @@
using Microsoft.Extensions.Logging.Abstractions;
using FluentAssertions; using FluentAssertions;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -16,7 +17,7 @@ public class MasterControllerTests
public MasterControllerTests() public MasterControllerTests()
{ {
_svc = Substitute.For<IMasterAvailabilityService>(); _svc = Substitute.For<IMasterAvailabilityService>();
_controller = new MasterController(_svc); _controller = new MasterController(_svc, NullLogger<MasterController>.Instance);
_controller.ControllerContext = new ControllerContext _controller.ControllerContext = new ControllerContext
{ {
HttpContext = new DefaultHttpContext() HttpContext = new DefaultHttpContext()
@@ -1,4 +1,6 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using SlpModularCms.Core.Observability;
using SlpModularCms.Modules.Availability.Models; using SlpModularCms.Modules.Availability.Models;
using SlpModularCms.Modules.Availability.Services; using SlpModularCms.Modules.Availability.Services;
@@ -9,36 +11,60 @@ namespace SlpModularCms.Modules.Availability.Controllers;
public class MasterController : ControllerBase public class MasterController : ControllerBase
{ {
private readonly IMasterAvailabilityService _svc; private readonly IMasterAvailabilityService _svc;
private readonly ILogger<MasterController> _logger;
public MasterController(IMasterAvailabilityService svc) => _svc = svc; public MasterController(IMasterAvailabilityService svc, ILogger<MasterController> logger)
{
_svc = svc;
_logger = logger;
}
[HttpPost("register")] [HttpPost("register")]
public async Task<IActionResult> Register([FromBody] RegisterMasterRequest request) public async Task<IActionResult> Register([FromBody] RegisterMasterRequest request)
{ {
var apiKey = Request.Headers["X-Master-Api-Key"].FirstOrDefault(); var apiKey = Request.Headers["X-Master-Api-Key"].FirstOrDefault();
if (string.IsNullOrEmpty(apiKey)) return Unauthorized(); if (string.IsNullOrEmpty(apiKey)) return RejectKey();
var success = await _svc.RegisterAsync(request.MasterUrl, apiKey); var success = await _svc.RegisterAsync(request.MasterUrl, apiKey);
return success ? Ok() : Unauthorized(); return success ? Ok() : RejectKey();
} }
[HttpPost("status")] [HttpPost("status")]
public async Task<IActionResult> PushStatus([FromBody] PushStatusRequest request) public async Task<IActionResult> PushStatus([FromBody] PushStatusRequest request)
{ {
var apiKey = Request.Headers["X-Master-Api-Key"].FirstOrDefault(); var apiKey = Request.Headers["X-Master-Api-Key"].FirstOrDefault();
if (string.IsNullOrEmpty(apiKey)) return Unauthorized(); if (string.IsNullOrEmpty(apiKey)) return RejectKey();
var success = await _svc.PushStatusAsync(apiKey, request.IsAvailable, request.DisableMessage); var success = await _svc.PushStatusAsync(apiKey, request.IsAvailable, request.DisableMessage);
return success ? Ok() : Unauthorized(); return success ? Ok() : RejectKey();
} }
[HttpGet("registered-url")] [HttpGet("registered-url")]
public async Task<IActionResult> GetRegisteredUrl() public async Task<IActionResult> GetRegisteredUrl()
{ {
var apiKey = Request.Headers["X-Master-Api-Key"].FirstOrDefault(); var apiKey = Request.Headers["X-Master-Api-Key"].FirstOrDefault();
if (string.IsNullOrEmpty(apiKey)) return Unauthorized(); if (string.IsNullOrEmpty(apiKey)) return RejectKey();
var url = await _svc.GetRegisteredUrlAsync(apiKey); var url = await _svc.GetRegisteredUrlAsync(apiKey);
return url is not null ? Ok(new { MasterUrl = url }) : Unauthorized(); return url is not null ? Ok(new { MasterUrl = url }) : RejectKey();
}
/// <summary>
/// Reports the rejection and returns 401. Never logs the key or any part of it.
/// </summary>
/// <remarks>
/// This event is ambiguous by nature: it means either an intruder, or that the Data
/// Protection key ring has become unreadable so a legitimate master can no longer be
/// recognised. The durability work exists to make the second cause impossible, but if it ever
/// happens this is the first sign of it — and the two need telling apart quickly.
/// </remarks>
private IActionResult RejectKey()
{
SecurityEvents.MasterApiKeyRejected(
_logger,
Request.Path,
HttpContext.Connection.RemoteIpAddress?.ToString() ?? "(unknown)");
return Unauthorized();
} }
} }
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using SlpModularCms.Core.Availability; using SlpModularCms.Core.Availability;
using SlpModularCms.Core.Hosting.Security; using SlpModularCms.Core.Hosting.Security;
using SlpModularCms.Core.Observability;
using SlpModularCms.Modules.Availability.Services; using SlpModularCms.Modules.Availability.Services;
namespace SlpModularCms.Modules.Availability.Middleware; namespace SlpModularCms.Modules.Availability.Middleware;
@@ -109,6 +110,17 @@ public class AvailabilityMiddleware
/// </remarks> /// </remarks>
private bool IsAdminBypass(HttpContext context) private bool IsAdminBypass(HttpContext context)
{ {
return _adminTokenValidator.IsVerifiedAdmin(context.Request.Headers.Authorization.ToString()); var result = _adminTokenValidator.Validate(context.Request.Headers.Authorization.ToString());
// A rejected bypass only became a meaningful signal once the gate started verifying
// signatures: before that, a forged token succeeded silently. An absent header is not
// reported — every anonymous request to a disabled instance has one, so reporting it
// would drown the cases that matter.
if (!result.IsVerifiedAdmin && result.Reason != BypassRejectionReason.Absent)
{
SecurityEvents.AdminBypassRejected(_logger, context.Request.Path, result.Reason);
}
return result.IsVerifiedAdmin;
} }
} }
@@ -1,3 +1,4 @@
using Microsoft.Extensions.Logging.Abstractions;
using FluentAssertions; using FluentAssertions;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -14,7 +15,7 @@ public class SlaveStatusControllerTests
public SlaveStatusControllerTests() public SlaveStatusControllerTests()
{ {
_controller = new SlaveStatusController(_service); _controller = new SlaveStatusController(_service, NullLogger<SlaveStatusController>.Instance);
_controller.ControllerContext = new ControllerContext _controller.ControllerContext = new ControllerContext
{ {
HttpContext = new DefaultHttpContext() HttpContext = new DefaultHttpContext()
@@ -1,5 +1,7 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using SlpModularCms.Core.Observability;
using SlpModularCms.Modules.Master.Services; using SlpModularCms.Modules.Master.Services;
namespace SlpModularCms.Modules.Master.Controllers; namespace SlpModularCms.Modules.Master.Controllers;
@@ -14,17 +16,30 @@ namespace SlpModularCms.Modules.Master.Controllers;
[ApiController] [ApiController]
[Route("SlaveStatus")] [Route("SlaveStatus")]
[AllowAnonymous] [AllowAnonymous]
public class SlaveStatusController(ICmsInstanceService service) : ControllerBase public class SlaveStatusController(
ICmsInstanceService service,
ILogger<SlaveStatusController> logger) : ControllerBase
{ {
[HttpGet] [HttpGet]
public async Task<IActionResult> Get() public async Task<IActionResult> Get()
{ {
var apiKey = Request.Headers["X-Master-Api-Key"].FirstOrDefault(); var apiKey = Request.Headers["X-Master-Api-Key"].FirstOrDefault();
if (string.IsNullOrEmpty(apiKey)) return Unauthorized(); if (string.IsNullOrEmpty(apiKey)) return RejectKey();
var result = await service.GetStatusForApiKeyAsync(apiKey); var result = await service.GetStatusForApiKeyAsync(apiKey);
if (result is null) return Unauthorized(); if (result is null) return RejectKey();
return Ok(new { result.IsAvailable, result.DisableMessage }); return Ok(new { result.IsAvailable, result.DisableMessage });
} }
/// <summary>Reports the rejection and returns 401. Never logs the key or any part of it.</summary>
private IActionResult RejectKey()
{
SecurityEvents.MasterApiKeyRejected(
logger,
Request.Path,
HttpContext.Connection.RemoteIpAddress?.ToString() ?? "(unknown)");
return Unauthorized();
}
} }