Files
T
SluijsensandClaude Opus 5 357d395629 Designs the security headers and observability units
Records the functional design for the two remaining application units,
before any of their code exists.

Security headers have to come from the application, because relying on
nginx or IIS configuration is exactly what this deployment model rules
out. Strict applies to /admin, /api/v1 and /health; a relaxed policy
applies to the public website, which this repository does not author.

The strict policy needs style-src 'unsafe-inline'. That is not a
shortcut: Radix positions dropdowns and dialogs with inline style
attributes recalculated per click and scroll position, and CSP nonces
apply only to style elements, never to style attributes. No nonce- or
hash-based variant leaves the admin UI working. The exception is bounded
to styles — script-src stays closed, which is where XSS actually lives.

The website's policy is enforcing rather than absent, so every
HTML-serving path carries a CSP and no exception has to be recorded. It
still blocks external script origins, so it remains a real boundary.

HSTS is skipped in development: browsers remember it per host and
localhost is shared with unrelated projects. Every other header applies
locally, so a CSP violation surfaces while developing.

For observability, browser error reports tunnel through the API rather
than going to Sentry directly. Ad blockers block Sentry domains, which
loses errors precisely for the users most likely to have browser
oddities. The tunnel forwards only to the host derived from the
configured DSN — a caller-supplied destination would turn an anonymous
endpoint into a request-forgery primitive.

Two consequences of the chosen options are recorded rather than left
implicit:

Enabling SendDefaultPii attaches request headers, and this application
carries two standing credentials in them. Besides the refreshToken
cookie, X-Master-Api-Key would have been sent to a third party on every
error raised during a master/slave call. The scrub list removes the
whole Cookie header, Authorization, X-Master-Api-Key and the request
body.

Console logging at Information plus structured logging to Sentry would,
taken literally, mean one Sentry event per request — exhausting the free
plan within hours and burying real errors in request noise. The
thresholds are split: console keeps Information, Sentry takes warnings
and above as events with Information as breadcrumbs, so every event
arrives carrying the trail that led to it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHoJpxYXzHACSQguHrC5fw
2026-07-28 00:01:04 +02:00

7.1 KiB

Domain Entities — U4 Observability Integration

No persisted entity. U4 adds no table, no migration and no database column. It adds two configuration sections, one anonymous endpoint, and build-time frontend values.


Concept Relationships

graph TD
    obsconfig["Observability configuration<br/>backend"]
    dsn["Sentry DSN"]
    env["Environment name"]
    release["Release identifier"]
    logging["Structured logger"]
    corr["Correlation identifier"]
    scrubber["Credential scrubber"]
    sentrybe["Sentry client<br/>backend"]
    tunnel["Tunnel endpoint"]
    vite["Vite build-time values<br/>frontend"]
    sentryfe["Sentry client<br/>frontend"]
    umami["Umami script component"]
    apicfg["API base URL resolution"]

    obsconfig --> dsn
    obsconfig --> env
    obsconfig --> release
    dsn --> sentrybe
    dsn --> tunnel
    env --> sentrybe
    release --> sentrybe
    logging --> corr
    logging --> sentrybe
    scrubber -->|"filters before send"| sentrybe
    vite --> sentryfe
    vite --> umami
    vite --> apicfg
    sentryfe -->|"posts envelopes to"| tunnel
    tunnel -->|"forwards to DSN host only"| sentrybe

    classDef cfg fill:#fbd38d,stroke:#c05621,stroke-width:1px,color:#000;
    classDef backend fill:#90cdf4,stroke:#2b6cb0,stroke-width:1px,color:#000;
    classDef frontend fill:#d6bcfa,stroke:#6b46c1,stroke-width:1px,color:#000;
    classDef guard fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000;
    class obsconfig,dsn,env,release,vite cfg;
    class logging,corr,sentrybe,tunnel backend;
    class sentryfe,umami,apicfg frontend;
    class scrubber guard;

Text alternative: backend configuration supplies the Sentry DSN, environment and release; the structured logger attaches a correlation identifier and feeds Sentry through a credential scrubber; the frontend reads build-time values and routes its error envelopes through the same-origin tunnel, which forwards only to the configured DSN host.


