diff --git a/aidlc-docs/features/gitea-deployment-workflow/construction/plans/u1-hosting-serving-code-generation-plan.md b/aidlc-docs/features/gitea-deployment-workflow/construction/plans/u1-hosting-serving-code-generation-plan.md new file mode 100644 index 0000000..1dcc467 --- /dev/null +++ b/aidlc-docs/features/gitea-deployment-workflow/construction/plans/u1-hosting-serving-code-generation-plan.md @@ -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 diff --git a/aidlc-docs/features/gitea-deployment-workflow/construction/plans/u1-hosting-serving-fd-questions.md b/aidlc-docs/features/gitea-deployment-workflow/construction/plans/u1-hosting-serving-fd-questions.md new file mode 100644 index 0000000..facab4d --- /dev/null +++ b/aidlc-docs/features/gitea-deployment-workflow/construction/plans/u1-hosting-serving-fd-questions.md @@ -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 diff --git a/aidlc-docs/features/gitea-deployment-workflow/construction/plans/u1-hosting-serving-functional-design-plan.md b/aidlc-docs/features/gitea-deployment-workflow/construction/plans/u1-hosting-serving-functional-design-plan.md new file mode 100644 index 0000000..afa5847 --- /dev/null +++ b/aidlc-docs/features/gitea-deployment-workflow/construction/plans/u1-hosting-serving-functional-design-plan.md @@ -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 diff --git a/aidlc-docs/features/gitea-deployment-workflow/construction/u1-hosting-serving/code/generation-summary.md b/aidlc-docs/features/gitea-deployment-workflow/construction/u1-hosting-serving/code/generation-summary.md new file mode 100644 index 0000000..61044d0 --- /dev/null +++ b/aidlc-docs/features/gitea-deployment-workflow/construction/u1-hosting-serving/code/generation-summary.md @@ -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. diff --git a/aidlc-docs/features/gitea-deployment-workflow/construction/u1-hosting-serving/functional-design/business-logic-model.md b/aidlc-docs/features/gitea-deployment-workflow/construction/u1-hosting-serving/functional-design/business-logic-model.md new file mode 100644 index 0000000..b4e3f17 --- /dev/null +++ b/aidlc-docs/features/gitea-deployment-workflow/construction/u1-hosting-serving/functional-design/business-logic-model.md @@ -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
response-start callback"] + adminmount{"Path starts with /admin ?"} + adminslash{"Path is exactly /admin
without trailing slash ?"} + redirect["308 redirect to /admin/"] + adminfile{"File exists in
wwwroot/admin ?"} + serveadmin["Serve admin asset"] + webfile{"File exists in
wwwroot/web ?"} + serveweb["Serve website asset"] + gate["Availability gate"] + bypass{"Bypass prefix
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
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
so a disabled instance still answers"] + inproc["Read in-process state only
no database, no dependency probe"] + compose["Compose report:
status, timestamp, version, modules"] + ok["200 Healthy"] + dead["Process not running:
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
explaining no site is deployed
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. diff --git a/aidlc-docs/features/gitea-deployment-workflow/construction/u1-hosting-serving/functional-design/business-rules.md b/aidlc-docs/features/gitea-deployment-workflow/construction/u1-hosting-serving/functional-design/business-rules.md new file mode 100644 index 0000000..c4564dc --- /dev/null +++ b/aidlc-docs/features/gitea-deployment-workflow/construction/u1-hosting-serving/functional-design/business-rules.md @@ -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 | diff --git a/aidlc-docs/features/gitea-deployment-workflow/construction/u1-hosting-serving/functional-design/domain-entities.md b/aidlc-docs/features/gitea-deployment-workflow/construction/u1-hosting-serving/functional-design/domain-entities.md new file mode 100644 index 0000000..c32948c --- /dev/null +++ b/aidlc-docs/features/gitea-deployment-workflow/construction/u1-hosting-serving/functional-design/domain-entities.md @@ -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
request path /"] + mountadmin["StaticMount: admin SPA
request path /admin"] + provider["File provider
per mount"] + fallbackweb["SPA fallback: website"] + fallbackadmin["SPA fallback: admin"] + placeholder["Placeholder page
embedded resource"] + report["HealthReport
response model"] + orchestrator["ModuleOrchestrator
existing"] + bypass["Bypass prefix list
existing, extended"] + tokenparams["Token validation parameters
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. diff --git a/src/SlpModularCms.Api/Extensions/StaticContentExtensions.cs b/src/SlpModularCms.Api/Extensions/StaticContentExtensions.cs new file mode 100644 index 0000000..decce16 --- /dev/null +++ b/src/SlpModularCms.Api/Extensions/StaticContentExtensions.cs @@ -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; + +/// +/// Serves the two independent front-ends this host carries. +/// +/// +/// Shared hosting typically allows only one site or application pool, so this single process +/// serves everything: +/// +/// / — the customer's public website, from wwwroot/web/. Built and +/// deployed separately; it is NOT part of this repository and must survive every CMS deploy. +/// /admin — the CMS admin SPA, from wwwroot/admin/, produced by +/// dotnet publish. +/// +/// 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. +/// +public static class StaticContentExtensions +{ + /// Directory under the web root holding the customer's public website. + public const string WebsiteDirectoryName = "web"; + + /// Directory under the web root holding the admin SPA. + public const string AdminDirectoryName = "admin"; + + /// Request path the admin SPA is mounted at. + public const string AdminRequestPath = "/admin"; + + private const string PlaceholderResourceName = "SlpModularCms.Api.Extensions.WebsitePlaceholder.html"; + + /// + /// Registers both static mounts and the /admin trailing-slash redirect. + /// Must be called BEFORE any middleware that needs to observe static responses, because + /// static files short-circuit the pipeline. + /// + 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() + .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; + } + + /// + /// Maps the SPA fallbacks for both mounts. + /// + /// + /// The nonfile 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. + /// + 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("Nog geen website geplaatst" + + "

