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:
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user