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
This commit is contained in:
+122
@@ -0,0 +1,122 @@
|
||||
# Functional Design Questions — U3 HTTP Security Headers & CSP
|
||||
|
||||
Vul je keuze in achter elke `[Answer]:`-tag. Kies de laatste optie (`Anders`) als niets past.
|
||||
|
||||
---
|
||||
|
||||
## Question 1 — De admin-SPA gaat kapot van een echt strikte CSP
|
||||
|
||||
**Context**: de admin-SPA gebruikt Radix UI-componenten (dialogen, dropdowns, selects) via shadcn-stijl wrappers. Die plaatsen positionering als **inline `style`-attributen** op elementen — dat is hoe een dropdown weet waar hij moet staan.
|
||||
|
||||
Een CSP met `style-src 'self'` en zonder `'unsafe-inline'` blokkeert die inline styles. Het gevolg is niet een foutmelding maar verkeerd gepositioneerde of onzichtbare menu's en dialogen: kapot op een manier die je pas in de browser ziet, en pas op de plekken waar je klikt.
|
||||
|
||||
Er zijn drie manieren om hiermee om te gaan:
|
||||
|
||||
A) `style-src 'self' 'unsafe-inline'` in de strikte policy, met een gedocumenteerde onderbouwing waarom die uitzondering nodig is — pragmatisch en meteen werkend. `script-src` blijft wél strikt, en dat is waar XSS-risico echt zit
|
||||
B) Nonces of hashes gebruiken voor styles — theoretisch netter, maar Radix genereert styles op runtime per interactie, dus dit werkt in de praktijk niet zonder de componenten te herschrijven
|
||||
C) Begin met `Content-Security-Policy-Report-Only` voor `/admin`, kijk wat er daadwerkelijk overtreden wordt, en zet hem daarna afdwingend — niets breekt stil, maar de bescherming staat er in de tussentijd niet
|
||||
X) Anders (beschrijf hieronder na de [Answer]:-tag)
|
||||
|
||||
[Answer]: B
|
||||
|
||||
---
|
||||
|
||||
## Question 2 — Wat mag de publieke website?
|
||||
|
||||
**Context**: je koos een ruimere CSP voor de publieke website (D-31), omdat een website-bouwer niet beperkt moet worden door een policy die hij nooit gezien heeft. Maar "ruimer" moet nog wel iets betekenen — de website wordt door hetzelfde proces geserveerd als de admin-UI en de API.
|
||||
|
||||
A) Ruim maar niet leeg: sta scripts, styles, afbeeldingen en fonts van eigen origin plus inline toe, en verbindingen naar eigen origin plus de geconfigureerde origins. Externe scripts (bijv. een YouTube-embed) worden dan geblokkeerd tenzij toegevoegd
|
||||
B) Alleen de echt beschermende headers voor de website (`X-Content-Type-Options`, HSTS, `Referrer-Policy`) en **geen** CSP — dan kan een website-bouwer nooit verrast worden
|
||||
C) Ruim, plus in het website-contract vastleggen dat een bouwer extra origins kan laten toevoegen aan de configuratie als hij externe bronnen nodig heeft
|
||||
X) Anders (beschrijf hieronder na de [Answer]:-tag)
|
||||
|
||||
[Answer]:B
|
||||
|
||||
---
|
||||
|
||||
## Question 3 — Gelden de headers ook lokaal?
|
||||
|
||||
**Context**: `Strict-Transport-Security` vertelt de browser: gebruik voor dit domein voortaan altijd HTTPS. Browsers onthouden dat **per host, langdurig**. Stuur je die header op `localhost`, dan kan dat je lokale ontwikkeling van andere projecten op dezelfde `localhost` gaan dwarszitten, en het is niet triviaal om weer ongedaan te maken.
|
||||
|
||||
De CSP lokaal wél actief hebben is juist nuttig: dan merk je een overtreding tijdens ontwikkelen in plaats van in productie.
|
||||
|
||||
A) CSP en de overige headers ook in Development; **HSTS alleen buiten Development** — je ziet CSP-problemen vroeg, zonder je `localhost` te vervuilen
|
||||
B) Alle headers in alle omgevingen, inclusief HSTS lokaal
|
||||
C) Geen enkele header in Development — lokaal zo weinig ruis als mogelijk
|
||||
X) Anders (beschrijf hieronder na de [Answer]:-tag)
|
||||
|
||||
[Answer]:A
|
||||
|
||||
---
|
||||
|
||||
## Question 4 — Mag `/admin` in een iframe?
|
||||
|
||||
**Context**: je koos `X-Frame-Options: DENY` in de requirements (FR-18). Dat betekent dat geen enkele pagina van dit domein in een iframe geplaatst mag worden — ook niet door de site zelf.
|
||||
|
||||
Dat is voor de admin-UI precies goed. Maar het geldt dan ook voor de publieke website, en een klant die op zijn eigen site een pagina in een iframe toont (bijvoorbeeld een formulier of een kaart in een eigen iframe), loopt daar tegenaan.
|
||||
|
||||
A) `DENY` voor `/admin` en `/api/v1`, `SAMEORIGIN` voor de publieke website — de admin-UI blijft maximaal beschermd, de website kan zijn eigen pagina's insluiten
|
||||
B) `DENY` overal, zoals in de requirements — en een klant die iframes nodig heeft, komt dan bij jou terecht
|
||||
C) `SAMEORIGIN` overal — eenvoudiger, iets minder strikt voor de admin-UI
|
||||
X) Anders (beschrijf hieronder na de [Answer]:-tag)
|
||||
|
||||
[Answer]:A
|
||||
|
||||
---
|
||||
|
||||
## Question 5 — Wat als er geen origins geconfigureerd zijn?
|
||||
|
||||
**Context**: de Umami- en Sentry-origins komen uit configuratie en verschillen per omgeving. In een omgeving zonder Sentry of Umami — bijvoorbeeld lokaal, of een instantie zonder analytics — zijn die lijsten leeg.
|
||||
|
||||
Een strikte CSP zonder die origins is correcter (minder toegestaan), maar als iemand later Sentry aanzet en de origin vergeet toe te voegen, worden de foutmeldingen stil geblokkeerd: je monitoring lijkt dan te werken maar ontvangt niets.
|
||||
|
||||
A) Lege lijsten zijn normaal — de policy wordt dan simpelweg strikter. Bij opstarten een informatieregel loggen welke origins actief zijn, zodat je in het log kunt zien wat is toegestaan
|
||||
B) Lege lijsten zijn normaal, en géén logging — het is een gewone toestand
|
||||
C) Bij opstarten een waarschuwing als er een Sentry-DSN of Umami-website-ID is geconfigureerd maar de bijbehorende origin niet in de CSP staat — vangt precies de fout waarbij monitoring stil niets ontvangt
|
||||
X) Anders (beschrijf hieronder na de [Answer]:-tag)
|
||||
|
||||
[Answer]:C
|
||||
|
||||
---
|
||||
|
||||
# Vervolgvragen (ronde 2)
|
||||
|
||||
---
|
||||
|
||||
## Follow-up Question 1 — Optie B bij vraag 1 kan technisch niet
|
||||
|
||||
Je koos bij vraag 1 optie B: nonces of hashes voor styles. Ik moet dat terugleggen, want ik heb die optie te mild beschreven — hij is niet "theoretisch netter maar lastig", hij **werkt principieel niet** voor dit probleem.
|
||||
|
||||
**Waarom niet:** een CSP-nonce werkt alleen op `<style>`- en `<script>`-**elementen**. Inline `style="..."`-**attributen** vallen daar volledig buiten; een nonce kan er niet aan gehangen worden. Dat is geen implementatiedetail maar hoe de specificatie werkt.
|
||||
|
||||
En juist die attributen zijn wat Radix gebruikt: een dropdown die opengaat krijgt zijn positie als `style="transform: translate(...)"` op het element. Die waarde verandht per klik, per schermgrootte en per scrollpositie.
|
||||
|
||||
De enige CSP-mechanismen die inline `style`-attributen kunnen toestaan zijn:
|
||||
- `'unsafe-inline'` — staat ze allemaal toe
|
||||
- `'unsafe-hashes'` met een hash per exacte attribuutwaarde — en die waarden zijn hier dynamisch, dus er is geen eindige verzameling om te hashen
|
||||
|
||||
Er is dus geen variant van B die de admin-UI heel laat. Wat overblijft:
|
||||
|
||||
A) `style-src 'self' 'unsafe-inline'` in de strikte policy, met gedocumenteerde onderbouwing. `script-src` blijft strikt zonder `unsafe-inline` en zonder `unsafe-eval` — en daar zit het werkelijke XSS-risico. Dit is wat vrijwel elke applicatie met een component-bibliotheek doet
|
||||
C) Beginnen met `Content-Security-Policy-Report-Only` voor `/admin`, kijken wat er in de praktijk overtreden wordt, en daarna afdwingend zetten met de dan blijkbaar benodigde uitzonderingen. Niets breekt stil, maar de CSP beschermt `/admin` in de tussentijd niet
|
||||
D) Radix vervangen door componenten zonder inline styles — een herbouw van de hele admin-UI, ver buiten de scope van deze feature
|
||||
X) Anders (beschrijf hieronder na de [Answer]:-tag)
|
||||
|
||||
[Answer]: A
|
||||
|
||||
---
|
||||
|
||||
## Follow-up Question 2 — Bevestiging: geen CSP op de publieke website is een afwijking
|
||||
|
||||
Bij vraag 2 koos je B: alleen de beschermende headers voor de publieke website en **geen** CSP. Dat is een verdedigbare keuze en past bij je uitgangspunt dat een website-bouwer niet mag struikelen over een policy die hij nooit gezien heeft.
|
||||
|
||||
Wel moet ik het expliciet vastleggen, want SECURITY-04 vraagt een CSP op **alle** HTML-servende endpoints. Zonder CSP op de website betekent dat: als daar ooit een XSS-gat zit, is er geen tweede verdedigingslaag die het beperkt. `/admin` en `/api/v1` houden hun strikte policy, dus het beheerdeel blijft wel beschermd.
|
||||
|
||||
Hoe wil je dit vastleggen?
|
||||
|
||||
A) Als gedocumenteerde afwijking (DEV-06) met deze onderbouwing, en in het website-contract opnemen dat een bouwer zelf verantwoordelijk is voor uitvoer-escaping op zijn site
|
||||
B) Toch een CSP op de website, maar alleen rapporterend (`Report-Only`) — dan beperkt hij niets en breekt er niets, maar je ziet wel wat er zou zijn geblokkeerd
|
||||
C) Toch een afdwingende ruime CSP op de website (optie A van de oorspronkelijke vraag), zodat SECURITY-04 gewoon gehaald wordt
|
||||
X) Anders (beschrijf hieronder na de [Answer]:-tag)
|
||||
|
||||
[Answer]: C
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
# Functional Design Plan — U3 HTTP Security Headers & CSP
|
||||
|
||||
**Unit**: U3 HTTP Security Headers & CSP
|
||||
**Round**: R2 (with U4 Observability)
|
||||
**Requirements**: FR-18
|
||||
**Components**: C-01, C-02, C-03, U3 portion of C-16
|
||||
|
||||
**Scope boundary**: this stage designs *behaviour* — which headers apply to which response, what each policy permits, and what happens in edge cases. The *pattern* decisions (how the policy is composed, how the configuration surface is shaped) belong to this unit's NFR Design, which follows.
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Analyze unit context
|
||||
- [x] Read the U3 definition from `unit-of-work.md`
|
||||
- [x] Read FR-18 and D-31 from `requirements.md`
|
||||
- [x] Read the per-header scoping decision (FU1 = A) and the configuration split (FU2 = A)
|
||||
- [x] Confirm the settled path layout from U1 as delivered
|
||||
|
||||
## Step 2: Design header applicability
|
||||
- [x] Define which headers apply to all responses and which to HTML only
|
||||
- [x] Define behaviour for redirects, `304 Not Modified` and error responses
|
||||
- [x] Define behaviour when a header is already present
|
||||
- [x] Define whether headers apply in Development — see Question 3
|
||||
|
||||
## Step 3: Design the two policies
|
||||
- [x] Define what the `Strict` policy permits for `/admin` and `/api/v1` — see Question 1
|
||||
- [x] Define what the `Relaxed` policy permits for the public website — see Question 2
|
||||
- [x] Define how the Umami and Sentry origins enter the policy
|
||||
- [x] Define behaviour for the placeholder page
|
||||
|
||||
## Step 4: Design failure behaviour
|
||||
- [x] Define startup behaviour on an unknown policy name
|
||||
- [x] Define behaviour when no origins are configured
|
||||
- [x] Confirm header application never throws into the response path
|
||||
|
||||
## Step 5: Define business rules
|
||||
- [x] Enumerate applicability rules
|
||||
- [x] Enumerate policy-content rules
|
||||
- [x] Identify error and edge-case scenarios
|
||||
|
||||
## Step 6: Generate artifacts
|
||||
- [x] Generate `business-logic-model.md`
|
||||
- [x] Generate `business-rules.md`
|
||||
- [x] Generate `domain-entities.md`
|
||||
- [x] Validate all diagrams against the Mermaid standards
|
||||
- [x] Verify Security Baseline compliance for this unit's design
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
# Functional Design Questions — U4 Observability Integration
|
||||
|
||||
Vul je keuze in achter elke `[Answer]:`-tag. Kies de laatste optie (`Anders`) als niets past.
|
||||
|
||||
---
|
||||
|
||||
## Question 1 — Gaan Sentry-meldingen direct of via een tunnel?
|
||||
|
||||
**Context**: in je SlpSoftware-workflow loopt Sentry via een **tunnel**: de browser stuurt fouten naar een pad op je eigen domein (`/sentry-tunnel`), dat ze doorstuurt naar Sentry. Dat is daar gedaan omdat adblockers verzoeken naar Sentry-domeinen blokkeren met `ERR_BLOCKED_BY_CLIENT` — waardoor je juist bij de gebruikers met een adblocker geen fouten meer ziet.
|
||||
|
||||
Dat probleem geldt hier net zo goed. Een tunnel heeft bovendien een neveneffect dat hier goed uitkomt: alle verkeer blijft same-origin, dus `connect-src 'self'` volstaat en de CSP van U3 heeft geen externe Sentry-origin nodig.
|
||||
|
||||
Nadeel: de tunnel moet ergens draaien. In jouw referentie doet nginx dat; hier kan het geen serverconfiguratie zijn, dus zou de .NET-app het zelf moeten doorsturen.
|
||||
|
||||
A) Tunnel via de API — een endpoint in de app stuurt de Sentry-envelope door. Werkt met adblockers, houdt de CSP eenvoudig, en vereist geen serverconfiguratie. Wel nieuwe code die uitgaand verkeer doet
|
||||
B) Direct naar Sentry, en de Sentry-ingest-origin in de CSP toestaan — eenvoudiger, geen extra endpoint, maar fouten van gebruikers met een adblocker komen niet aan
|
||||
C) Direct nu, tunnel als vervolgpunt als blijkt dat er te veel gemist wordt
|
||||
X) Anders (beschrijf hieronder na de [Answer]:-tag)
|
||||
|
||||
[Answer]: A
|
||||
|
||||
---
|
||||
|
||||
## Question 2 — Mag Sentry gebruikersgegevens meesturen?
|
||||
|
||||
**Context**: `Sentry.AspNetCore` heeft een instelling `SendDefaultPii`. Staat die aan, dan stuurt Sentry bij elke fout ook het IP-adres, de gebruikersnaam en request-headers mee — inclusief cookies. In deze applicatie zit in die cookies de `refreshToken`.
|
||||
|
||||
Standaard staat de instelling uit. SECURITY-03 verbiedt expliciet het loggen van secrets en PII.
|
||||
|
||||
A) Uit laten — geen PII, geen cookies, geen IP. Fouten bevatten dan alleen de technische context, wat voor diagnose vrijwel altijd genoeg is
|
||||
B) Aan, maar met een filter dat de `refreshToken`-cookie en de `Authorization`-header verwijdert — meer context bij een fout, met het gevoelige eruit gehaald
|
||||
C) Aan zonder filter — maximale context
|
||||
X) Anders (beschrijf hieronder na de [Answer]:-tag)
|
||||
|
||||
[Answer]: B
|
||||
|
||||
---
|
||||
|
||||
## Question 3 — Welk logniveau in productie?
|
||||
|
||||
**Context**: `appsettings.json` staat nu op `Warning`. Dat betekent dat een normale productie-run vrijwel niets logt — geen opstartmeldingen, geen module-discovery, geen migratie-uitkomst. Juist die drie zijn na een deploy het interessantst, en `ModuleOrchestrator` logt op `Information` dat hij modules gevonden heeft (bij falen logt hij een waarschuwing, maar hij stopt niet).
|
||||
|
||||
Je koos structured logging naar Sentry op niveaus verder dan alleen exceptions (Q16 = C).
|
||||
|
||||
A) `Information` als standaard in productie, met `Microsoft.AspNetCore` op `Warning` — dan zie je opstart, modules en migraties, zonder request-ruis
|
||||
B) `Warning` behouden en de belangrijke opstartmeldingen expliciet naar `Warning` tillen — minimale logvolume, maar dan staat "alles ging goed" als waarschuwing in het log
|
||||
C) `Information` voor alles, inclusief het framework — meeste inzicht, meeste volume
|
||||
X) Anders (beschrijf hieronder na de [Answer]:-tag)
|
||||
|
||||
[Answer]:C
|
||||
|
||||
---
|
||||
|
||||
## Question 4 — Welke gebeurtenissen zijn het waard om op te alarmeren?
|
||||
|
||||
**Context**: SECURITY-14 vraagt alerting op authenticatiefouten en autorisatieschendingen. De applicatie moet die dus eerst als gebeurtenis uitsturen voordat je er in Sentry een alertregel op kunt zetten.
|
||||
|
||||
Kandidaten die deze applicatie kan onderscheiden. Meerdere letters mogen (bijv. `A, B, D`).
|
||||
|
||||
A) Mislukte logins — meerdere achter elkaar duidt op een aanval of een vergeten wachtwoord
|
||||
B) Geweigerde autorisatie op een beveiligd endpoint — iemand probeert iets waarvoor hij geen rechten heeft
|
||||
C) Een geweigerde master-API-key op `/api/v1/master/*` of `/api/v1/SlaveStatus` — dat betekent óf een aanvaller, óf een echt probleem met de key ring
|
||||
D) Een geweigerde admin-bypass op de availability-gate — sinds de FR-24-fix betekent dit iemand die het met een ongeldig token probeerde
|
||||
E) Rate limiting die aanslaat op de login-endpoints
|
||||
F) Migratiefouten bij het opstarten — geen beveiligingsgebeurtenis, maar wel iets waarvan je direct wilt weten
|
||||
X) Anders (beschrijf hieronder na de [Answer]:-tag)
|
||||
|
||||
[Answer]:A, B, C, D, E, F
|
||||
|
||||
---
|
||||
|
||||
## Question 5 — Umami op de admin-SPA: altijd, of respecteren wat de browser vraagt?
|
||||
|
||||
**Context**: je koos Umami op zowel de publieke website als de admin-SPA (Q20 = B). De admin-SPA is een intern beheerscherm, en de gebruikers daarvan zijn jouw klanten en hun medewerkers — herkenbare, kleine groepen.
|
||||
|
||||
Umami is privacyvriendelijk (geen cookies, geen persoonsgegevens), dus dit is geen juridische vraag maar een keuze over verwachtingen.
|
||||
|
||||
A) Altijd meten in test en productie, nooit lokaal — eenvoudig en consistent
|
||||
B) Altijd meten, maar de `Do Not Track`-voorkeur van de browser respecteren en dan niets laden
|
||||
C) Alleen de publieke website meten en de admin-SPA overslaan, in afwijking van Q20 — beheerders worden dan niet gemeten
|
||||
X) Anders (beschrijf hieronder na de [Answer]:-tag)
|
||||
|
||||
[Answer]:A
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
# Functional Design Plan — U4 Observability Integration
|
||||
|
||||
**Unit**: U4 Observability Integration
|
||||
**Round**: R2 (with U3 Security Headers & CSP)
|
||||
**Requirements**: FR-13, FR-14, FR-15, FR-16, FR-19
|
||||
**Components**: C-08, C-09, C-14, C-15, U4 portion of C-16
|
||||
|
||||
**Scope boundary**: this stage designs *behaviour* — what is reported, when, and what happens when a service is absent or unreachable. The *pattern* decisions (structured-log shape, correlation-ID mechanism per OPEN-01, alert-rule design) belong to this unit's NFR Design, which follows.
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Analyze unit context
|
||||
- [x] Read the U4 definition from `unit-of-work.md`
|
||||
- [x] Read FR-13 through FR-16 and FR-19 from `requirements.md`
|
||||
- [x] Read the reference project's Sentry and Umami implementation for the frontend
|
||||
- [x] Note the coupling to U3: the origins this unit introduces must be permitted by U3's CSP
|
||||
|
||||
## Step 2: Design backend error and log reporting
|
||||
- [x] Define behaviour when no Sentry DSN is configured
|
||||
- [x] Define behaviour when Sentry is configured but unreachable
|
||||
- [x] Define what is sent and what is deliberately withheld — see Question 2
|
||||
- [x] Define the production log level — see Question 3
|
||||
- [x] Define environment and release tagging
|
||||
|
||||
## Step 3: Design security event emission
|
||||
- [x] Define which events are emitted for alerting — see Question 4
|
||||
- [x] Define what context each event carries, and what it must never carry
|
||||
- [x] Confirm no event carries a password, token or PII
|
||||
|
||||
## Step 4: Design frontend observability
|
||||
- [x] Define Sentry initialisation and the absent-DSN path
|
||||
- [x] Define the transport route to Sentry — see Question 1
|
||||
- [x] Define Umami inclusion behaviour and its absent-configuration path — see Question 5
|
||||
- [x] Define behaviour in local development
|
||||
|
||||
## Step 5: Design the same-origin API base URL
|
||||
- [x] Define resolution when `VITE_API_BASE_URL` is absent or empty
|
||||
- [x] Define resolution when an explicit absolute URL is supplied
|
||||
- [x] Define validation behaviour for a malformed value
|
||||
- [x] Confirm the local master and slave development setups keep working
|
||||
|
||||
## Step 6: Define business rules
|
||||
- [x] Enumerate reporting rules
|
||||
- [x] Enumerate degradation rules
|
||||
- [x] Enumerate configuration-resolution rules
|
||||
- [x] Identify error and edge-case scenarios
|
||||
|
||||
## Step 7: Generate artifacts
|
||||
- [x] Generate `business-logic-model.md`
|
||||
- [x] Generate `business-rules.md`
|
||||
- [x] Generate `domain-entities.md`
|
||||
- [x] Generate `frontend-components.md`
|
||||
- [x] Validate all diagrams against the Mermaid standards
|
||||
- [x] Verify Security Baseline compliance for this unit's design
|
||||
+226
@@ -0,0 +1,226 @@
|
||||
# Business Logic Model — U3 HTTP Security Headers & CSP
|
||||
|
||||
**Unit**: U3 HTTP Security Headers & CSP
|
||||
**Requirements**: FR-18
|
||||
|
||||
---
|
||||
|
||||
## 1. Scope of the Logic
|
||||
|
||||
Normally these headers come from nginx or IIS configuration. NFR-01 forbids relying on server configuration, so the application must supply them itself — which turns a configuration file into request-processing logic with three decisions per response:
|
||||
|
||||
1. **Which policy** applies to this request path
|
||||
2. **Which headers** apply to this response, based on its content type
|
||||
3. **Whether** headers apply at all in this environment
|
||||
|
||||
---
|
||||
|
||||
## 2. Header Application Flow
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
req["Incoming request"]
|
||||
enabled{"Headers enabled ?"}
|
||||
skip["Continue without headers"]
|
||||
resolve["Resolve policy name<br/>from request path"]
|
||||
hook["Register response-start callback"]
|
||||
next["Continue pipeline"]
|
||||
start["Response starting"]
|
||||
always["Apply always-headers:<br/>X-Content-Type-Options<br/>plus HSTS outside Development"]
|
||||
ishtml{"Content type is HTML ?"}
|
||||
htmlonly["Apply HTML-only headers:<br/>Content-Security-Policy<br/>X-Frame-Options<br/>Referrer-Policy"]
|
||||
done["Response sent"]
|
||||
|
||||
req --> enabled
|
||||
enabled -->|no| skip
|
||||
enabled -->|yes| resolve
|
||||
resolve --> hook
|
||||
hook --> next
|
||||
next --> start
|
||||
start --> always
|
||||
always --> ishtml
|
||||
ishtml -->|yes| htmlonly
|
||||
ishtml -->|no| done
|
||||
htmlonly --> done
|
||||
|
||||
classDef entry fill:#f6e05e,stroke:#c05621,stroke-width:1px,color:#000;
|
||||
classDef decision fill:#90cdf4,stroke:#2b6cb0,stroke-width:1px,color:#000;
|
||||
classDef step fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000;
|
||||
classDef neutral fill:#e2e8f0,stroke:#4a5568,stroke-width:1px,color:#000;
|
||||
class req,start entry;
|
||||
class enabled,ishtml decision;
|
||||
class resolve,hook,next,always,htmlonly step;
|
||||
class skip,done neutral;
|
||||
```
|
||||
|
||||
Text alternative: the policy for the path is resolved when the request arrives, but headers are written at response start — because the content type, which decides whether the HTML-only headers apply, is not known any earlier.
|
||||
|
||||
**Why the work is split across two moments**: path matching happens once per request, cheaply, before the pipeline continues. Content-type inspection can only happen at response start. Doing both at response start would repeat path matching on every static asset; doing both early would force an all-or-nothing choice on header scope.
|
||||
|
||||
**Why registration must precede static files**: static-file middleware short-circuits the pipeline. Anything registered after it never observes a static response — and static responses are exactly what the public website consists of.
|
||||
|
||||
---
|
||||
|
||||
## 3. Per-Header Scoping
|
||||
|
||||
Per FU1 = A, scope is decided per header rather than uniformly.
|
||||
|
||||
| Header | Applies to | Reason |
|
||||
|---|---|---|
|
||||
| `X-Content-Type-Options: nosniff` | **All** responses | Exists specifically to stop MIME-sniffing of non-HTML resources. Restricting it to HTML would remove it exactly where it does its job |
|
||||
| `Strict-Transport-Security` | **All** responses, **outside Development only** | A host-level transport directive, not a page directive. A visitor whose first request is an asset would otherwise never receive it |
|
||||
| `Content-Security-Policy` | HTML responses only | Meaningless on an image or a script file |
|
||||
| `X-Frame-Options` | HTML responses only | Governs framing of documents |
|
||||
| `Referrer-Policy` | HTML responses only | Governs navigation and resource referrers from a document |
|
||||
|
||||
**HSTS and Development** (Q3 = A): browsers remember HSTS per host, for a long time, and `localhost` is shared with every other local project. Sending it during development would affect unrelated work and is awkward to undo. Every other header **does** apply in Development, so a CSP violation surfaces while developing rather than in production.
|
||||
|
||||
---
|
||||
|
||||
## 4. Policy Selection
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
path["Request path"]
|
||||
admin{"Starts with /admin ?"}
|
||||
api{"Starts with /api/v1 ?"}
|
||||
health{"Is /health ?"}
|
||||
strict["Strict policy"]
|
||||
relaxed["Relaxed policy"]
|
||||
|
||||
path --> admin
|
||||
admin -->|yes| strict
|
||||
admin -->|no| api
|
||||
api -->|yes| strict
|
||||
api -->|no| health
|
||||
health -->|yes| strict
|
||||
health -->|no| relaxed
|
||||
|
||||
classDef entry fill:#f6e05e,stroke:#c05621,stroke-width:1px,color:#000;
|
||||
classDef decision fill:#90cdf4,stroke:#2b6cb0,stroke-width:1px,color:#000;
|
||||
classDef strictnode fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000;
|
||||
classDef relaxednode fill:#fbd38d,stroke:#c05621,stroke-width:1px,color:#000;
|
||||
class path entry;
|
||||
class admin,api,health decision;
|
||||
class strict strictnode;
|
||||
class relaxed relaxednode;
|
||||
```
|
||||
|
||||
Text alternative: paths under `/admin`, `/api/v1` and `/health` get the strict policy; everything else — the public website and the placeholder page — gets the relaxed policy.
|
||||
|
||||
The mapping itself is configuration (FU2 = A), so a path can be added without code changes. The two policies are defined in code, so a misconfiguration can misroute a path but cannot invent a broken policy.
|
||||
|
||||
---
|
||||
|
||||
## 5. What Each Policy Permits
|
||||
|
||||
### Strict — `/admin`, `/api/v1`, `/health`
|
||||
|
||||
| Directive | Value | Reason |
|
||||
|---|---|---|
|
||||
| `default-src` | `'self'` | Deny by default |
|
||||
| `script-src` | `'self'` | **No `'unsafe-inline'`, no `'unsafe-eval'`.** This is where XSS risk actually lives, and it stays closed |
|
||||
| `style-src` | `'self' 'unsafe-inline'` | Required — see below |
|
||||
| `img-src` | `'self' data:` | `data:` covers inlined icons in the built bundle |
|
||||
| `font-src` | `'self'` | Fonts ship with the bundle |
|
||||
| `connect-src` | `'self'` + configured origins | Same-origin API. The Sentry tunnel (U4) keeps error reporting same-origin too |
|
||||
| `frame-ancestors` | `'none'` | Matches `X-Frame-Options: DENY` for modern browsers |
|
||||
| `base-uri` | `'self'` | Prevents base-tag injection redirecting relative URLs |
|
||||
| `form-action` | `'self'` | Prevents form hijacking |
|
||||
| `object-src` | `'none'` | No plugins |
|
||||
|
||||
**`style-src 'unsafe-inline'` — why it is unavoidable** (FU1 = A):
|
||||
|
||||
Radix UI positions dropdowns, dialogs and selects by writing inline `style` attributes such as `style="transform: translate(...)"`, recalculated per click, viewport and scroll position.
|
||||
|
||||
CSP nonces apply only to `<style>` and `<script>` **elements** — inline `style` **attributes** are outside their reach entirely. The only mechanisms that can permit them are `'unsafe-inline'`, or `'unsafe-hashes'` with a hash per exact attribute value, and those values are dynamic so no finite set exists. There is therefore no nonce- or hash-based variant that leaves the admin UI functional.
|
||||
|
||||
The exception is contained: it applies to `style-src` only. Injected CSS can restyle a page, but `script-src 'self'` still prevents execution of injected script, which is the actual escalation path.
|
||||
|
||||
### Relaxed — the public website
|
||||
|
||||
Per FU2 = C, the website receives an **enforcing** CSP rather than none, so SECURITY-04 is satisfied on every HTML-serving path.
|
||||
|
||||
| Directive | Value | Reason |
|
||||
|---|---|---|
|
||||
| `default-src` | `'self'` | Deny by default |
|
||||
| `script-src` | `'self' 'unsafe-inline'` | A website author may use inline scripts and has never seen this policy |
|
||||
| `style-src` | `'self' 'unsafe-inline'` | Same |
|
||||
| `img-src` | `'self' data: https:` | Images from any HTTPS source — commonplace on a marketing site |
|
||||
| `font-src` | `'self' data: https:` | Web fonts from any HTTPS source |
|
||||
| `connect-src` | `'self'` + configured origins | Includes the Umami origin when configured |
|
||||
| `frame-src` | `'self' https:` | Embeds such as maps and video |
|
||||
| `frame-ancestors` | `'self'` | Matches `X-Frame-Options: SAMEORIGIN` |
|
||||
| `base-uri` | `'self'` | Retained — cheap and breaks nothing |
|
||||
| `object-src` | `'none'` | Retained |
|
||||
|
||||
**What "relaxed" deliberately still blocks**: an external `script-src`. A website author who needs a third-party script must have its origin added to configuration — which the website contract (FR-09) documents. That keeps the policy from being a rubber stamp while still not surprising anyone with a broken layout.
|
||||
|
||||
**Note on `'unsafe-inline'` and external scripts**: when a source list contains `'unsafe-inline'`, browsers honour it *and* the listed origins. Adding an origin therefore does not silently disable inline scripts.
|
||||
|
||||
---
|
||||
|
||||
## 6. Frame Options Per Policy
|
||||
|
||||
Per Q4 = A, this refines FR-18, which specified `DENY` globally without accounting for the public website:
|
||||
|
||||
| Path | `X-Frame-Options` | `frame-ancestors` |
|
||||
|---|---|---|
|
||||
| `/admin`, `/api/v1`, `/health` | `DENY` | `'none'` |
|
||||
| Public website | `SAMEORIGIN` | `'self'` |
|
||||
|
||||
The admin UI stays maximally protected against click-jacking. A customer embedding one of their own pages in an iframe on their own site is not broken by a policy they never chose.
|
||||
|
||||
Both headers are emitted because they overlap rather than replace: `X-Frame-Options` covers older browsers, `frame-ancestors` is the modern equivalent and takes precedence where supported.
|
||||
|
||||
---
|
||||
|
||||
## 7. Startup Validation
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
boot["Startup"]
|
||||
unknown{"Every configured policy name<br/>is a known policy ?"}
|
||||
fail["Throw: process does not start"]
|
||||
build["Build both policy strings once"]
|
||||
monitoring{"Umami website ID configured<br/>but its origin missing from CSP ?"}
|
||||
warn["Log a warning naming the missing origin"]
|
||||
log["Log which origins are permitted"]
|
||||
ready["Ready to serve"]
|
||||
|
||||
boot --> unknown
|
||||
unknown -->|no| fail
|
||||
unknown -->|yes| build
|
||||
build --> monitoring
|
||||
monitoring -->|yes| warn
|
||||
monitoring -->|no| log
|
||||
warn --> log
|
||||
log --> ready
|
||||
|
||||
classDef entry fill:#f6e05e,stroke:#c05621,stroke-width:1px,color:#000;
|
||||
classDef decision fill:#90cdf4,stroke:#2b6cb0,stroke-width:1px,color:#000;
|
||||
classDef step fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000;
|
||||
classDef warnnode fill:#fbd38d,stroke:#c05621,stroke-width:1px,color:#000;
|
||||
classDef bad fill:#fbb6ce,stroke:#b83280,stroke-width:1px,color:#000;
|
||||
class boot entry;
|
||||
class unknown,monitoring decision;
|
||||
class build,log,ready step;
|
||||
class warn warnnode;
|
||||
class fail bad;
|
||||
```
|
||||
|
||||
Text alternative: an unknown policy name stops the process at startup rather than degrading per request, and a configured Umami website ID whose origin is missing from the CSP produces a warning — catching the case where analytics appears configured but is silently blocked.
|
||||
|
||||
**Why an unknown policy name is fatal** (fail closed, SECURITY-15): the alternative is falling back to something, and every fallback is either wrong or silently permissive. A typo in a path-policy mapping should stop a deployment, not quietly serve `/admin` under the relaxed policy.
|
||||
|
||||
**Why the monitoring warning covers Umami only** (Q5 = C): the question anticipated needing a Sentry ingest origin in `connect-src`. U4 Q1 = A chose a **tunnel through the API**, so browser error reports go to the application's own origin and `connect-src 'self'` already covers them. There is no Sentry origin to forget, so the warning would have nothing to check. Umami's script is still loaded from its own origin, so that check remains meaningful.
|
||||
|
||||
**Policies are built once at startup**, then reused. Composing a CSP per response would be wasteful on a workload that is mostly static files.
|
||||
|
||||
---
|
||||
|
||||
## 8. Interaction With the Placeholder Page
|
||||
|
||||
The built-in placeholder (U1) is served at `/`, so it receives the **relaxed** policy. It is a self-contained HTML document with a `<style>` block and no scripts, which the relaxed policy permits via `style-src 'unsafe-inline'`.
|
||||
|
||||
Worth stating because it is easy to overlook: the placeholder is the one HTML document this repository serves at the website path, so if the relaxed policy were ever tightened, it is the first thing that would break — and it would break on a fresh installation, which is the worst moment for a confusing failure.
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
# Business Rules — U3 HTTP Security Headers & CSP
|
||||
|
||||
---
|
||||
|
||||
## Decision Logic
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
start["Response starting"]
|
||||
exists{"Header already set<br/>by something else ?"}
|
||||
leave["Leave it untouched"]
|
||||
dev{"Environment is Development<br/>and header is HSTS ?"}
|
||||
skiphsts["Skip HSTS"]
|
||||
always{"Header is nosniff or HSTS ?"}
|
||||
apply["Apply"]
|
||||
html{"Content type is HTML ?"}
|
||||
skiphtml["Skip: not an HTML response"]
|
||||
|
||||
start --> exists
|
||||
exists -->|yes| leave
|
||||
exists -->|no| dev
|
||||
dev -->|yes| skiphsts
|
||||
dev -->|no| always
|
||||
always -->|yes| apply
|
||||
always -->|no| html
|
||||
html -->|yes| apply
|
||||
html -->|no| skiphtml
|
||||
|
||||
classDef entry fill:#f6e05e,stroke:#c05621,stroke-width:1px,color:#000;
|
||||
classDef decision fill:#90cdf4,stroke:#2b6cb0,stroke-width:1px,color:#000;
|
||||
classDef good fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000;
|
||||
classDef neutral fill:#e2e8f0,stroke:#4a5568,stroke-width:1px,color:#000;
|
||||
class start entry;
|
||||
class exists,dev,always,html decision;
|
||||
class apply good;
|
||||
class leave,skiphsts,skiphtml neutral;
|
||||
```
|
||||
|
||||
Text alternative: an already-present header is never overwritten; HSTS is skipped in Development; `nosniff` and HSTS apply to every response while the remaining three apply only to HTML responses.
|
||||
|
||||
---
|
||||
|
||||
## Applicability Rules
|
||||
|
||||
| ID | Rule |
|
||||
|---|---|
|
||||
| **BR-U3-01** | `X-Content-Type-Options: nosniff` is applied to **every** response. |
|
||||
| **BR-U3-02** | `Strict-Transport-Security` is applied to **every** response **except in Development**. |
|
||||
| **BR-U3-03** | `Content-Security-Policy`, `X-Frame-Options` and `Referrer-Policy` are applied **only** to responses whose content type is HTML. |
|
||||
| **BR-U3-04** | A header already present on the response is never overwritten. |
|
||||
| **BR-U3-05** | Headers are written at response start, not before the pipeline continues, because the content type is unknown earlier. |
|
||||
| **BR-U3-06** | The middleware is registered **before** static-file middleware, which short-circuits the pipeline. |
|
||||
| **BR-U3-07** | Header application never throws into the response path. A configuration error is a startup failure, not a per-request one. |
|
||||
| **BR-U3-08** | Every header except HSTS applies in Development, so a CSP violation surfaces during development. |
|
||||
| **BR-U3-09** | Headers apply to error responses too — the middleware sits inside the exception handler. |
|
||||
|
||||
**Rationale for BR-U3-01**: `nosniff` exists to stop a browser guessing the type of a **non-HTML** resource. An uploaded `.txt` or `.svg` interpreted as HTML or JavaScript is the attack it prevents, so restricting it to HTML would remove it precisely where it works.
|
||||
|
||||
**Rationale for BR-U3-04**: a component that deliberately set a header — a download endpoint setting its own `Content-Disposition`-adjacent policy, for example — knows something this middleware does not. Overwriting would be silently destructive.
|
||||
|
||||
---
|
||||
|
||||
## Policy Content Rules
|
||||
|
||||
| ID | Rule |
|
||||
|---|---|
|
||||
| **BR-U3-10** | Exactly two policies exist, defined **in code**: `Strict` and `Relaxed`. |
|
||||
| **BR-U3-11** | Path-to-policy assignment and allowed origins come from **configuration**, so a path or origin can be added without a code change. |
|
||||
| **BR-U3-12** | `Strict` applies to `/admin`, `/api/v1` and `/health`. `Relaxed` is the default for everything else. |
|
||||
| **BR-U3-13** | `Strict` sets `script-src 'self'` — **no `'unsafe-inline'` and no `'unsafe-eval'`**. This must not be relaxed. |
|
||||
| **BR-U3-14** | `Strict` sets `style-src 'self' 'unsafe-inline'`. Documented exception, unavoidable — see below. |
|
||||
| **BR-U3-15** | `Relaxed` is **enforcing**, not report-only, so SECURITY-04 is satisfied on every HTML-serving path. |
|
||||
| **BR-U3-16** | `Relaxed` permits inline scripts and styles, and images, fonts and frames from any HTTPS origin — but **not** external script origins. |
|
||||
| **BR-U3-17** | Both policies set `object-src 'none'` and `base-uri 'self'`. |
|
||||
| **BR-U3-18** | Policy strings are composed once at startup and reused. |
|
||||
| **BR-U3-19** | `X-Frame-Options` is `DENY` under `Strict` and `SAMEORIGIN` under `Relaxed`, with `frame-ancestors` set to match. |
|
||||
|
||||
**Rationale for BR-U3-14 — the one exception, and why it is not negotiable**: Radix UI positions dropdowns, dialogs and selects using inline `style` attributes whose values are recomputed per click, viewport and scroll position. CSP nonces apply only to `<style>` and `<script>` *elements*; inline `style` *attributes* are outside their scope entirely. The alternatives are `'unsafe-inline'` or `'unsafe-hashes'` with a hash per exact value — and the values are dynamic, so no finite set exists. No nonce- or hash-based variant leaves the admin UI functional.
|
||||
|
||||
The exception is bounded to `style-src`. Injected CSS can restyle a page; it cannot execute, because `script-src 'self'` still holds. The escalation path stays closed.
|
||||
|
||||
**Rationale for BR-U3-13**: this is the directive that matters. If it is ever relaxed, the value of the whole policy collapses — so it is stated as a rule rather than left as a default.
|
||||
|
||||
**Rationale for BR-U3-16**: "relaxed" must not mean "absent". Blocking external script origins keeps a genuine boundary while not surprising a website author with a broken layout. A third-party script requires adding its origin to configuration, which FR-09's contract documents.
|
||||
|
||||
---
|
||||
|
||||
## Startup Validation Rules
|
||||
|
||||
| ID | Rule |
|
||||
|---|---|
|
||||
| **BR-U3-20** | A configured policy name that is not a known policy causes startup to **fail**. No fallback. |
|
||||
| **BR-U3-21** | Empty origin lists are a normal state. The policy simply becomes stricter. |
|
||||
| **BR-U3-22** | When a Umami website ID is configured but its script origin is absent from the allowed origins, a **warning** is logged at startup naming the missing origin. |
|
||||
| **BR-U3-23** | The permitted origins are logged at startup at informational level, so the log records what was actually allowed. |
|
||||
| **BR-U3-24** | Headers can be disabled wholesale by configuration, for diagnosis. Disabling is logged as a warning. |
|
||||
|
||||
**Rationale for BR-U3-20** (fail closed, SECURITY-15): any fallback is either wrong or silently permissive. A typo in a path mapping should stop a deployment rather than quietly serve `/admin` under the relaxed policy.
|
||||
|
||||
**Rationale for BR-U3-22**: this catches a failure that is otherwise invisible — analytics configured, appearing to work, and silently blocked by the browser. Note it deliberately does **not** check for a Sentry origin: U4's tunnel keeps error reporting same-origin, so there is no Sentry origin to forget.
|
||||
|
||||
**Rationale for BR-U3-24**: a diagnostic escape hatch is worth having, but silently disabled security headers are worse than none, so switching them off announces itself.
|
||||
|
||||
---
|
||||
|
||||
## Error and Edge-Case Scenarios
|
||||
|
||||
| Scenario | Expected behaviour |
|
||||
|---|---|
|
||||
| Request for `/admin/dashboard`, HTML response | All five headers; `Strict` policy; `X-Frame-Options: DENY` |
|
||||
| Request for `/admin/assets/app.js` | `nosniff` and HSTS only — not an HTML response |
|
||||
| Request for `/`, website HTML | All five headers; `Relaxed` policy; `SAMEORIGIN` |
|
||||
| Request for `/` on a fresh install, placeholder page | `Relaxed` policy. The placeholder's `<style>` block is permitted by `style-src 'unsafe-inline'` |
|
||||
| Request for `/api/v1/Users`, JSON response | `nosniff` and HSTS only. `Strict` policy resolved but the CSP is not written to a JSON response |
|
||||
| `503` from the availability gate, JSON `ProblemDetails` | `nosniff` and HSTS. The middleware sits before the gate, so the response still carries them |
|
||||
| Unhandled exception, `ProblemDetails` response | Headers applied — the middleware is inside the exception handler |
|
||||
| `304 Not Modified` | `nosniff` and HSTS. No body, so the HTML-only headers do not apply |
|
||||
| Redirect from `/admin` to `/admin/` | `nosniff` and HSTS. A redirect has no HTML body |
|
||||
| Running in Development | Every header except HSTS |
|
||||
| No origins configured | Policies composed without them; stricter, and logged |
|
||||
| Umami ID configured, origin missing | Warning at startup naming the origin |
|
||||
| Configured policy name is `Stricct` | Startup fails with the unknown name |
|
||||
| Headers disabled by configuration | No headers, and a warning logged |
|
||||
| A downstream component already set `Referrer-Policy` | Its value is kept |
|
||||
|
||||
---
|
||||
|
||||
## Security Compliance for U3
|
||||
|
||||
| Rule | Status | Notes |
|
||||
|---|---|---|
|
||||
| SECURITY-04 | **Compliant** | All five headers present. HSTS `max-age` is one year with `includeSubDomains`. A CSP applies to **every** HTML-serving path, including the public website (BR-U3-15) — so no deviation is needed. `'unsafe-inline'` appears only in `style-src` under `Strict`, documented in BR-U3-14; `Relaxed` additionally permits inline scripts, documented in BR-U3-16 as a deliberate choice for content this repository does not author |
|
||||
| SECURITY-09 | Compliant | No internal detail is exposed by any header |
|
||||
| SECURITY-11 | **Improved** | Defence in depth: the CSP is a second layer behind output escaping, on both the admin UI and the website |
|
||||
| SECURITY-15 | Compliant | Fails closed at startup on an unknown policy; never throws per request |
|
||||
|
||||
**No deviation recorded.** The earlier answer of "no CSP on the public website" was superseded by FU2 = C, which keeps SECURITY-04 satisfied outright rather than accepting a documented exception.
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
# Domain Entities — U3 HTTP Security Headers & CSP
|
||||
|
||||
**No persisted entity.** U3 adds no table, no migration and no database column. It introduces one configuration section and two in-code policy definitions.
|
||||
|
||||
---
|
||||
|
||||
## Concept Relationships
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
config["SecurityHeaders configuration section"]
|
||||
rules["PathPolicyRule list<br/>path prefix to policy name"]
|
||||
origins["Allowed origin lists<br/>script and connect"]
|
||||
toggle["Enabled flag"]
|
||||
builder["Policy builder"]
|
||||
strict["Strict policy<br/>defined in code"]
|
||||
relaxed["Relaxed policy<br/>defined in code"]
|
||||
composed["Composed policy strings<br/>built once at startup"]
|
||||
middleware["Security headers middleware"]
|
||||
response["HTTP response"]
|
||||
|
||||
config --> rules
|
||||
config --> origins
|
||||
config --> toggle
|
||||
rules -->|"selects"| builder
|
||||
origins -->|"injected into"| builder
|
||||
builder --> strict
|
||||
builder --> relaxed
|
||||
strict --> composed
|
||||
relaxed --> composed
|
||||
composed -->|"read by"| middleware
|
||||
toggle -->|"gates"| middleware
|
||||
middleware -->|"writes headers to"| response
|
||||
|
||||
classDef cfg fill:#fbd38d,stroke:#c05621,stroke-width:1px,color:#000;
|
||||
classDef code fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000;
|
||||
classDef runtime fill:#90cdf4,stroke:#2b6cb0,stroke-width:1px,color:#000;
|
||||
classDef output fill:#d6bcfa,stroke:#6b46c1,stroke-width:1px,color:#000;
|
||||
class config,rules,origins,toggle cfg;
|
||||
class builder,strict,relaxed code;
|
||||
class composed,middleware runtime;
|
||||
class response output;
|
||||
```
|
||||
|
||||
Text alternative: configuration supplies the path-to-policy mapping, the allowed origins and an enable flag; the two policy definitions live in code and are composed into strings once at startup, which the middleware then writes onto responses.
|
||||
|
||||
**The split is the design** (FU2 = A): configuration decides *where* a policy applies and *which external origins* are permitted. Code decides *what a policy means*. A misconfiguration can therefore misroute a path or omit an origin — both recoverable and both visible — but cannot produce a policy that is subtly wrong.
|
||||
|
||||
---
|
||||
|
||||
## SecurityHeaders configuration section
|
||||
|
||||
New section in `appsettings.json`, following the existing Options pattern used by `JwtSettings`, `MasterModule`, `MasterPolling` and `Availability`.
|
||||
|
||||
| Field | Type | Default | Purpose |
|
||||
|---|---|---|---|
|
||||
| `Enabled` | bool | `true` | Diagnostic escape hatch. Disabling logs a warning (BR-U3-24) |
|
||||
| `PathPolicies` | list of rules | `/admin` → Strict, `/api/v1` → Strict, `/health` → Strict | Ordered path-prefix to policy-name mapping |
|
||||
| `DefaultPolicy` | string | `Relaxed` | Applied when no prefix matches — the public website |
|
||||
| `AllowedScriptOrigins` | string list | empty | Added to `script-src`. The Umami script host |
|
||||
| `AllowedConnectOrigins` | string list | empty | Added to `connect-src` |
|
||||
|
||||
### PathPolicyRule
|
||||
|
||||
| Field | Type | Purpose |
|
||||
|---|---|---|
|
||||
| `PathPrefix` | string | Matched case-insensitively against the start of the request path |
|
||||
| `Policy` | string | Must name a known policy, or startup fails (BR-U3-20) |
|
||||
|
||||
### Validation
|
||||
|
||||
| Aspect | Rule |
|
||||
|---|---|
|
||||
| Unknown policy name | Startup **fails**. No fallback |
|
||||
| Empty origin lists | Valid — the policy is simply stricter (BR-U3-21) |
|
||||
| Empty `PathPolicies` | Valid — everything falls to `DefaultPolicy` |
|
||||
| Origin format | Must be a scheme-and-host origin, without a path |
|
||||
|
||||
**No Sentry ingest origin is expected**, because U4's tunnel keeps browser error reporting same-origin. `AllowedConnectOrigins` exists for Umami and any future external call, not for Sentry.
|
||||
|
||||
---
|
||||
|
||||
## Policy definitions (in code, not configuration)
|
||||
|
||||
### Strict
|
||||
|
||||
| Directive | Value |
|
||||
|---|---|
|
||||
| `default-src` | `'self'` |
|
||||
| `script-src` | `'self'` |
|
||||
| `style-src` | `'self' 'unsafe-inline'` |
|
||||
| `img-src` | `'self' data:` |
|
||||
| `font-src` | `'self'` |
|
||||
| `connect-src` | `'self'` + `AllowedConnectOrigins` |
|
||||
| `frame-ancestors` | `'none'` |
|
||||
| `base-uri` | `'self'` |
|
||||
| `form-action` | `'self'` |
|
||||
| `object-src` | `'none'` |
|
||||
|
||||
Companion headers: `X-Frame-Options: DENY`, `Referrer-Policy: strict-origin-when-cross-origin`.
|
||||
|
||||
### Relaxed
|
||||
|
||||
| Directive | Value |
|
||||
|---|---|
|
||||
| `default-src` | `'self'` |
|
||||
| `script-src` | `'self' 'unsafe-inline'` + `AllowedScriptOrigins` |
|
||||
| `style-src` | `'self' 'unsafe-inline'` |
|
||||
| `img-src` | `'self' data: https:` |
|
||||
| `font-src` | `'self' data: https:` |
|
||||
| `connect-src` | `'self'` + `AllowedConnectOrigins` |
|
||||
| `frame-src` | `'self' https:` |
|
||||
| `frame-ancestors` | `'self'` |
|
||||
| `base-uri` | `'self'` |
|
||||
| `object-src` | `'none'` |
|
||||
|
||||
Companion headers: `X-Frame-Options: SAMEORIGIN`, `Referrer-Policy: strict-origin-when-cross-origin`.
|
||||
|
||||
**Both policies always carry** `X-Content-Type-Options: nosniff` and, outside Development, `Strict-Transport-Security: max-age=31536000; includeSubDomains`.
|
||||
|
||||
---
|
||||
|
||||
## Non-persisted runtime state
|
||||
|
||||
| Item | Lifetime | Notes |
|
||||
|---|---|---|
|
||||
| Composed policy strings | Singleton, built at startup | Two strings, keyed by policy name. Never rebuilt per request |
|
||||
| Resolved policy name per request | Request scope | Resolved on the way in, used at response start |
|
||||
|
||||
---
|
||||
|
||||
## Persistence Summary
|
||||
|
||||
| Question | Answer |
|
||||
|---|---|
|
||||
| New tables? | None |
|
||||
| New migrations? | None |
|
||||
| New configuration sections? | One — `SecurityHeaders` |
|
||||
| Anything written at runtime? | Only HTTP response headers |
|
||||
| Secrets in configuration? | None. Origins are public hostnames |
|
||||
|
||||
---
|
||||
|
||||
## Environment-Specific Values
|
||||
|
||||
| Environment | `AllowedScriptOrigins` | HSTS | Notes |
|
||||
|---|---|---|---|
|
||||
| Local | empty | **Not sent** | Umami is not loaded locally, so no origin is needed |
|
||||
| Test | Umami host | Sent | Umami website ID for test |
|
||||
| Production | Umami host | Sent | Umami website ID for production |
|
||||
|
||||
The Umami script origin is the same host across test and production — only the website ID differs, and that is a frontend build-time value rather than a CSP concern.
|
||||
+237
@@ -0,0 +1,237 @@
|
||||
# Business Logic Model — U4 Observability Integration
|
||||
|
||||
**Unit**: U4 Observability Integration
|
||||
**Requirements**: FR-13, FR-14, FR-15, FR-16, FR-19
|
||||
|
||||
---
|
||||
|
||||
## 1. Scope of the Logic
|
||||
|
||||
U4 answers three questions that cannot otherwise be answered without host access: is the application erroring, is it being used, and which build is running. Its logic is mostly about **graceful absence** — every observability service must be optional, because local development and any deployment without them must work unchanged.
|
||||
|
||||
---
|
||||
|
||||
## 2. Degradation Model
|
||||
|
||||
Three fully functional configurations rather than one required setup:
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
boot["Startup"]
|
||||
logging["Structured console logging<br/>always active"]
|
||||
dsn{"Sentry DSN configured ?"}
|
||||
sentryon["Sentry initialised<br/>environment and release tagged"]
|
||||
sentryoff["Sentry skipped<br/>console only"]
|
||||
reachable{"Sentry reachable ?"}
|
||||
delivered["Events delivered"]
|
||||
buffered["Sentry buffers and drops<br/>application never blocked"]
|
||||
|
||||
boot --> logging
|
||||
logging --> dsn
|
||||
dsn -->|yes| sentryon
|
||||
dsn -->|no| sentryoff
|
||||
sentryon --> reachable
|
||||
reachable -->|yes| delivered
|
||||
reachable -->|no| buffered
|
||||
|
||||
classDef entry fill:#f6e05e,stroke:#c05621,stroke-width:1px,color:#000;
|
||||
classDef always fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000;
|
||||
classDef decision fill:#90cdf4,stroke:#2b6cb0,stroke-width:1px,color:#000;
|
||||
classDef degraded fill:#fbd38d,stroke:#c05621,stroke-width:1px,color:#000;
|
||||
class boot entry;
|
||||
class logging,sentryon,delivered always;
|
||||
class dsn,reachable decision;
|
||||
class sentryoff,buffered degraded;
|
||||
```
|
||||
|
||||
Text alternative: structured console logging is always active; Sentry initialises only when a DSN is present, and an unreachable Sentry never blocks the application.
|
||||
|
||||
**An absent DSN is a supported state, not an error.** Logging is registered before Sentry so that a problem initialising Sentry is itself logged.
|
||||
|
||||
---
|
||||
|
||||
## 3. Log Levels — Console Versus Sentry
|
||||
|
||||
Q3 = C asked for `Information` across the board, including the framework. That is right for the **console**, and would be wrong for **Sentry**.
|
||||
|
||||
Sending every framework `Information` entry to Sentry means an event per request. Sentry's free plan would be exhausted within hours, and the errors that matter would be lost among request noise — the opposite of what monitoring is for.
|
||||
|
||||
The two destinations therefore get different thresholds:
|
||||
|
||||
| Destination | Threshold | Rationale |
|
||||
|---|---|---|
|
||||
| **Console** | `Information` for everything, framework included (Q3 = C) | On the Pi the console is captured by the process manager, so volume is cheap and detail is useful |
|
||||
| **Sentry — events** | `Warning` and above | Still "more than exceptions" as Q16 = C requires, since warnings are included, without one event per request |
|
||||
| **Sentry — breadcrumbs** | `Information` | Informational entries travel *attached to* an event as context, so the detail is there when something goes wrong without being an event itself |
|
||||
|
||||
This satisfies both answers rather than choosing between them: the console gets everything, Sentry gets warnings and errors, and every Sentry event arrives carrying the informational trail that led to it.
|
||||
|
||||
---
|
||||
|
||||
## 4. Security Event Emission
|
||||
|
||||
Per Q4 = all of A–F, six event types are emitted for alerting.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph auth["Authentication and authorization"]
|
||||
e1["Failed login"]
|
||||
e2["Authorization denied<br/>on a protected endpoint"]
|
||||
e5["Rate limit triggered<br/>on login endpoints"]
|
||||
end
|
||||
subgraph proto["Master and slave protocol"]
|
||||
e3["Master API key rejected"]
|
||||
e4["Admin bypass rejected<br/>at the availability gate"]
|
||||
end
|
||||
subgraph infra["Infrastructure"]
|
||||
e6["Migration failure at startup"]
|
||||
end
|
||||
sink["Structured log entry<br/>plus Sentry event"]
|
||||
alert["Sentry alert rule<br/>configured in Operations"]
|
||||
|
||||
e1 --> sink
|
||||
e2 --> sink
|
||||
e3 --> sink
|
||||
e4 --> sink
|
||||
e5 --> sink
|
||||
e6 --> sink
|
||||
sink --> alert
|
||||
|
||||
classDef authgrp fill:#fbb6ce,stroke:#b83280,stroke-width:1px,color:#000;
|
||||
classDef protogrp fill:#90cdf4,stroke:#2b6cb0,stroke-width:1px,color:#000;
|
||||
classDef infragrp fill:#fbd38d,stroke:#c05621,stroke-width:1px,color:#000;
|
||||
classDef out fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000;
|
||||
class e1,e2,e5 authgrp;
|
||||
class e3,e4 protogrp;
|
||||
class e6 infragrp;
|
||||
class sink,alert out;
|
||||
```
|
||||
|
||||
Text alternative: six event types across authentication, the master/slave protocol and infrastructure all flow into structured log entries that also become Sentry events, on which alert rules are configured during the Operations phase.
|
||||
|
||||
### What each event means, and what it carries
|
||||
|
||||
| Event | What it indicates | Context carried | Never carried |
|
||||
|---|---|---|---|
|
||||
| Failed login | Repeated occurrences suggest an attack or a forgotten password | Timestamp, endpoint, correlation ID, whether the account exists | Password, the attempted password, full email |
|
||||
| Authorization denied | Someone reached an endpoint they lack rights for | Endpoint, required policy, role held, correlation ID | Token contents |
|
||||
| Master API key rejected | An attacker, **or** a genuine key-ring problem | Endpoint, calling host, correlation ID | The key, or any part of it |
|
||||
| Admin bypass rejected | Since FR-24, someone attempted the availability gate with an invalid token | Path, reason class (invalid signature, expired, wrong role), correlation ID | The token |
|
||||
| Rate limit triggered | Brute-force pressure on login | Limiter name, endpoint, correlation ID | Client identity beyond what the limiter partitions on |
|
||||
| Migration failure | Not a security event, but you want to know immediately | Exception, attempt count, correlation ID | Connection string, credentials |
|
||||
|
||||
**The two most diagnostically valuable are also the least obvious.** A rejected master API key is ambiguous by nature — it means either an intruder or that the key ring has become unreadable. Distinguishing them is exactly what the U2 durability work exists to make unnecessary, but if it ever happens, this event is the first sign. And a rejected admin bypass only became a meaningful signal *because* FR-24 started validating properly; before that, a forged token succeeded silently.
|
||||
|
||||
---
|
||||
|
||||
## 5. Sentry Transport — Tunnel Through the API
|
||||
|
||||
Per Q1 = A, browser error reports do not go directly to Sentry.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
box rgba(246,224,94,0.4) Browser
|
||||
participant SPA as Admin SPA
|
||||
end
|
||||
box rgba(144,205,244,0.4) Application
|
||||
participant T as Tunnel endpoint
|
||||
end
|
||||
box rgba(251,182,206,0.4) External
|
||||
participant S as Sentry ingest
|
||||
end
|
||||
SPA->>T: POST envelope to same-origin tunnel path
|
||||
T->>T: validate size and content type
|
||||
T->>S: forward envelope to the configured DSN host
|
||||
S-->>T: accepted
|
||||
T-->>SPA: 200
|
||||
```
|
||||
|
||||
Text alternative: the admin SPA posts its Sentry envelope to a same-origin tunnel endpoint, which forwards it to Sentry's ingest host and returns success to the browser.
|
||||
|
||||
**Why a tunnel** — two reasons, one of them the actual motivation:
|
||||
1. **Ad blockers block requests to Sentry domains** with `ERR_BLOCKED_BY_CLIENT`. Without a tunnel, errors are lost precisely for the users who have an ad blocker — a silently biased sample of exactly the group most likely to have browser oddities.
|
||||
2. It keeps browser traffic same-origin, so U3's CSP needs `connect-src 'self'` and no external Sentry origin. Simpler policy, and one fewer thing to forget.
|
||||
|
||||
**The reference project tunnels through nginx.** NFR-01 forbids relying on server configuration, so here the application forwards it.
|
||||
|
||||
**Constraints on the tunnel**, because it is an anonymous endpoint that makes outbound requests on request:
|
||||
- Only forwards to the host derived from the configured DSN — never to a caller-supplied destination
|
||||
- Rejects payloads above a fixed size
|
||||
- Does nothing at all when no DSN is configured
|
||||
- Not on the availability bypass list: if the instance is switched off, error reporting from the admin SPA stopping is acceptable
|
||||
|
||||
**The backend's own Sentry reporting does not use the tunnel** — server-side code has no ad blocker and no CSP, and reports directly.
|
||||
|
||||
---
|
||||
|
||||
## 6. Sentry Request Context and Scrubbing
|
||||
|
||||
Per Q2 = B, `SendDefaultPii` is enabled with a filter. This needs care, because "PII" understates what is actually attached.
|
||||
|
||||
With `SendDefaultPii` on, Sentry includes request headers — and this application carries a `refreshToken` in a cookie and a master API key in a header. Both are **credentials**, not merely personal data. Sending them to a third party would be worse than the problem the setting solves.
|
||||
|
||||
The filter therefore removes, before any event leaves the process:
|
||||
|
||||
| Removed | Why |
|
||||
|---|---|
|
||||
| The entire `Cookie` header | Contains the `refreshToken`. Removing one cookie by rewriting the header is error-prone; removing the header is not |
|
||||
| `Authorization` header | Bearer token |
|
||||
| `X-Master-Api-Key` header | The master/slave shared secret. **Not mentioned when this was chosen, but the same class of secret** |
|
||||
| Request body | Login and password-change bodies contain passwords |
|
||||
|
||||
What remains and is genuinely useful: method, path, query string, user agent, IP address, authenticated username, and the correlation ID.
|
||||
|
||||
**Query strings are retained** — but note that invitation tokens travel as `?token=…` on `/api/v1/Invitation/validate`. That is a single-use, time-limited token rather than a standing credential, and the diagnostic value of seeing which endpoint was called outweighs it. Recorded so the decision is visible rather than accidental.
|
||||
|
||||
---
|
||||
|
||||
## 7. Frontend Configuration Resolution
|
||||
|
||||
Per FR-13, the API base URL becomes same-origin by default.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
read["Read VITE_API_BASE_URL"]
|
||||
empty{"Absent or empty ?"}
|
||||
same["Same-origin: use relative paths"]
|
||||
valid{"Valid absolute URL ?"}
|
||||
explicit["Use the explicit origin"]
|
||||
invalid["Development: warn loudly<br/>Production: use as given"]
|
||||
|
||||
read --> empty
|
||||
empty -->|yes| same
|
||||
empty -->|no| valid
|
||||
valid -->|yes| explicit
|
||||
valid -->|no| invalid
|
||||
|
||||
classDef entry fill:#f6e05e,stroke:#c05621,stroke-width:1px,color:#000;
|
||||
classDef decision fill:#90cdf4,stroke:#2b6cb0,stroke-width:1px,color:#000;
|
||||
classDef good fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000;
|
||||
classDef warn fill:#fbd38d,stroke:#c05621,stroke-width:1px,color:#000;
|
||||
class read entry;
|
||||
class empty,valid decision;
|
||||
class same,explicit good;
|
||||
class invalid warn;
|
||||
```
|
||||
|
||||
Text alternative: an absent or empty API base URL means same-origin relative requests; an explicit absolute URL is used as given; a malformed value warns loudly in development rather than being silently accepted.
|
||||
|
||||
**Why same-origin is the right default here**: in the single-host model the API is served by the same process as `/admin`, so a relative path always works and no CORS configuration is needed. The explicit form remains fully supported because local development runs the SPA on port 5173 against the API on 7221 (or 7222 for the slave) — that setup must keep working exactly as before.
|
||||
|
||||
**Malformed values are not silently accepted.** Validation is relaxed to permit an empty string, not to permit anything.
|
||||
|
||||
---
|
||||
|
||||
## 8. Umami Analytics
|
||||
|
||||
Per Q5 = A: measured in test and production, never locally.
|
||||
|
||||
| Condition | Behaviour |
|
||||
|---|---|
|
||||
| Local development | Script never loaded, regardless of configuration |
|
||||
| No website ID configured | Nothing rendered |
|
||||
| Website ID configured, test or production | Script loaded with the environment's own website ID |
|
||||
|
||||
Per-environment website IDs are why two separate frontend builds exist (D-15): the ID is a build-time value, so one bundle cannot carry both.
|
||||
|
||||
`Do Not Track` is deliberately **not** consulted (Q5 = A rather than B). Umami sets no cookies and collects no personal data, and the admin SPA's audience is a known set of operators — so honouring DNT would reduce data without protecting anyone. Recorded as a conscious choice.
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
# Business Rules — U4 Observability Integration
|
||||
|
||||
---
|
||||
|
||||
## Reporting Decision Logic
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
entry["Log entry or exception"]
|
||||
console["Write to console<br/>Information and above"]
|
||||
dsn{"Sentry DSN configured ?"}
|
||||
stop["Done: console only"]
|
||||
level{"Level is Warning or above ?"}
|
||||
crumb["Attach as breadcrumb<br/>context for a future event"]
|
||||
scrub["Scrub credentials from request context"]
|
||||
event["Send as Sentry event<br/>environment and release tagged"]
|
||||
|
||||
entry --> console
|
||||
console --> dsn
|
||||
dsn -->|no| stop
|
||||
dsn -->|yes| level
|
||||
level -->|no| crumb
|
||||
level -->|yes| scrub
|
||||
scrub --> event
|
||||
|
||||
classDef entrynode fill:#f6e05e,stroke:#c05621,stroke-width:1px,color:#000;
|
||||
classDef decision fill:#90cdf4,stroke:#2b6cb0,stroke-width:1px,color:#000;
|
||||
classDef step fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000;
|
||||
classDef neutral fill:#e2e8f0,stroke:#4a5568,stroke-width:1px,color:#000;
|
||||
class entry entrynode;
|
||||
class dsn,level decision;
|
||||
class console,crumb,scrub,event step;
|
||||
class stop neutral;
|
||||
```
|
||||
|
||||
Text alternative: everything goes to the console; with a DSN configured, informational entries become breadcrumbs while warnings and above become Sentry events, always after credentials are scrubbed from the request context.
|
||||
|
||||
---
|
||||
|
||||
## Logging Rules
|
||||
|
||||
| ID | Rule |
|
||||
|---|---|
|
||||
| **BR-U4-01** | Structured console logging is always active, independent of Sentry. |
|
||||
| **BR-U4-02** | Console threshold is `Information`, including framework categories. |
|
||||
| **BR-U4-03** | Every log entry carries a correlation identifier. |
|
||||
| **BR-U4-04** | Logging is registered **before** Sentry, so a Sentry initialisation problem is itself logged. |
|
||||
| **BR-U4-05** | No log entry may contain a password, token, API key, connection string or cookie value. |
|
||||
| **BR-U4-06** | Sentry **event** threshold is `Warning` and above. |
|
||||
| **BR-U4-07** | Sentry **breadcrumb** threshold is `Information`, so events arrive with the trail that led to them. |
|
||||
|
||||
**Rationale for BR-U4-06 and BR-U4-07 — reconciling two answers rather than choosing between them**: Q3 = C asked for `Information` everywhere, and Q16 = C asked for structured logging to Sentry beyond exceptions. Taken literally together, every framework `Information` entry would become a Sentry event — an event per request, exhausting the free plan within hours and burying real errors in request noise.
|
||||
|
||||
Splitting the thresholds honours both: the console gets everything (Q3 = C), and Sentry gets warnings and errors — still more than exceptions, as Q16 = C requires — each carrying its informational breadcrumbs.
|
||||
|
||||
---
|
||||
|
||||
## Sentry Rules
|
||||
|
||||
| ID | Rule |
|
||||
|---|---|
|
||||
| **BR-U4-08** | An absent DSN is a normal, supported state. Sentry initialisation is skipped and console logging continues. |
|
||||
| **BR-U4-09** | An unreachable Sentry never blocks or fails a request. |
|
||||
| **BR-U4-10** | Every event is tagged with the environment and the release. |
|
||||
| **BR-U4-11** | One Sentry project serves both backend and frontend, and both environments, distinguished by tags. |
|
||||
| **BR-U4-12** | Request context is included, with credentials scrubbed per BR-U4-13. |
|
||||
| **BR-U4-13** | Before any event leaves the process, these are removed: the entire `Cookie` header, the `Authorization` header, the `X-Master-Api-Key` header, and the request body. |
|
||||
| **BR-U4-14** | Scrubbing happens in-process, before transmission — never relying on a server-side setting in Sentry. |
|
||||
|
||||
**Rationale for BR-U4-13 — this list is longer than the question implied.** Enabling `SendDefaultPii` attaches request headers, and this application carries two standing credentials in headers: the `refreshToken` cookie and the `X-Master-Api-Key` used by the master/slave protocol. The API key was not mentioned when the setting was chosen, but it is the same class of secret and would otherwise be sent to a third party on every error raised during a master/slave call.
|
||||
|
||||
The request body is removed because the login and password-change endpoints carry passwords in it.
|
||||
|
||||
**Rationale for BR-U4-14**: server-side scrubbing means the secret already left the building. Doing it in-process is the only version that actually protects anything.
|
||||
|
||||
**What is deliberately retained**: method, path, query string, user agent, IP address, authenticated username and correlation ID. Note that invitation tokens travel as `?token=…` on one endpoint — a single-use, time-limited token rather than a standing credential, and the diagnostic value of the path and query outweighs it. Recorded so the trade-off is visible rather than accidental.
|
||||
|
||||
---
|
||||
|
||||
## Sentry Tunnel Rules
|
||||
|
||||
| ID | Rule |
|
||||
|---|---|
|
||||
| **BR-U4-15** | The browser sends Sentry envelopes to a same-origin tunnel endpoint, not directly to Sentry. |
|
||||
| **BR-U4-16** | The tunnel forwards **only** to the host derived from the configured DSN. A caller-supplied destination is never honoured. |
|
||||
| **BR-U4-17** | The tunnel rejects payloads above a fixed maximum size. |
|
||||
| **BR-U4-18** | With no DSN configured, the tunnel accepts nothing and does nothing. |
|
||||
| **BR-U4-19** | The tunnel is anonymous — error reports must work for a user whose session just expired. |
|
||||
| **BR-U4-20** | The tunnel is **not** on the availability bypass list. |
|
||||
| **BR-U4-21** | The backend's own Sentry reporting bypasses the tunnel and reports directly. |
|
||||
|
||||
**Rationale for BR-U4-16 — the rule that keeps this endpoint from being a liability**: an anonymous endpoint that makes an outbound request on demand is a server-side request forgery primitive if the destination comes from the caller. Deriving the destination solely from configuration removes that entirely.
|
||||
|
||||
**Rationale for BR-U4-19 and BR-U4-20 together**: the tunnel must be anonymous, because the errors most worth capturing include authentication failures. But it need not survive the instance being switched off — if the CMS is deliberately disabled, losing admin-SPA error reports is acceptable, and keeping it off the bypass list means one less anonymous, outbound-capable endpoint reachable on a disabled instance.
|
||||
|
||||
---
|
||||
|
||||
## Frontend Configuration Rules
|
||||
|
||||
| ID | Rule |
|
||||
|---|---|
|
||||
| **BR-U4-22** | An absent or empty `VITE_API_BASE_URL` resolves to same-origin: requests use relative paths. |
|
||||
| **BR-U4-23** | An explicit absolute URL is used as supplied. |
|
||||
| **BR-U4-24** | Validation accepts an empty string **or** a valid absolute URL — nothing else. A malformed value is not silently accepted. |
|
||||
| **BR-U4-25** | Local development against `https://localhost:7221` (master) and `:7222` (slave) must keep working unchanged. |
|
||||
| **BR-U4-26** | Frontend Sentry initialisation is skipped when no DSN is configured. |
|
||||
| **BR-U4-27** | The Umami script is never loaded in local development, regardless of configuration. |
|
||||
| **BR-U4-28** | With no Umami website ID configured, nothing is rendered. |
|
||||
| **BR-U4-29** | `Do Not Track` is not consulted. |
|
||||
|
||||
**Rationale for BR-U4-24**: relaxing validation to allow an empty value is not the same as removing validation. A typo such as `htp://localhost:7221` must still be caught, or the SPA silently issues requests to a nonexistent origin.
|
||||
|
||||
**Rationale for BR-U4-29** (Q5 = A): Umami sets no cookies and collects no personal data, and the admin SPA's audience is a known set of operators. Honouring DNT would reduce data without protecting anyone. A conscious choice rather than an omission.
|
||||
|
||||
---
|
||||
|
||||
## Error and Edge-Case Scenarios
|
||||
|
||||
| Scenario | Expected behaviour |
|
||||
|---|---|
|
||||
| No DSN, application runs normally | Console logging only. No error, no warning about the absence |
|
||||
| DSN configured, Sentry unreachable | Sentry buffers and eventually drops. No request fails |
|
||||
| Exception during a master/slave call | Event sent; `X-Master-Api-Key` scrubbed |
|
||||
| Failed login | Event at warning level; no password, no attempted password |
|
||||
| Startup migration fails | Event sent, then the process does not start. The event must be delivered before exit |
|
||||
| Sentry initialisation itself throws | Logged by the already-registered console logger; the application continues without Sentry |
|
||||
| Browser posts to the tunnel with no DSN configured | Rejected; nothing forwarded |
|
||||
| Browser posts an oversized payload to the tunnel | Rejected |
|
||||
| Instance availability-disabled, browser posts to the tunnel | `503` from the gate. Accepted loss |
|
||||
| Ad blocker active | The tunnel is same-origin, so reports arrive — the reason it exists |
|
||||
| `VITE_API_BASE_URL` unset in a production build | Same-origin. The intended production configuration |
|
||||
| `VITE_API_BASE_URL` set to `https://localhost:7221` locally | Used as given; local development unchanged |
|
||||
| `VITE_API_BASE_URL` set to `htp://typo` | Development: loud warning. Production: used as given, and the requests visibly fail |
|
||||
| Umami configured but its origin missing from the CSP | Script blocked by the browser; U3's startup warning (BR-U3-22) flags the misconfiguration |
|
||||
| Local development with a Umami ID configured | Script not loaded |
|
||||
|
||||
---
|
||||
|
||||
## Security Compliance for U4
|
||||
|
||||
| Rule | Status | Notes |
|
||||
|---|---|---|
|
||||
| SECURITY-03 | **Compliant** | Structured logging with a correlation ID on every entry (BR-U4-03); credentials and PII excluded by BR-U4-05 and BR-U4-13; centralised destination via Sentry |
|
||||
| SECURITY-11 | Compliant | The tunnel's fixed destination (BR-U4-16) prevents it becoming a request-forgery primitive |
|
||||
| SECURITY-13 | Compliant | The Umami script is external and constrained by U3's CSP; SRI applied where the provider supports it |
|
||||
| SECURITY-14 | **Addressed, with DEV-01** | Six alertable event types emitted (BR-U4-01 group); alert rules configured in Operations. Retention remains the accepted deviation — Sentry's plan retains roughly 30 days against the 90 the rule asks for |
|
||||
| SECURITY-15 | Compliant | Observability failures never propagate into request handling (BR-U4-09) |
|
||||
|
||||
**No new deviation.** DEV-01 already covers the retention shortfall; nothing here introduces another.
|
||||
|
||||
**One risk closed that was not in the original scope**: BR-U4-13 adds `X-Master-Api-Key` to the scrub list. Without it, enabling `SendDefaultPii` would have sent the master/slave shared secret to a third-party service on every error raised during a master/slave call.
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
# 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
|
||||
|
||||
```mermaid
|
||||
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 here** — `HttpContext.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 |
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
# Frontend Components — U4 Observability Integration
|
||||
|
||||
Changes to the admin SPA in `frontend/`. Two new components, two modified files.
|
||||
|
||||
---
|
||||
|
||||
## Component Hierarchy
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
main["main.tsx<br/>entry point"]
|
||||
sentryinit["initSentry<br/>called before render"]
|
||||
config["lib/config.ts<br/>getAppConfig"]
|
||||
apiclient["lib/api-client.ts<br/>ApiClient"]
|
||||
query["QueryClientProvider"]
|
||||
authprov["AuthProvider"]
|
||||
errbound["SentryErrorBoundary<br/>NEW"]
|
||||
inner["InnerApp"]
|
||||
umami["UmamiAnalytics<br/>NEW"]
|
||||
router["RouterProvider"]
|
||||
toaster["Toaster"]
|
||||
|
||||
main --> sentryinit
|
||||
main --> config
|
||||
sentryinit --> config
|
||||
config --> apiclient
|
||||
main --> query
|
||||
query --> authprov
|
||||
authprov --> errbound
|
||||
errbound --> inner
|
||||
errbound --> umami
|
||||
inner --> router
|
||||
authprov --> toaster
|
||||
|
||||
classDef root fill:#4CAF50,stroke:#2e7d32,color:#000;
|
||||
classDef guard fill:#FF9800,stroke:#e65100,color:#000;
|
||||
classDef page fill:#2196F3,stroke:#0d47a1,color:#000;
|
||||
classDef hook fill:#9C27B0,stroke:#4a148c,color:#000;
|
||||
classDef newcomp fill:#9ae6b4,stroke:#2f855a,color:#000;
|
||||
class main root;
|
||||
class errbound,authprov guard;
|
||||
class inner,router,toaster page;
|
||||
class config,apiclient,sentryinit hook;
|
||||
class umami newcomp;
|
||||
```
|
||||
|
||||
Text alternative: Sentry initialises before rendering, a new error boundary wraps the application inside the auth provider, and a new Umami component sits alongside the app tree; the existing config module now also feeds Sentry initialisation.
|
||||
|
||||
---
|
||||
|
||||
## New Component — `SentryErrorBoundary`
|
||||
|
||||
**Location**: `frontend/src/components/SentryErrorBoundary.tsx`
|
||||
|
||||
| Aspect | Detail |
|
||||
|---|---|
|
||||
| Purpose | Catch render-time React errors that would otherwise blank the screen, report them, and show a recoverable fallback |
|
||||
| Props | `children: ReactNode` |
|
||||
| State | Held by Sentry's own boundary implementation |
|
||||
| Placement | **Inside** `AuthProvider`, **outside** `InnerApp` |
|
||||
| Behaviour without a DSN | Still catches and still shows the fallback; simply reports nothing |
|
||||
| `data-testid` | `error-boundary-fallback`, `error-boundary-retry-button` |
|
||||
|
||||
**Why inside `AuthProvider` rather than outermost**: the fallback needs to be reachable for a logged-in user, and an error inside a page should not tear down the session context — otherwise recovering from a render error would also log the user out.
|
||||
|
||||
### Fallback content rules
|
||||
|
||||
| Must | Must not |
|
||||
|---|---|
|
||||
| State that something went wrong | Show the exception message |
|
||||
| Offer a retry that remounts the subtree | Show a stack trace |
|
||||
| Offer a link to the dashboard | Show a Sentry event ID as the primary content |
|
||||
|
||||
Exception text frequently contains internal detail; showing it to an operator is both unhelpful and a small information leak (SECURITY-09).
|
||||
|
||||
---
|
||||
|
||||
## New Component — `UmamiAnalytics`
|
||||
|
||||
**Location**: `frontend/src/components/UmamiAnalytics.tsx`
|
||||
|
||||
| Aspect | Detail |
|
||||
|---|---|
|
||||
| Purpose | Inject the Umami tracking script when configured |
|
||||
| Props | None — reads configuration directly |
|
||||
| Renders | Nothing visible |
|
||||
| `data-testid` | Not applicable — no interactive element |
|
||||
|
||||
### Behaviour
|
||||
|
||||
| Condition | Result |
|
||||
|---|---|
|
||||
| `import.meta.env.DEV` | Script **never** injected (BR-U4-27) |
|
||||
| Script URL or website ID absent | Nothing injected (BR-U4-28) |
|
||||
| Both present, not local | Script injected once with the website ID |
|
||||
| Component re-renders | Script injected **once** — guarded against duplicates |
|
||||
| `Do Not Track` set | Ignored; script still injected (BR-U4-29) |
|
||||
|
||||
**Why a component rather than a tag in `index.html`**: the website ID is a build-time variable, and `index.html` cannot read `import.meta.env`. A component also makes the "never in development" and "once only" rules testable.
|
||||
|
||||
---
|
||||
|
||||
## Modified — `frontend/src/lib/config.ts`
|
||||
|
||||
| Change | Detail |
|
||||
|---|---|
|
||||
| `apiBaseUrl` | An absent or empty `VITE_API_BASE_URL` now resolves to `''`, meaning same-origin |
|
||||
| Zod schema | Accepts an empty string **or** a valid absolute URL — nothing else (BR-U4-24) |
|
||||
| New fields | `sentryDsn`, `appEnv`, `umamiScriptUrl`, `umamiWebsiteId` |
|
||||
| Existing behaviour | An explicit absolute URL still works unchanged, so local development against `:7221` and `:7222` is unaffected |
|
||||
|
||||
**The validation is relaxed by exactly one case, not removed.** A value like `htp://localhost:7221` must still be caught, or the SPA silently issues requests to a nonexistent origin — a failure that looks like the API being down.
|
||||
|
||||
---
|
||||
|
||||
## Modified — `frontend/src/main.tsx`
|
||||
|
||||
| Change | Order |
|
||||
|---|---|
|
||||
| Call `initSentry()` | **First**, before the query client and before render — so an error during startup is still captured |
|
||||
| Wrap the tree in `SentryErrorBoundary` | Inside `AuthProvider` |
|
||||
| Render `UmamiAnalytics` | Alongside `InnerApp` |
|
||||
| Existing `document.title` and MSW logic | Unchanged |
|
||||
|
||||
---
|
||||
|
||||
## Sentry Initialisation
|
||||
|
||||
**Location**: `frontend/src/lib/sentry.ts` (new)
|
||||
|
||||
| Aspect | Detail |
|
||||
|---|---|
|
||||
| Skipped when | No DSN configured (BR-U4-26) |
|
||||
| `environment` | From `VITE_APP_ENV` |
|
||||
| `release` | From the package version, matching the existing `__APP_VERSION__` pattern in the reference project |
|
||||
| `tunnel` | Same-origin tunnel path — **not** Sentry's ingest URL |
|
||||
| `sendDefaultPii` | `false` on the frontend |
|
||||
|
||||
**Why `sendDefaultPii` is false here even though the backend enables it with scrubbing**: the backend can scrub in-process before transmission because it controls the send. In the browser there is no equivalent guarantee, and the frontend has nothing to add that the backend cannot already report. There is no reason to accept the risk.
|
||||
|
||||
**Why the tunnel matters most on the frontend**: ad blockers block requests to Sentry domains, so without the tunnel the admin SPA loses errors precisely for users who have one.
|
||||
|
||||
---
|
||||
|
||||
## User Interaction Flows
|
||||
|
||||
### Render error recovery
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
render["Page renders"]
|
||||
err["Component throws"]
|
||||
catch["SentryErrorBoundary catches"]
|
||||
report{"DSN configured ?"}
|
||||
send["Report via the tunnel"]
|
||||
skip["No report"]
|
||||
fallback["Show fallback:<br/>message, retry, dashboard link"]
|
||||
retry["User clicks retry"]
|
||||
remount["Subtree remounts;<br/>session preserved"]
|
||||
|
||||
render --> err
|
||||
err --> catch
|
||||
catch --> report
|
||||
report -->|yes| send
|
||||
report -->|no| skip
|
||||
send --> fallback
|
||||
skip --> fallback
|
||||
fallback --> retry
|
||||
retry --> remount
|
||||
|
||||
classDef entry fill:#f6e05e,stroke:#c05621,stroke-width:1px,color:#000;
|
||||
classDef decision fill:#90cdf4,stroke:#2b6cb0,stroke-width:1px,color:#000;
|
||||
classDef step fill:#2196F3,stroke:#0d47a1,color:#000;
|
||||
classDef good fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000;
|
||||
class render,err entry;
|
||||
class report decision;
|
||||
class catch,send,skip,fallback step;
|
||||
class retry,remount good;
|
||||
```
|
||||
|
||||
Text alternative: a component error is caught by the boundary, reported through the tunnel when a DSN is configured, and shown as a recoverable fallback whose retry remounts the subtree while preserving the session.
|
||||
|
||||
---
|
||||
|
||||
## Form Validation Rules
|
||||
|
||||
U4 adds no form. Existing validation via `react-hook-form` and Zod is unchanged.
|
||||
|
||||
One indirect effect worth noting: existing form submission errors surface as `ProblemDetailsError` from `ApiClient`. Those are **handled** errors, already shown to the user, and must **not** become Sentry events — otherwise every validation failure a user makes becomes an alert. Only unhandled errors and `NetworkError` are reported.
|
||||
|
||||
---
|
||||
|
||||
## API Integration Points
|
||||
|
||||
| Component | Endpoint | Notes |
|
||||
|---|---|---|
|
||||
| `ApiClient` | `/api/v1/**` | Now same-origin by default (BR-U4-22) |
|
||||
| Sentry client | Same-origin tunnel path | New. Not an `/api/v1` route, so no version prefix |
|
||||
| `UmamiAnalytics` | Umami script origin | External; must be permitted by U3's CSP `script-src` |
|
||||
|
||||
---
|
||||
|
||||
## Testing Approach
|
||||
|
||||
| Component | Assertions |
|
||||
|---|---|
|
||||
| `config.ts` | Empty value resolves to same-origin; explicit URL preserved; malformed value rejected |
|
||||
| `UmamiAnalytics` | Nothing injected in development; nothing without a website ID; injected once when configured; not injected twice on re-render |
|
||||
| `SentryErrorBoundary` | Fallback shown on a child throw; fallback contains no exception text; retry remounts; works without a DSN |
|
||||
| Sentry initialisation | Skipped without a DSN; tunnel option set rather than a direct ingest URL |
|
||||
|
||||
All use the existing Vitest, Testing Library and MSW setup. Note that `pnpm run lint` is still failing for pre-existing reasons until U5 — so lint should be run on the **changed files** during this unit, to avoid new violations hiding among the five existing ones.
|
||||
Reference in New Issue
Block a user