Feature/gitea deployment workflow #1

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