Er staat hier nog geen website. Beheer via /admin/.

"); + 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); + } +} diff --git a/src/SlpModularCms.Api/Extensions/WebsitePlaceholder.html b/src/SlpModularCms.Api/Extensions/WebsitePlaceholder.html new file mode 100644 index 0000000..2b8abf8 --- /dev/null +++ b/src/SlpModularCms.Api/Extensions/WebsitePlaceholder.html @@ -0,0 +1,56 @@ + + + + + + + Nog geen website geplaatst + + + +
+

Er staat hier nog geen website

+

+ Het CMS draait, maar er is nog geen publieke website geplaatst. De website + wordt apart aangeleverd en hoort in de map wwwroot/web/ te staan, + met een index.html in de hoofdmap daarvan. +

+

+ Beheerders kunnen inloggen via /admin/. +

+

+ Deze pagina wordt automatisch vervangen zodra de website is geplaatst. +

+
+ + diff --git a/src/SlpModularCms.Api/SlpModularCms.Api.csproj b/src/SlpModularCms.Api/SlpModularCms.Api.csproj index 3f4e12c..85c0df0 100644 --- a/src/SlpModularCms.Api/SlpModularCms.Api.csproj +++ b/src/SlpModularCms.Api/SlpModularCms.Api.csproj @@ -17,6 +17,16 @@ + + + + + diff --git a/src/SlpModularCms.Core.Tests/Hosting/AdminTokenValidatorTests.cs b/src/SlpModularCms.Core.Tests/Hosting/AdminTokenValidatorTests.cs new file mode 100644 index 0000000..55d05dd --- /dev/null +++ b/src/SlpModularCms.Core.Tests/Hosting/AdminTokenValidatorTests.cs @@ -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; + +/// +/// Guards the availability gate's admin bypass. +/// +/// +/// 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. +/// +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); + } +} diff --git a/src/SlpModularCms.Core.Tests/Hosting/HealthReportTests.cs b/src/SlpModularCms.Core.Tests/Hosting/HealthReportTests.cs new file mode 100644 index 0000000..0dfd8fc --- /dev/null +++ b/src/SlpModularCms.Core.Tests/Hosting/HealthReportTests.cs @@ -0,0 +1,56 @@ +using System.Text.Json; +using FluentAssertions; +using SlpModularCms.Core.Hosting.Health; +using Xunit; + +namespace SlpModularCms.Core.Tests.Hosting; + +/// +/// Guards the shape of the liveness report. +/// +/// +/// 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 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. +/// +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"); + } +} diff --git a/src/SlpModularCms.Core/Hosting/Health/HealthCheckExtensions.cs b/src/SlpModularCms.Core/Hosting/Health/HealthCheckExtensions.cs new file mode 100644 index 0000000..cae0b9c --- /dev/null +++ b/src/SlpModularCms.Core/Hosting/Health/HealthCheckExtensions.cs @@ -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; + +/// +/// Registers the infrastructure liveness endpoint. +/// +/// +/// 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 ). A process +/// that cannot reach its database therefore never starts, so /health stops answering +/// entirely. The unhealthy signal is the absence of a response, not a response saying so. +/// +public static class HealthCheckExtensions +{ + /// Path of the liveness endpoint. Also present in the availability gate's bypass list. + public const string HealthPath = "/health"; + + public static IServiceCollection AddCmsHealthChecks(this IServiceCollection services) + { + services.AddHealthChecks(); + return services; + } + + /// + /// Maps GET /health, returning a JSON report composed from in-process state only. + /// + public static IEndpointRouteBuilder MapCmsHealthChecks(this IEndpointRouteBuilder endpoints) + { + endpoints.MapGet(HealthPath, (HttpContext context) => + { + var orchestrator = context.RequestServices.GetService(); + + 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()?.InformationalVersion + ?? assembly.GetName().Version?.ToString() + ?? "unknown"; + } +} diff --git a/src/SlpModularCms.Core/Hosting/Health/HealthReport.cs b/src/SlpModularCms.Core/Hosting/Health/HealthReport.cs new file mode 100644 index 0000000..20bad01 --- /dev/null +++ b/src/SlpModularCms.Core/Hosting/Health/HealthReport.cs @@ -0,0 +1,23 @@ +using System.Diagnostics.CodeAnalysis; + +namespace SlpModularCms.Core.Hosting.Health; + +/// +/// Response model for the infrastructure liveness endpoint. +/// +/// +/// Reports infrastructure liveness ONLY. This is deliberately not the same thing as the CMS's +/// own availability state (/api/v1/Availability/status) or its loaded-capability report +/// (/api/v1/System/capabilities), 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. +/// +[ExcludeFromCodeCoverage] +public sealed record HealthReport( + string Status, + DateTimeOffset Timestamp, + string Version, + IReadOnlyList Modules); diff --git a/src/SlpModularCms.Core/Hosting/JwtTokenValidation.cs b/src/SlpModularCms.Core/Hosting/JwtTokenValidation.cs new file mode 100644 index 0000000..82d34cc --- /dev/null +++ b/src/SlpModularCms.Core/Hosting/JwtTokenValidation.cs @@ -0,0 +1,41 @@ +using System.Text; +using Microsoft.IdentityModel.Tokens; +using SlpModularCms.Core.Identity.Models; + +namespace SlpModularCms.Core.Hosting; + +/// +/// Single source of the JWT validation parameters used across the application. +/// +/// +/// These parameters are consumed in two places: the JWT bearer authentication scheme, +/// and the availability gate's admin bypass (see IAdminTokenValidator). +/// +/// 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. +/// +public static class JwtTokenValidation +{ + /// + /// Builds the validation parameters for the given settings. + /// + 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 + }; + } +} diff --git a/src/SlpModularCms.Core/Hosting/Security/AdminTokenValidator.cs b/src/SlpModularCms.Core/Hosting/Security/AdminTokenValidator.cs new file mode 100644 index 0000000..c5c5108 --- /dev/null +++ b/src/SlpModularCms.Core/Hosting/Security/AdminTokenValidator.cs @@ -0,0 +1,64 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using Microsoft.IdentityModel.Tokens; + +namespace SlpModularCms.Core.Hosting.Security; + +/// +/// Validates a bearer token against the application's JWT validation parameters and checks +/// for an administrative role. +/// +/// +/// This replaces an earlier implementation that parsed the token with +/// JwtSecurityTokenHandler.ReadJwtToken — 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. +/// +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)); + } +} diff --git a/src/SlpModularCms.Core/Hosting/Security/IAdminTokenValidator.cs b/src/SlpModularCms.Core/Hosting/Security/IAdminTokenValidator.cs new file mode 100644 index 0000000..705ee38 --- /dev/null +++ b/src/SlpModularCms.Core/Hosting/Security/IAdminTokenValidator.cs @@ -0,0 +1,24 @@ +namespace SlpModularCms.Core.Hosting.Security; + +/// +/// Decides whether a request carries a genuinely valid Owner or Administrator token. +/// +/// +/// Used by the availability gate, which runs before authentication middleware and therefore +/// cannot read HttpContext.User. Validation uses the same parameters as the JWT bearer +/// scheme (see ), so the gate can never be more permissive +/// than authentication itself. +/// +public interface IAdminTokenValidator +{ + /// + /// Returns true only when the supplied Authorization header contains a bearer token that + /// validates successfully and carries the Owner or Administrator role. + /// + /// Raw Authorization header value; may be null or empty. + /// + /// 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. + /// + bool IsVerifiedAdmin(string? authorizationHeader); +} diff --git a/src/SlpModularCms.Core/Hosting/ServiceCollectionExtensions.cs b/src/SlpModularCms.Core/Hosting/ServiceCollectionExtensions.cs index f14cd30..f573781 100644 --- a/src/SlpModularCms.Core/Hosting/ServiceCollectionExtensions.cs +++ b/src/SlpModularCms.Core/Hosting/ServiceCollectionExtensions.cs @@ -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(); // 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(_ => 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 diff --git a/src/SlpModularCms.Modules.Availability.Tests/AvailabilityMiddlewareMasterGateTests.cs b/src/SlpModularCms.Modules.Availability.Tests/AvailabilityMiddlewareMasterGateTests.cs index b1d651d..be14df0 100644 --- a/src/SlpModularCms.Modules.Availability.Tests/AvailabilityMiddlewareMasterGateTests.cs +++ b/src/SlpModularCms.Modules.Availability.Tests/AvailabilityMiddlewareMasterGateTests.cs @@ -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(); _masterSvc = Substitute.For(); + _adminTokenValidator = Substitute.For(); _next = Substitute.For(); - _middleware = new AvailabilityMiddleware(_next, NullLogger.Instance); + _middleware = new AvailabilityMiddleware( + _next, + NullLogger.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()).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(); } } diff --git a/src/SlpModularCms.Modules.Availability.Tests/AvailabilityMiddlewareTests.cs b/src/SlpModularCms.Modules.Availability.Tests/AvailabilityMiddlewareTests.cs index 9908df3..d5b2556 100644 --- a/src/SlpModularCms.Modules.Availability.Tests/AvailabilityMiddlewareTests.cs +++ b/src/SlpModularCms.Modules.Availability.Tests/AvailabilityMiddlewareTests.cs @@ -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(); _masterService = Substitute.For(); + _adminTokenValidator = Substitute.For(); _next = Substitute.For(); - _middleware = new AvailabilityMiddleware(_next, NullLogger.Instance); + _middleware = new AvailabilityMiddleware( + _next, + NullLogger.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); } + /// + /// 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. + /// + [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()).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()).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()); } - [Fact] - public async Task InvokeAsync_ShouldNotBypass_WhenInvalidJwtToken() + /// + /// 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. + /// + public class WithRealValidator { - var context = new DefaultHttpContext(); - context.Response.Body = new MemoryStream(); - context.Request.Headers.Authorization = "Bearer not-a-valid-jwt"; - _service.IsAvailableAsync().Returns(AvailabilityStatus.NotAvailable); + private const string Secret = "TestSecretKeyThatIsLongEnoughForHmacSha256Signing!!"; + private const string Issuer = "SlpModularCms"; + private const string Audience = "SlpModularCmsPortal"; - await _middleware.InvokeAsync(context, _service, _masterService); + private readonly IAvailabilityService _service = Substitute.For(); + private readonly IMasterAvailabilityService _masterService = Substitute.For(); + private readonly RequestDelegate _next = Substitute.For(); + private readonly AvailabilityMiddleware _middleware; - await _next.DidNotReceive().Invoke(Arg.Any()); - } + public WithRealValidator() + { + var parameters = JwtTokenValidation.Create(new JwtSettings + { + Secret = Secret, + Issuer = Issuer, + Audience = Audience + }); - private static string CreateJwtWithRole(string role) - { - 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); + _middleware = new AvailabilityMiddleware( + _next, + NullLogger.Instance, + new AdminTokenValidator(parameters)); + + _masterService.GetMasterStatus().Returns(new MasterGateStatus(true, null)); + } + + [Fact] + 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 {forged}"; + _service.IsAvailableAsync().Returns(AvailabilityStatus.NotAvailable); + + await _middleware.InvokeAsync(context, _service, _masterService); + + await _next.DidNotReceive().Invoke(Arg.Any()); + context.Response.StatusCode.Should().Be(StatusCodes.Status503ServiceUnavailable); + } + + [Fact] + public async Task InvokeAsync_ShouldStillBypass_ForGenuineOwnerToken() + { + // 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: 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); + } } } diff --git a/src/SlpModularCms.Modules.Availability/Middleware/AvailabilityMiddleware.cs b/src/SlpModularCms.Modules.Availability/Middleware/AvailabilityMiddleware.cs index 0ba0cac..3bd734d 100644 --- a/src/SlpModularCms.Modules.Availability/Middleware/AvailabilityMiddleware.cs +++ b/src/SlpModularCms.Modules.Availability/Middleware/AvailabilityMiddleware.cs @@ -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 _logger; + private readonly IAdminTokenValidator _adminTokenValidator; - public AvailabilityMiddleware(RequestDelegate next, ILogger logger) + public AvailabilityMiddleware( + RequestDelegate next, + ILogger 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 }); } + /// + /// Lets a verified Owner or Administrator through the gate, so administrators can always + /// reach a disabled instance to switch it back on. + /// + /// + /// 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. + /// 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()); } }