Separates website and admin roots, adds /health, hardens the availability gate

Prepares the single-host layout for deployment. The customer's public
website moves from wwwroot/ to wwwroot/web/, so a CMS deploy can no
longer overwrite content it does not own: with the website in its own
directory, the release directory can be swapped without touching it.

Each front-end gets its own file provider, and both tolerate a missing
directory at startup — a fresh deployment has no website until a
separate workspace deploys one, and the CMS must still serve /admin and
the API. When the website's index.html is absent, an embedded
placeholder is served instead of a 404, which also doubles as proof the
CMS itself is running. The placeholder is embedded in the assembly
rather than shipped into wwwroot/web/, because that directory is owned
and overwritten by the website workspace.

Adds GET /health for uptime monitoring. It reports infrastructure
liveness only and is deliberately NOT the same thing as
/api/v1/Availability/status or /api/v1/System/capabilities: those are
CMS domain state that also serve the master/slave protocol. A healthy
instance can be switched off by design, and a switched-on instance can
be unhealthy, so conflating them would alert on business state and stay
silent on real outages. /health is on the availability gate's bypass
list for the same reason.

Fixes a real defect in the gate's admin bypass. It parsed the bearer
token with ReadJwtToken, which reads claims without verifying the
signature, so an unauthenticated caller could forge an unsigned token
carrying an Owner role claim and bypass the gate that suspends a
customer's site. Protected endpoints still rejected them, so nothing
leaked — but the gate itself was bypassable. The token is now fully
validated against the same parameters as the bearer scheme, resolved
from one shared source so the two cannot drift apart.

