Settles how the headers and observability units get built
NFR Design for U3 and U4. Two decisions the earlier stages had deliberately left open, plus four risks the functional design did not name. OPEN-01 closed: the correlation ID is the W3C trace ID from the ambient Activity, with TraceIdentifier as the fallback. It propagates across the master/slave boundary via traceparent, which TraceIdentifier cannot do at all, and it is the same value ProblemDetails already returns to the client. REF-U3-01 raised: BR-U3-22's Umami-origin startup warning cannot work. The backend never sees VITE_UMAMI_WEBSITE_ID, so the check would either always warn or never warn. Withdrawn from U3 and replaced by a blocking U5 CI gate that compares the frontend build variable against that environment's CSP origins, where both values are visible. Four additions beyond the functional design: - Set-Cookie added to the scrub list; the login response issues the refreshToken there, so scrubbing only the request cookie protects nothing - SetBeforeSendTransaction alongside SetBeforeSend; transactions carry request data too - OnRejected on the rate limiter; today a 429 leaves no trace anywhere - FlushAsync before the migration-failure rethrow, or the one Critical event in the system dies with the process Two traps recorded with tests attached: Sentry groups log events by message template, so interpolated messages make FR-19's rate-based alert rules unimplementable while appearing to work; and DefaultHttpContext.Response .OnStarting is a no-op, so the obvious middleware test asserts nothing. Three values chosen rather than escalated, each one line to change and all three listed for review at the end of U4's pattern document: JSON console outside Development, TracesSampleRate 0.1, tunnel cap 200 KB. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HHoJpxYXzHACSQguHrC5fw
This commit is contained in:
+233
@@ -0,0 +1,233 @@
|
||||
# Logical Components — U4 Observability Integration
|
||||
|
||||
Backend types live in `SlpModularCms.Core/Hosting/Observability/`, with the exception of `SecurityEvents`, which sits at `SlpModularCms.Core/Observability/` because it is referenced from four assemblies and is not host wiring. Everything is in `Core`, therefore everything is reachable from `SlpModularCms.Core.Tests`.
|
||||
|
||||
Frontend additions are two components, one library module and three modified files under `frontend/src/`.
|
||||
|
||||
---
|
||||
|
||||
## Backend Component Wiring
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph Config["Configuration"]
|
||||
Section["Observability section"]
|
||||
Options["ObservabilityOptions"]
|
||||
LogCfg["Logging:LogLevel<br/>raised to Information"]
|
||||
end
|
||||
|
||||
subgraph Logging["Logging — always active"]
|
||||
Activity["ActivityTrackingOptions<br/>TraceId, SpanId, ParentId"]
|
||||
Console["JsonConsole or SimpleConsole<br/>IncludeScopes = true"]
|
||||
end
|
||||
|
||||
subgraph SentryWiring["Sentry — only when a DSN is present"]
|
||||
UseExt["UseCmsSentry"]
|
||||
Scrubber["ISentryEventScrubber"]
|
||||
Processor["SecurityEventProcessor<br/>ISentryEventProcessor"]
|
||||
Sdk["Sentry SDK<br/>MinimumEventLevel = Warning<br/>MinimumBreadcrumbLevel = Information"]
|
||||
end
|
||||
|
||||
subgraph Events["Alertable Events"]
|
||||
SecEvents["SecurityEvents<br/>LoggerMessage, EventId 5001-5006"]
|
||||
Reason["BypassRejectionReason"]
|
||||
Sites["Emission sites:<br/>Identity, Master, Availability, Core"]
|
||||
end
|
||||
|
||||
subgraph Tunnel["Sentry Tunnel"]
|
||||
Target["SentryTunnelTarget<br/>singleton, parsed at startup"]
|
||||
Endpoint["MapSentryTunnel<br/>/sentry-tunnel"]
|
||||
Limiter["sentry-tunnel rate limiter"]
|
||||
Client["Named HttpClient<br/>5s timeout, no retry"]
|
||||
end
|
||||
|
||||
Ingest["Sentry ingest"]
|
||||
|
||||
Section --> Options
|
||||
Options --> UseExt
|
||||
Options --> Target
|
||||
LogCfg --> Console
|
||||
Activity --> Console
|
||||
Activity --> Sdk
|
||||
UseExt --> Sdk
|
||||
Scrubber --> Sdk
|
||||
Processor --> Sdk
|
||||
Sites --> SecEvents
|
||||
SecEvents --> Reason
|
||||
SecEvents --> Console
|
||||
SecEvents --> Sdk
|
||||
Endpoint --> Limiter
|
||||
Endpoint --> Target
|
||||
Endpoint --> Client
|
||||
Client --> Ingest
|
||||
Sdk --> Ingest
|
||||
|
||||
classDef cfg fill:#fbd38d,stroke:#c05621,stroke-width:1px,color:#000;
|
||||
classDef always fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000;
|
||||
classDef optional fill:#90cdf4,stroke:#2b6cb0,stroke-width:1px,color:#000;
|
||||
classDef guard fill:#fbb6ce,stroke:#b83280,stroke-width:1px,color:#000;
|
||||
classDef ext fill:#e2e8f0,stroke:#4a5568,stroke-width:1px,color:#000;
|
||||
class Section,Options,LogCfg cfg;
|
||||
class Activity,Console,SecEvents,Reason,Sites always;
|
||||
class UseExt,Sdk,Target,Endpoint,Client,Processor optional;
|
||||
class Scrubber,Limiter guard;
|
||||
class Ingest ext;
|
||||
```
|
||||
|
||||
Text alternative: activity tracking and console logging are always active and independent of Sentry; the Sentry SDK is registered only when a DSN is present and always behind the scrubber and the security-event tag processor; the six security events are ordinary log entries that reach both destinations; the tunnel endpoint forwards browser envelopes to a destination parsed once at startup.
|
||||
|
||||
---
|
||||
|
||||
## Frontend Component Wiring
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
main["main.tsx"]
|
||||
sentryts["lib/sentry.ts<br/>initSentry — NEW"]
|
||||
config["lib/config.ts<br/>MODIFIED"]
|
||||
apiclient["lib/api-client.ts<br/>MODIFIED — empty base"]
|
||||
vite["vite.config.ts<br/>MODIFIED — define + dev proxy"]
|
||||
boundary["SentryErrorBoundary<br/>NEW"]
|
||||
umami["UmamiAnalytics<br/>NEW"]
|
||||
inner["InnerApp"]
|
||||
tunnelpath["POST /sentry-tunnel"]
|
||||
|
||||
main --> sentryts
|
||||
main --> boundary
|
||||
main --> umami
|
||||
boundary --> inner
|
||||
config --> sentryts
|
||||
config --> apiclient
|
||||
config --> umami
|
||||
vite --> sentryts
|
||||
vite --> tunnelpath
|
||||
sentryts --> tunnelpath
|
||||
|
||||
classDef newc fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000;
|
||||
classDef modc fill:#fbd38d,stroke:#c05621,stroke-width:1px,color:#000;
|
||||
classDef exist fill:#90cdf4,stroke:#2b6cb0,stroke-width:1px,color:#000;
|
||||
classDef ext fill:#e2e8f0,stroke:#4a5568,stroke-width:1px,color:#000;
|
||||
class sentryts,boundary,umami newc;
|
||||
class config,apiclient,vite modc;
|
||||
class main,inner exist;
|
||||
class tunnelpath ext;
|
||||
```
|
||||
|
||||
Text alternative: a new sentry library module is initialised first from main.tsx and posts to the same-origin tunnel; the modified config module feeds Sentry, the API client and the Umami component; vite supplies the release version and, in development, proxies the tunnel path to the local API.
|
||||
|
||||
---
|
||||
|
||||
## Backend Component Responsibilities
|
||||
|
||||
| Component | Lifetime | Responsibility | Pattern |
|
||||
|---|---|---|---|
|
||||
| `ObservabilityOptions` | Options singleton | `SentryDsn`, `Environment`, `TracesSampleRate`, `TunnelMaxPayloadBytes` | 10 |
|
||||
| `LoggingExtensions.AddCmsLogging` | Static | Activity tracking, `IncludeScopes`, console provider per environment | 1, 2 |
|
||||
| `SentryExtensions.UseCmsSentry` | Static | No-op without a DSN; otherwise thresholds, scrubber, processor | 3, 8 |
|
||||
| `ISentryEventScrubber` / `SentryEventScrubber` | Singleton | Removes four credential headers and the body from events **and** transactions | 4 |
|
||||
| `SecurityEventProcessor` | Singleton | Maps `EventId` → `security_event` tag so alert rules filter on a tag | 6 |
|
||||
| `SecurityEvents` | Static partial | Six source-generated `LoggerMessage` methods, `EventId` 5001–5006 | 6 |
|
||||
| `BypassRejectionReason` | Enum | Why an admin bypass was refused — never the token | 6 |
|
||||
| `SentryTunnelTarget` | Singleton | Envelope endpoint parsed from the DSN once, at startup | 7 |
|
||||
| `SentryTunnelExtensions.MapSentryTunnel` | Static | The `/sentry-tunnel` endpoint: size cap, forward, `202` on failure | 7 |
|
||||
|
||||
### Modified existing components
|
||||
|
||||
| File | Change | Why |
|
||||
|---|---|---|
|
||||
| `Program.cs` (both hosts) | `AddCmsLogging`, `UseCmsSentry`, `MapSentryTunnel` | Host wiring |
|
||||
| `appsettings.json` (both hosts) | `Observability` section; `Logging` raised to `Information`; EF command logging pinned at `Warning` | Pattern 3 — otherwise breadcrumbs arrive empty and EF logs parameter values |
|
||||
| `ServiceCollectionExtensions.AddCmsRateLimiting` | `OnRejected` callback; new `sentry-tunnel` limiter | Pattern 6, Pattern 7 — `429`s currently leave no trace at all |
|
||||
| `DatabaseMigrationExtensions` (U2) | `FlushAsync` before rethrow | Pattern 5 — otherwise the `Critical` event dies with the process |
|
||||
| `AdminTokenValidator` (U1) | Overload returning `BypassRejectionReason` | Pattern 6 |
|
||||
| `AvailabilityMiddleware` | Emit `AdminBypassRejected` | Pattern 6 |
|
||||
|
||||
Two of these touch files U1 and U2 already committed. Both are additive and both are named in the U4 commit message.
|
||||
|
||||
---
|
||||
|
||||
## Frontend Component Responsibilities
|
||||
|
||||
| Component | Kind | Responsibility |
|
||||
|---|---|---|
|
||||
| `lib/sentry.ts` | Module | `initSentry()`: skip without a DSN, tunnel path, no router integration |
|
||||
| `components/SentryErrorBoundary.tsx` | Class component | Catch render errors, report, show a recoverable fallback with no exception text |
|
||||
| `components/UmamiAnalytics.tsx` | Component | Inject the script once, never in development, no cleanup |
|
||||
| `lib/config.ts` | Modified | Empty-or-URL schema, four new fields |
|
||||
| `lib/api-client.ts` | Modified | Correct URL construction with an empty base |
|
||||
| `main.tsx` | Modified | `initSentry()` first; boundary inside `AuthProvider`; `UmamiAnalytics` alongside `InnerApp` |
|
||||
| `vite.config.ts` | Modified | `__APP_VERSION__` define; dev proxy for `/sentry-tunnel` → local API |
|
||||
| `vite-env.d.ts` | Modified | Declare the five new `VITE_` variables and `__APP_VERSION__` |
|
||||
|
||||
---
|
||||
|
||||
## Packages Added
|
||||
|
||||
| Project | Package | Version | Note |
|
||||
|---|---|---|---|
|
||||
| `SlpModularCms.Core` | `Sentry.AspNetCore` | `6.8.0` | Latest on nuget.org as of 2026-07-28. **`net10.0` compatibility to be confirmed at code generation** — if the package resolves only to a `net9.0` asset the build still succeeds, but a warning must not be ignored silently |
|
||||
| `frontend` | `@sentry/react` | `^10.68.0` | Same major as the reference project |
|
||||
|
||||
`Sentry.AspNetCore` in `Core` rather than in the two host projects, because `UseCmsSentry`, the scrubber, the processor and the tunnel all live in `Core` and both hosts consume them. It brings `Sentry` and `Sentry.Extensions.Logging` transitively.
|
||||
|
||||
**FR-22 interaction**: this adds a dependency subtree to `Core` while U5 is due to pin two packages against known advisories. The new tree must be checked in the same pass, not assumed clean because it is new.
|
||||
|
||||
---
|
||||
|
||||
## DI Registration Order
|
||||
|
||||
```
|
||||
builder.Logging:
|
||||
1. AddCmsLogging(builder.Environment) ← ClearProviders, ActivityTracking, IncludeScopes
|
||||
builder.WebHost:
|
||||
2. UseCmsSentry(builder.Configuration) ← no-op without a DSN
|
||||
builder.Services:
|
||||
3. AddOptions<ObservabilityOptions>().Bind(...).ValidateOnStart()
|
||||
4. AddSingleton<ISentryEventScrubber, SentryEventScrubber>()
|
||||
5. AddSingleton<SecurityEventProcessor>()
|
||||
6. AddSingleton<SentryTunnelTarget>() ← throws at startup on an unparseable DSN
|
||||
7. AddHttpClient("sentry-tunnel")
|
||||
8. AddCmsRateLimiting(...) ← existing call, now also registers "sentry-tunnel"
|
||||
app:
|
||||
9. MapSentryTunnel() ← after MapControllers, before MapCmsSpaFallbacks
|
||||
```
|
||||
|
||||
**Steps 1 and 2 are order-critical** (BR-U4-04): a Sentry initialisation problem must be logged by an already-registered provider.
|
||||
|
||||
**Step 9's position matters**: `MapCmsSpaFallbacks` maps `{*path:nonfile}`. A catch-all registered before the tunnel would not actually shadow it — route precedence favours the literal segment — but relying on precedence for a security-relevant endpoint is a poor trade against writing the two lines in the obvious order.
|
||||
|
||||
---
|
||||
|
||||
## NFR Coverage Traceability
|
||||
|
||||
| NFR / Rule | Pattern | Component |
|
||||
|---|---|---|
|
||||
| SECURITY-03 — correlation ID on every entry | 1 | `ActivityTrackingOptions` + `IncludeScopes` |
|
||||
| SECURITY-03 — no secrets or PII in logs | 3, 4 | EF command logging pinned; `SecurityEvents` templates carry no credentials |
|
||||
| SECURITY-11 — no request forgery | 7 | `SentryTunnelTarget` — destination never from the caller |
|
||||
| SECURITY-14 — alertable security events | 6 | `SecurityEvents` + `SecurityEventProcessor` tag |
|
||||
| SECURITY-15 — failures never propagate | 8 | `UseCmsSentry` no-op path; tunnel returns `202` on forwarding failure |
|
||||
| NFR-01 — no server configuration | 7 | The tunnel is in-process, not in nginx |
|
||||
| NFR-07 — no secrets committed | 10 | DSN empty in `appsettings`, supplied as `Observability__SentryDsn` |
|
||||
| NFR-08 — graceful degradation | 8 | Three fully functional configurations |
|
||||
| BR-U4-01…05 | 1, 2, 3 | Logging always active, before Sentry, `Information` console |
|
||||
| BR-U4-06, BR-U4-07 | 3 | `MinimumEventLevel` / `MinimumBreadcrumbLevel` |
|
||||
| BR-U4-08…12 | 8 | DSN gate, environment and release tags |
|
||||
| BR-U4-13, BR-U4-14 | 4 | `SentryEventScrubber`, in-process, two layers |
|
||||
| BR-U4-15…21 | 7 | Tunnel endpoint and target |
|
||||
| BR-U4-22…25 | 9 | `config.ts` union schema, `api-client.ts` URL construction |
|
||||
| BR-U4-26…29 | 9 | `initSentry` DSN gate, `UmamiAnalytics` guards |
|
||||
|
||||
---
|
||||
|
||||
## Carried Forward
|
||||
|
||||
| Item | To | Why |
|
||||
|---|---|---|
|
||||
| Sentry alert rules for `security_event:*` | **Operations — Monitoring Setup** (FR-19) | The tag exists in code; the rules are configured in Sentry |
|
||||
| `Observability__SentryDsn` as an environment variable per environment | **U6 Deploy Workflow** | Must not be committed |
|
||||
| `VITE_SENTRY_DSN`, `VITE_APP_ENV`, `VITE_UMAMI_*` per environment build | **U5 CI Workflow** | Build-time values; two artifacts (D-15) |
|
||||
| New dependency subtree checked against advisories | **U5** (FR-22) | `Sentry.AspNetCore` is new and must not be assumed clean |
|
||||
| `Sentry.AspNetCore` `net10.0` asset confirmation | **Code Generation** | A `net9.0` fallback builds but should be a conscious acceptance |
|
||||
| Trace ID propagation master → slave verified end to end | **Build and Test** | Needs both hosts running and a real master/slave call — the whole justification for Pattern 1 |
|
||||
| `apiBaseUrl === ''` request construction verified | **Build and Test** + unit test | Only fails in the production configuration, which nobody runs locally |
|
||||
+462
@@ -0,0 +1,462 @@
|
||||
# NFR Design Patterns — U4 Observability Integration
|
||||
|
||||
**Unit**: U4 Observability Integration
|
||||
**NFRs addressed**: NFR-01 (no server configuration), NFR-07 (no secrets in the repository), NFR-08 (graceful degradation), SECURITY-03, SECURITY-11, SECURITY-14, SECURITY-15
|
||||
|
||||
Two things dominate this design. First, **every observability service must be optional** — the same binary has to run with no DSN, no Umami and no network egress at all. Second, **the mechanisms that protect credentials and enable alerting are the ones most likely to look correct while doing nothing**, so each is designed to be independently testable rather than asserted by a comment.
|
||||
|
||||
---
|
||||
|
||||
## Pattern 1 — OPEN-01 Resolved: the Correlation ID Is the W3C Trace ID
|
||||
|
||||
**OPEN-01** asked whether the correlation identifier required by SECURITY-03 should be `HttpContext.TraceIdentifier` or the W3C `traceparent` trace ID. **Decision: the W3C trace ID**, taken from the ambient `Activity`, with `TraceIdentifier` as the fallback when no `Activity` exists.
|
||||
|
||||
```csharp
|
||||
builder.Logging.Configure(options =>
|
||||
options.ActivityTrackingOptions =
|
||||
ActivityTrackingOptions.TraceId |
|
||||
ActivityTrackingOptions.SpanId |
|
||||
ActivityTrackingOptions.ParentId);
|
||||
```
|
||||
|
||||
That single call puts `TraceId` into the logging scope of **every** entry from every category, including framework categories, without touching a single call site. BR-U4-03 ("every log entry carries a correlation identifier") is satisfied by configuration rather than by discipline — which matters, because the alternative is remembering to pass an ID into thousands of existing log calls.
|
||||
|
||||
### Why the W3C trace ID rather than `TraceIdentifier`
|
||||
|
||||
| Property | `HttpContext.TraceIdentifier` | W3C trace ID |
|
||||
|---|---|---|
|
||||
| Crosses the master/slave HTTP boundary | **No** — host-local, meaningless elsewhere | **Yes** — `HttpClient` injects `traceparent` automatically |
|
||||
| Matches what the client already sees | No | **Yes** — see below |
|
||||
| Understood by Sentry natively | No | **Yes** — becomes the event's trace |
|
||||
| Available outside a request (startup, background service) | No — there is no `HttpContext` | Yes, when an `Activity` is started |
|
||||
| Cost | None | One line of configuration |
|
||||
|
||||
**The decisive argument is the master/slave boundary.** This codebase's hardest diagnostic question is "the master says the slave rejected its API key — what did the slave actually see?". `TraceIdentifier` cannot answer it: the master's ID and the slave's ID are unrelated strings. The W3C trace ID is propagated by `HttpClient` on the outbound call and adopted by the slave's hosting layer, so both sides' log entries and both sides' Sentry events carry the **same** value. That is the entire reason a correlation ID exists here.
|
||||
|
||||
**The second argument is alignment with what already exists.** ASP.NET Core's built-in `ProblemDetails` writes `traceId` as `Activity.Current?.Id ?? HttpContext.TraceIdentifier`. Choosing the W3C trace ID means the value in the log, the value in the Sentry event, the value propagated to the slave and the value the browser was shown in the error response are **one value** — so an operator can be handed a `traceId` from a screenshot and find the log entry. Choosing `TraceIdentifier` would produce two competing identifiers for the same request, which is worse than having one imperfect one.
|
||||
|
||||
The `?? TraceIdentifier` fallback is kept for exactly the framework's reason: no `Activity` means no trace ID, and a null correlation ID is worse than a host-local one.
|
||||
|
||||
### The trap that makes this silently not work
|
||||
|
||||
Scopes are only rendered if the console provider is told to render them:
|
||||
|
||||
```csharp
|
||||
options.IncludeScopes = true; // without this, TraceId is collected and then discarded
|
||||
```
|
||||
|
||||
`ActivityTrackingOptions` populates the scope; `IncludeScopes` is what writes it out. Configure the first and forget the second and every log entry looks completely normal, with no correlation ID and no error anywhere. **A test asserts a `TraceId` field is present in rendered console output**, not merely that the option was set.
|
||||
|
||||
---
|
||||
|
||||
## Pattern 2 — Registration Order: Logging Before Sentry
|
||||
|
||||
**Rule**: BR-U4-04.
|
||||
|
||||
```csharp
|
||||
// 1. Logging first — so a Sentry problem is itself logged.
|
||||
builder.Logging.ClearProviders();
|
||||
builder.Logging.Configure(o => o.ActivityTrackingOptions = ...);
|
||||
if (builder.Environment.IsDevelopment())
|
||||
{
|
||||
builder.Logging.AddSimpleConsole(o => { o.IncludeScopes = true; o.SingleLine = true; });
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Logging.AddJsonConsole(o => { o.IncludeScopes = true; });
|
||||
}
|
||||
|
||||
// 2. Sentry second, and only when a DSN is present.
|
||||
builder.WebHost.UseCmsSentry(builder.Configuration);
|
||||
```
|
||||
|
||||
**Why the two console formats differ by environment**: in test and production the process is supervised (systemd on the Pi) and its stdout lands in the journal, where JSON is greppable and `TraceId` is a field rather than a substring. Locally a developer reads it with their eyes, and JSON is hostile to that. Both set `IncludeScopes`, so the correlation ID is present either way — the formatting differs, the information does not.
|
||||
|
||||
**Why `ClearProviders()`**: the default host already added a console provider without `IncludeScopes`. Adding a second one produces every line twice, once with the correlation ID and once without — which reads as a logging bug and wastes real time.
|
||||
|
||||
---
|
||||
|
||||
## Pattern 3 — Threshold Split Maps Onto Two Existing Options
|
||||
|
||||
**Rules**: BR-U4-02, BR-U4-06, BR-U4-07. This is the reconciliation of Q3 = C with Q16 = C, and it needs no custom code:
|
||||
|
||||
```csharp
|
||||
options.MinimumBreadcrumbLevel = LogLevel.Information; // BR-U4-07
|
||||
options.MinimumEventLevel = LogLevel.Warning; // BR-U4-06
|
||||
```
|
||||
|
||||
| Destination | Threshold | Mechanism |
|
||||
|---|---|---|
|
||||
| Console | `Information`, framework included | `Logging:LogLevel:Default` raised from `Warning` to `Information` |
|
||||
| Sentry breadcrumbs | `Information` | `MinimumBreadcrumbLevel` |
|
||||
| Sentry events | `Warning` | `MinimumEventLevel` |
|
||||
|
||||
**`appsettings.json` must change too.** Today it says `"Default": "Warning"` and `"Microsoft.AspNetCore": "Warning"`. Leaving those in place would cap the console *and* the breadcrumbs at `Warning`, and every Sentry event would arrive with an empty breadcrumb trail — the feature present, configured, and useless. Both move to `Information`; `Microsoft.EntityFrameworkCore.Database.Command` stays at `Warning`, because `Information` there logs every SQL statement including parameter values.
|
||||
|
||||
That last exclusion is not a volume concern, it is BR-U4-05: EF's command logging at `Information` prints parameter values, and the login path passes a normalised email through it.
|
||||
|
||||
---
|
||||
|
||||
## Pattern 4 — Scrubbing as an Injectable, Testable Filter
|
||||
|
||||
**Rules**: BR-U4-13, BR-U4-14. Defence in depth, in two layers.
|
||||
|
||||
### Layer 1 — never capture the body at all
|
||||
|
||||
```csharp
|
||||
options.MaxRequestBodySize = RequestSize.None; // the SDK default; set explicitly
|
||||
```
|
||||
|
||||
Set explicitly with a comment, because the safest version of "the request body is never sent to Sentry" is that it is never read into an event in the first place. Relying on the default means a future `RequestSize.Small` added by someone chasing a bug quietly starts shipping login payloads.
|
||||
|
||||
### Layer 2 — remove credential headers before send
|
||||
|
||||
```csharp
|
||||
public interface ISentryEventScrubber
|
||||
{
|
||||
SentryEvent Scrub(SentryEvent @event);
|
||||
}
|
||||
|
||||
internal sealed class SentryEventScrubber : ISentryEventScrubber
|
||||
{
|
||||
internal static readonly string[] RemovedHeaders =
|
||||
[
|
||||
"Cookie", // carries refreshToken
|
||||
"Set-Cookie",
|
||||
"Authorization", // bearer token
|
||||
"X-Master-Api-Key" // master/slave shared secret
|
||||
];
|
||||
|
||||
public SentryEvent Scrub(SentryEvent @event)
|
||||
{
|
||||
foreach (var header in RemovedHeaders)
|
||||
{
|
||||
@event.Request.Headers.Remove(header);
|
||||
}
|
||||
|
||||
@event.Request.Data = null;
|
||||
return @event;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Wired in through both send hooks:
|
||||
|
||||
```csharp
|
||||
options.SetBeforeSend((e, _) => scrubber.Scrub(e));
|
||||
options.SetBeforeSendTransaction((t, _) => scrubber.ScrubTransaction(t));
|
||||
```
|
||||
|
||||
**Why `SetBeforeSendTransaction` as well as `SetBeforeSend`**: performance transactions carry request data too. Scrubbing only events leaves a second, less obvious channel open — and it is less obvious precisely because nobody thinks of a transaction as containing headers. Enabling tracing later without touching this file would otherwise start leaking.
|
||||
|
||||
**Why an interface rather than an inline lambda**: the scrub list is the single most security-critical piece of code in this unit, and a lambda inside a `UseSentry` callback cannot be unit-tested without initialising the SDK. As an injected class it is tested directly: construct a `SentryEvent` with all four headers plus a body, scrub, assert each is gone and that the retained fields (method, path, query, user agent, IP, username) survive.
|
||||
|
||||
**`Set-Cookie` is in the list although the functional design did not name it.** Response headers can be attached to an event, and `Set-Cookie` on the login and refresh responses contains the `refreshToken` being issued. Removing the request cookie while sending the response cookie would protect nothing.
|
||||
|
||||
**Not configurable, by construction** (functional design, `domain-entities.md`): `RemovedHeaders` is a `static readonly` array in code, not an options property. A configurable scrub list is a supported way to switch the protection off by omission.
|
||||
|
||||
---
|
||||
|
||||
## Pattern 5 — Flush Before Exit, or the Event That Matters Most Is the One You Lose
|
||||
|
||||
The Sentry SDK batches and sends in the background. A process that throws during startup and exits kills that background sender before it transmits — so the **migration failure**, the one event in the whole system that requires immediate human attention (BR-U4 security event table, level `Critical`), is exactly the event most likely never to arrive.
|
||||
|
||||
`MigrateCoreDatabase` (U2) therefore gains a flush on its failure path:
|
||||
|
||||
```csharp
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogCritical(ex, "..."); // existing behaviour
|
||||
await SentrySdk.FlushAsync(TimeSpan.FromSeconds(5));
|
||||
throw; // still fails fast — unchanged
|
||||
}
|
||||
```
|
||||
|
||||
Five seconds, bounded: a host that cannot reach its database is already down, and five seconds of additional downtime buys the alert that tells someone why. `FlushAsync` is a no-op when the SDK was never initialised, so the no-DSN path is unaffected.
|
||||
|
||||
This is a **change to a U2 file made by U4** and is called out in the commit message, since U2 is already committed.
|
||||
|
||||
---
|
||||
|
||||
## Pattern 6 — Alertable Security Events: Source-Generated Templates + a Sentry Tag
|
||||
|
||||
**Rules**: the six events in `domain-entities.md`; FR-19 alert rules; SECURITY-14. This is the pattern that decides whether FR-19 is implementable at all.
|
||||
|
||||
### The problem
|
||||
|
||||
Sentry groups log-derived events by their **message**. Emitted with interpolation —
|
||||
|
||||
```csharp
|
||||
_logger.LogWarning($"Login failed for {email} on {endpoint}"); // WRONG
|
||||
```
|
||||
|
||||
— every distinct email produces a **separate Sentry issue**. An alert rule of the form "more than 20 failed logins in 5 minutes" can then never fire, because no single issue ever reaches 20. The feature appears to work: events arrive, they are visible, they are tagged. Only the alerting silently cannot exist.
|
||||
|
||||
### The pattern
|
||||
|
||||
One source-generated `LoggerMessage` per event type, with a fixed template and a stable `EventId`:
|
||||
|
||||
```csharp
|
||||
internal 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;
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = FailedLoginEventId,
|
||||
Level = LogLevel.Warning,
|
||||
Message = "Security event: login failed on {Endpoint} (account exists: {AccountExists})")]
|
||||
public static partial void FailedLogin(ILogger logger, string endpoint, bool accountExists);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = AdminBypassRejectedEventId,
|
||||
Level = LogLevel.Warning,
|
||||
Message = "Security event: admin bypass rejected on {Path} (reason: {Reason})")]
|
||||
public static partial void AdminBypassRejected(ILogger logger, string path, BypassRejectionReason reason);
|
||||
|
||||
// …4 more
|
||||
}
|
||||
```
|
||||
|
||||
The template is a compile-time constant, so all failed logins group into one Sentry issue with the variable parts as structured fields. The rate-based alert rule becomes possible.
|
||||
|
||||
### Tagging, so alert rules filter on a tag rather than a substring
|
||||
|
||||
```csharp
|
||||
internal sealed class SecurityEventProcessor : ISentryEventProcessor
|
||||
{
|
||||
private static readonly FrozenDictionary<int, string> Tags = /* 5001 → "failed_login", … */;
|
||||
|
||||
public SentryEvent Process(SentryEvent @event)
|
||||
{
|
||||
if (@event.Extra.TryGetValue("EventId", out var id) &&
|
||||
id is int eventId &&
|
||||
Tags.TryGetValue(eventId, out var tag))
|
||||
{
|
||||
@event.SetTag("security_event", tag);
|
||||
}
|
||||
return @event;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Alert rules then read `security_event:failed_login`, which is stable across message-wording changes. Matching on message text would break the day someone improves the wording — silently, because a rule that matches nothing looks identical to a rule with nothing to match.
|
||||
|
||||
### `BypassRejectionReason`
|
||||
|
||||
An enum — `InvalidSignature`, `Expired`, `WrongIssuer`, `WrongAudience`, `NotAdmin`, `Malformed` — not a string, and never the token. `AdminTokenValidator` (U1) currently returns `bool`; it gains an overload returning the reason so this event can carry it. The distinction is operationally real: `InvalidSignature` means someone is forging tokens, `Expired` means an administrator left a tab open.
|
||||
|
||||
### Where each event is emitted
|
||||
|
||||
| Event | Assembly | Emission site |
|
||||
|---|---|---|
|
||||
| Failed login | `Modules.Identity` | Auth service login path |
|
||||
| Authorization denied | `Core` | Authorization failure handler |
|
||||
| Master API key rejected | `Modules.Master` | API key authentication handler |
|
||||
| Admin bypass rejected | `Modules.Availability` | `AvailabilityMiddleware.IsAdminBypass` |
|
||||
| Rate limit triggered | `Core` | `AddCmsRateLimiting` → `OnRejected` |
|
||||
| Migration failure | `Core` | `MigrateCoreDatabase` catch (Pattern 5) |
|
||||
|
||||
`SecurityEvents` therefore lives in **`SlpModularCms.Core`**, which all four assemblies already reference.
|
||||
|
||||
**`OnRejected` does not exist yet** on the rate limiter — `AddCmsRateLimiting` sets only `RejectionStatusCode`. It is added here. Without it, a brute-force attempt against `/api/v1/Auth/login` returns `429` and leaves no trace anywhere, which makes the one rate limiter this application has unobservable.
|
||||
|
||||
---
|
||||
|
||||
## Pattern 7 — The Sentry Tunnel Endpoint
|
||||
|
||||
**Rules**: BR-U4-15…21. An anonymous endpoint that performs an outbound request on demand needs every one of these constraints.
|
||||
|
||||
### Destination computed once, at startup
|
||||
|
||||
```csharp
|
||||
public sealed class SentryTunnelTarget
|
||||
{
|
||||
public Uri EnvelopeEndpoint { get; } // https://{host}/api/{projectId}/envelope/
|
||||
public bool IsConfigured { get; }
|
||||
|
||||
// Parsed from the DSN: https://{publicKey}@{host}/{projectId}
|
||||
}
|
||||
```
|
||||
|
||||
Registered as a singleton and parsed at startup. **No part of the destination ever comes from the request** (BR-U4-16). This is the single rule that separates a tunnel from a server-side request forgery primitive, and computing it once makes it structurally impossible to accidentally read from the caller.
|
||||
|
||||
An unparseable DSN fails at startup rather than per request — consistent with U3 Pattern 1 and with SECURITY-15.
|
||||
|
||||
### Size limit enforced twice
|
||||
|
||||
```csharp
|
||||
if (request.ContentLength > maxBytes)
|
||||
{
|
||||
return Results.StatusCode(StatusCodes.Status413PayloadTooLarge);
|
||||
}
|
||||
|
||||
var buffer = await request.Body.ReadAtMostAsync(maxBytes + 1, cancellationToken);
|
||||
if (buffer.Length > maxBytes)
|
||||
{
|
||||
return Results.StatusCode(StatusCodes.Status413PayloadTooLarge);
|
||||
}
|
||||
```
|
||||
|
||||
**Both checks are needed.** `Content-Length` is absent under chunked transfer encoding and is attacker-controlled in any case, so the pre-check is an optimisation, not the control. The bounded read is the control. Trusting `ContentLength` alone gives an anonymous caller an unbounded memory allocation on a Raspberry Pi.
|
||||
|
||||
### Path, and why it is `/sentry-tunnel`
|
||||
|
||||
Same path as the reference project, where nginx serves it. Here the application does — NFR-01 forbids depending on the reverse proxy — but keeping the path identical means the frontend `tunnel` option, the vite dev proxy and any operator's muscle memory carry over unchanged between the two workspaces.
|
||||
|
||||
Deliberately **not** under `/api/v1`: it is not a versioned CMS API and must not appear in the OpenAPI document, and `/api/v1` maps to the strict CSP path list in U3 for reasons that have nothing to do with this endpoint.
|
||||
|
||||
### Rate limiting — added beyond what the functional design asked for
|
||||
|
||||
The tunnel gets its own fixed-window limiter (`"sentry-tunnel"`, in the existing `RateLimiting` configuration section). 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 three existing controls — fixed destination, size cap, no DSN means no forwarding — bound *what* each call can do but not *how many* calls there can be. This closes that.
|
||||
|
||||
### Behaviour on failure
|
||||
|
||||
| Condition | Response | Rationale |
|
||||
|---|---|---|
|
||||
| No DSN configured | `404` | BR-U4-18. `404` rather than `503`, because with no DSN the endpoint genuinely does not exist |
|
||||
| Payload too large | `413` | BR-U4-17 |
|
||||
| Rate limit exceeded | `429` | Existing limiter behaviour |
|
||||
| Forwarding to Sentry fails | `202`, logged server-side at `Warning` | The browser must not retry or console-error over a failed error report. Failing to report an error must not itself become an error |
|
||||
| Instance availability-disabled | `503` from the gate | BR-U4-20, accepted loss |
|
||||
|
||||
**BR-U4-20 needs no code.** The tunnel is a mapped endpoint, and `AvailabilityMiddleware` runs before endpoint execution, so it is gated by default. Not being on `_bypassPrefixes` is the whole implementation — worth stating so nobody "fixes" it later.
|
||||
|
||||
### `HttpClient`
|
||||
|
||||
A named client via `IHttpClientFactory` with a short timeout (5s) and no retry. A dropped error report is acceptable; a request thread held open by an anonymous caller is not.
|
||||
|
||||
---
|
||||
|
||||
## Pattern 8 — Graceful Absence Everywhere
|
||||
|
||||
**Rules**: BR-U4-08, BR-U4-09, NFR-08.
|
||||
|
||||
```csharp
|
||||
public static IWebHostBuilder UseCmsSentry(this IWebHostBuilder b, IConfiguration config)
|
||||
{
|
||||
var dsn = config["Observability:SentryDsn"];
|
||||
if (string.IsNullOrWhiteSpace(dsn))
|
||||
{
|
||||
return b; // BR-U4-08 — a supported state, not an error, and not a warning
|
||||
}
|
||||
return b.UseSentry(o => { /* … */ });
|
||||
}
|
||||
```
|
||||
|
||||
**No warning is logged when the DSN is absent.** Local development is the common case, and a startup warning that always appears is noise that trains people to ignore startup warnings — including the ones from U3 Pattern 9 that matter.
|
||||
|
||||
Beyond that, the SDK is already fire-and-forget: an unreachable Sentry queues, then drops. Nothing wraps a request in a try/catch for Sentry's benefit, because there is nothing to catch. The only place the design adds a deliberate barrier is the tunnel's `202`-on-failure above.
|
||||
|
||||
---
|
||||
|
||||
## Pattern 9 — Frontend: Sentry, Umami, and Same-Origin Configuration
|
||||
|
||||
### Packages
|
||||
|
||||
| Package | Version | Note |
|
||||
|---|---|---|
|
||||
| `@sentry/react` | `^10.68.0` | Same major as the reference project, so patterns transfer |
|
||||
| — | | No Umami package; a script tag is the whole integration |
|
||||
|
||||
### `lib/sentry.ts`
|
||||
|
||||
```ts
|
||||
export function initSentry(): void {
|
||||
const { sentryDsn, appEnv } = getAppConfig();
|
||||
if (!sentryDsn) return; // BR-U4-26
|
||||
|
||||
Sentry.init({
|
||||
dsn: sentryDsn,
|
||||
environment: appEnv,
|
||||
release: __APP_VERSION__,
|
||||
tracesSampleRate: 0.1,
|
||||
sendDefaultPii: false,
|
||||
tunnel: '/sentry-tunnel', // BR-U4-15 — never the ingest URL
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
`__APP_VERSION__` requires `define` in `vite.config.ts` and a declaration in `vite-env.d.ts`, both following the reference project exactly.
|
||||
|
||||
**`tanstackRouterBrowserTracingIntegration` is not adopted.** The reference project uses it, but it needs the router instance, and importing `@/router` from `sentry.ts` — which `main.tsx` calls before anything else — inverts the initialisation order and drags the whole route tree into the startup path. Route-level tracing is not worth that; it can be added later inside `main.tsx` where the router is already imported.
|
||||
|
||||
### The dev tunnel proxy points at our own API
|
||||
|
||||
```ts
|
||||
server: {
|
||||
proxy: {
|
||||
'/sentry-tunnel': { target: 'https://localhost:7221', changeOrigin: true, secure: false },
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
Different from the reference, and the difference is the point: the reference proxies straight to Sentry's ingest host, which means the DSN's project ID is hard-coded in `vite.config.ts` and has to stay in sync by hand (the reference file carries a `LET OP` comment saying exactly that). Here the proxy targets the local API, which derives the destination from its own DSN — so there is nothing to keep in sync and no project ID in a committed file.
|
||||
|
||||
### `config.ts` — relaxed by exactly one case
|
||||
|
||||
```ts
|
||||
const configSchema = z.object({
|
||||
apiBaseUrl: z.union([z.literal(''), z.string().url()]), // BR-U4-24
|
||||
appTitle: z.string(),
|
||||
sentryDsn: z.string().optional(),
|
||||
appEnv: z.string().optional(),
|
||||
umamiScriptUrl: z.string().optional(),
|
||||
umamiWebsiteId: z.string().optional(),
|
||||
});
|
||||
```
|
||||
|
||||
`apiBaseUrl` resolves via `import.meta.env.VITE_API_BASE_URL ?? ''`. `z.union` with `z.literal('')` permits empty and still rejects `htp://localhost:7221`; `z.string().url().optional()` would not, and `z.string()` alone would accept anything.
|
||||
|
||||
**`api-client.ts` needs one check.** With `apiBaseUrl === ''`, request URLs must be built as `` `${base}${path}` `` where `path` already starts with `/`. Any code doing `` `${base}/${path}` `` or `new URL(path, base)` breaks on an empty base — `new URL` throws on an empty base. This is verified by test rather than by reading, because the failure only appears in the production configuration, which is the one nobody runs locally.
|
||||
|
||||
### `UmamiAnalytics`
|
||||
|
||||
Adopted essentially verbatim from the reference project: `useEffect`, guard on `import.meta.env.DEV || !scriptUrl || !websiteId`, guard on an existing element by id, `defer`, `data-website-id`.
|
||||
|
||||
One deliberate change: **no cleanup that removes the script.** The reference returns a cleanup removing the element, which under React 18 `StrictMode` double-invocation means inject → remove → inject. Umami's script registers listeners and sends the initial page view on load; removing the element does not unregister them, so the double-invocation can produce a duplicated first page view. The component is mounted for the application's lifetime and has nothing to clean up. The duplicate guard (`getElementById`) handles re-invocation on its own.
|
||||
|
||||
This is why the functional design's `UmamiAnalytics` test list includes "not injected twice on re-render" — that assertion is what catches the regression if the cleanup is ever reinstated.
|
||||
|
||||
---
|
||||
|
||||
## Pattern 10 — Configuration Shape
|
||||
|
||||
```jsonc
|
||||
"Observability": {
|
||||
"SentryDsn": "", // from an environment variable in test/production
|
||||
"Environment": "", // falls back to ASPNETCORE_ENVIRONMENT
|
||||
"TracesSampleRate": 0.1,
|
||||
"TunnelMaxPayloadBytes": 204800 // 200 KB
|
||||
}
|
||||
```
|
||||
|
||||
`Logging` changes alongside it:
|
||||
|
||||
```jsonc
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Information",
|
||||
"Microsoft.EntityFrameworkCore.Database.Command": "Warning"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**The DSN is committed empty and supplied per environment as `Observability__SentryDsn`.** Consistent with D-16 and NFR-07. A DSN is not a standing credential — the frontend's copy is in the page source — but it identifies a project, it belongs to an account, and there is no reason for the repository to be where it is written down.
|
||||
|
||||
---
|
||||
|
||||
## Decisions Taken Here Without Asking
|
||||
|
||||
Three values were chosen rather than escalated, because a sensible default exists and the work would otherwise have blocked. Each is one line to change:
|
||||
|
||||
| Decision | Chosen | Reasoning | Change it if |
|
||||
|---|---|---|---|
|
||||
| Console format | JSON outside Development, human-readable locally | Supervised process in test/production; journald and `jq` both handle JSON, and `TraceId` becomes a field | You read production logs by eye more often than you query them |
|
||||
| `TracesSampleRate` | `0.1`, backend and frontend | Sentry's free plan counts transactions against the same quota as errors, and this design's value is in errors, not performance traces. The reference project uses `1.0` — for a low-traffic marketing site with no API | Traffic stays low and you want full traces |
|
||||
| Tunnel max payload | 200 KB | Sentry envelopes with a stack trace and breadcrumbs run tens of KB; 200 KB is generous without being an allocation risk | Envelopes are rejected in practice |
|
||||
Reference in New Issue
Block a user