Feature/gitea deployment workflow #1
+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