Host wiring for these changes lands with the data-durability commit,
since both units touch the same lines of Program.cs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHoJpxYXzHACSQguHrC5fw
This commit is contained in:
2026-07-28 00:00:13 +02:00
co-authored by Claude Opus 5
parent 8568ca43c6
commit 29a93ef873
21 changed files with 1677 additions and 86 deletions
@@ -0,0 +1,149 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using FluentAssertions;
using Microsoft.IdentityModel.Tokens;
using SlpModularCms.Core.Hosting;
using SlpModularCms.Core.Hosting.Security;
using SlpModularCms.Core.Identity.Models;
using Xunit;
namespace SlpModularCms.Core.Tests.Hosting;
/// <summary>
/// Guards the availability gate's admin bypass.
/// </summary>
/// <remarks>
/// The behaviour under test is the fix for a real defect: the bypass previously parsed the
/// bearer token without verifying its signature, so an unauthenticated caller could forge an
/// unsigned token carrying an Owner role claim and pass the gate. The forged-token cases below
/// are the point of this suite — the happy paths only prove the fix did not break the feature.
/// </remarks>
public class AdminTokenValidatorTests
{
private const string Secret = "TestSecretKeyThatIsLongEnoughForHmacSha256Signing!!";
private const string Issuer = "SlpModularCms";
private const string Audience = "SlpModularCmsPortal";
private readonly AdminTokenValidator _validator;
public AdminTokenValidatorTests()
{
var settings = new JwtSettings
{
Secret = Secret,
Issuer = Issuer,
Audience = Audience
};
_validator = new AdminTokenValidator(JwtTokenValidation.Create(settings));
}
[Theory]
[InlineData("Owner")]
[InlineData("Administrator")]
public void IsVerifiedAdmin_ShouldReturnTrue_ForValidAdminToken(string role)
{
var header = $"Bearer {CreateToken(role)}";
_validator.IsVerifiedAdmin(header).Should().BeTrue();
}
[Fact]
public void IsVerifiedAdmin_ShouldReturnFalse_ForValidNonAdminToken()
{
var header = $"Bearer {CreateToken("User")}";
_validator.IsVerifiedAdmin(header).Should().BeFalse();
}
[Fact]
public void IsVerifiedAdmin_ShouldReturnFalse_ForForgedUnsignedToken()
{
// The exact attack the fix closes: a token that carries the right claim but was never
// signed by us. Reading claims without validating would have accepted this.
var forged = CreateUnsignedToken("Owner");
_validator.IsVerifiedAdmin($"Bearer {forged}").Should().BeFalse();
}
[Fact]
public void IsVerifiedAdmin_ShouldReturnFalse_ForTokenSignedWithAnotherKey()
{
var otherKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes("AnEntirelyDifferentSecretKeyUsedByNobodyElse!!!!!"));
var credentials = new SigningCredentials(otherKey, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: Issuer,
audience: Audience,
claims: [new Claim(ClaimTypes.Role, "Owner")],
expires: DateTime.UtcNow.AddMinutes(10),
signingCredentials: credentials);
var header = $"Bearer {new JwtSecurityTokenHandler().WriteToken(token)}";
_validator.IsVerifiedAdmin(header).Should().BeFalse();
}
[Fact]
public void IsVerifiedAdmin_ShouldReturnFalse_ForExpiredAdminToken()
{
var header = $"Bearer {CreateToken("Owner", expiresIn: TimeSpan.FromMinutes(-5))}";
_validator.IsVerifiedAdmin(header).Should().BeFalse();
}
[Fact]
public void IsVerifiedAdmin_ShouldReturnFalse_ForWrongIssuer()
{
var header = $"Bearer {CreateToken("Owner", issuer: "SomeoneElse")}";
_validator.IsVerifiedAdmin(header).Should().BeFalse();
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("Bearer")]
[InlineData("Bearer ")]
[InlineData("Basic dXNlcjpwYXNz")]
[InlineData("Bearer not-a-token")]
[InlineData("Bearer a.b.c")]
public void IsVerifiedAdmin_ShouldReturnFalse_ForAbsentOrMalformedHeaders(string? header)
{
// Never throws — an unusable header simply means "not an admin". Rejecting the request
// is the authentication middleware's job, not the availability gate's.
_validator.IsVerifiedAdmin(header).Should().BeFalse();
}
private static string CreateToken(
string role,
TimeSpan? expiresIn = null,
string issuer = Issuer)
{
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Secret));
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: issuer,
audience: Audience,
claims: [new Claim(ClaimTypes.Role, role)],
expires: DateTime.UtcNow.Add(expiresIn ?? TimeSpan.FromMinutes(10)),
signingCredentials: credentials);
return new JwtSecurityTokenHandler().WriteToken(token);
}
private static string CreateUnsignedToken(string role)
{
var token = new JwtSecurityToken(
issuer: Issuer,
audience: Audience,
claims: [new Claim(ClaimTypes.Role, role)],
expires: DateTime.UtcNow.AddMinutes(10));
return new JwtSecurityTokenHandler().WriteToken(token);
}
}
@@ -0,0 +1,56 @@
using System.Text.Json;
using FluentAssertions;
using SlpModularCms.Core.Hosting.Health;
using Xunit;
namespace SlpModularCms.Core.Tests.Hosting;
/// <summary>
/// Guards the shape of the liveness report.
/// </summary>
/// <remarks>
/// The report is served anonymously, so what it does NOT contain matters as much as what it does.
/// It carries the loaded module names because <c>ModuleOrchestrator</c> logs rather than throws
/// when a module fails to load — an instance can start "successfully" with a missing capability,
/// and this is the only way to detect that after a deploy without host access.
/// </remarks>
public class HealthReportTests
{
[Fact]
public void HealthReport_ShouldCarryTheFourReportedFields()
{
var report = new HealthReport("Healthy", DateTimeOffset.UtcNow, "1.2.3", ["Identity", "Availability"]);
report.Status.Should().Be("Healthy");
report.Version.Should().Be("1.2.3");
report.Modules.Should().Equal("Identity", "Availability");
report.Timestamp.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
}
[Fact]
public void HealthReport_ShouldSerializeWithoutAnyAdditionalFields()
{
// Anonymous endpoint: no configuration values, connection details, paths or environment
// data may leak in through an accidentally added property.
var report = new HealthReport("Healthy", DateTimeOffset.UtcNow, "1.0.0", ["Identity"]);
var json = JsonSerializer.Serialize(report);
using var document = JsonDocument.Parse(json);
document.RootElement.EnumerateObject()
.Select(p => p.Name.ToLowerInvariant())
.Should().BeEquivalentTo("status", "timestamp", "version", "modules");
}
[Fact]
public void HealthReport_ShouldSupportAnEmptyModuleList()
{
// A host with no modules discovered is still alive. Liveness must not depend on
// capability — that distinction is the entire reason this endpoint exists separately
// from /api/v1/System/capabilities.
var report = new HealthReport("Healthy", DateTimeOffset.UtcNow, "1.0.0", []);
report.Modules.Should().BeEmpty();
report.Status.Should().Be("Healthy");
}
}