Feature/gitea deployment workflow #1

Merged
Sluijsens merged 35 commits from feature/gitea-deployment-workflow into master 2026-07-29 16:50:44 +02:00
21 changed files with 1677 additions and 86 deletions
Showing only changes of commit 29a93ef873 - Show all commits
@@ -0,0 +1,149 @@
# Code Generation Plan — U1 Hosting & Serving
**This plan is the single source of truth for Code Generation of U1.** Generation executes exactly these steps in order; no step is added or skipped during execution.
---
## Unit Context
| Aspect | Detail |
|---|---|
| **Unit** | U1 Hosting & Serving |
| **Round** | R1 (with U2 Data Durability) |
| **Workspace root** | `K:\Development\Projects\SlpModularCms` |
| **Project type** | Brownfield — existing structure retained, files modified in place |
| **Requirements** | FR-07, FR-10, FR-24 |
| **Components** | C-04 health checks, C-10 static mounts, C-13 availability middleware, U1 portion of C-16 |
| **Business rules** | BR-U1-01 … BR-U1-22 |
| **Depends on** | Nothing. U1 and U2 are mutually independent |
| **Depended on by** | U3 (path layout for CSP scoping), U6 (deploys into this layout) |
| **New database entities** | **None** — U1 adds no table, migration or configuration section |
### Requirement traceability
| Requirement | Implemented by steps |
|---|---|
| FR-07 — serve `/` from `wwwroot/web/`, `/admin` from `wwwroot/admin/` | 4, 5, 6 |
| FR-10 — `/health` liveness endpoint on the availability bypass list | 2, 6, 7 |
| FR-24 — validate the token in the availability gate's admin bypass | 3, 7, 8 |
---
## Generation Steps
### Step 1: Shared JWT validation parameters (Core)
- [x] Create `src/SlpModularCms.Core/Hosting/JwtTokenValidation.cs` — a factory producing `TokenValidationParameters` from `JwtSettings`, with `ClockSkew.Zero`, matching the current inline configuration exactly
- [x] Modify `src/SlpModularCms.Core/Hosting/ServiceCollectionExtensions.cs` so `AddJwtBearer` consumes the factory instead of building parameters inline
- [x] Register the produced `TokenValidationParameters` as a singleton so the availability gate resolves the **same instance**
*Implements the BR-U1-11 single-source constraint: two copies could drift, and a gate more permissive than the bearer scheme would silently re-open the hole FR-24 closes.*
### Step 2: Health check registration (Core)
- [x] Create `src/SlpModularCms.Core/Hosting/Health/HealthCheckExtensions.cs` with `AddCmsHealthChecks()` and `MapCmsHealthChecks()`
- [x] Create the `HealthReport` response model — `status`, `timestamp`, `version`, `modules`
- [x] Compose the report from in-process state only: no database call, no dependency probe (BR-U1-15)
- [x] Read module names from the existing `ModuleOrchestrator`
- [x] Read the version from the assembly's informational version
- [x] Deliberately expose **no** options parameter, so adding a database check later is a visible code change rather than configuration drift
### Step 3: Admin token validator (Core)
- [x] Create `src/SlpModularCms.Core/Hosting/Security/IAdminTokenValidator.cs` and `AdminTokenValidator.cs`
- [x] Validate the bearer token against the shared `TokenValidationParameters` from Step 1 — signature, issuer, audience and lifetime (BR-U1-11)
- [x] Return true only when validation succeeds **and** the principal carries role `Owner` or `Administrator` (BR-U1-13)
- [x] Return false — never throw — for absent, malformed, forged or expired tokens (BR-U1-12, BR-U1-14)
- [x] Register in `ServiceCollectionExtensions.AddCoreInfrastructure`
### Step 4: Static content composition (Api host)
- [x] Create `src/SlpModularCms.Api/Extensions/StaticContentExtensions.cs` with `UseCmsStaticContent()`
- [x] Register the `/admin` mount **first**, then the root mount, each with its own `PhysicalFileProvider` (BR-U1-01)
- [x] Enable default-file handling per mount; leave directory browsing disabled (BR-U1-07)
- [x] Tolerate a missing physical directory at startup for both mounts (BR-U1-20, BR-U1-22)
- [x] Log a warning naming the absolute expected path when a directory is absent (BR-U1-21)
- [x] Redirect the exact path `/admin` to `/admin/`, leaving deeper paths untouched (BR-U1-03)
### Step 5: Placeholder page (Api host)
- [x] Create `src/SlpModularCms.Api/Extensions/WebsitePlaceholder.html` — states no website is deployed, names the expected target path, links to `/admin`
- [x] Contain no version, environment name, module list or configuration (domain-entities.md)
- [x] Modify `src/SlpModularCms.Api/SlpModularCms.Api.csproj` to embed it as an `EmbeddedResource`
- [x] Serve it with status `200` when the website fallback is needed and `wwwroot/web/index.html` is absent (BR-U1-06)
*Embedded rather than placed in `wwwroot/web/`, because that directory is owned and overwritten by a website workspace — a file there would be deleted by the first real deployment or mistaken for part of the customer's site.*
### Step 6: Api host composition
- [x] Modify `src/SlpModularCms.Api/Program.cs`:
- [x] Replace `UseDefaultFiles()` + `UseStaticFiles()` with `UseCmsStaticContent()`
- [x] Register `AddCmsHealthChecks()` alongside the existing service registrations
- [x] Map `MapCmsHealthChecks()` after `MapControllers()`
- [x] Retarget both `MapFallbackToFile` registrations to the two mounts, preserving the `nonfile` constraint (BR-U1-04, BR-U1-05)
### Step 7: Slave host composition
- [x] Modify `src/SlpModularCms.Api.Slave/Program.cs`:
- [x] Register `AddCmsHealthChecks()` and map `MapCmsHealthChecks()`
- [x] Add **no** static mounts — the Slave serves no static content (Q2 of Application Design = A)
### Step 8: Availability middleware (Modules.Availability)
- [x] Modify `src/SlpModularCms.Modules.Availability/Middleware/AvailabilityMiddleware.cs`:
- [x] Add `/health` to `_bypassPrefixes` (BR-U1-09), leaving the existing entries unchanged (BR-U1-10)
- [x] Replace the `JwtSecurityTokenHandler.ReadJwtToken` call in `IsAdminBypass` with the injected `IAdminTokenValidator`
- [x] Remove the now-unused `System.IdentityModel.Tokens.Jwt` usage
### Step 9: Core unit tests
- [x] Create `src/SlpModularCms.Core.Tests/Hosting/AdminTokenValidatorTests.cs` — valid Owner token accepted; valid Administrator accepted; valid User rejected; forged unsigned token rejected; expired token rejected; malformed header rejected; absent header rejected
- [x] Create `src/SlpModularCms.Core.Tests/Hosting/HealthReportTests.cs` — report composition; module names sourced from the orchestrator; no configuration or path values present
### Step 10: Availability module unit tests
- [x] Modify `src/SlpModularCms.Modules.Availability.Tests/AvailabilityMiddlewareTests.cs` for the constructor change, and add: `/health` bypasses while the instance is disabled; a forged Owner token grants **no** bypass; a valid Owner token still bypasses
- [x] Modify `src/SlpModularCms.Modules.Availability.Tests/AvailabilityMiddlewareMasterGateTests.cs` for the constructor change
### Step 11: Static content unit tests — **DEVIATED, see generation-summary.md**
- [~] `StaticContentTests.cs` **not created**: the plan placed it in `SlpModularCms.Core.Tests`, but `StaticContentExtensions` lives in `SlpModularCms.Api`, which `Core.Tests` does not reference. `SlpModularCms.Api` has no test project by the same convention that gives `SlpModularCms.Api.Slave` none
- [x] Behaviour requiring a composed pipeline recorded in the unit summary as carried to the phase-level Build and Test stage
### Step 12: Documentation
- [x] Create `aidlc-docs/features/gitea-deployment-workflow/construction/u1-hosting-serving/code/generation-summary.md` — files created and modified, decisions taken, and any deviation from this plan
### Step 13: Build and test verification (automatic)
- [x] `dotnet build SlpModularCms.sln -c Release`
- [x] `dotnet test` for `SlpModularCms.Core.Tests` and `SlpModularCms.Modules.Availability.Tests`
- [x] Fix any failure directly and re-run until green
- [x] Record the outcome for the completion message
---
## Files Touched
### Created
| Path | Purpose |
|---|---|
| `src/SlpModularCms.Core/Hosting/JwtTokenValidation.cs` | Shared validation parameters |
| `src/SlpModularCms.Core/Hosting/Health/HealthCheckExtensions.cs` | Health registration and endpoint |
| `src/SlpModularCms.Core/Hosting/Security/IAdminTokenValidator.cs` | Contract |
| `src/SlpModularCms.Core/Hosting/Security/AdminTokenValidator.cs` | Implementation |
| `src/SlpModularCms.Api/Extensions/StaticContentExtensions.cs` | Two-mount composition |
| `src/SlpModularCms.Api/Extensions/WebsitePlaceholder.html` | Embedded placeholder |
| `src/SlpModularCms.Core.Tests/Hosting/AdminTokenValidatorTests.cs` | Tests |
| `src/SlpModularCms.Core.Tests/Hosting/HealthReportTests.cs` | Tests |
| `src/SlpModularCms.Core.Tests/Hosting/StaticContentTests.cs` | Tests |
### Modified
| Path | Change |
|---|---|
| `src/SlpModularCms.Core/Hosting/ServiceCollectionExtensions.cs` | Use the shared factory; register the validator |
| `src/SlpModularCms.Api/Program.cs` | Static content, health checks, retargeted fallbacks |
| `src/SlpModularCms.Api/SlpModularCms.Api.csproj` | Embed the placeholder |
| `src/SlpModularCms.Api.Slave/Program.cs` | Health checks only |
| `src/SlpModularCms.Modules.Availability/Middleware/AvailabilityMiddleware.cs` | `/health` bypass; validated admin bypass |
| `src/SlpModularCms.Modules.Availability.Tests/AvailabilityMiddlewareTests.cs` | Constructor change plus new cases |
| `src/SlpModularCms.Modules.Availability.Tests/AvailabilityMiddlewareMasterGateTests.cs` | Constructor change |
**Brownfield rule**: every file above that exists is modified in place. No `*_new`, `*_modified` or parallel copies.
---
## Out of Scope for U1
- Security headers — U3
- Sentry, Umami, same-origin frontend config — U4
- Data Protection and automatic migrations — U2
- Anything under `.gitea/` — U5 and U6
- README and website contract — U7
@@ -0,0 +1,69 @@
# Functional Design Questions — U1 Hosting & Serving
Vul je keuze in achter elke `[Answer]:`-tag. Kies de laatste optie (`Anders`) als niets past.
---
## Question 1 — Hoe implementeren we de FR-24-fix?
**Context**: dit is het conflict uit § 5.2 van het applicatieontwerp. `AvailabilityMiddleware` wordt geïnstalleerd door `orchestrator.UseModules(app)`, wat **vóór** `app.UseAuthentication()` staat. Op dat moment is `HttpContext.User` dus nog leeg — de middleware kan niet simpelweg de al geauthenticeerde gebruiker uitlezen.
Dat is precies waarom de huidige code `ReadJwtToken` gebruikt: die werkt zonder authenticatie, maar valideert de handtekening niet.
A) Valideer het token in de middleware zelf, met dezelfde `TokenValidationParameters` als het bearer-schema — die parameters worden dan uit één gedeelde bron gehaald in plaats van gekopieerd. Afgebakend: alleen deze middleware verandert
B) Verplaats `app.UseAuthentication()` naar vóór `orchestrator.UseModules(app)`, zodat de middleware `HttpContext.User` kan gebruiken. Kleinere wijziging in regels code, maar verandert de pipeline voor élke module — ook toekomstige
C) Laat de gate-bypass helemaal vervallen en gebruik in plaats daarvan een vaste bypass-prefix voor de admin-endpoints — geen tokenlogica meer in de middleware
X) Anders (beschrijf hieronder na de [Answer]:-tag)
[Answer]:A
---
## Question 2 — Wat gebeurt er als `wwwroot/web/` niet bestaat?
**Context**: bij een verse deploy is er nog geen publieke website — die komt uit een andere workspace. De CMS moet dan gewoon starten en `/admin` en `/api/v1` blijven serveren. Maar wat krijgt een bezoeker op `/` te zien?
A) Een standaard 404 — er is niets, dus dat is het eerlijke antwoord
B) Een ingebouwde placeholderpagina met de melding dat er nog geen website is geplaatst, plus een verwijzing naar `/admin` — handig bij een verse installatie, en meteen bewijs dat de CMS draait
C) Een redirect naar `/admin` — de enige zinvolle bestemming zolang er geen website is
X) Anders (beschrijf hieronder na de [Answer]:-tag)
[Answer]:B
---
## Question 3 — Moet de applicatie hierover iets loggen bij het opstarten?
**Context**: een ontbrekende `wwwroot/web/` is bij een verse installatie normaal, maar op een draaiende productieomgeving zou het betekenen dat de website van de klant verdwenen is — precies het scenario dat we met de release-opzet proberen te voorkomen.
A) Waarschuwing bij opstarten als de map ontbreekt, met het verwachte pad erbij — zichtbaar in Sentry en de console, zonder het opstarten te blokkeren
B) Alleen een informatieregel — het is een normale toestand bij een verse installatie
C) Niets loggen — de 404 of placeholder zegt genoeg
X) Anders (beschrijf hieronder na de [Answer]:-tag)
[Answer]:A
---
## Question 4 — Wat geeft `/health` terug?
**Context**: het framework-standaardantwoord is platte tekst `Healthy` met status 200, of `Unhealthy` met 503. UptimeRobot heeft aan de statuscode genoeg.
A) De standaard platte tekst — minimaal, snel, en geeft niets prijs over de applicatie
B) Een klein JSON-object met status en tijdstip — iets makkelijker te lezen bij handmatig controleren
C) JSON met status, tijdstip, versie en geladen modules — dan zie je meteen of alle modules geladen zijn na een deploy
X) Anders (beschrijf hieronder na de [Answer]:-tag)
[Answer]:C
---
## Question 5 — Moet `/admin` zonder slash doorverwijzen naar `/admin/`?
**Context**: de admin-SPA is gebouwd met `base: '/admin/'`. Als iemand `/admin` intypt zonder afsluitende slash, worden relatieve verwijzingen in de pagina één niveau te hoog opgelost, waardoor de SPA stuk kan gaan. Een redirect naar `/admin/` voorkomt dat.
A) Ja, redirect `/admin` naar `/admin/` — voorkomt een categorie fouten die lastig te herkennen is
B) Nee, laat de SPA-fallback het afhandelen — minder magie in de pipeline
X) Anders (beschrijf hieronder na de [Answer]:-tag)
[Answer]:A
@@ -0,0 +1,47 @@
# Functional Design Plan — U1 Hosting & Serving
**Unit**: U1 Hosting & Serving
**Round**: R1 (with U2 Data Durability)
**Requirements**: FR-07, FR-10, FR-24
**Components**: C-04, C-10, C-13, U1 portion of C-16
---
## Step 1: Analyze unit context
- [x] Read the U1 definition from `unit-of-work.md`
- [x] Read the requirement assignment from `unit-of-work-story-map.md`
- [x] Read the carried-in design items — § 5.2 pipeline ordering, missing-directory startup behaviour
## Step 2: Design static-file serving behaviour
- [x] Define mount registration order and request-path resolution for the two mounts
- [x] Define default-file handling per mount
- [x] Define SPA fallback precedence between `/admin/{*path:nonfile}` and `{*path:nonfile}`
- [x] Define behaviour when `wwwroot/web/` is absent at startup
- [x] Define behaviour when `wwwroot/web/` exists but has no `index.html`
- [x] Define trailing-slash handling for `/admin`
- [x] Confirm directory browsing stays disabled
## Step 3: Design the health endpoint
- [x] Define the response contract for healthy and unhealthy states
- [x] Confirm no dependency probing is performed
- [x] Define behaviour while the instance is availability-disabled
- [x] Define what the endpoint must never expose
## Step 4: Design the availability-gate changes
- [x] Define the `/health` bypass placement within the existing prefix list
- [x] Resolve the FR-24 implementation approach — pipeline ordering versus in-middleware validation
- [x] Define admin-bypass behaviour for valid, forged, expired and absent tokens
- [x] Confirm the preserved behaviour: a valid Owner or Administrator token still bypasses the gate
## Step 5: Define business rules
- [x] Enumerate path-resolution rules with precedence
- [x] Enumerate health-reporting rules
- [x] Enumerate admin-bypass 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
@@ -0,0 +1,86 @@
# Code Generation Summary — U1 Hosting & Serving
**Date**: 2026-07-27
**Requirements**: FR-07, FR-10, FR-24
---
## Files Created
| Path | Purpose |
|---|---|
| `src/SlpModularCms.Core/Hosting/JwtTokenValidation.cs` | Single source of the JWT validation parameters |
| `src/SlpModularCms.Core/Hosting/Health/HealthReport.cs` | Liveness response model |
| `src/SlpModularCms.Core/Hosting/Health/HealthCheckExtensions.cs` | `AddCmsHealthChecks()` / `MapCmsHealthChecks()` |
| `src/SlpModularCms.Core/Hosting/Security/IAdminTokenValidator.cs` | Contract for the validated admin bypass |
| `src/SlpModularCms.Core/Hosting/Security/AdminTokenValidator.cs` | Implementation |
| `src/SlpModularCms.Api/Extensions/StaticContentExtensions.cs` | Two-mount composition and SPA fallbacks |
| `src/SlpModularCms.Api/Extensions/WebsitePlaceholder.html` | Embedded placeholder page |
| `src/SlpModularCms.Core.Tests/Hosting/AdminTokenValidatorTests.cs` | 13 tests |
| `src/SlpModularCms.Core.Tests/Hosting/HealthReportTests.cs` | 3 tests |
## Files Modified
| Path | Change |
|---|---|
| `src/SlpModularCms.Core/Hosting/ServiceCollectionExtensions.cs` | Validation parameters built once via the factory, registered as a singleton and shared with the bearer scheme; `IAdminTokenValidator` registered |
| `src/SlpModularCms.Api/Program.cs` | `AddCmsHealthChecks()`, `UseCmsStaticContent()`, `MapCmsHealthChecks()`, `MapCmsSpaFallbacks()` |
| `src/SlpModularCms.Api/SlpModularCms.Api.csproj` | Placeholder embedded as a resource |
| `src/SlpModularCms.Api.Slave/Program.cs` | Health checks; no static mounts |
| `src/SlpModularCms.Modules.Availability/Middleware/AvailabilityMiddleware.cs` | `/health` bypass; `IsAdminBypass` delegates to the validator; unvalidated `ReadJwtToken` removed |
| `src/SlpModularCms.Modules.Availability.Tests/AvailabilityMiddlewareTests.cs` | Constructor change; new bypass and forged-token cases |
| `src/SlpModularCms.Modules.Availability.Tests/AvailabilityMiddlewareMasterGateTests.cs` | Constructor change; `/health` bypass case |
No duplicate or parallel files were created — every existing file was modified in place.
---
## Implementation Decisions
### The forged-token fix is proven against the real validator, not only a substitute
The middleware's own tests substitute `IAdminTokenValidator`, which is correct unit-testing practice — the middleware's job is to *ask*, not to validate. But a substitute keeps passing even if the middleware were later rewired back to unvalidated token parsing.
A nested `WithRealValidator` class therefore wires the middleware to the actual `AdminTokenValidator` and asserts both halves of the fix: a **forged unsigned Owner token is rejected**, and a **genuine Owner token still bypasses**. The second matters as much as the first — an administrator must always be able to reach a disabled instance to switch it back on.
### `AddCmsHealthChecks()` deliberately takes no options
Adding a database probe therefore requires editing this method, which is visible in review, rather than flipping a setting. Liveness-only is enforced by the shape of the API instead of by discipline.
### The placeholder is an embedded resource, and that was verified
`wwwroot/web/` is owned and overwritten by a separate website workspace, so a placeholder file there would be deleted by the first real deployment or mistaken for part of the customer's site. Embedding keeps it outside that boundary.
Because a wrong resource name would fail *silently* — falling back to a minimal inline HTML string — the compiled assembly's manifest was inspected to confirm the name resolves: `SlpModularCms.Api.Extensions.WebsitePlaceholder.html`.
### Static mounts are resolved at startup
`RegisterMount` only registers a mount when its directory exists, so a directory created *after* the process started is not served until the next restart. This is correct for the intended deployment model — the atomic release switch links `wwwroot/web/` into place before the process starts — but it is behaviour worth knowing: dropping a website into a running instance requires a restart.
---
## Deviation From the Plan
**Step 11 (`StaticContentTests`) was not implemented as written.** The plan placed it in `SlpModularCms.Core.Tests`, but `StaticContentExtensions` lives in the `SlpModularCms.Api` project, which `Core.Tests` does not reference and must not.
`SlpModularCms.Api` has no test project, by the same deliberate convention that gives `SlpModularCms.Api.Slave` none — the Clients solution folder holds deployables, not tested libraries. Creating one would have been a structural change outside this unit's scope.
What the step was meant to cover is mostly ASP.NET Core's own static-file behaviour rather than this project's logic. The genuinely project-specific behaviours — mount ordering, fallback precedence, the `nonfile` constraint, the placeholder path and the `/admin` redirect — require a composed host and are therefore **carried to the phase-level Build and Test stage**, where both hosts are started.
Carried to Build and Test:
- `/admin` redirects to `/admin/`
- A missing asset under either mount returns `404`, never HTML
- A client-side route under `/admin` serves the admin `index.html`
- A client-side route at the root serves the website `index.html`, or the placeholder when absent
- `/health` answers while the instance is availability-disabled
---
## Verification
| Check | Result |
|---|---|
| `dotnet build SlpModularCms.sln -c Release` | ✅ 0 errors |
| `SlpModularCms.Core.Tests` | ✅ 83 passed (was 54) |
| `SlpModularCms.Modules.Availability.Tests` | ✅ 82 passed (was 78) |
| `SlpModularCms.Modules.Identity.Tests` | ✅ 37 passed (unchanged) |
| `SlpModularCms.Modules.Master.Tests` | ✅ 51 passed (was 50) |
| Embedded resource name resolves | ✅ Verified against the compiled assembly manifest |
No failures occurred during generation; nothing needed fixing and retrying.
@@ -0,0 +1,213 @@
# Business Logic Model — U1 Hosting & Serving
**Unit**: U1 Hosting & Serving
**Requirements**: FR-07, FR-10, FR-24
---
## 1. Scope of the Logic
U1 contains no domain business logic in the usual sense — no customer, order or invoice. What it does contain is **request-resolution logic**: given an incoming path, which of three co-hosted surfaces should answer, and under what conditions may the answer be suppressed.
Three decisions are made per request:
1. Which static mount, if any, owns this path
2. Whether the availability gate applies
3. Which fallback resolves a client-side route
---
## 2. Request Resolution Flow
```mermaid
graph TD
req["Incoming request"]
hdr["Security headers register<br/>response-start callback"]
adminmount{"Path starts with /admin ?"}
adminslash{"Path is exactly /admin<br/>without trailing slash ?"}
redirect["308 redirect to /admin/"]
adminfile{"File exists in<br/>wwwroot/admin ?"}
serveadmin["Serve admin asset"]
webfile{"File exists in<br/>wwwroot/web ?"}
serveweb["Serve website asset"]
gate["Availability gate"]
bypass{"Bypass prefix<br/>or valid admin token ?"}
blocked["503 ProblemDetails"]
route["Routing"]
health{"Path is /health ?"}
healthresp["Health report"]
api{"Path starts with /api/v1 ?"}
ctrl["Controller"]
fallback{"Path has a file extension ?"}
notfound["404"]
whichspa{"Path starts with /admin ?"}
adminindex["Serve wwwroot/admin/index.html"]
webindex{"wwwroot/web/index.html<br/>exists ?"}
serveindex["Serve website index.html"]
placeholder["Serve built-in placeholder page"]
req --> hdr
hdr --> adminmount
adminmount -->|yes| adminslash
adminslash -->|yes| redirect
adminslash -->|no| adminfile
adminfile -->|yes| serveadmin
adminfile -->|no| gate
adminmount -->|no| webfile
webfile -->|yes| serveweb
webfile -->|no| gate
gate --> bypass
bypass -->|no, and disabled| blocked
bypass -->|yes or available| route
route --> health
health -->|yes| healthresp
health -->|no| api
api -->|yes| ctrl
api -->|no| fallback
fallback -->|yes| notfound
fallback -->|no| whichspa
whichspa -->|yes| adminindex
whichspa -->|no| webindex
webindex -->|yes| serveindex
webindex -->|no| placeholder
classDef entry fill:#f6e05e,stroke:#c05621,stroke-width:1px,color:#000;
classDef decision fill:#90cdf4,stroke:#2b6cb0,stroke-width:1px,color:#000;
classDef serve fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000;
classDef bad fill:#fbb6ce,stroke:#b83280,stroke-width:1px,color:#000;
class req,hdr entry;
class adminmount,adminslash,adminfile,webfile,bypass,health,api,fallback,whichspa,webindex decision;
class serveadmin,serveweb,route,healthresp,ctrl,adminindex,serveindex,placeholder,redirect serve;
class blocked,notfound bad;
```
Text alternative: static mounts are checked first and short-circuit when a file exists; everything else passes the availability gate, then routes to the health endpoint, a controller, a 404 for missing assets, or one of the two SPA fallbacks — with a built-in placeholder when the website's index is absent.
**Key property**: static files short-circuit before the availability gate. A deliberately disabled instance therefore still serves the customer's website while blocking `/api/v1` and the admin SPA's routes. This is pre-existing behaviour, retained deliberately (see `architecture.md`).
---
## 3. Admin Bypass Evaluation (FR-24)
The gate's admin bypass currently parses the bearer token **without verifying its signature**, so a forged token grants bypass. Per Q1 = A the middleware will validate the token itself using the same `TokenValidationParameters` as the JWT bearer scheme, obtained from a **single shared source** rather than copied.
This approach was chosen over moving `UseAuthentication()` earlier, which would have changed the pipeline for every module including future ones.
```mermaid
sequenceDiagram
box rgba(246,224,94,0.4) Caller
participant C as Client
end
box rgba(144,205,244,0.4) Gate
participant M as AvailabilityMiddleware
participant V as Token validator
end
box rgba(154,230,180,0.4) Downstream
participant N as Rest of pipeline
end
C->>M: Request with Authorization header
M->>M: check bypass prefixes
M->>V: validate token with shared parameters
alt token valid and role is Owner or Administrator
V-->>M: validated principal
M->>N: continue, gate bypassed
else token invalid, forged, or expired
V-->>M: validation failed
M->>M: evaluate master gate and local status
M-->>C: 503 if disabled, otherwise continue
end
```
Text alternative: the middleware validates the bearer token with the same parameters as the bearer scheme; only a genuinely valid Owner or Administrator token bypasses the gate, while a forged or expired token falls through to normal availability evaluation.
**Behaviour preserved**: an administrator with a valid token can always reach a disabled instance to switch it back on. **Behaviour removed**: an unauthenticated caller can no longer bypass the gate with a self-made token.
---
## 4. Health Reporting
`/health` reports **infrastructure liveness only**. It performs no database call and probes no dependency (D-21).
Per Q4 = C the response is JSON containing status, timestamp, application version and loaded module names.
```mermaid
graph TD
call["GET /health"]
bypasslist["On the availability bypass list<br/>so a disabled instance still answers"]
inproc["Read in-process state only<br/>no database, no dependency probe"]
compose["Compose report:<br/>status, timestamp, version, modules"]
ok["200 Healthy"]
dead["Process not running:<br/>no response at all"]
call --> bypasslist
bypasslist --> inproc
inproc --> compose
compose --> ok
call -.->|"if startup failed"| dead
classDef entry fill:#f6e05e,stroke:#c05621,stroke-width:1px,color:#000;
classDef step fill:#90cdf4,stroke:#2b6cb0,stroke-width:1px,color:#000;
classDef good fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000;
classDef bad fill:#fbb6ce,stroke:#b83280,stroke-width:1px,color:#000;
class call entry;
class bypasslist,inproc,compose step;
class ok good;
class dead bad;
```
Text alternative: the health endpoint answers from in-process state only and stays reachable while the instance is disabled; the failure signal is the absence of a response when the process did not start.
**Why the module list is included**: `ModuleOrchestrator` logs rather than throws when a module fails to load, so an instance can start successfully with reduced capability. Without this field there is no way to detect that after a deploy without host access — which NFR-06 explicitly requires.
**Where the "unhealthy" signal comes from**: not from this endpoint reporting failure, but from the process not answering at all. U2's fail-fast startup is what produces that signal. A liveness check whose process always starts would be worthless; combined with fail-fast migration it is meaningful.
---
## 5. Missing Website Directory
A fresh deployment has no `wwwroot/web/` — the customer's website is deployed separately. The CMS must still start and serve `/admin` and `/api/v1` (Q2 = B, Q3 = A).
```mermaid
graph TD
boot["Startup"]
check{"wwwroot/web exists ?"}
warn["Log a warning with the expected path"]
normal["Register mount normally"]
reg["Register mount tolerating absence"]
run["Application starts either way"]
visit["Visitor requests /"]
hasindex{"index.html present ?"}
site["Serve the website"]
ph["Serve built-in placeholder<br/>explaining no site is deployed<br/>and linking to /admin"]
boot --> check
check -->|no| warn
warn --> reg
check -->|yes| normal
reg --> run
normal --> run
run --> visit
visit --> hasindex
hasindex -->|yes| site
hasindex -->|no| ph
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;
class boot,visit entry;
class check,hasindex decision;
class normal,reg,run,site,ph step;
class warn warnnode;
```
Text alternative: a missing website directory logs a warning but never blocks startup; visitors then receive a built-in placeholder page instead of an error, and the admin SPA and API remain fully available.
**Why a warning rather than an informational line** (Q3 = A): on a fresh install the absence is normal, but on a running production instance it means the customer's website has vanished — the exact scenario the release design exists to prevent. A warning is visible in Sentry without blocking startup, so the normal case costs nothing while the dangerous case is not silent.
---
## 6. Trailing Slash for `/admin`
The admin SPA is built with `base: '/admin/'`. A request to `/admin` without the trailing slash resolves relative references one level too high, breaking asset loading in a way that looks like a deployment fault. Per Q5 = A, `/admin` redirects to `/admin/`.
The redirect applies **only** to the exact path `/admin`. Deeper paths such as `/admin/dashboard` are handled by the SPA fallback unchanged.
@@ -0,0 +1,134 @@
# Business Rules — U1 Hosting & Serving
---
## Rule Categories
```mermaid
graph TD
start["Request or startup event"]
cat1{"Path resolution ?"}
cat2{"Availability gate ?"}
cat3{"Health reporting ?"}
cat4{"Startup validation ?"}
r1["BR-U1-01 to BR-U1-08"]
r2["BR-U1-09 to BR-U1-14"]
r3["BR-U1-15 to BR-U1-19"]
r4["BR-U1-20 to BR-U1-22"]
start --> cat1
start --> cat2
start --> cat3
start --> cat4
cat1 --> r1
cat2 --> r2
cat3 --> r3
cat4 --> r4
classDef entry fill:#f6e05e,stroke:#c05621,stroke-width:1px,color:#000;
classDef decision fill:#90cdf4,stroke:#2b6cb0,stroke-width:1px,color:#000;
classDef rules fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000;
class start entry;
class cat1,cat2,cat3,cat4 decision;
class r1,r2,r3,r4 rules;
```
Text alternative: rules divide into four groups — path resolution, availability gating, health reporting and startup validation.
---
## Path Resolution Rules
| ID | Rule |
|---|---|
| **BR-U1-01** | The `/admin` mount is evaluated **before** the root mount. A path beginning `/admin` is never resolved against `wwwroot/web/`. |
| **BR-U1-02** | `wwwroot/admin/` serves paths under `/admin`; `wwwroot/web/` serves paths under `/`. Neither mount may serve files from outside its own directory. |
| **BR-U1-03** | A request for the exact path `/admin` (no trailing slash) returns a redirect to `/admin/`. Deeper paths are unaffected. |
| **BR-U1-04** | A request whose path contains a file extension and matches no file returns `404`. It is never given an `index.html`. |
| **BR-U1-05** | A request whose path contains no file extension and matches no file falls back to an `index.html`: `wwwroot/admin/index.html` when the path begins `/admin`, otherwise the website's. |
| **BR-U1-06** | When the website fallback is required but `wwwroot/web/index.html` does not exist, the built-in placeholder page is returned with status `200`. |
| **BR-U1-07** | Directory browsing is disabled on both mounts. A request for a directory path returns its default file or falls through to the fallback rules — never a file listing. |
| **BR-U1-08** | Static-file responses short-circuit the pipeline. Any behaviour that must apply to them — notably security headers — must be registered before the static-file middleware. |
**Rationale for BR-U1-04**: distinguishing "missing asset" from "client-side route" is what keeps a broken deployment visible. Without it, a missing JavaScript bundle would receive an HTML page, and the browser error would point at a parse failure rather than the real cause.
---
## Availability Gate Rules
| ID | Rule |
|---|---|
| **BR-U1-09** | `/health` is on the bypass prefix list. The availability gate never blocks it. |
| **BR-U1-10** | The existing bypass prefixes are retained unchanged: `/api/v1/Availability/status`, `/api/v1/Auth/`, `/api/v1/Setup/status`, `/api/v1/master/`, `/api/v1/SlaveStatus`. |
| **BR-U1-11** | The admin bypass applies only when the bearer token **validates successfully** against the same `TokenValidationParameters` used by the JWT bearer scheme — signature, issuer, audience and lifetime. |
| **BR-U1-12** | A token that fails validation for any reason grants no bypass. The request proceeds to normal availability evaluation as if no token were present. |
| **BR-U1-13** | A validated token grants bypass only when it carries the role `Owner` or `Administrator`. |
| **BR-U1-14** | Token validation failure is never itself an error response. The gate does not return `401`; that remains the responsibility of the authentication middleware on protected endpoints. |
**Rationale for BR-U1-11 and BR-U1-12**: this is the FR-24 fix. Previously the token was parsed but not verified, so an unauthenticated caller could present a self-made token carrying an `Owner` claim and bypass the gate. Protected endpoints still rejected them, so no data was exposed — but the gate itself, the mechanism that suspends a customer's site, was bypassable by anyone who knew the claim name.
**Rationale for BR-U1-14**: the gate's job is to decide whether to serve, not to authenticate. Returning `401` from the gate would change the response for anonymous endpoints that are legitimately reachable, such as `/api/v1/Setup/status`.
**Single source for validation parameters**: the parameters must be resolved from one shared definition used by both the bearer scheme and the gate. Copying them would allow the two to drift, and a drift in which the gate is *more* permissive than the scheme silently re-opens the hole this rule closes.
---
## Health Reporting Rules
| ID | Rule |
|---|---|
| **BR-U1-15** | `/health` performs no database call and probes no external dependency. Its answer is derived entirely from in-process state. |
| **BR-U1-16** | A running process always answers `200`. The unhealthy signal is the **absence** of a response, produced by fail-fast startup (U2). |
| **BR-U1-17** | The response body reports status, timestamp, application version and the names of loaded modules. |
| **BR-U1-18** | `/health` is anonymous. It must never expose configuration values, connection strings, environment variable contents, file paths, or stack traces. |
| **BR-U1-19** | `/health` is never presented as, aliased to, or documented as equivalent to `/api/v1/Availability/status` or `/api/v1/System/capabilities`. Those report CMS domain state; `/health` reports infrastructure liveness. |
**Rationale for BR-U1-17**: the module list exists because `ModuleOrchestrator` logs rather than throws when a module fails to load. An instance can therefore start "successfully" with a missing capability, and NFR-06 requires that to be detectable after a deploy without host access. The version field serves the same purpose for the deploy itself — confirming which build is actually running.
**Disclosure note**: module names are already publicly available from `/api/v1/System/capabilities`, which is anonymous, so BR-U1-17 adds no new disclosure there. The version field *is* new disclosure. It is accepted deliberately: verifying which build is live is the primary reason the endpoint exists, and the alternative — an authenticated health endpoint — would not work with UptimeRobot. Recorded as a conscious trade-off rather than an oversight.
---
## Startup Validation Rules
| ID | Rule |
|---|---|
| **BR-U1-20** | A missing `wwwroot/web/` directory never prevents startup. |
| **BR-U1-21** | A missing `wwwroot/web/` directory is logged as a **warning** at startup, including the absolute path that was expected. |
| **BR-U1-22** | A missing `wwwroot/admin/` directory is logged as a warning but likewise does not prevent startup — it indicates a publish problem, not a reason to refuse traffic to `/api/v1`. |
**Rationale for BR-U1-21**: normal on a fresh installation, alarming on a running production instance where it means the customer's website has disappeared. A warning is visible in Sentry and the console without cost in the normal case.
---
## Error and Edge-Case Scenarios
| Scenario | Expected behaviour |
|---|---|
| Fresh install, no website deployed, visitor requests `/` | Placeholder page, `200` |
| Fresh install, visitor requests `/admin` | Redirect to `/admin/`, then the admin SPA |
| Website deployed but `index.html` missing | Placeholder page, `200`. The directory existing is not proof of a valid site |
| Request for `/assets/app.js` that does not exist | `404`, never HTML |
| Request for `/admin/assets/app.js` that does not exist | `404`, never HTML |
| Request for `/some/client/route` with no extension | Website `index.html`, or placeholder if absent |
| Request for `/admin/dashboard` | `wwwroot/admin/index.html` |
| Instance disabled, request for `/health` | `200` with the health report — the gate does not apply |
| Instance disabled, request for `/` where the website exists | The website is served — static files short-circuit before the gate |
| Instance disabled, request for `/admin/dashboard` | `503 ProblemDetails` — the fallback is an endpoint, so the gate applies |
| Instance disabled, valid Owner token | Bypass granted, request proceeds |
| Instance disabled, forged unsigned token claiming Owner | **No bypass.** `503`. This is the FR-24 fix |
| Instance disabled, expired but genuine Owner token | No bypass — lifetime validation is part of BR-U1-11 |
| Instance disabled, no token | `503 ProblemDetails` |
| Malformed `Authorization` header | Treated as no token; no exception surfaces to the caller |
| Path traversal attempt, e.g. `/../appsettings.json` | Rejected by the file provider; never resolves outside its mount root |
---
## Security Compliance for U1
| Rule | Status | Notes |
|---|---|---|
| SECURITY-05 | Compliant | `/health` accepts no input. Path traversal is prevented by the file providers |
| SECURITY-08 | **Improved** | BR-U1-11 to BR-U1-14 close the forged-token bypass. `/health` is deliberately anonymous and exposes no resource data |
| SECURITY-09 | Compliant | Directory browsing disabled; BR-U1-18 forbids exposing internals; the version disclosure in BR-U1-17 is documented and justified |
| SECURITY-15 | Compliant | The gate fails closed — a token that cannot be validated grants nothing |
@@ -0,0 +1,128 @@
# Domain Entities — U1 Hosting & Serving
**Note**: U1 introduces **no persisted entity**. It adds no table, no migration and no database column. Its "domain" consists of in-memory configuration descriptors and a response model. They are documented here because they are the data structures the unit's logic operates on, and because reviewers should be able to confirm that nothing is being persisted.
---
## Concept Relationships
```mermaid
graph TD
host["Host application"]
mountweb["StaticMount: website<br/>request path /"]
mountadmin["StaticMount: admin SPA<br/>request path /admin"]
provider["File provider<br/>per mount"]
fallbackweb["SPA fallback: website"]
fallbackadmin["SPA fallback: admin"]
placeholder["Placeholder page<br/>embedded resource"]
report["HealthReport<br/>response model"]
orchestrator["ModuleOrchestrator<br/>existing"]
bypass["Bypass prefix list<br/>existing, extended"]
tokenparams["Token validation parameters<br/>existing, now shared"]
host -->|"registers"| mountweb
host -->|"registers"| mountadmin
mountweb -->|"resolves files via"| provider
mountadmin -->|"resolves files via"| provider
mountweb -->|"falls back to"| fallbackweb
mountadmin -->|"falls back to"| fallbackadmin
fallbackweb -->|"substitutes when index absent"| placeholder
host -->|"exposes"| report
report -->|"reads module names from"| orchestrator
host -->|"configures"| bypass
host -->|"shares"| tokenparams
bypass -->|"used by availability gate"| tokenparams
classDef hostnode fill:#90cdf4,stroke:#2b6cb0,stroke-width:1px,color:#000;
classDef mount fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000;
classDef model fill:#fbd38d,stroke:#c05621,stroke-width:1px,color:#000;
classDef existing fill:#e2e8f0,stroke:#4a5568,stroke-width:1px,color:#000;
class host hostnode;
class mountweb,mountadmin,provider,fallbackweb,fallbackadmin,placeholder mount;
class report model;
class orchestrator,bypass,tokenparams existing;
```
Text alternative: the host registers two independent static mounts each with its own file provider and SPA fallback, plus a health response model that reads module names from the existing orchestrator; the bypass list and token validation parameters are existing structures this unit extends and shares.
---
## Static Mount Descriptor (configuration, in-memory)
Not a class to be persisted — this describes what each `UseStaticFiles` registration is configured with.
| Field | Website mount | Admin mount |
|---|---|---|
| Physical root | `{contentRoot}/wwwroot/web` | `{contentRoot}/wwwroot/admin` |
| Request path | `""` (root) | `/admin` |
| Default file | `index.html` | `index.html` |
| Directory browsing | Disabled | Disabled |
| Tolerates missing root | **Yes** — logs a warning | Yes — logs a warning |
| Registration order | Second | **First** |
**Registration order matters**: the admin mount must be registered first, or `/admin/...` would be resolved against the website root.
---
## HealthReport (response model, not persisted)
| Field | Type | Purpose |
|---|---|---|
| `status` | string | `"Healthy"`. A running process always reports healthy; absence of a response is the unhealthy signal |
| `timestamp` | timestamp with offset | When the report was produced, so a cached response is recognisable |
| `version` | string | The application's informational version, so a deploy can be confirmed without host access |
| `modules` | string array | Names of modules loaded by `ModuleOrchestrator` |
**Validation and constraints**:
- Every field is derived from in-process state. No field may require a database query, file read or network call (BR-U1-15).
- No field may contain configuration values, paths, connection details or environment variable contents (BR-U1-18).
- `modules` is read from the existing `ModuleOrchestrator.ModuleNames`, which is already exposed anonymously by `/api/v1/System/capabilities` — so this field introduces no new disclosure.
---
## Placeholder Page (embedded static content)
| Property | Value |
|---|---|
| Storage | Embedded resource in the assembly, not a file in `wwwroot` |
| Served when | The website fallback is needed and `wwwroot/web/index.html` is absent |
| Status code | `200` |
| Content | A statement that no website has been deployed yet, the expected target path, and a link to `/admin` |
**Why embedded rather than a file**: a file in `wwwroot/web/` would be inside the directory a website workspace owns and overwrites — it would be deleted by the first real website deployment, or worse, mistaken for part of the customer's site. Embedding keeps it outside that boundary entirely.
**What it must not contain**: no version, no environment name, no module list, no configuration. It is served anonymously to any visitor of the site root, which is a wider audience than `/health`.
---
## Extended Existing Structures
### Bypass prefix list (`AvailabilityMiddleware`)
| Aspect | Detail |
|---|---|
| Current contents | `/api/v1/Availability/status`, `/api/v1/Auth/`, `/api/v1/Setup/status`, `/api/v1/master/`, `/api/v1/SlaveStatus` |
| Added by U1 | `/health` |
| Matching | Case-insensitive prefix match, unchanged |
### Shared token validation parameters
| Aspect | Detail |
|---|---|
| Currently | Configured once inside `AddJwtBearer` in `ServiceCollectionExtensions.AddCoreInfrastructure` |
| Change | Extracted so the same instance is used by both the bearer scheme and the availability gate |
| Constraint | Exactly one definition. Two copies could drift, and a gate more permissive than the scheme would silently re-open the hole FR-24 closes |
---
## Persistence Summary
| Question | Answer |
|---|---|
| New tables? | None |
| New migrations? | None |
| New columns? | None |
| New configuration sections? | None — U1 adds no `appsettings` section |
| Anything written to disk at runtime? | No |
All persistence work in Round 1 belongs to **U2**, which adds the Data Protection keys table and the automatic Core migration.
@@ -0,0 +1,187 @@
using System.Reflection;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Logging;
namespace SlpModularCms.Api.Extensions;
/// <summary>
/// Serves the two independent front-ends this host carries.
/// </summary>
/// <remarks>
/// Shared hosting typically allows only one site or application pool, so this single process
/// serves everything:
/// <list type="bullet">
/// <item><description><c>/</c> — the customer's public website, from <c>wwwroot/web/</c>. Built and
/// deployed separately; it is NOT part of this repository and must survive every CMS deploy.</description></item>
/// <item><description><c>/admin</c> — the CMS admin SPA, from <c>wwwroot/admin/</c>, produced by
/// <c>dotnet publish</c>.</description></item>
/// </list>
/// Each mount gets its own file provider so neither can ever serve files belonging to the other,
/// and so each can later carry its own headers or caching without disturbing the other.
/// </remarks>
public static class StaticContentExtensions
{
/// <summary>Directory under the web root holding the customer's public website.</summary>
public const string WebsiteDirectoryName = "web";
/// <summary>Directory under the web root holding the admin SPA.</summary>
public const string AdminDirectoryName = "admin";
/// <summary>Request path the admin SPA is mounted at.</summary>
public const string AdminRequestPath = "/admin";
private const string PlaceholderResourceName = "SlpModularCms.Api.Extensions.WebsitePlaceholder.html";
/// <summary>
/// Registers both static mounts and the <c>/admin</c> trailing-slash redirect.
/// Must be called BEFORE any middleware that needs to observe static responses, because
/// static files short-circuit the pipeline.
/// </summary>
public static WebApplication UseCmsStaticContent(this WebApplication app)
{
var webRoot = app.Environment.WebRootPath
?? Path.Combine(app.Environment.ContentRootPath, "wwwroot");
var adminRoot = Path.Combine(webRoot, AdminDirectoryName);
var websiteRoot = Path.Combine(webRoot, WebsiteDirectoryName);
var logger = app.Services.GetRequiredService<ILoggerFactory>()
.CreateLogger(typeof(StaticContentExtensions).FullName!);
WarnIfMissing(logger, adminRoot, "admin SPA");
WarnIfMissing(logger, websiteRoot, "public website");
// A request for exactly "/admin" must become "/admin/", or the SPA — built with
// base '/admin/' — resolves its relative asset references one level too high and
// fails in a way that looks like a broken deployment.
app.Use(async (context, next) =>
{
if (context.Request.Path.Equals(AdminRequestPath, StringComparison.OrdinalIgnoreCase))
{
var target = $"{AdminRequestPath}/{context.Request.QueryString}";
context.Response.Redirect(target, permanent: true);
return;
}
await next();
});
// The admin mount is registered FIRST. Registered the other way around, a request for
// /admin/... would be resolved against the website root.
RegisterMount(app, adminRoot, AdminRequestPath);
RegisterMount(app, websiteRoot, requestPath: string.Empty);
return app;
}
/// <summary>
/// Maps the SPA fallbacks for both mounts.
/// </summary>
/// <remarks>
/// The <c>nonfile</c> constraint on both routes is deliberate: a request for a path that
/// looks like a file (has an extension) and does not exist must stay a 404. Serving HTML
/// for a missing script would turn a clear "asset is missing" into a confusing parse error.
/// </remarks>
public static WebApplication MapCmsSpaFallbacks(this WebApplication app)
{
var webRoot = app.Environment.WebRootPath
?? Path.Combine(app.Environment.ContentRootPath, "wwwroot");
var adminIndex = Path.Combine(webRoot, AdminDirectoryName, "index.html");
var websiteIndex = Path.Combine(webRoot, WebsiteDirectoryName, "index.html");
app.MapFallback($"{AdminRequestPath}/{{*path:nonfile}}", async context =>
{
if (File.Exists(adminIndex))
{
context.Response.ContentType = "text/html; charset=utf-8";
await context.Response.SendFileAsync(adminIndex);
return;
}
context.Response.StatusCode = StatusCodes.Status404NotFound;
});
app.MapFallback("{*path:nonfile}", async context =>
{
if (File.Exists(websiteIndex))
{
context.Response.ContentType = "text/html; charset=utf-8";
await context.Response.SendFileAsync(websiteIndex);
return;
}
// No website deployed yet. Serving the placeholder rather than a 404 makes a fresh
// installation self-explanatory and doubles as proof the CMS itself is running.
await WritePlaceholderAsync(context);
});
return app;
}
private static void RegisterMount(WebApplication app, string physicalRoot, string requestPath)
{
// Tolerating a missing directory is required, not defensive: a fresh deployment has no
// wwwroot/web/ until a website workspace deploys into it, and the CMS must still start
// and serve /admin and /api/v1.
if (!Directory.Exists(physicalRoot))
{
return;
}
var provider = new PhysicalFileProvider(physicalRoot);
app.UseDefaultFiles(new DefaultFilesOptions
{
FileProvider = provider,
RequestPath = requestPath
});
app.UseStaticFiles(new StaticFileOptions
{
FileProvider = provider,
RequestPath = requestPath
// Directory browsing is not enabled — a request for a directory resolves to its
// default file or falls through to the fallback rules, never to a file listing.
});
}
private static async Task WritePlaceholderAsync(HttpContext context)
{
context.Response.StatusCode = StatusCodes.Status200OK;
context.Response.ContentType = "text/html; charset=utf-8";
// Embedded in the assembly rather than placed in wwwroot/web/, because that directory is
// owned and overwritten by a website workspace: a file there would be deleted by the first
// real website deployment, or mistaken for part of the customer's site.
await using var stream = typeof(StaticContentExtensions).Assembly
.GetManifestResourceStream(PlaceholderResourceName);
if (stream is null)
{
await context.Response.WriteAsync("<!doctype html><title>Nog geen website geplaatst</title>" +
"<p>Er staat hier nog geen website. Beheer via <a href=\"/admin/\">/admin/</a>.</p>");
return;
}
await stream.CopyToAsync(context.Response.Body);
}
private static void WarnIfMissing(ILogger logger, string path, string description)
{
if (Directory.Exists(path))
{
return;
}
// Normal on a fresh installation, but on a running production instance it means the
// content has disappeared — worth being visible in Sentry without blocking startup.
logger.LogWarning(
"Static content directory for the {Description} was not found at {Path}. " +
"The application will start, but this path will not serve any files until content is deployed there.",
description,
path);
}
}
@@ -0,0 +1,56 @@
<!doctype html>
<html lang="nl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>Nog geen website geplaatst</title>
<style>
:root { color-scheme: light dark; }
body {
margin: 0;
min-height: 100svh;
display: grid;
place-items: center;
padding: 2rem;
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
line-height: 1.6;
background: #fafafa;
color: #1a1a1a;
}
@media (prefers-color-scheme: dark) {
body { background: #141414; color: #ededed; }
code { background: #262626; }
a { color: #ff6b6b; }
}
main { max-width: 34rem; }
h1 { font-size: 1.5rem; margin: 0 0 1rem; }
p { margin: 0 0 1rem; }
code {
background: #ececec;
padding: 0.15em 0.4em;
border-radius: 4px;
font-family: ui-monospace, "Cascadia Code", Consolas, monospace;
font-size: 0.9em;
}
a { color: #ac0000; }
.muted { font-size: 0.9rem; opacity: 0.75; }
</style>
</head>
<body>
<main>
<h1>Er staat hier nog geen website</h1>
<p>
Het CMS draait, maar er is nog geen publieke website geplaatst. De website
wordt apart aangeleverd en hoort in de map <code>wwwroot/web/</code> te staan,
met een <code>index.html</code> in de hoofdmap daarvan.
</p>
<p>
Beheerders kunnen inloggen via <a href="/admin/">/admin/</a>.
</p>
<p class="muted">
Deze pagina wordt automatisch vervangen zodra de website is geplaatst.
</p>
</main>
</body>
</html>
@@ -17,6 +17,16 @@
<PackageReference Include="Scalar.AspNetCore" Version="2.16.3" />
</ItemGroup>
<!--
Served at '/' when no public website has been deployed into wwwroot/web/ yet.
Embedded rather than shipped as a file under wwwroot/web/, because that directory is
owned and overwritten by a separate website workspace — a file there would be deleted
by the first real website deployment, or mistaken for part of the customer's site.
-->
<ItemGroup>
<EmbeddedResource Include="Extensions\WebsitePlaceholder.html" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\SlpModularCms.Core\SlpModularCms.Core.csproj" />
<ProjectReference Include="..\SlpModularCms.Modules.Availability\SlpModularCms.Modules.Availability.csproj" />
@@ -0,0 +1,149 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using FluentAssertions;
using Microsoft.IdentityModel.Tokens;
using SlpModularCms.Core.Hosting;
using SlpModularCms.Core.Hosting.Security;
using SlpModularCms.Core.Identity.Models;
using Xunit;
namespace SlpModularCms.Core.Tests.Hosting;
/// <summary>
/// Guards the availability gate's admin bypass.
/// </summary>
/// <remarks>
/// The behaviour under test is the fix for a real defect: the bypass previously parsed the
/// bearer token without verifying its signature, so an unauthenticated caller could forge an
/// unsigned token carrying an Owner role claim and pass the gate. The forged-token cases below
/// are the point of this suite — the happy paths only prove the fix did not break the feature.
/// </remarks>
public class AdminTokenValidatorTests
{
private const string Secret = "TestSecretKeyThatIsLongEnoughForHmacSha256Signing!!";
private const string Issuer = "SlpModularCms";
private const string Audience = "SlpModularCmsPortal";
private readonly AdminTokenValidator _validator;
public AdminTokenValidatorTests()
{
var settings = new JwtSettings
{
Secret = Secret,
Issuer = Issuer,
Audience = Audience
};
_validator = new AdminTokenValidator(JwtTokenValidation.Create(settings));
}
[Theory]
[InlineData("Owner")]
[InlineData("Administrator")]
public void IsVerifiedAdmin_ShouldReturnTrue_ForValidAdminToken(string role)
{
var header = $"Bearer {CreateToken(role)}";
_validator.IsVerifiedAdmin(header).Should().BeTrue();
}
[Fact]
public void IsVerifiedAdmin_ShouldReturnFalse_ForValidNonAdminToken()
{
var header = $"Bearer {CreateToken("User")}";
_validator.IsVerifiedAdmin(header).Should().BeFalse();
}
[Fact]
public void IsVerifiedAdmin_ShouldReturnFalse_ForForgedUnsignedToken()
{
// The exact attack the fix closes: a token that carries the right claim but was never
// signed by us. Reading claims without validating would have accepted this.
var forged = CreateUnsignedToken("Owner");
_validator.IsVerifiedAdmin($"Bearer {forged}").Should().BeFalse();
}
[Fact]
public void IsVerifiedAdmin_ShouldReturnFalse_ForTokenSignedWithAnotherKey()
{
var otherKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes("AnEntirelyDifferentSecretKeyUsedByNobodyElse!!!!!"));
var credentials = new SigningCredentials(otherKey, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: Issuer,
audience: Audience,
claims: [new Claim(ClaimTypes.Role, "Owner")],
expires: DateTime.UtcNow.AddMinutes(10),
signingCredentials: credentials);
var header = $"Bearer {new JwtSecurityTokenHandler().WriteToken(token)}";
_validator.IsVerifiedAdmin(header).Should().BeFalse();
}
[Fact]
public void IsVerifiedAdmin_ShouldReturnFalse_ForExpiredAdminToken()
{
var header = $"Bearer {CreateToken("Owner", expiresIn: TimeSpan.FromMinutes(-5))}";
_validator.IsVerifiedAdmin(header).Should().BeFalse();
}
[Fact]
public void IsVerifiedAdmin_ShouldReturnFalse_ForWrongIssuer()
{
var header = $"Bearer {CreateToken("Owner", issuer: "SomeoneElse")}";
_validator.IsVerifiedAdmin(header).Should().BeFalse();
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("Bearer")]
[InlineData("Bearer ")]
[InlineData("Basic dXNlcjpwYXNz")]
[InlineData("Bearer not-a-token")]
[InlineData("Bearer a.b.c")]
public void IsVerifiedAdmin_ShouldReturnFalse_ForAbsentOrMalformedHeaders(string? header)
{
// Never throws — an unusable header simply means "not an admin". Rejecting the request
// is the authentication middleware's job, not the availability gate's.
_validator.IsVerifiedAdmin(header).Should().BeFalse();
}
private static string CreateToken(
string role,
TimeSpan? expiresIn = null,
string issuer = Issuer)
{
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Secret));
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: issuer,
audience: Audience,
claims: [new Claim(ClaimTypes.Role, role)],
expires: DateTime.UtcNow.Add(expiresIn ?? TimeSpan.FromMinutes(10)),
signingCredentials: credentials);
return new JwtSecurityTokenHandler().WriteToken(token);
}
private static string CreateUnsignedToken(string role)
{
var token = new JwtSecurityToken(
issuer: Issuer,
audience: Audience,
claims: [new Claim(ClaimTypes.Role, role)],
expires: DateTime.UtcNow.AddMinutes(10));
return new JwtSecurityTokenHandler().WriteToken(token);
}
}
@@ -0,0 +1,56 @@
using System.Text.Json;
using FluentAssertions;
using SlpModularCms.Core.Hosting.Health;
using Xunit;
namespace SlpModularCms.Core.Tests.Hosting;
/// <summary>
/// Guards the shape of the liveness report.
/// </summary>
/// <remarks>
/// The report is served anonymously, so what it does NOT contain matters as much as what it does.
/// It carries the loaded module names because <c>ModuleOrchestrator</c> logs rather than throws
/// when a module fails to load — an instance can start "successfully" with a missing capability,
/// and this is the only way to detect that after a deploy without host access.
/// </remarks>
public class HealthReportTests
{
[Fact]
public void HealthReport_ShouldCarryTheFourReportedFields()
{
var report = new HealthReport("Healthy", DateTimeOffset.UtcNow, "1.2.3", ["Identity", "Availability"]);
report.Status.Should().Be("Healthy");
report.Version.Should().Be("1.2.3");
report.Modules.Should().Equal("Identity", "Availability");
report.Timestamp.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
}
[Fact]
public void HealthReport_ShouldSerializeWithoutAnyAdditionalFields()
{
// Anonymous endpoint: no configuration values, connection details, paths or environment
// data may leak in through an accidentally added property.
var report = new HealthReport("Healthy", DateTimeOffset.UtcNow, "1.0.0", ["Identity"]);
var json = JsonSerializer.Serialize(report);
using var document = JsonDocument.Parse(json);
document.RootElement.EnumerateObject()
.Select(p => p.Name.ToLowerInvariant())
.Should().BeEquivalentTo("status", "timestamp", "version", "modules");
}
[Fact]
public void HealthReport_ShouldSupportAnEmptyModuleList()
{
// A host with no modules discovered is still alive. Liveness must not depend on
// capability — that distinction is the entire reason this endpoint exists separately
// from /api/v1/System/capabilities.
var report = new HealthReport("Healthy", DateTimeOffset.UtcNow, "1.0.0", []);
report.Modules.Should().BeEmpty();
report.Status.Should().Be("Healthy");
}
}
@@ -0,0 +1,69 @@
using System.Reflection;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
namespace SlpModularCms.Core.Hosting.Health;
/// <summary>
/// Registers the infrastructure liveness endpoint.
/// </summary>
/// <remarks>
/// Deliberately takes no options parameter. Adding a database probe or any other dependency
/// check must therefore be a visible code change here rather than a configuration setting
/// someone can flip — liveness-only is enforced by the shape of this API, not by discipline.
///
/// Why liveness alone is a meaningful signal: startup applies database migrations and fails
/// fast when they cannot be applied (see <see cref="DatabaseMigrationExtensions"/>). A process
/// that cannot reach its database therefore never starts, so <c>/health</c> stops answering
/// entirely. The unhealthy signal is the absence of a response, not a response saying so.
/// </remarks>
public static class HealthCheckExtensions
{
/// <summary>Path of the liveness endpoint. Also present in the availability gate's bypass list.</summary>
public const string HealthPath = "/health";
public static IServiceCollection AddCmsHealthChecks(this IServiceCollection services)
{
services.AddHealthChecks();
return services;
}
/// <summary>
/// Maps <c>GET /health</c>, returning a JSON report composed from in-process state only.
/// </summary>
public static IEndpointRouteBuilder MapCmsHealthChecks(this IEndpointRouteBuilder endpoints)
{
endpoints.MapGet(HealthPath, (HttpContext context) =>
{
var orchestrator = context.RequestServices.GetService<ModuleOrchestrator>();
var report = new HealthReport(
Status: "Healthy",
Timestamp: DateTimeOffset.UtcNow,
Version: GetVersion(),
// Module names are already public via /api/v1/System/capabilities, so including
// them here discloses nothing new. They are included because ModuleOrchestrator
// logs rather than throws when a module fails to load: an instance can start
// "successfully" with a missing capability, and this is the only way to detect
// that after a deploy without host access.
Modules: orchestrator?.ModuleNames ?? []);
return Results.Ok(report);
})
.AllowAnonymous()
.WithName("HealthCheck");
return endpoints;
}
private static string GetVersion()
{
var assembly = Assembly.GetEntryAssembly() ?? Assembly.GetExecutingAssembly();
return assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion
?? assembly.GetName().Version?.ToString()
?? "unknown";
}
}
@@ -0,0 +1,23 @@
using System.Diagnostics.CodeAnalysis;
namespace SlpModularCms.Core.Hosting.Health;
/// <summary>
/// Response model for the infrastructure liveness endpoint.
/// </summary>
/// <remarks>
/// Reports infrastructure liveness ONLY. This is deliberately not the same thing as the CMS's
/// own availability state (<c>/api/v1/Availability/status</c>) or its loaded-capability report
/// (<c>/api/v1/System/capabilities</c>), both of which are domain functionality that also serve
/// the master/slave protocol. A healthy instance can be switched off by design, and a switched-on
/// instance can be unhealthy — so the two must never be conflated in monitoring.
///
/// Every field is derived from in-process state. Nothing here may require a database query,
/// file read or network call.
/// </remarks>
[ExcludeFromCodeCoverage]
public sealed record HealthReport(
string Status,
DateTimeOffset Timestamp,
string Version,
IReadOnlyList<string> Modules);
@@ -0,0 +1,41 @@
using System.Text;
using Microsoft.IdentityModel.Tokens;
using SlpModularCms.Core.Identity.Models;
namespace SlpModularCms.Core.Hosting;
/// <summary>
/// Single source of the JWT validation parameters used across the application.
/// </summary>
/// <remarks>
/// These parameters are consumed in two places: the JWT bearer authentication scheme,
/// and the availability gate's admin bypass (see <c>IAdminTokenValidator</c>).
///
/// They MUST come from here rather than being configured separately in each place.
/// If the two ever drifted apart and the gate became the more permissive of the two,
/// a token the bearer scheme rejects could still bypass the availability gate — which
/// is exactly the defect the validated admin bypass was introduced to close.
/// </remarks>
public static class JwtTokenValidation
{
/// <summary>
/// Builds the validation parameters for the given settings.
/// </summary>
public static TokenValidationParameters Create(JwtSettings settings)
{
ArgumentNullException.ThrowIfNull(settings);
return new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = settings.Issuer,
ValidAudience = settings.Audience,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(settings.Secret)),
// Exact expiry — a token is valid until its expiry moment and not a second longer.
ClockSkew = TimeSpan.Zero
};
}
}
@@ -0,0 +1,64 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using Microsoft.IdentityModel.Tokens;
namespace SlpModularCms.Core.Hosting.Security;
/// <summary>
/// Validates a bearer token against the application's JWT validation parameters and checks
/// for an administrative role.
/// </summary>
/// <remarks>
/// This replaces an earlier implementation that parsed the token with
/// <c>JwtSecurityTokenHandler.ReadJwtToken</c> — which reads claims WITHOUT verifying the
/// signature. Under that implementation an unauthenticated caller could present a self-made,
/// unsigned token carrying an Owner role claim and bypass the availability gate. Protected
/// endpoints still rejected such a caller, so no data was exposed, but the gate that suspends
/// a customer's site could be bypassed by anyone who knew the claim name.
/// </remarks>
public sealed class AdminTokenValidator : IAdminTokenValidator
{
private const string BearerPrefix = "Bearer ";
private static readonly string[] AdminRoles = ["Owner", "Administrator"];
private readonly TokenValidationParameters _validationParameters;
private readonly JwtSecurityTokenHandler _handler = new();
public AdminTokenValidator(TokenValidationParameters validationParameters)
{
_validationParameters = validationParameters ?? throw new ArgumentNullException(nameof(validationParameters));
}
public bool IsVerifiedAdmin(string? authorizationHeader)
{
if (string.IsNullOrEmpty(authorizationHeader) ||
!authorizationHeader.StartsWith(BearerPrefix, StringComparison.OrdinalIgnoreCase))
{
return false;
}
var token = authorizationHeader[BearerPrefix.Length..].Trim();
if (token.Length == 0)
{
return false;
}
ClaimsPrincipal principal;
try
{
// Validates signature, issuer, audience and lifetime. A forged or expired token
// throws here and is treated as "not an admin" rather than as an error — deciding
// whether to serve is this component's job; returning 401 is not.
principal = _handler.ValidateToken(token, _validationParameters, out _);
}
catch (Exception)
{
return false;
}
return AdminRoles.Any(role => principal.IsInRole(role))
|| principal.FindAll(ClaimTypes.Role).Any(c => AdminRoles.Contains(c.Value))
|| principal.FindAll("role").Any(c => AdminRoles.Contains(c.Value));
}
}
@@ -0,0 +1,24 @@
namespace SlpModularCms.Core.Hosting.Security;
/// <summary>
/// Decides whether a request carries a genuinely valid Owner or Administrator token.
/// </summary>
/// <remarks>
/// Used by the availability gate, which runs before authentication middleware and therefore
/// cannot read <c>HttpContext.User</c>. Validation uses the same parameters as the JWT bearer
/// scheme (see <see cref="JwtTokenValidation"/>), so the gate can never be more permissive
/// than authentication itself.
/// </remarks>
public interface IAdminTokenValidator
{
/// <summary>
/// Returns true only when the supplied Authorization header contains a bearer token that
/// validates successfully and carries the Owner or Administrator role.
/// </summary>
/// <param name="authorizationHeader">Raw Authorization header value; may be null or empty.</param>
/// <returns>
/// True when the caller is a verified Owner or Administrator; false in every other case,
/// including an absent, malformed, forged, expired or non-admin token. Never throws.
/// </returns>
bool IsVerifiedAdmin(string? authorizationHeader);
}
@@ -11,11 +11,11 @@ using Microsoft.IdentityModel.Tokens;
using SlpModularCms.Core.Availability;
using SlpModularCms.Core.Data;
using SlpModularCms.Core.Exceptions;
using SlpModularCms.Core.Hosting.Security;
using SlpModularCms.Core.Identity.Authorization;
using SlpModularCms.Core.Identity.Entities;
using SlpModularCms.Core.Identity.Models;
using SlpModularCms.Core.Identity.Services;
using System.Text;
using System.Threading.RateLimiting;
using System.Diagnostics.CodeAnalysis;
@@ -56,6 +56,16 @@ public static class ServiceCollectionExtensions
services.AddScoped<ISetupService, SetupService>();
// 4. Authentication
//
// The validation parameters are built once and shared: the bearer scheme below and the
// availability gate's admin bypass (IAdminTokenValidator) both use this same instance.
// Configuring them separately would allow the two to drift, and a gate more permissive
// than the bearer scheme would let a token that authentication rejects still bypass the
// availability gate.
var tokenValidationParameters = JwtTokenValidation.Create(jwtSettings);
services.AddSingleton(tokenValidationParameters);
services.AddSingleton<IAdminTokenValidator>(_ => new AdminTokenValidator(tokenValidationParameters));
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
@@ -63,17 +73,7 @@ public static class ServiceCollectionExtensions
})
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = jwtSettings.Issuer,
ValidAudience = jwtSettings.Audience,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSettings.Secret)),
ClockSkew = TimeSpan.Zero
};
options.TokenValidationParameters = tokenValidationParameters;
});
// 5. Authorization
@@ -1,12 +1,9 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.IdentityModel.Tokens;
using NSubstitute;
using SlpModularCms.Core.Availability;
using SlpModularCms.Core.Hosting.Security;
using SlpModularCms.Modules.Availability.Middleware;
using SlpModularCms.Modules.Availability.Services;
@@ -16,6 +13,7 @@ public class AvailabilityMiddlewareMasterGateTests
{
private readonly IAvailabilityService _localSvc;
private readonly IMasterAvailabilityService _masterSvc;
private readonly IAdminTokenValidator _adminTokenValidator;
private readonly AvailabilityMiddleware _middleware;
private readonly RequestDelegate _next;
@@ -23,8 +21,12 @@ public class AvailabilityMiddlewareMasterGateTests
{
_localSvc = Substitute.For<IAvailabilityService>();
_masterSvc = Substitute.For<IMasterAvailabilityService>();
_adminTokenValidator = Substitute.For<IAdminTokenValidator>();
_next = Substitute.For<RequestDelegate>();
_middleware = new AvailabilityMiddleware(_next, NullLogger<AvailabilityMiddleware>.Instance);
_middleware = new AvailabilityMiddleware(
_next,
NullLogger<AvailabilityMiddleware>.Instance,
_adminTokenValidator);
_localSvc.IsAvailableAsync().Returns(AvailabilityStatus.Available);
_masterSvc.GetMasterStatus().Returns(new MasterGateStatus(true, null));
@@ -108,7 +110,8 @@ public class AvailabilityMiddlewareMasterGateTests
public async Task InvokeAsync_BypassesBothGates_WhenAdminJwtPresent()
{
var context = new DefaultHttpContext();
context.Request.Headers.Authorization = $"Bearer {CreateJwtWithRole("Owner")}";
context.Request.Headers.Authorization = "Bearer owner-token";
_adminTokenValidator.IsVerifiedAdmin("Bearer owner-token").Returns(true);
_masterSvc.GetMasterStatus().Returns(new MasterGateStatus(false, null));
_localSvc.IsAvailableAsync().Returns(AvailabilityStatus.NotAvailable);
@@ -119,11 +122,12 @@ public class AvailabilityMiddlewareMasterGateTests
}
[Fact]
public async Task InvokeAsync_DoesNotBypass_WhenUserRoleJwtAndMasterBlocks()
public async Task InvokeAsync_DoesNotBypass_WhenNonAdminJwtAndMasterBlocks()
{
var context = new DefaultHttpContext();
context.Response.Body = new MemoryStream();
context.Request.Headers.Authorization = $"Bearer {CreateJwtWithRole("User")}";
context.Request.Headers.Authorization = "Bearer user-token";
_adminTokenValidator.IsVerifiedAdmin(Arg.Any<string>()).Returns(false);
_masterSvc.GetMasterStatus().Returns(new MasterGateStatus(false, null));
await _middleware.InvokeAsync(context, _localSvc, _masterSvc);
@@ -132,14 +136,17 @@ public class AvailabilityMiddlewareMasterGateTests
context.Response.StatusCode.Should().Be(StatusCodes.Status503ServiceUnavailable);
}
private static string CreateJwtWithRole(string role)
[Fact]
public async Task InvokeAsync_BypassesBothGates_ForHealthEndpoint()
{
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("test-signing-key-at-least-32-chars-long!"));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
claims: [new Claim(ClaimTypes.Role, role)],
expires: DateTime.UtcNow.AddHours(1),
signingCredentials: creds);
return new JwtSecurityTokenHandler().WriteToken(token);
var context = new DefaultHttpContext();
context.Request.Path = "/health";
_masterSvc.GetMasterStatus().Returns(new MasterGateStatus(false, null));
_localSvc.IsAvailableAsync().Returns(AvailabilityStatus.NotAvailable);
await _middleware.InvokeAsync(context, _localSvc, _masterSvc);
await _next.Received(1).Invoke(context);
_masterSvc.DidNotReceive().GetMasterStatus();
}
}
@@ -7,6 +7,9 @@ using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.IdentityModel.Tokens;
using NSubstitute;
using SlpModularCms.Core.Availability;
using SlpModularCms.Core.Hosting;
using SlpModularCms.Core.Hosting.Security;
using SlpModularCms.Core.Identity.Models;
using SlpModularCms.Modules.Availability.Middleware;
using SlpModularCms.Modules.Availability.Services;
using Xunit;
@@ -17,6 +20,7 @@ public class AvailabilityMiddlewareTests
{
private readonly IAvailabilityService _service;
private readonly IMasterAvailabilityService _masterService;
private readonly IAdminTokenValidator _adminTokenValidator;
private readonly AvailabilityMiddleware _middleware;
private readonly RequestDelegate _next;
@@ -24,8 +28,12 @@ public class AvailabilityMiddlewareTests
{
_service = Substitute.For<IAvailabilityService>();
_masterService = Substitute.For<IMasterAvailabilityService>();
_adminTokenValidator = Substitute.For<IAdminTokenValidator>();
_next = Substitute.For<RequestDelegate>();
_middleware = new AvailabilityMiddleware(_next, NullLogger<AvailabilityMiddleware>.Instance);
_middleware = new AvailabilityMiddleware(
_next,
NullLogger<AvailabilityMiddleware>.Instance,
_adminTokenValidator);
// Master gate passes by default in these local gate tests
_masterService.GetMasterStatus().Returns(new MasterGateStatus(true, null));
@@ -91,6 +99,34 @@ public class AvailabilityMiddlewareTests
await _next.Received(1).Invoke(context);
}
/// <summary>
/// Infrastructure liveness must survive the CMS being switched off. A deliberately disabled
/// instance is still perfectly healthy, and monitoring must not report it as down.
/// </summary>
[Fact]
public async Task InvokeAsync_ShouldAllowBypass_ForHealthEndpoint_WhenSystemUnavailable()
{
var context = new DefaultHttpContext();
context.Request.Path = "/health";
_service.IsAvailableAsync().Returns(AvailabilityStatus.NotAvailable);
await _middleware.InvokeAsync(context, _service, _masterService);
await _next.Received(1).Invoke(context);
}
[Fact]
public async Task InvokeAsync_ShouldAllowBypass_ForHealthEndpoint_WhenMasterGateClosed()
{
var context = new DefaultHttpContext();
context.Request.Path = "/health";
_masterService.GetMasterStatus().Returns(new MasterGateStatus(false, "Disabled by master"));
await _middleware.InvokeAsync(context, _service, _masterService);
await _next.Received(1).Invoke(context);
}
[Fact]
public async Task InvokeAsync_ShouldBlockRequest_WhenSystemInMaintenance()
{
@@ -105,10 +141,11 @@ public class AvailabilityMiddlewareTests
}
[Fact]
public async Task InvokeAsync_ShouldAllowAdminBypass_WhenOwnerToken()
public async Task InvokeAsync_ShouldAllowAdminBypass_WhenValidatorAcceptsTheToken()
{
var context = new DefaultHttpContext();
context.Request.Headers.Authorization = $"Bearer {CreateJwtWithRole("Owner")}";
context.Request.Headers.Authorization = "Bearer some-token";
_adminTokenValidator.IsVerifiedAdmin("Bearer some-token").Returns(true);
_service.IsAvailableAsync().Returns(AvailabilityStatus.Maintenance);
await _middleware.InvokeAsync(context, _service, _masterService);
@@ -117,23 +154,12 @@ public class AvailabilityMiddlewareTests
}
[Fact]
public async Task InvokeAsync_ShouldAllowAdminBypass_WhenAdministratorToken()
{
var context = new DefaultHttpContext();
context.Request.Headers.Authorization = $"Bearer {CreateJwtWithRole("Administrator")}";
_service.IsAvailableAsync().Returns(AvailabilityStatus.Maintenance);
await _middleware.InvokeAsync(context, _service, _masterService);
await _next.Received(1).Invoke(context);
}
[Fact]
public async Task InvokeAsync_ShouldNotBypass_WhenUserRoleToken()
public async Task InvokeAsync_ShouldNotBypass_WhenValidatorRejectsTheToken()
{
var context = new DefaultHttpContext();
context.Response.Body = new MemoryStream();
context.Request.Headers.Authorization = $"Bearer {CreateJwtWithRole("User")}";
context.Request.Headers.Authorization = "Bearer some-token";
_adminTokenValidator.IsVerifiedAdmin(Arg.Any<string>()).Returns(false);
_service.IsAvailableAsync().Returns(AvailabilityStatus.Maintenance);
await _middleware.InvokeAsync(context, _service, _masterService);
@@ -147,6 +173,7 @@ public class AvailabilityMiddlewareTests
{
var context = new DefaultHttpContext();
context.Response.Body = new MemoryStream();
_adminTokenValidator.IsVerifiedAdmin(Arg.Any<string>()).Returns(false);
_service.IsAvailableAsync().Returns(AvailabilityStatus.NotAvailable);
await _middleware.InvokeAsync(context, _service, _masterService);
@@ -154,28 +181,80 @@ public class AvailabilityMiddlewareTests
await _next.DidNotReceive().Invoke(Arg.Any<HttpContext>());
}
/// <summary>
/// Wires the middleware to the real validator instead of a substitute, so the two are proven
/// to fit together. A substitute alone would keep passing even if the middleware were wired
/// back to unvalidated token parsing.
/// </summary>
public class WithRealValidator
{
private const string Secret = "TestSecretKeyThatIsLongEnoughForHmacSha256Signing!!";
private const string Issuer = "SlpModularCms";
private const string Audience = "SlpModularCmsPortal";
private readonly IAvailabilityService _service = Substitute.For<IAvailabilityService>();
private readonly IMasterAvailabilityService _masterService = Substitute.For<IMasterAvailabilityService>();
private readonly RequestDelegate _next = Substitute.For<RequestDelegate>();
private readonly AvailabilityMiddleware _middleware;
public WithRealValidator()
{
var parameters = JwtTokenValidation.Create(new JwtSettings
{
Secret = Secret,
Issuer = Issuer,
Audience = Audience
});
_middleware = new AvailabilityMiddleware(
_next,
NullLogger<AvailabilityMiddleware>.Instance,
new AdminTokenValidator(parameters));
_masterService.GetMasterStatus().Returns(new MasterGateStatus(true, null));
}
[Fact]
public async Task InvokeAsync_ShouldNotBypass_WhenInvalidJwtToken()
public async Task InvokeAsync_ShouldNotBypass_ForForgedUnsignedOwnerToken()
{
// The defect this unit fixes: an unsigned token carrying an Owner claim used to pass.
var forged = new JwtSecurityTokenHandler().WriteToken(new JwtSecurityToken(
issuer: Issuer,
audience: Audience,
claims: [new Claim(ClaimTypes.Role, "Owner")],
expires: DateTime.UtcNow.AddHours(1)));
var context = new DefaultHttpContext();
context.Response.Body = new MemoryStream();
context.Request.Headers.Authorization = "Bearer not-a-valid-jwt";
context.Request.Headers.Authorization = $"Bearer {forged}";
_service.IsAvailableAsync().Returns(AvailabilityStatus.NotAvailable);
await _middleware.InvokeAsync(context, _service, _masterService);
await _next.DidNotReceive().Invoke(Arg.Any<HttpContext>());
context.Response.StatusCode.Should().Be(StatusCodes.Status503ServiceUnavailable);
}
private static string CreateJwtWithRole(string role)
[Fact]
public async Task InvokeAsync_ShouldStillBypass_ForGenuineOwnerToken()
{
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("test-signing-key-at-least-32-chars-long!"));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
claims: [new Claim(ClaimTypes.Role, role)],
// Preserved behaviour: an administrator can always reach a disabled instance to
// switch it back on.
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Secret));
var genuine = new JwtSecurityTokenHandler().WriteToken(new JwtSecurityToken(
issuer: Issuer,
audience: Audience,
claims: [new Claim(ClaimTypes.Role, "Owner")],
expires: DateTime.UtcNow.AddHours(1),
signingCredentials: creds
);
return new JwtSecurityTokenHandler().WriteToken(token);
signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256)));
var context = new DefaultHttpContext();
context.Request.Headers.Authorization = $"Bearer {genuine}";
_service.IsAvailableAsync().Returns(AvailabilityStatus.NotAvailable);
await _middleware.InvokeAsync(context, _service, _masterService);
await _next.Received(1).Invoke(context);
}
}
}
@@ -1,9 +1,8 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using SlpModularCms.Core.Availability;
using SlpModularCms.Core.Hosting.Security;
using SlpModularCms.Modules.Availability.Services;
namespace SlpModularCms.Modules.Availability.Middleware;
@@ -12,11 +11,16 @@ public class AvailabilityMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<AvailabilityMiddleware> _logger;
private readonly IAdminTokenValidator _adminTokenValidator;
public AvailabilityMiddleware(RequestDelegate next, ILogger<AvailabilityMiddleware> logger)
public AvailabilityMiddleware(
RequestDelegate next,
ILogger<AvailabilityMiddleware> logger,
IAdminTokenValidator adminTokenValidator)
{
_next = next;
_logger = logger;
_adminTokenValidator = adminTokenValidator;
}
// Paths that are always accessible regardless of system availability.
@@ -25,6 +29,9 @@ public class AvailabilityMiddleware
// Master endpoints bypass so master can always push status or re-register.
// SlaveStatus bypasses so a slave can always pull the master's status, even if the
// master instance is (for whatever reason) reporting itself as locally unavailable.
// /health bypasses because it reports infrastructure liveness, which is a different
// question from whether the CMS is switched on: an instance that is deliberately
// disabled is still perfectly healthy, and must not be reported as down.
private static readonly string[] _bypassPrefixes =
[
"/api/v1/Availability/status",
@@ -32,6 +39,7 @@ public class AvailabilityMiddleware
"/api/v1/Setup/status",
"/api/v1/master/",
"/api/v1/SlaveStatus",
"/health",
];
public async Task InvokeAsync(
@@ -89,26 +97,18 @@ public class AvailabilityMiddleware
});
}
/// <summary>
/// Lets a verified Owner or Administrator through the gate, so administrators can always
/// reach a disabled instance to switch it back on.
/// </summary>
/// <remarks>
/// The token is fully validated — signature, issuer, audience and lifetime — against the
/// same parameters as the JWT bearer scheme. An earlier implementation read the claims
/// without verifying the signature, which meant an unauthenticated caller could present a
/// self-made token carrying an Owner role claim and bypass the gate.
/// </remarks>
private bool IsAdminBypass(HttpContext context)
{
var authHeader = context.Request.Headers.Authorization.ToString();
if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
{
return false;
}
try
{
var tokenString = authHeader.Substring("Bearer ".Length);
var handler = new JwtSecurityTokenHandler();
var token = handler.ReadJwtToken(tokenString);
var roles = token.Claims.Where(c => c.Type == ClaimTypes.Role).Select(c => c.Value);
return roles.Any(r => r == "Owner" || r == "Administrator");
}
catch (Exception)
{
return false;
}
return _adminTokenValidator.IsVerifiedAdmin(context.Request.Headers.Authorization.ToString());
}
}