diff --git a/aidlc-docs/features/gitea-deployment-workflow/aidlc-state.md b/aidlc-docs/features/gitea-deployment-workflow/aidlc-state.md
index 843d899..acc8c07 100644
--- a/aidlc-docs/features/gitea-deployment-workflow/aidlc-state.md
+++ b/aidlc-docs/features/gitea-deployment-workflow/aidlc-state.md
@@ -5,7 +5,7 @@
- **Feature Slug**: gitea-deployment-workflow
- **Project Type**: Brownfield
- **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
## 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
- [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
-- [~] 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**
### OPERATIONS
@@ -84,9 +84,9 @@ Execution rounds (Q4 = B): **R1** = U1 + U2 · **R2** = U3 + U4 · **R3** = U5 +
## Current Status
- **Lifecycle Phase**: CONSTRUCTION
-- **Current Stage**: Round 2 — NFR Design complete for U3 Security Headers & CSP and U4 Observability
-- **Next Stage**: Code Generation for U3 + U4
-- **Status**: Awaiting NFR Design approval. Round 1 (U1 + U2) code approved and committed 2026-07-28
+- **Current Stage**: Round 2 complete — U3 Security Headers & CSP and U4 Observability generated, built and tested
+- **Next Stage**: Round 3 — U5 CI Workflow & Gates + U6 Deploy Workflow (U6 needs Infrastructure Design first)
+- **Status**: Awaiting Round 2 code approval. Rounds 1 and 2 committed, nothing pushed
## Round 2 Design Record (2026-07-28)
- 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
- **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`
+
+## 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
diff --git a/aidlc-docs/features/gitea-deployment-workflow/audit.md b/aidlc-docs/features/gitea-deployment-workflow/audit.md
index e41773d..d9b68aa 100644
--- a/aidlc-docs/features/gitea-deployment-workflow/audit.md
+++ b/aidlc-docs/features/gitea-deployment-workflow/audit.md
@@ -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.**
---
+
+## 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` 5001–5006, 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.**
+
+---
diff --git a/aidlc-docs/features/gitea-deployment-workflow/construction/u4-observability/code/generation-summary.md b/aidlc-docs/features/gitea-deployment-workflow/construction/u4-observability/code/generation-summary.md
new file mode 100644
index 0000000..8623b7e
--- /dev/null
+++ b/aidlc-docs/features/gitea-deployment-workflow/construction/u4-observability/code/generation-summary.md
@@ -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` 5001–5006 |
+| `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` |
diff --git a/frontend/package.json b/frontend/package.json
index a13925a..9c61a4d 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -23,6 +23,7 @@
"@radix-ui/react-label": "^2.1.10",
"@radix-ui/react-select": "^2.3.1",
"@radix-ui/react-slot": "^1.3.0",
+ "@sentry/react": "^10.68.0",
"@tanstack/react-query": "^5.101.0",
"@tanstack/react-router": "^1.170.16",
"class-variance-authority": "^0.7.1",
diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml
index 84abb3a..c6960a3 100644
--- a/frontend/pnpm-lock.yaml
+++ b/frontend/pnpm-lock.yaml
@@ -26,6 +26,9 @@ importers:
'@radix-ui/react-slot':
specifier: ^1.3.0
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':
specifier: ^5.101.0
version: 5.101.0(react@19.2.7)
@@ -875,6 +878,40 @@ packages:
'@rolldown/pluginutils@1.0.1':
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':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
@@ -2997,6 +3034,47 @@ snapshots:
'@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/utils@0.3.0': {}
diff --git a/frontend/src/components/SentryErrorBoundary.test.tsx b/frontend/src/components/SentryErrorBoundary.test.tsx
new file mode 100644
index 0000000..cf0af38
--- /dev/null
+++ b/frontend/src/components/SentryErrorBoundary.test.tsx
@@ -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(
+
+
+ ,
+ );
+
+ 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(
+
+
+ ,
+ );
+
+ 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
+ ,
+ );
+
+ 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();
+ });
+});
diff --git a/frontend/src/components/SentryErrorBoundary.tsx b/frontend/src/components/SentryErrorBoundary.tsx
new file mode 100644
index 0000000..a8754cd
--- /dev/null
+++ b/frontend/src/components/SentryErrorBoundary.tsx
@@ -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 (
+
+
+
{t('error.unexpected.title')}
+
{t('error.unexpected.message')}
+
+
+ );
+}
+
+/**
+ * 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 (
+ }
+ >
+ {children}
+
+ );
+}
diff --git a/frontend/src/components/UmamiAnalytics.test.tsx b/frontend/src/components/UmamiAnalytics.test.tsx
new file mode 100644
index 0000000..293c069
--- /dev/null
+++ b/frontend/src/components/UmamiAnalytics.test.tsx
@@ -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 {
+ return document.head.querySelectorAll(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();
+
+ 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();
+
+ 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();
+
+ expect(scripts()).toHaveLength(0);
+ });
+
+ it('injects the script once when configured outside development', () => {
+ vi.stubEnv('DEV', false);
+ configure();
+
+ render();
+
+ 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();
+ rerender();
+ render();
+
+ expect(scripts()).toHaveLength(1);
+ });
+
+ it('renders nothing visible', () => {
+ vi.stubEnv('DEV', false);
+ configure();
+
+ const { container } = render();
+
+ expect(container.firstChild).toBeNull();
+ });
+});
diff --git a/frontend/src/components/UmamiAnalytics.tsx b/frontend/src/components/UmamiAnalytics.tsx
new file mode 100644
index 0000000..00a2e1e
--- /dev/null
+++ b/frontend/src/components/UmamiAnalytics.tsx
@@ -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;
+}
diff --git a/frontend/src/i18n/locales/en/translation.json b/frontend/src/i18n/locales/en/translation.json
index e6b6635..4272d91 100644
--- a/frontend/src/i18n/locales/en/translation.json
+++ b/frontend/src/i18n/locales/en/translation.json
@@ -239,7 +239,11 @@
"title": "Page Not Found",
"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": {
"network": "Unable to reach the server. Check your connection and try again.",
diff --git a/frontend/src/i18n/locales/nl/translation.json b/frontend/src/i18n/locales/nl/translation.json
index e1e78f3..300ba67 100644
--- a/frontend/src/i18n/locales/nl/translation.json
+++ b/frontend/src/i18n/locales/nl/translation.json
@@ -239,7 +239,11 @@
"title": "Pagina niet gevonden",
"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": {
"network": "Kan de server niet bereiken. Controleer je verbinding en probeer opnieuw.",
diff --git a/frontend/src/lib/api-client.test.ts b/frontend/src/lib/api-client.test.ts
index daa2b64..f4111b1 100644
--- a/frontend/src/lib/api-client.test.ts
+++ b/frontend/src/lib/api-client.test.ts
@@ -183,4 +183,31 @@ describe('ApiClient', () => {
const client = makeClient();
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');
+ });
+ });
});
diff --git a/frontend/src/lib/config.test.ts b/frontend/src/lib/config.test.ts
new file mode 100644
index 0000000..4852d7e
--- /dev/null
+++ b/frontend/src/lib/config.test.ts
@@ -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();
+ });
+});
diff --git a/frontend/src/lib/config.ts b/frontend/src/lib/config.ts
index a990647..23271d8 100644
--- a/frontend/src/lib/config.ts
+++ b/frontend/src/lib/config.ts
@@ -6,8 +6,21 @@ import { z } from 'zod';
* once and only warns in development — production trusts the build-time env.
*/
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(),
+ sentryDsn: z.string().optional(),
+ appEnv: z.string().optional(),
+ umamiScriptUrl: z.string().optional(),
+ umamiWebsiteId: z.string().optional(),
});
export type AppConfig = z.infer;
@@ -20,8 +33,16 @@ export function getAppConfig(): 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',
+ 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) {
@@ -35,3 +56,8 @@ export function getAppConfig(): AppConfig {
cached = raw;
return cached;
}
+
+/** Test-only: clears the memoised configuration so a different env can be observed. */
+export function resetAppConfigCache(): void {
+ cached = null;
+}
diff --git a/frontend/src/lib/sentry.test.ts b/frontend/src/lib/sentry.test.ts
new file mode 100644
index 0000000..0508ff7
--- /dev/null
+++ b/frontend/src/lib/sentry.test.ts
@@ -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\//);
+ });
+});
diff --git a/frontend/src/lib/sentry.ts b/frontend/src/lib/sentry.ts
new file mode 100644
index 0000000..86779fd
--- /dev/null
+++ b/frontend/src/lib/sentry.ts
@@ -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,
+ });
+}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index 659059a..3f85b51 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -9,6 +9,13 @@ import { useAuth } from '@/contexts/auth-context';
import { Toaster } from '@/components/ui/sonner';
import { router } from '@/router';
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;
@@ -55,7 +62,15 @@ void enableMocking().then(() => {
-
+ {/*
+ * 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.
+ */}
+
+
+
+
diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts
index c121ccb..00becba 100644
--- a/frontend/src/vite-env.d.ts
+++ b/frontend/src/vite-env.d.ts
@@ -1,15 +1,30 @@
///
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. */
readonly VITE_ENABLE_MSW?: string;
/** Browser tab title; lets local master/slave dev instances be told apart. Defaults to "SlpModularCms". */
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.
}
interface ImportMeta {
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;
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index 6f70454..d04e770 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -3,12 +3,17 @@ import path from 'node:path';
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
+import { version } from './package.json';
// https://vite.dev/config/
export default defineConfig(({ command }) => ({
// Production builds are deployed under /admin (see Program.cs); the dev server keeps serving from '/'.
base: command === 'build' ? '/admin/' : '/',
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: {
alias: {
'@': path.resolve(__dirname, './src'),
@@ -16,6 +21,22 @@ export default defineConfig(({ command }) => ({
},
server: {
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: {
globals: true,
@@ -33,6 +54,9 @@ export default defineConfig(({ command }) => ({
'src/mocks/**',
'src/main.tsx',
'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',
],
},
},
diff --git a/src/SlpModularCms.Api.Slave/Program.cs b/src/SlpModularCms.Api.Slave/Program.cs
index d544d94..7ae84f8 100644
--- a/src/SlpModularCms.Api.Slave/Program.cs
+++ b/src/SlpModularCms.Api.Slave/Program.cs
@@ -1,5 +1,6 @@
-using SlpModularCms.Core.Hosting;
+using SlpModularCms.Core.Hosting;
using SlpModularCms.Core.Hosting.Health;
+using SlpModularCms.Core.Hosting.Observability;
using SlpModularCms.Core.Hosting.Security;
using Scalar.AspNetCore;
@@ -8,6 +9,10 @@ var builder = WebApplication.CreateBuilder(args);
// Load local developer overrides
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
var loggerFactory = LoggerFactory.Create(lb => lb.AddConsole());
var orchestrator = new ModuleOrchestrator(loggerFactory.CreateLogger());
@@ -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
// host without nosniff and HSTS. The path rules that do not apply here simply never match.
builder.Services.AddCmsSecurityHeaders(builder.Configuration);
+builder.Services.AddCmsObservability(builder.Configuration);
// Registered BEFORE module services — see the note in DataProtectionExtensions.
builder.Services.AddCmsDataProtection();
@@ -79,4 +85,8 @@ app.MapControllers();
// API instance looks like, so it behaves like one in every other respect.
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();
diff --git a/src/SlpModularCms.Api.Slave/appsettings.json b/src/SlpModularCms.Api.Slave/appsettings.json
index 78bd163..e682e57 100644
--- a/src/SlpModularCms.Api.Slave/appsettings.json
+++ b/src/SlpModularCms.Api.Slave/appsettings.json
@@ -1,8 +1,11 @@
-{
+{
"Logging": {
"LogLevel": {
- "Default": "Warning",
- "Microsoft.AspNetCore": "Warning"
+ "Default": "Information",
+ "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": "*",
@@ -36,6 +39,10 @@
"Refresh": {
"PermitLimit": 20,
"WindowSeconds": 60
+ },
+ "SentryTunnel": {
+ "PermitLimit": 60,
+ "WindowSeconds": 60
}
},
"SecurityHeaders": {
@@ -47,5 +54,16 @@
],
"AllowedScriptOrigins": [],
"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
}
}
diff --git a/src/SlpModularCms.Api/Program.cs b/src/SlpModularCms.Api/Program.cs
index 6c1447e..914a0b5 100644
--- a/src/SlpModularCms.Api/Program.cs
+++ b/src/SlpModularCms.Api/Program.cs
@@ -1,6 +1,7 @@
using SlpModularCms.Api.Extensions;
using SlpModularCms.Core.Hosting;
using SlpModularCms.Core.Hosting.Health;
+using SlpModularCms.Core.Hosting.Observability;
using SlpModularCms.Core.Hosting.Security;
using Scalar.AspNetCore;
@@ -9,6 +10,15 @@ var builder = WebApplication.CreateBuilder(args);
// Load local developer overrides
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
var loggerFactory = LoggerFactory.Create(lb => lb.AddConsole());
var orchestrator = new ModuleOrchestrator(loggerFactory.CreateLogger());
@@ -20,6 +30,7 @@ builder.Services.AddCmsCors(builder.Configuration);
builder.Services.AddCmsRateLimiting(builder.Configuration);
builder.Services.AddCmsHealthChecks();
builder.Services.AddCmsSecurityHeaders(builder.Configuration);
+builder.Services.AddCmsObservability(builder.Configuration);
// Registered BEFORE module services: modules must not configure Data Protection themselves,
// 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.
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
// instead of 404ing. The "nonfile" constraint keeps genuinely missing assets (e.g. /admin/assets/x.js) as 404s.
app.MapCmsSpaFallbacks();
diff --git a/src/SlpModularCms.Api/appsettings.json b/src/SlpModularCms.Api/appsettings.json
index 2ffd1f6..a7b338b 100644
--- a/src/SlpModularCms.Api/appsettings.json
+++ b/src/SlpModularCms.Api/appsettings.json
@@ -1,8 +1,11 @@
-{
+{
"Logging": {
"LogLevel": {
- "Default": "Warning",
- "Microsoft.AspNetCore": "Warning"
+ "Default": "Information",
+ "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": "*",
@@ -41,6 +44,10 @@
"Refresh": {
"PermitLimit": 20,
"WindowSeconds": 60
+ },
+ "SentryTunnel": {
+ "PermitLimit": 60,
+ "WindowSeconds": 60
}
},
"SecurityHeaders": {
@@ -53,5 +60,16 @@
],
"AllowedScriptOrigins": [],
"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
}
}
diff --git a/src/SlpModularCms.Core.Tests/Hosting/AdminTokenValidatorTests.cs b/src/SlpModularCms.Core.Tests/Hosting/AdminTokenValidatorTests.cs
index 55d05dd..30e6074 100644
--- a/src/SlpModularCms.Core.Tests/Hosting/AdminTokenValidatorTests.cs
+++ b/src/SlpModularCms.Core.Tests/Hosting/AdminTokenValidatorTests.cs
@@ -1,4 +1,4 @@
-using System.IdentityModel.Tokens.Jwt;
+using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using FluentAssertions;
@@ -46,7 +46,7 @@ public class AdminTokenValidatorTests
{
var header = $"Bearer {CreateToken(role)}";
- _validator.IsVerifiedAdmin(header).Should().BeTrue();
+ _validator.Validate(header).IsVerifiedAdmin.Should().BeTrue();
}
[Fact]
@@ -54,7 +54,7 @@ public class AdminTokenValidatorTests
{
var header = $"Bearer {CreateToken("User")}";
- _validator.IsVerifiedAdmin(header).Should().BeFalse();
+ _validator.Validate(header).IsVerifiedAdmin.Should().BeFalse();
}
[Fact]
@@ -64,7 +64,7 @@ public class AdminTokenValidatorTests
// signed by us. Reading claims without validating would have accepted this.
var forged = CreateUnsignedToken("Owner");
- _validator.IsVerifiedAdmin($"Bearer {forged}").Should().BeFalse();
+ _validator.Validate($"Bearer {forged}").IsVerifiedAdmin.Should().BeFalse();
}
[Fact]
@@ -83,7 +83,7 @@ public class AdminTokenValidatorTests
var header = $"Bearer {new JwtSecurityTokenHandler().WriteToken(token)}";
- _validator.IsVerifiedAdmin(header).Should().BeFalse();
+ _validator.Validate(header).IsVerifiedAdmin.Should().BeFalse();
}
[Fact]
@@ -91,7 +91,7 @@ public class AdminTokenValidatorTests
{
var header = $"Bearer {CreateToken("Owner", expiresIn: TimeSpan.FromMinutes(-5))}";
- _validator.IsVerifiedAdmin(header).Should().BeFalse();
+ _validator.Validate(header).IsVerifiedAdmin.Should().BeFalse();
}
[Fact]
@@ -99,7 +99,7 @@ public class AdminTokenValidatorTests
{
var header = $"Bearer {CreateToken("Owner", issuer: "SomeoneElse")}";
- _validator.IsVerifiedAdmin(header).Should().BeFalse();
+ _validator.Validate(header).IsVerifiedAdmin.Should().BeFalse();
}
[Theory]
@@ -115,7 +115,7 @@ public class AdminTokenValidatorTests
{
// Never throws — an unusable header simply means "not an admin". Rejecting the request
// 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(
diff --git a/src/SlpModularCms.Core.Tests/Hosting/DeployedConfigurationTests.cs b/src/SlpModularCms.Core.Tests/Hosting/DeployedConfigurationTests.cs
new file mode 100644
index 0000000..c7428b5
--- /dev/null
+++ b/src/SlpModularCms.Core.Tests/Hosting/DeployedConfigurationTests.cs
@@ -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;
+
+///
+/// Loads the committed appsettings.json of both hosts and runs the startup validators
+/// against them.
+///
+///
+/// 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 // 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 SecurityHeaders policy names are validated with ValidateOnStart, so a typo in
+/// the committed file stops the process. Better to fail here than in a deployment.
+///
+public class DeployedConfigurationTests
+{
+ public static TheoryData 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().Validate();
+
+ act.Should().NotThrow();
+ }
+
+ [Theory]
+ [MemberData(nameof(HostConfigurations))]
+ public void HostConfiguration_ShouldShipWithoutASentryDsn(string relativePath)
+ {
+ var options = Load(relativePath)
+ .GetSection(ObservabilityOptions.SectionName)
+ .Get();
+
+ 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);
+ }
+
+ ///
+ /// At Information, 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.
+ ///
+ [Theory]
+ [MemberData(nameof(HostConfigurations))]
+ public void HostConfiguration_ShouldPinEfCommandLoggingBelowInformation(string relativePath)
+ {
+ var level = Load(relativePath)["Logging:LogLevel:Microsoft.EntityFrameworkCore.Database.Command"];
+
+ level.Should().Be("Warning");
+ }
+
+ ///
+ /// 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.
+ ///
+ [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("RateLimiting:SentryTunnel:PermitLimit").Should().BeGreaterThan(0);
+ configuration.GetValue("RateLimiting:SentryTunnel:WindowSeconds").Should().BeGreaterThan(0);
+ }
+
+ /// The admin SPA must never fall to the relaxed policy on the host that serves it.
+ [Fact]
+ public void MasterHostConfiguration_ShouldApplyTheStrictPolicyToAdmin()
+ {
+ var options = Load(Path.Combine("host-config", "master.appsettings.json"))
+ .GetSection(SecurityHeadersOptions.SectionName)
+ .Get();
+
+ options.Should().NotBeNull();
+ options!.Enabled.Should().BeTrue();
+ options.PathPolicies
+ .Should().Contain(rule => rule.PathPrefix == "/admin" && rule.Policy == CspPolicyCatalog.Strict);
+ }
+}
diff --git a/src/SlpModularCms.Core.Tests/Hosting/Observability/SecurityEventsTests.cs b/src/SlpModularCms.Core.Tests/Hosting/Observability/SecurityEventsTests.cs
new file mode 100644
index 0000000..bdfd748
--- /dev/null
+++ b/src/SlpModularCms.Core.Tests/Hosting/Observability/SecurityEventsTests.cs
@@ -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;
+
+///
+/// Guards the property that makes alert rules possible at all.
+///
+///
+/// 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.
+///
+public class SecurityEventsTests
+{
+ private sealed class CapturingLogger : ILogger
+ {
+ public List<(LogLevel Level, EventId EventId, string Message)> Entries { get; } = [];
+
+ public IDisposable? BeginScope(TState state) where TState : notnull => null;
+
+ public bool IsEnabled(LogLevel logLevel) => true;
+
+ public void Log(
+ LogLevel logLevel,
+ EventId eventId,
+ TState state,
+ Exception? exception,
+ Func 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);
+ }
+
+ ///
+ /// All six must cross the Sentry event threshold by construction rather than by a
+ /// coincidence of configuration.
+ ///
+ [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);
+ }
+
+ ///
+ /// The reason class distinguishes forgery from a stale tab. The token itself must never
+ /// appear anywhere in the entry.
+ ///
+ [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);
+ }
+
+ ///
+ /// 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.
+ ///
+ [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);
+ }
+}
diff --git a/src/SlpModularCms.Core.Tests/Hosting/Observability/SentryEventScrubberTests.cs b/src/SlpModularCms.Core.Tests/Hosting/Observability/SentryEventScrubberTests.cs
new file mode 100644
index 0000000..5a15ff1
--- /dev/null
+++ b/src/SlpModularCms.Core.Tests/Hosting/Observability/SentryEventScrubberTests.cs
@@ -0,0 +1,111 @@
+using FluentAssertions;
+using Sentry;
+using SlpModularCms.Core.Hosting.Observability;
+using Xunit;
+
+namespace SlpModularCms.Core.Tests.Hosting.Observability;
+
+///
+/// The most security-critical code in the observability unit.
+///
+///
+/// Enabling SendDefaultPii attaches request headers, and this application carries two
+/// standing credentials in them: the refreshToken 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.
+///
+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();
+ }
+
+ ///
+ /// The retained fields are the entire diagnostic value of the request context. Scrubbing
+ /// everything would be safe and useless.
+ ///
+ [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();
+ }
+
+ ///
+ /// Transactions carry request data too, and are the channel nobody thinks of. Raising
+ /// TracesSampleRate without this would start leaking headers.
+ ///
+ [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();
+ }
+
+ ///
+ /// A guard against the list shrinking by accident. Each entry has a reason recorded next to
+ /// it in the implementation.
+ ///
+ [Fact]
+ public void RemovedHeaders_ShouldCoverAllFourCredentialCarriers()
+ {
+ SentryEventScrubber.RemovedHeaders.Should().BeEquivalentTo(
+ ["Cookie", "Set-Cookie", "Authorization", "X-Master-Api-Key"]);
+ }
+}
diff --git a/src/SlpModularCms.Core.Tests/Hosting/Observability/SentryTunnelTargetTests.cs b/src/SlpModularCms.Core.Tests/Hosting/Observability/SentryTunnelTargetTests.cs
new file mode 100644
index 0000000..3b4a0f7
--- /dev/null
+++ b/src/SlpModularCms.Core.Tests/Hosting/Observability/SentryTunnelTargetTests.cs
@@ -0,0 +1,69 @@
+using FluentAssertions;
+using Microsoft.Extensions.Options;
+using SlpModularCms.Core.Hosting.Observability;
+using Xunit;
+
+namespace SlpModularCms.Core.Tests.Hosting.Observability;
+
+///
+/// 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.
+///
+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();
+ }
+
+ ///
+ /// Fails at startup rather than per request, consistent with failing closed everywhere else.
+ ///
+ [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();
+ }
+
+ [Fact]
+ public void Constructor_ShouldFallBackToADefault_WhenTheSizeCapIsNotPositive()
+ {
+ var target = Create("https://abc@host.sentry.io/42", maxBytes: 0);
+
+ target.MaxPayloadBytes.Should().BeGreaterThan(0);
+ }
+
+ /// Kept in sync with the frontend's tunnel option and the vite dev proxy.
+ [Fact]
+ public void Path_ShouldBeOutsideTheVersionedApi()
+ {
+ SentryTunnelTarget.Path.Should().Be("/sentry-tunnel");
+ SentryTunnelTarget.Path.Should().NotStartWith("/api/");
+ }
+}
diff --git a/src/SlpModularCms.Core.Tests/Hosting/Security/AdminTokenRejectionReasonTests.cs b/src/SlpModularCms.Core.Tests/Hosting/Security/AdminTokenRejectionReasonTests.cs
new file mode 100644
index 0000000..d17242c
--- /dev/null
+++ b/src/SlpModularCms.Core.Tests/Hosting/Security/AdminTokenRejectionReasonTests.cs
@@ -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;
+
+///
+/// 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.
+///
+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();
+ }
+}
diff --git a/src/SlpModularCms.Core.Tests/Identity/AuthServiceTests.cs b/src/SlpModularCms.Core.Tests/Identity/AuthServiceTests.cs
index b3e6f7f..7501779 100644
--- a/src/SlpModularCms.Core.Tests/Identity/AuthServiceTests.cs
+++ b/src/SlpModularCms.Core.Tests/Identity/AuthServiceTests.cs
@@ -1,3 +1,4 @@
+using Microsoft.Extensions.Logging.Abstractions;
using FluentAssertions;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
@@ -38,7 +39,7 @@ public class AuthServiceTests
RefreshTokenExpiryDays = 7
};
- _service = new AuthService(_userManager, _context, Options.Create(jwtSettings));
+ _service = new AuthService(_userManager, _context, Options.Create(jwtSettings), NullLogger.Instance);
}
[Fact]
diff --git a/src/SlpModularCms.Core.Tests/SlpModularCms.Core.Tests.csproj b/src/SlpModularCms.Core.Tests/SlpModularCms.Core.Tests.csproj
index a4ad655..2d25a44 100644
--- a/src/SlpModularCms.Core.Tests/SlpModularCms.Core.Tests.csproj
+++ b/src/SlpModularCms.Core.Tests/SlpModularCms.Core.Tests.csproj
@@ -31,4 +31,14 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/SlpModularCms.Core/Hosting/DatabaseMigrationExtensions.cs b/src/SlpModularCms.Core/Hosting/DatabaseMigrationExtensions.cs
index 9b4ec87..a7d708b 100644
--- a/src/SlpModularCms.Core/Hosting/DatabaseMigrationExtensions.cs
+++ b/src/SlpModularCms.Core/Hosting/DatabaseMigrationExtensions.cs
@@ -3,7 +3,9 @@ using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
+using Sentry;
using SlpModularCms.Core.Data;
+using SlpModularCms.Core.Observability;
namespace SlpModularCms.Core.Hosting;
@@ -79,10 +81,15 @@ public static class DatabaseMigrationExtensions
{
// Logged with the failure reason but never the connection string or credentials —
// this message travels to the console and to Sentry.
- logger.LogCritical(
- ex,
- "Core database migration failed after {Attempts} attempt(s). The application will not start.",
- attempt);
+ SecurityEvents.MigrationFailure(logger, ex, attempt);
+
+ // The SDK batches and sends in the background, and this process is about to exit
+ // — 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;
}
diff --git a/src/SlpModularCms.Core/Hosting/Observability/LoggingExtensions.cs b/src/SlpModularCms.Core/Hosting/Observability/LoggingExtensions.cs
new file mode 100644
index 0000000..99db75d
--- /dev/null
+++ b/src/SlpModularCms.Core/Hosting/Observability/LoggingExtensions.cs
@@ -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
+{
+ ///
+ /// Configures structured console logging with a correlation identifier on every entry.
+ ///
+ ///
+ /// Must be called before Sentry is registered, so that a problem initialising Sentry
+ /// is itself logged.
+ ///
+ 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;
+ }
+}
diff --git a/src/SlpModularCms.Core/Hosting/Observability/ObservabilityOptions.cs b/src/SlpModularCms.Core/Hosting/Observability/ObservabilityOptions.cs
new file mode 100644
index 0000000..c02a9e6
--- /dev/null
+++ b/src/SlpModularCms.Core/Hosting/Observability/ObservabilityOptions.cs
@@ -0,0 +1,52 @@
+namespace SlpModularCms.Core.Hosting.Observability;
+
+///
+/// Configuration for logging and error reporting.
+///
+///
+/// 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 not 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.
+///
+public sealed class ObservabilityOptions
+{
+ public const string SectionName = "Observability";
+
+ ///
+ /// Sentry DSN. Empty means Sentry is skipped entirely.
+ ///
+ ///
+ /// 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 (Observability__SentryDsn) in test and production rather than being
+ /// committed: it belongs to an account, and the repository is not where it should be written
+ /// down.
+ ///
+ public string SentryDsn { get; set; } = string.Empty;
+
+ /// Environment tag. Falls back to ASPNETCORE_ENVIRONMENT when empty.
+ public string Environment { get; set; } = string.Empty;
+
+ ///
+ /// Performance sampling rate.
+ ///
+ ///
+ /// 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.
+ ///
+ public double TracesSampleRate { get; set; } = 0.1;
+
+ ///
+ /// 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.
+ ///
+ public int TunnelMaxPayloadBytes { get; set; } = 204_800;
+
+ public bool IsSentryConfigured => !string.IsNullOrWhiteSpace(SentryDsn);
+}
diff --git a/src/SlpModularCms.Core/Hosting/Observability/SecurityAuthorizationResultHandler.cs b/src/SlpModularCms.Core/Hosting/Observability/SecurityAuthorizationResultHandler.cs
new file mode 100644
index 0000000..87b712c
--- /dev/null
+++ b/src/SlpModularCms.Core/Hosting/Observability/SecurityAuthorizationResultHandler.cs
@@ -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;
+
+///
+/// Reports authorization denials, then defers entirely to the framework's own handler.
+///
+///
+/// Hooked in at rather than inside an
+/// , 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.
+///
+public sealed class SecurityAuthorizationResultHandler : IAuthorizationMiddlewareResultHandler
+{
+ private readonly AuthorizationMiddlewareResultHandler _inner = new();
+ private readonly ILogger _logger;
+
+ public SecurityAuthorizationResultHandler(ILogger 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);
+ }
+
+ ///
+ /// 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.
+ ///
+ private static string DescribePolicy(AuthorizationPolicy policy) =>
+ string.Join(", ", policy.Requirements.Select(requirement => requirement.GetType().Name));
+}
diff --git a/src/SlpModularCms.Core/Hosting/Observability/SecurityEventProcessor.cs b/src/SlpModularCms.Core/Hosting/Observability/SecurityEventProcessor.cs
new file mode 100644
index 0000000..fe59d46
--- /dev/null
+++ b/src/SlpModularCms.Core/Hosting/Observability/SecurityEventProcessor.cs
@@ -0,0 +1,51 @@
+using System.Collections.Frozen;
+using Sentry;
+using Sentry.Extensibility;
+using SlpModularCms.Core.Observability;
+
+namespace SlpModularCms.Core.Hosting.Observability;
+
+///
+/// Promotes a security event's constant name to a Sentry tag.
+///
+///
+/// Alert rules then filter on security_event:failed_login, 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.
+///
+public sealed class SecurityEventProcessor : ISentryEventProcessor
+{
+ public const string TagName = "security_event";
+
+ private static readonly FrozenSet KnownEventNames = new[]
+ {
+ SecurityEventNames.FailedLogin,
+ SecurityEventNames.AuthorizationDenied,
+ SecurityEventNames.MasterApiKeyRejected,
+ SecurityEventNames.AdminBypassRejected,
+ SecurityEventNames.RateLimitTriggered,
+ SecurityEventNames.MigrationFailure
+ }.ToFrozenSet(StringComparer.Ordinal);
+
+ ///
+ /// The structured property name that attaches to every entry.
+ ///
+ 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;
+ }
+}
diff --git a/src/SlpModularCms.Core/Hosting/Observability/SentryEventScrubber.cs b/src/SlpModularCms.Core/Hosting/Observability/SentryEventScrubber.cs
new file mode 100644
index 0000000..d293c69
--- /dev/null
+++ b/src/SlpModularCms.Core/Hosting/Observability/SentryEventScrubber.cs
@@ -0,0 +1,101 @@
+using Sentry;
+
+namespace SlpModularCms.Core.Hosting.Observability;
+
+/// Removes credentials from an outbound Sentry event.
+public interface ISentryEventScrubber
+{
+ SentryEvent Scrub(SentryEvent sentryEvent);
+
+ SentryTransaction ScrubTransaction(SentryTransaction transaction);
+}
+
+///
+/// Strips credential-bearing headers and the request body before anything leaves the process.
+///
+///
+/// Enabling SendDefaultPii attaches request headers, and "PII" understates what this
+/// application carries in them: a refreshToken cookie and the master/slave shared secret
+/// are credentials, not merely personal data. Sending them to a third party would be
+/// worse than the problem the setting solves.
+///
+/// This runs in-process, before transmission. 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 UseSentry callback because it is the most
+/// security-critical code in this unit, and a lambda there cannot be unit-tested without
+/// initialising the SDK.
+///
+public sealed class SentryEventScrubber : ISentryEventScrubber
+{
+ ///
+ /// A static readonly array in code, never an options property: a configurable scrub
+ /// list is a supported way to switch the protection off by omission.
+ ///
+ public static IReadOnlyList 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;
+ }
+
+ ///
+ /// 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 TracesSampleRate later, without touching this file, would
+ /// start leaking.
+ ///
+ public SentryTransaction ScrubTransaction(SentryTransaction transaction)
+ {
+ ArgumentNullException.ThrowIfNull(transaction);
+
+ ScrubRequest(transaction.Request);
+ return transaction;
+ }
+
+ ///
+ /// What is deliberately retained: method, path, query string, user agent, IP address,
+ /// authenticated username and the correlation ID — all genuinely diagnostic.
+ ///
+ ///
+ /// Query strings are kept even though invitation tokens travel as ?token=… 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.
+ ///
+ 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;
+ }
+}
diff --git a/src/SlpModularCms.Core/Hosting/Observability/SentryExtensions.cs b/src/SlpModularCms.Core/Hosting/Observability/SentryExtensions.cs
new file mode 100644
index 0000000..2d01d64
--- /dev/null
+++ b/src/SlpModularCms.Core/Hosting/Observability/SentryExtensions.cs
@@ -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()
+ .Bind(configuration.GetSection(ObservabilityOptions.SectionName))
+ .ValidateOnStart();
+
+ services.AddSingleton();
+ services.AddSingleton();
+
+ // Resolving this at startup is what makes an unparseable DSN a startup failure rather
+ // than a per-request one.
+ services.AddSingleton();
+
+ 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;
+ }
+
+ ///
+ /// Registers Sentry, or does nothing at all when no DSN is configured.
+ ///
+ ///
+ /// An absent DSN is a normal, supported state and produces no warning. Local
+ /// development is the common case, and a startup warning that always appears trains people to
+ /// ignore startup warnings — including the ones that matter.
+ ///
+ public static IWebHostBuilder UseCmsSentry(this IWebHostBuilder webHost, IConfiguration configuration)
+ {
+ ArgumentNullException.ThrowIfNull(webHost);
+ ArgumentNullException.ThrowIfNull(configuration);
+
+ var settings = configuration.GetSection(ObservabilityOptions.SectionName).Get()
+ ?? 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()?.InformationalVersion
+ ?? assembly.GetName().Version?.ToString()
+ ?? "unknown";
+ }
+}
diff --git a/src/SlpModularCms.Core/Hosting/Observability/SentryTunnelExtensions.cs b/src/SlpModularCms.Core/Hosting/Observability/SentryTunnelExtensions.cs
new file mode 100644
index 0000000..4f1b5d1
--- /dev/null
+++ b/src/SlpModularCms.Core/Hosting/Observability/SentryTunnelExtensions.cs
@@ -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;
+
+///
+/// Forwards browser Sentry envelopes through this application's own origin.
+///
+///
+/// Ad blockers block requests to Sentry domains with ERR_BLOCKED_BY_CLIENT. 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 connect-src 'self' 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.
+///
+[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 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();
+ }
+
+ ///
+ /// Reads at most . Returns null when the stream carries more.
+ ///
+ private static async Task 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];
+ }
+}
diff --git a/src/SlpModularCms.Core/Hosting/Observability/SentryTunnelTarget.cs b/src/SlpModularCms.Core/Hosting/Observability/SentryTunnelTarget.cs
new file mode 100644
index 0000000..784ba5f
--- /dev/null
+++ b/src/SlpModularCms.Core/Hosting/Observability/SentryTunnelTarget.cs
@@ -0,0 +1,71 @@
+using Microsoft.Extensions.Options;
+
+namespace SlpModularCms.Core.Hosting.Observability;
+
+///
+/// The one destination the Sentry tunnel is allowed to forward to, derived from the configured
+/// DSN at startup.
+///
+///
+/// This class is the rule that keeps the tunnel from being a liability. 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.
+///
+public sealed class SentryTunnelTarget
+{
+ /// Same path the reference project uses, where nginx serves it.
+ ///
+ /// 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
+ /// tunnel option, the vite dev proxy and an operator's muscle memory all carry over
+ /// unchanged between the two workspaces.
+ ///
+ /// Deliberately outside /api/v1: it is not a versioned CMS API, it must not appear in
+ /// the OpenAPI document, and /api/v1 maps to the strict CSP policy for reasons that
+ /// have nothing to do with this endpoint.
+ ///
+ public const string Path = "/sentry-tunnel";
+
+ /// Null when no DSN is configured — the tunnel then accepts nothing.
+ public Uri? EnvelopeEndpoint { get; }
+
+ public int MaxPayloadBytes { get; }
+
+ public bool IsConfigured => EnvelopeEndpoint is not null;
+
+ public SentryTunnelTarget(IOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+ var value = options.Value;
+
+ MaxPayloadBytes = value.TunnelMaxPayloadBytes > 0 ? value.TunnelMaxPayloadBytes : 204_800;
+ EnvelopeEndpoint = value.IsSentryConfigured ? BuildEnvelopeEndpoint(value.SentryDsn) : null;
+ }
+
+ ///
+ /// Turns https://{publicKey}@{host}/{projectId} into
+ /// https://{host}/api/{projectId}/envelope/.
+ ///
+ 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/");
+ }
+}
diff --git a/src/SlpModularCms.Core/Hosting/Security/AdminTokenValidator.cs b/src/SlpModularCms.Core/Hosting/Security/AdminTokenValidator.cs
index c5c5108..be3e154 100644
--- a/src/SlpModularCms.Core/Hosting/Security/AdminTokenValidator.cs
+++ b/src/SlpModularCms.Core/Hosting/Security/AdminTokenValidator.cs
@@ -1,6 +1,7 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using Microsoft.IdentityModel.Tokens;
+using SlpModularCms.Core.Observability;
namespace SlpModularCms.Core.Hosting.Security;
@@ -30,18 +31,18 @@ public sealed class AdminTokenValidator : IAdminTokenValidator
_validationParameters = validationParameters ?? throw new ArgumentNullException(nameof(validationParameters));
}
- public bool IsVerifiedAdmin(string? authorizationHeader)
+ public AdminTokenResult Validate(string? authorizationHeader)
{
if (string.IsNullOrEmpty(authorizationHeader) ||
!authorizationHeader.StartsWith(BearerPrefix, StringComparison.OrdinalIgnoreCase))
{
- return false;
+ return Rejected(BypassRejectionReason.Absent);
}
var token = authorizationHeader[BearerPrefix.Length..].Trim();
if (token.Length == 0)
{
- return false;
+ return Rejected(BypassRejectionReason.Absent);
}
ClaimsPrincipal principal;
@@ -52,13 +53,34 @@ public sealed class AdminTokenValidator : IAdminTokenValidator
// whether to serve is this component's job; returning 401 is not.
principal = _handler.ValidateToken(token, _validationParameters, out _);
}
- catch (Exception)
+ catch (Exception ex)
{
- return false;
+ return Rejected(Classify(ex));
}
- return AdminRoles.Any(role => principal.IsInRole(role))
- || principal.FindAll(ClaimTypes.Role).Any(c => AdminRoles.Contains(c.Value))
- || principal.FindAll("role").Any(c => AdminRoles.Contains(c.Value));
+ var isAdmin = AdminRoles.Any(role => principal.IsInRole(role))
+ || principal.FindAll(ClaimTypes.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);
+
+ ///
+ /// 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.
+ ///
+ 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
+ };
}
diff --git a/src/SlpModularCms.Core/Hosting/Security/IAdminTokenValidator.cs b/src/SlpModularCms.Core/Hosting/Security/IAdminTokenValidator.cs
index 705ee38..dd7743b 100644
--- a/src/SlpModularCms.Core/Hosting/Security/IAdminTokenValidator.cs
+++ b/src/SlpModularCms.Core/Hosting/Security/IAdminTokenValidator.cs
@@ -1,5 +1,17 @@
+using SlpModularCms.Core.Observability;
+
namespace SlpModularCms.Core.Hosting.Security;
+///
+/// Outcome of validating an admin bypass token.
+///
+/// True only for a fully valid Owner or Administrator token.
+///
+/// Why a rejected token was rejected; meaningless when is true.
+/// Never the token or any part of it.
+///
+public readonly record struct AdminTokenResult(bool IsVerifiedAdmin, BypassRejectionReason Reason);
+
///
/// Decides whether a request carries a genuinely valid Owner or Administrator token.
///
@@ -12,13 +24,25 @@ namespace SlpModularCms.Core.Hosting.Security;
public interface IAdminTokenValidator
{
///
- /// Returns true only when the supplied Authorization header contains a bearer token that
- /// validates successfully and carries the Owner or Administrator role.
+ /// Validates the supplied Authorization header and reports both the decision and, on
+ /// rejection, its cause.
///
/// Raw Authorization header value; may be null or empty.
///
- /// True when the caller is a verified Owner or Administrator; false in every other case,
- /// including an absent, malformed, forged, expired or non-admin token. Never throws.
+ /// A verified-admin result only for a bearer token that validates successfully and carries the
+ /// Owner or Administrator role. Every other case — absent, malformed, forged, expired or
+ /// non-admin — is a rejection with a reason. Never throws.
///
- bool IsVerifiedAdmin(string? authorizationHeader);
+ ///
+ /// Deliberately the only method on this interface. 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:
+ /// suggests forgery, while
+ /// is usually an administrator with a stale tab,
+ /// and an alert that cannot tell those apart is one nobody acts on.
+ ///
+ AdminTokenResult Validate(string? authorizationHeader);
}
diff --git a/src/SlpModularCms.Core/Hosting/ServiceCollectionExtensions.cs b/src/SlpModularCms.Core/Hosting/ServiceCollectionExtensions.cs
index f573781..2c409df 100644
--- a/src/SlpModularCms.Core/Hosting/ServiceCollectionExtensions.cs
+++ b/src/SlpModularCms.Core/Hosting/ServiceCollectionExtensions.cs
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Authorization.Policy;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
@@ -7,6 +8,7 @@ using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
using Microsoft.IdentityModel.Tokens;
using SlpModularCms.Core.Availability;
using SlpModularCms.Core.Data;
@@ -16,6 +18,8 @@ using SlpModularCms.Core.Identity.Authorization;
using SlpModularCms.Core.Identity.Entities;
using SlpModularCms.Core.Identity.Models;
using SlpModularCms.Core.Identity.Services;
+using SlpModularCms.Core.Hosting.Observability;
+using SlpModularCms.Core.Observability;
using System.Threading.RateLimiting;
using System.Diagnostics.CodeAnalysis;
@@ -86,6 +90,11 @@ public static class ServiceCollectionExtensions
services.AddSingleton();
+ // 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();
+
// 6. Exception Handling
services.AddExceptionHandler();
services.AddProblemDetails();
@@ -126,6 +135,24 @@ public static class ServiceCollectionExtensions
{
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()
+ .CreateLogger(typeof(ServiceCollectionExtensions).FullName!);
+
+ SecurityEvents.RateLimitTriggered(
+ logger,
+ context.HttpContext.GetEndpoint()?.Metadata
+ .GetMetadata()?.PolicyName ?? "(unknown)",
+ context.HttpContext.Request.Path);
+
+ return ValueTask.CompletedTask;
+ };
+
options.AddFixedWindowLimiter("login", opt =>
{
var settings = configuration.GetSection("RateLimiting:Login");
@@ -142,6 +169,17 @@ public static class ServiceCollectionExtensions
opt.SegmentsPerWindow = 4;
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("PermitLimit", 60);
+ opt.Window = TimeSpan.FromSeconds(settings.GetValue("WindowSeconds", 60));
+ opt.QueueLimit = 0;
+ });
});
return services;
diff --git a/src/SlpModularCms.Core/Identity/Services/AuthService.cs b/src/SlpModularCms.Core/Identity/Services/AuthService.cs
index 238bed7..78a9599 100644
--- a/src/SlpModularCms.Core/Identity/Services/AuthService.cs
+++ b/src/SlpModularCms.Core/Identity/Services/AuthService.cs
@@ -4,12 +4,14 @@ using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using SlpModularCms.Core.Data;
using SlpModularCms.Core.Exceptions;
using SlpModularCms.Core.Identity.Entities;
using SlpModularCms.Core.Identity.Models;
+using SlpModularCms.Core.Observability;
namespace SlpModularCms.Core.Identity.Services;
@@ -18,15 +20,18 @@ public class AuthService : IAuthService
private readonly UserManager _userManager;
private readonly ApplicationDbContext _context;
private readonly JwtSettings _jwtSettings;
+ private readonly ILogger _logger;
public AuthService(
UserManager userManager,
ApplicationDbContext context,
- IOptions jwtSettings)
+ IOptions jwtSettings,
+ ILogger logger)
{
_userManager = userManager;
_context = context;
_jwtSettings = jwtSettings.Value;
+ _logger = logger;
}
public async Task AuthenticateAsync(string email, string password)
@@ -34,6 +39,11 @@ public class AuthService : IAuthService
var user = await _userManager.FindByEmailAsync(email);
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.");
}
diff --git a/src/SlpModularCms.Core/Observability/BypassRejectionReason.cs b/src/SlpModularCms.Core/Observability/BypassRejectionReason.cs
new file mode 100644
index 0000000..1c913d5
--- /dev/null
+++ b/src/SlpModularCms.Core/Observability/BypassRejectionReason.cs
@@ -0,0 +1,34 @@
+namespace SlpModularCms.Core.Observability;
+
+///
+/// Why the availability gate refused an admin bypass token.
+///
+///
+/// A classification rather than the token itself, and the distinction is operationally real:
+/// means someone is forging tokens, whereas
+/// is almost always an administrator who left a tab open. An alert that cannot tell those apart
+/// is an alert nobody acts on.
+///
+public enum BypassRejectionReason
+{
+ /// No Authorization header, or one that is not a bearer token.
+ Absent,
+
+ /// Not a readable JWT at all.
+ Malformed,
+
+ /// Signature verification failed — the interesting one.
+ InvalidSignature,
+
+ /// Valid signature, but past its lifetime.
+ Expired,
+
+ /// Signed by a different issuer.
+ WrongIssuer,
+
+ /// Issued for a different audience.
+ WrongAudience,
+
+ /// A genuinely valid token whose holder is not an Owner or Administrator.
+ NotAdmin
+}
diff --git a/src/SlpModularCms.Core/Observability/SecurityEvents.cs b/src/SlpModularCms.Core/Observability/SecurityEvents.cs
new file mode 100644
index 0000000..82d6a9d
--- /dev/null
+++ b/src/SlpModularCms.Core/Observability/SecurityEvents.cs
@@ -0,0 +1,120 @@
+using Microsoft.Extensions.Logging;
+
+namespace SlpModularCms.Core.Observability;
+
+/// Stable tag values for the alertable security events.
+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";
+}
+
+///
+/// The six security events that alert rules are built on.
+///
+///
+/// Why source-generated rather than plain log calls:
+/// Sentry groups log-derived events by their message. Emitted with interpolation —
+/// LogWarning($"Login failed for {email}") — 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 SecurityEvent property, which
+/// SecurityEventProcessor promotes to a Sentry tag so that alert rules filter on
+/// security_event:failed_login 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 Warning or above by construction, so they cross the Sentry event
+/// threshold rather than depending on a coincidence of configuration.
+///
+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;
+
+ ///
+ /// 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.
+ ///
+ public static void FailedLogin(ILogger logger, string endpoint, bool accountExists) =>
+ FailedLoginCore(logger, SecurityEventNames.FailedLogin, endpoint, accountExists);
+
+ /// Someone reached an endpoint they lack rights for.
+ public static void AuthorizationDenied(ILogger logger, string endpoint, string? requiredPolicy) =>
+ AuthorizationDeniedCore(logger, SecurityEventNames.AuthorizationDenied, endpoint, requiredPolicy ?? "(unnamed)");
+
+ ///
+ /// Ambiguous by nature: an intruder, or 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.
+ ///
+ public static void MasterApiKeyRejected(ILogger logger, string endpoint, string callingHost) =>
+ MasterApiKeyRejectedCore(logger, SecurityEventNames.MasterApiKeyRejected, endpoint, callingHost);
+
+ ///
+ /// Only became a meaningful signal once the gate started validating signatures: before that,
+ /// a forged token succeeded silently.
+ ///
+ public static void AdminBypassRejected(ILogger logger, string path, BypassRejectionReason reason) =>
+ AdminBypassRejectedCore(logger, SecurityEventNames.AdminBypassRejected, path, reason);
+
+ /// Brute-force pressure. Without this, a 429 leaves no trace anywhere.
+ public static void RateLimitTriggered(ILogger logger, string limiterName, string endpoint) =>
+ RateLimitTriggeredCore(logger, SecurityEventNames.RateLimitTriggered, limiterName, endpoint);
+
+ ///
+ /// 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.
+ ///
+ 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);
+}
diff --git a/src/SlpModularCms.Core/SlpModularCms.Core.csproj b/src/SlpModularCms.Core/SlpModularCms.Core.csproj
index e3b0653..c36bc68 100644
--- a/src/SlpModularCms.Core/SlpModularCms.Core.csproj
+++ b/src/SlpModularCms.Core/SlpModularCms.Core.csproj
@@ -26,6 +26,7 @@
+
diff --git a/src/SlpModularCms.Modules.Availability.Tests/AvailabilityMiddlewareMasterGateTests.cs b/src/SlpModularCms.Modules.Availability.Tests/AvailabilityMiddlewareMasterGateTests.cs
index be14df0..fd97599 100644
--- a/src/SlpModularCms.Modules.Availability.Tests/AvailabilityMiddlewareMasterGateTests.cs
+++ b/src/SlpModularCms.Modules.Availability.Tests/AvailabilityMiddlewareMasterGateTests.cs
@@ -1,9 +1,10 @@
-using FluentAssertions;
+using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using SlpModularCms.Core.Availability;
using SlpModularCms.Core.Hosting.Security;
+using SlpModularCms.Core.Observability;
using SlpModularCms.Modules.Availability.Middleware;
using SlpModularCms.Modules.Availability.Services;
@@ -111,7 +112,7 @@ public class AvailabilityMiddlewareMasterGateTests
{
var context = new DefaultHttpContext();
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));
_localSvc.IsAvailableAsync().Returns(AvailabilityStatus.NotAvailable);
@@ -127,7 +128,7 @@ public class AvailabilityMiddlewareMasterGateTests
var context = new DefaultHttpContext();
context.Response.Body = new MemoryStream();
context.Request.Headers.Authorization = "Bearer user-token";
- _adminTokenValidator.IsVerifiedAdmin(Arg.Any()).Returns(false);
+ _adminTokenValidator.Validate(Arg.Any()).Returns(new AdminTokenResult(false, BypassRejectionReason.Absent));
_masterSvc.GetMasterStatus().Returns(new MasterGateStatus(false, null));
await _middleware.InvokeAsync(context, _localSvc, _masterSvc);
diff --git a/src/SlpModularCms.Modules.Availability.Tests/AvailabilityMiddlewareTests.cs b/src/SlpModularCms.Modules.Availability.Tests/AvailabilityMiddlewareTests.cs
index d5b2556..65d882b 100644
--- a/src/SlpModularCms.Modules.Availability.Tests/AvailabilityMiddlewareTests.cs
+++ b/src/SlpModularCms.Modules.Availability.Tests/AvailabilityMiddlewareTests.cs
@@ -1,4 +1,4 @@
-using System.IdentityModel.Tokens.Jwt;
+using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using FluentAssertions;
@@ -9,6 +9,7 @@ using NSubstitute;
using SlpModularCms.Core.Availability;
using SlpModularCms.Core.Hosting;
using SlpModularCms.Core.Hosting.Security;
+using SlpModularCms.Core.Observability;
using SlpModularCms.Core.Identity.Models;
using SlpModularCms.Modules.Availability.Middleware;
using SlpModularCms.Modules.Availability.Services;
@@ -145,7 +146,7 @@ public class AvailabilityMiddlewareTests
{
var context = new DefaultHttpContext();
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);
await _middleware.InvokeAsync(context, _service, _masterService);
@@ -159,7 +160,7 @@ public class AvailabilityMiddlewareTests
var context = new DefaultHttpContext();
context.Response.Body = new MemoryStream();
context.Request.Headers.Authorization = "Bearer some-token";
- _adminTokenValidator.IsVerifiedAdmin(Arg.Any()).Returns(false);
+ _adminTokenValidator.Validate(Arg.Any()).Returns(new AdminTokenResult(false, BypassRejectionReason.Absent));
_service.IsAvailableAsync().Returns(AvailabilityStatus.Maintenance);
await _middleware.InvokeAsync(context, _service, _masterService);
@@ -173,7 +174,7 @@ public class AvailabilityMiddlewareTests
{
var context = new DefaultHttpContext();
context.Response.Body = new MemoryStream();
- _adminTokenValidator.IsVerifiedAdmin(Arg.Any()).Returns(false);
+ _adminTokenValidator.Validate(Arg.Any()).Returns(new AdminTokenResult(false, BypassRejectionReason.Absent));
_service.IsAvailableAsync().Returns(AvailabilityStatus.NotAvailable);
await _middleware.InvokeAsync(context, _service, _masterService);
diff --git a/src/SlpModularCms.Modules.Availability.Tests/Controllers/MasterControllerTests.cs b/src/SlpModularCms.Modules.Availability.Tests/Controllers/MasterControllerTests.cs
index 9a3f964..a8108de 100644
--- a/src/SlpModularCms.Modules.Availability.Tests/Controllers/MasterControllerTests.cs
+++ b/src/SlpModularCms.Modules.Availability.Tests/Controllers/MasterControllerTests.cs
@@ -1,3 +1,4 @@
+using Microsoft.Extensions.Logging.Abstractions;
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
@@ -16,7 +17,7 @@ public class MasterControllerTests
public MasterControllerTests()
{
_svc = Substitute.For();
- _controller = new MasterController(_svc);
+ _controller = new MasterController(_svc, NullLogger.Instance);
_controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext()
diff --git a/src/SlpModularCms.Modules.Availability/Controllers/MasterController.cs b/src/SlpModularCms.Modules.Availability/Controllers/MasterController.cs
index 0573a2b..d6693d4 100644
--- a/src/SlpModularCms.Modules.Availability/Controllers/MasterController.cs
+++ b/src/SlpModularCms.Modules.Availability/Controllers/MasterController.cs
@@ -1,4 +1,6 @@
using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Logging;
+using SlpModularCms.Core.Observability;
using SlpModularCms.Modules.Availability.Models;
using SlpModularCms.Modules.Availability.Services;
@@ -9,36 +11,60 @@ namespace SlpModularCms.Modules.Availability.Controllers;
public class MasterController : ControllerBase
{
private readonly IMasterAvailabilityService _svc;
+ private readonly ILogger _logger;
- public MasterController(IMasterAvailabilityService svc) => _svc = svc;
+ public MasterController(IMasterAvailabilityService svc, ILogger logger)
+ {
+ _svc = svc;
+ _logger = logger;
+ }
[HttpPost("register")]
public async Task Register([FromBody] RegisterMasterRequest request)
{
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);
- return success ? Ok() : Unauthorized();
+ return success ? Ok() : RejectKey();
}
[HttpPost("status")]
public async Task PushStatus([FromBody] PushStatusRequest request)
{
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);
- return success ? Ok() : Unauthorized();
+ return success ? Ok() : RejectKey();
}
[HttpGet("registered-url")]
public async Task GetRegisteredUrl()
{
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);
- return url is not null ? Ok(new { MasterUrl = url }) : Unauthorized();
+ return url is not null ? Ok(new { MasterUrl = url }) : RejectKey();
+ }
+
+ ///
+ /// Reports the rejection and returns 401. Never logs the key or any part of it.
+ ///
+ ///
+ /// 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.
+ ///
+ private IActionResult RejectKey()
+ {
+ SecurityEvents.MasterApiKeyRejected(
+ _logger,
+ Request.Path,
+ HttpContext.Connection.RemoteIpAddress?.ToString() ?? "(unknown)");
+
+ return Unauthorized();
}
}
diff --git a/src/SlpModularCms.Modules.Availability/Middleware/AvailabilityMiddleware.cs b/src/SlpModularCms.Modules.Availability/Middleware/AvailabilityMiddleware.cs
index 3bd734d..7f935bd 100644
--- a/src/SlpModularCms.Modules.Availability/Middleware/AvailabilityMiddleware.cs
+++ b/src/SlpModularCms.Modules.Availability/Middleware/AvailabilityMiddleware.cs
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using SlpModularCms.Core.Availability;
using SlpModularCms.Core.Hosting.Security;
+using SlpModularCms.Core.Observability;
using SlpModularCms.Modules.Availability.Services;
namespace SlpModularCms.Modules.Availability.Middleware;
@@ -109,6 +110,17 @@ public class AvailabilityMiddleware
///
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;
}
}
diff --git a/src/SlpModularCms.Modules.Master.Tests/Controllers/SlaveStatusControllerTests.cs b/src/SlpModularCms.Modules.Master.Tests/Controllers/SlaveStatusControllerTests.cs
index 6840c7d..732f853 100644
--- a/src/SlpModularCms.Modules.Master.Tests/Controllers/SlaveStatusControllerTests.cs
+++ b/src/SlpModularCms.Modules.Master.Tests/Controllers/SlaveStatusControllerTests.cs
@@ -1,3 +1,4 @@
+using Microsoft.Extensions.Logging.Abstractions;
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
@@ -14,7 +15,7 @@ public class SlaveStatusControllerTests
public SlaveStatusControllerTests()
{
- _controller = new SlaveStatusController(_service);
+ _controller = new SlaveStatusController(_service, NullLogger.Instance);
_controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext()
diff --git a/src/SlpModularCms.Modules.Master/Controllers/SlaveStatusController.cs b/src/SlpModularCms.Modules.Master/Controllers/SlaveStatusController.cs
index 63c721a..8da2e3c 100644
--- a/src/SlpModularCms.Modules.Master/Controllers/SlaveStatusController.cs
+++ b/src/SlpModularCms.Modules.Master/Controllers/SlaveStatusController.cs
@@ -1,5 +1,7 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Logging;
+using SlpModularCms.Core.Observability;
using SlpModularCms.Modules.Master.Services;
namespace SlpModularCms.Modules.Master.Controllers;
@@ -14,17 +16,30 @@ namespace SlpModularCms.Modules.Master.Controllers;
[ApiController]
[Route("SlaveStatus")]
[AllowAnonymous]
-public class SlaveStatusController(ICmsInstanceService service) : ControllerBase
+public class SlaveStatusController(
+ ICmsInstanceService service,
+ ILogger logger) : ControllerBase
{
[HttpGet]
public async Task Get()
{
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);
- if (result is null) return Unauthorized();
+ if (result is null) return RejectKey();
return Ok(new { result.IsAvailable, result.DisableMessage });
}
+
+ /// Reports the rejection and returns 401. Never logs the key or any part of it.
+ private IActionResult RejectKey()
+ {
+ SecurityEvents.MasterApiKeyRejected(
+ logger,
+ Request.Path,
+ HttpContext.Connection.RemoteIpAddress?.ToString() ?? "(unknown)");
+
+ return Unauthorized();
+ }
}