Backend configuration — Observability section

Field Type Default Purpose
SentryDsn string empty Absent means Sentry is skipped entirely (BR-U4-08)
Environment string falls back to ASPNETCORE_ENVIRONMENT Tag distinguishing test from production
TunnelMaxPayloadBytes int fixed default Upper bound enforced by the tunnel (BR-U4-17)
TracesSampleRate double conservative default Performance sampling; kept low so the free plan is not exhausted

Not configurable, deliberately: the scrub list (BR-U4-13) and the tunnel's destination host (BR-U4-16). 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 endpoint into a request-forgery primitive.

Secret handling: a Sentry DSN is 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 environment variables in production, consistent with D-16, rather than being committed.


Correlation identifier

Aspect Detail
Purpose Ties every log entry and Sentry event from one request together (SECURITY-03)
Scope One request
Presence On every log entry, not only on errors
Mechanism Not fixed hereHttpContext.TraceIdentifier versus W3C traceparent is OPEN-01, decided in this unit's NFR Design

Recorded as an entity because the choice affects the shape of every log entry, and NFR Design will settle it rather than leaving it to code generation.


Credential scrubber

Not persisted; a filter applied to every outbound Sentry event.

Removed Reason
Cookie header, entire Carries the refreshToken. Removing one cookie by rewriting the header is error-prone
Authorization header Bearer token
X-Master-Api-Key header Master/slave shared secret
Request body Login and password-change bodies carry passwords
Retained Reason
Method, path, query string Diagnostic value. Note the invitation-token caveat below
User agent Browser-specific failures
IP address Attack-pattern recognition
Authenticated username Whose session hit the problem
Correlation identifier Ties the event to its log entries

Invitation-token caveat: /api/v1/Invitation/validate?token=… puts a token in the query string, which is retained. It is single-use and time-limited rather than a standing credential, and the value of knowing which endpoint was called outweighs it. Documented so the trade-off is visible.


Security event types

Six event shapes, not persisted — emitted as structured log entries that also become Sentry events.

Event Level Context
Failed login Warning Endpoint, whether the account exists, correlation ID
Authorization denied Warning Endpoint, required policy, role held, correlation ID
Master API key rejected Warning Endpoint, calling host, correlation ID
Admin bypass rejected Warning Path, rejection reason class, correlation ID
Rate limit triggered Warning Limiter name, endpoint, correlation ID
Migration failure Critical Exception, attempt count, correlation ID

All six sit at Warning or above, so they cross the Sentry event threshold (BR-U4-06) by construction rather than by coincidence.

Rejection reason classes for the admin bypass — InvalidSignature, Expired, WrongIssuer, NotAdmin, Malformed — never the token itself. The distinction matters: InvalidSignature suggests forgery, while Expired is usually an administrator with a stale tab.


Frontend build-time values

Variable Purpose Absent behaviour
VITE_API_BASE_URL API origin Same-origin (BR-U4-22)
VITE_SENTRY_DSN Frontend Sentry project Sentry skipped (BR-U4-26)
VITE_APP_ENV Environment tag Untagged
VITE_UMAMI_SCRIPT_URL Umami script origin Umami not loaded
VITE_UMAMI_WEBSITE_ID Per-environment website ID Nothing rendered (BR-U4-28)
VITE_APP_TITLE Existing; unchanged Defaults to SlpModularCms

Why these force two build artifacts (D-15): Vite bakes them into the bundle at build time, so one dist/ cannot carry both the test and production website IDs or environment tags. The CI workflow therefore produces one artifact per environment.

Frontend config model change

AppConfig.apiBaseUrl gains one new legal value: the empty string, meaning same-origin. Its Zod schema becomes "empty string or valid absolute URL" — relaxed by exactly one case, not loosened to accept anything (BR-U4-24).


Persistence Summary

Question Answer
New tables? None
New migrations? None
New configuration sections? One backend section (Observability); frontend variables are build-time
New endpoints? One — the anonymous Sentry tunnel
Secrets stored? None. The DSN is not a standing credential and comes from environment variables
Data sent to third parties? Error events to Sentry, page views to self-hosted Umami. Credentials scrubbed per BR-U4-13