Separates website and admin roots, adds /health, hardens the availability gate
Prepares the single-host layout for deployment. The customer's public website moves from wwwroot/ to wwwroot/web/, so a CMS deploy can no longer overwrite content it does not own: with the website in its own directory, the release directory can be swapped without touching it. Each front-end gets its own file provider, and both tolerate a missing directory at startup — a fresh deployment has no website until a separate workspace deploys one, and the CMS must still serve /admin and the API. When the website's index.html is absent, an embedded placeholder is served instead of a 404, which also doubles as proof the CMS itself is running. The placeholder is embedded in the assembly rather than shipped into wwwroot/web/, because that directory is owned and overwritten by the website workspace. Adds GET /health for uptime monitoring. It reports infrastructure liveness only and is deliberately NOT the same thing as /api/v1/Availability/status or /api/v1/System/capabilities: those are CMS domain state 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 conflating them would alert on business state and stay silent on real outages. /health is on the availability gate's bypass list for the same reason. Fixes a real defect in the gate's admin bypass. It parsed the bearer token with ReadJwtToken, which reads claims without verifying the signature, so an unauthenticated caller could forge an unsigned token carrying an Owner role claim and bypass the gate that suspends a customer's site. Protected endpoints still rejected them, so nothing leaked — but the gate itself was bypassable. The token is now fully validated against the same parameters as the bearer scheme, resolved from one shared source so the two cannot drift apart. Host wiring for these changes lands with the data-durability commit, since both units touch the same lines of Program.cs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HHoJpxYXzHACSQguHrC5fw
This commit is contained in:
+149
@@ -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
|
||||
+69
@@ -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
|
||||
+47
@@ -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
|
||||
+86
@@ -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.
|
||||
+213
@@ -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.
|
||||
+134
@@ -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 |
|
||||
+128
@@ -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
|
||||
|
||||
+23
-16
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user