Adds 2 units and docs for unit 3. nfr-requirements plan
This commit is contained in:
@@ -1,9 +1,13 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SlpModularCms.Core.Availability;
|
||||
using SlpModularCms.Core.Modules;
|
||||
using SlpModularCms.Modules.Availability.Data;
|
||||
using SlpModularCms.Modules.Availability.Middleware;
|
||||
using SlpModularCms.Modules.Availability.Repositories;
|
||||
using SlpModularCms.Modules.Availability.Services;
|
||||
|
||||
namespace SlpModularCms.Modules.Availability;
|
||||
@@ -18,10 +22,25 @@ public class AvailabilityModule : IModule
|
||||
{
|
||||
services.AddScoped<IAvailabilityService, PersistentAvailabilityService>();
|
||||
services.AddScoped<PersistentAvailabilityService>();
|
||||
|
||||
services.AddDbContext<AvailabilityDbContext>((serviceProvider, options) =>
|
||||
{
|
||||
var configuration = serviceProvider.GetRequiredService<IConfiguration>();
|
||||
options.UseSqlServer(configuration.GetConnectionString("DefaultConnection"));
|
||||
});
|
||||
|
||||
services.AddDataProtection();
|
||||
services.AddSingleton<IMasterApiKeyProtector, MasterApiKeyProtector>();
|
||||
services.AddScoped<IMasterRegistrationRepository, MasterRegistrationRepository>();
|
||||
services.AddScoped<MasterAvailabilityServiceDependencies>();
|
||||
services.AddScoped<IMasterAvailabilityService, MasterAvailabilityService>();
|
||||
}
|
||||
|
||||
public void UseModule(IApplicationBuilder app)
|
||||
{
|
||||
using var scope = app.ApplicationServices.CreateScope();
|
||||
scope.ServiceProvider.GetRequiredService<AvailabilityDbContext>().Database.Migrate();
|
||||
|
||||
app.UseMiddleware<AvailabilityMiddleware>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SlpModularCms.Modules.Availability.Models;
|
||||
using SlpModularCms.Modules.Availability.Services;
|
||||
|
||||
namespace SlpModularCms.Modules.Availability.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("[controller]")]
|
||||
public class MasterController : ControllerBase
|
||||
{
|
||||
private readonly IMasterAvailabilityService _svc;
|
||||
|
||||
public MasterController(IMasterAvailabilityService svc) => _svc = svc;
|
||||
|
||||
[HttpPost("register")]
|
||||
public async Task<IActionResult> Register([FromBody] RegisterMasterRequest request)
|
||||
{
|
||||
var apiKey = Request.Headers["X-Master-Api-Key"].FirstOrDefault();
|
||||
if (string.IsNullOrEmpty(apiKey)) return Unauthorized();
|
||||
|
||||
var success = await _svc.RegisterAsync(request.MasterUrl, apiKey);
|
||||
return success ? Ok() : Unauthorized();
|
||||
}
|
||||
|
||||
[HttpPost("status")]
|
||||
public async Task<IActionResult> PushStatus([FromBody] PushStatusRequest request)
|
||||
{
|
||||
var apiKey = Request.Headers["X-Master-Api-Key"].FirstOrDefault();
|
||||
if (string.IsNullOrEmpty(apiKey)) return Unauthorized();
|
||||
|
||||
var success = await _svc.PushStatusAsync(apiKey, request.IsAvailable, request.DisableMessage);
|
||||
return success ? Ok() : Unauthorized();
|
||||
}
|
||||
|
||||
[HttpGet("registered-url")]
|
||||
public async Task<IActionResult> GetRegisteredUrl()
|
||||
{
|
||||
var apiKey = Request.Headers["X-Master-Api-Key"].FirstOrDefault();
|
||||
if (string.IsNullOrEmpty(apiKey)) return Unauthorized();
|
||||
|
||||
var url = await _svc.GetRegisteredUrlAsync(apiKey);
|
||||
return url is not null ? Ok(new { MasterUrl = url }) : Unauthorized();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SlpModularCms.Modules.Availability.Data.Entities;
|
||||
|
||||
namespace SlpModularCms.Modules.Availability.Data;
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
public class AvailabilityDbContext(DbContextOptions<AvailabilityDbContext> options) : DbContext(options)
|
||||
{
|
||||
public DbSet<MasterRegistration> MasterRegistrations => Set<MasterRegistration>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
modelBuilder.Entity<MasterRegistration>(entity =>
|
||||
{
|
||||
entity.ToTable("AvailabilityMasterRegistrations");
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.Property(e => e.MasterUrl).IsRequired().HasMaxLength(500);
|
||||
entity.Property(e => e.ApiKey).IsRequired().HasMaxLength(2000);
|
||||
entity.Property(e => e.RegisteredAt).IsRequired();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace SlpModularCms.Modules.Availability.Data.Entities;
|
||||
|
||||
public class MasterRegistration
|
||||
{
|
||||
public static readonly Guid SingletonId = new("00000000-0000-0000-0000-000000000001");
|
||||
|
||||
public Guid Id { get; set; }
|
||||
public string MasterUrl { get; set; } = string.Empty;
|
||||
public string ApiKey { get; set; } = string.Empty;
|
||||
public DateTimeOffset RegisteredAt { get; set; }
|
||||
public DateTimeOffset? LastContactedAt { get; set; }
|
||||
}
|
||||
@@ -4,13 +4,10 @@ using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SlpModularCms.Core.Availability;
|
||||
using SlpModularCms.Core.Exceptions;
|
||||
using SlpModularCms.Modules.Availability.Services;
|
||||
|
||||
namespace SlpModularCms.Modules.Availability.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// Middleware die de beschikbaarheid van het systeem controleert en requests blokkeert indien nodig.
|
||||
/// </summary>
|
||||
public class AvailabilityMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
@@ -25,14 +22,19 @@ public class AvailabilityMiddleware
|
||||
// Paths that are always accessible regardless of system availability.
|
||||
// Auth and Setup must stay open so admins can log in and the frontend
|
||||
// can determine whether the system is initialized.
|
||||
// Master endpoints bypass so master can always push status or re-register.
|
||||
private static readonly string[] _bypassPrefixes =
|
||||
[
|
||||
"/api/v1/Availability/status",
|
||||
"/api/v1/Auth/",
|
||||
"/api/v1/Setup/status",
|
||||
"/api/v1/master/",
|
||||
];
|
||||
|
||||
public async Task InvokeAsync(HttpContext context, IAvailabilityService availabilityService)
|
||||
public async Task InvokeAsync(
|
||||
HttpContext context,
|
||||
IAvailabilityService availabilityService,
|
||||
IMasterAvailabilityService masterAvailabilityService)
|
||||
{
|
||||
var path = context.Request.Path.Value ?? string.Empty;
|
||||
if (_bypassPrefixes.Any(p => path.StartsWith(p, StringComparison.OrdinalIgnoreCase)))
|
||||
@@ -41,35 +43,47 @@ public class AvailabilityMiddleware
|
||||
return;
|
||||
}
|
||||
|
||||
var status = await availabilityService.IsAvailableAsync();
|
||||
|
||||
if (status == AvailabilityStatus.Available)
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check voor Beheerder Bypass (Owner of Administrator)
|
||||
if (IsAdminBypass(context))
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogWarning("Request geblokkeerd vanwege systeemstatus: {Status}. Path: {Path}", status, context.Request.Path);
|
||||
var masterStatus = masterAvailabilityService.GetMasterStatus();
|
||||
if (!masterStatus.IsAvailable)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Request blocked by master gate. Path: {Path}, DisableMessage: {DisableMessage}",
|
||||
path, masterStatus.DisableMessage);
|
||||
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
|
||||
await context.Response.WriteAsJsonAsync(new ProblemDetails
|
||||
{
|
||||
Status = StatusCodes.Status503ServiceUnavailable,
|
||||
Title = "Service Unavailable",
|
||||
Detail = masterStatus.DisableMessage ?? "De service is tijdelijk niet beschikbaar.",
|
||||
Instance = context.Request.Path
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
var status = await availabilityService.IsAvailableAsync();
|
||||
if (status == AvailabilityStatus.Available)
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogWarning("Request geblokkeerd vanwege systeemstatus: {Status}. Path: {Path}", status, context.Request.Path);
|
||||
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
|
||||
var problemDetails = new ProblemDetails
|
||||
await context.Response.WriteAsJsonAsync(new ProblemDetails
|
||||
{
|
||||
Status = StatusCodes.Status503ServiceUnavailable,
|
||||
Title = "Service Unavailable",
|
||||
Detail = status == AvailabilityStatus.Maintenance
|
||||
? "Het systeem is momenteel in onderhoud. Probeer het later opnieuw."
|
||||
Detail = status == AvailabilityStatus.Maintenance
|
||||
? "Het systeem is momenteel in onderhoud. Probeer het later opnieuw."
|
||||
: "De service is tijdelijk niet beschikbaar.",
|
||||
Instance = context.Request.Path
|
||||
};
|
||||
|
||||
await context.Response.WriteAsJsonAsync(problemDetails);
|
||||
});
|
||||
}
|
||||
|
||||
private bool IsAdminBypass(HttpContext context)
|
||||
@@ -91,7 +105,6 @@ public class AvailabilityMiddleware
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Ongeldig token of parsing fout: geen bypass
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace SlpModularCms.Modules.Availability.Models;
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
public record RegisterMasterRequest(string MasterUrl);
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
public record PushStatusRequest(bool IsAvailable, string? DisableMessage);
|
||||
@@ -0,0 +1,11 @@
|
||||
using SlpModularCms.Modules.Availability.Data.Entities;
|
||||
|
||||
namespace SlpModularCms.Modules.Availability.Repositories;
|
||||
|
||||
public interface IMasterRegistrationRepository
|
||||
{
|
||||
Task<MasterRegistration?> GetAsync();
|
||||
Task AddAsync(MasterRegistration registration);
|
||||
void Update(MasterRegistration registration);
|
||||
Task SaveChangesAsync();
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SlpModularCms.Modules.Availability.Data;
|
||||
using SlpModularCms.Modules.Availability.Data.Entities;
|
||||
|
||||
namespace SlpModularCms.Modules.Availability.Repositories;
|
||||
|
||||
public class MasterRegistrationRepository(AvailabilityDbContext context) : IMasterRegistrationRepository
|
||||
{
|
||||
public Task<MasterRegistration?> GetAsync()
|
||||
=> context.MasterRegistrations.FirstOrDefaultAsync(r => r.Id == MasterRegistration.SingletonId);
|
||||
|
||||
public async Task AddAsync(MasterRegistration registration)
|
||||
=> await context.MasterRegistrations.AddAsync(registration);
|
||||
|
||||
public void Update(MasterRegistration registration)
|
||||
=> context.MasterRegistrations.Update(registration);
|
||||
|
||||
public Task SaveChangesAsync()
|
||||
=> context.SaveChangesAsync();
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace SlpModularCms.Modules.Availability.Services;
|
||||
|
||||
public interface IMasterApiKeyProtector
|
||||
{
|
||||
string Protect(string plainApiKey);
|
||||
string? Unprotect(string encryptedApiKey);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace SlpModularCms.Modules.Availability.Services;
|
||||
|
||||
public interface IMasterAvailabilityService
|
||||
{
|
||||
Task<bool> RegisterAsync(string masterUrl, string apiKey);
|
||||
Task<bool> PushStatusAsync(string apiKey, bool isAvailable, string? disableMessage);
|
||||
Task<string?> GetRegisteredUrlAsync(string apiKey);
|
||||
MasterGateStatus GetMasterStatus();
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
|
||||
namespace SlpModularCms.Modules.Availability.Services;
|
||||
|
||||
public class MasterApiKeyProtector : IMasterApiKeyProtector
|
||||
{
|
||||
private readonly IDataProtector _protector;
|
||||
|
||||
public MasterApiKeyProtector(IDataProtectionProvider provider)
|
||||
=> _protector = provider.CreateProtector("SlpModularCms.Availability.MasterApiKey");
|
||||
|
||||
public string Protect(string plainApiKey)
|
||||
=> _protector.Protect(plainApiKey);
|
||||
|
||||
public string? Unprotect(string encryptedApiKey)
|
||||
{
|
||||
try { return _protector.Unprotect(encryptedApiKey); }
|
||||
catch (CryptographicException) { return null; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SlpModularCms.Modules.Availability.Data.Entities;
|
||||
|
||||
namespace SlpModularCms.Modules.Availability.Services;
|
||||
|
||||
public class MasterAvailabilityService : IMasterAvailabilityService
|
||||
{
|
||||
private readonly MasterAvailabilityServiceDependencies _deps;
|
||||
|
||||
private static volatile bool _masterIsAvailable = true;
|
||||
private static volatile string? _masterDisableMessage;
|
||||
|
||||
public MasterAvailabilityService(MasterAvailabilityServiceDependencies deps)
|
||||
=> _deps = deps;
|
||||
|
||||
public MasterGateStatus GetMasterStatus()
|
||||
=> new(_masterIsAvailable, _masterDisableMessage);
|
||||
|
||||
public async Task<bool> RegisterAsync(string masterUrl, string apiKey)
|
||||
{
|
||||
var existing = await _deps.Repository.GetAsync();
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
var protectedKey = _deps.KeyProtector.Protect(apiKey);
|
||||
var reg = new MasterRegistration
|
||||
{
|
||||
Id = MasterRegistration.SingletonId,
|
||||
MasterUrl = masterUrl,
|
||||
ApiKey = protectedKey,
|
||||
RegisteredAt = DateTimeOffset.UtcNow,
|
||||
LastContactedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
await _deps.Repository.AddAsync(reg);
|
||||
await _deps.Repository.SaveChangesAsync();
|
||||
_deps.Logger.LogInformation("Master registered for the first time at {MasterUrl}", masterUrl);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!IsKeyValid(existing, apiKey))
|
||||
{
|
||||
_deps.Logger.LogWarning("Master API key mismatch on {Endpoint}", "POST /api/v1/master/register");
|
||||
return false;
|
||||
}
|
||||
|
||||
existing.MasterUrl = masterUrl;
|
||||
existing.LastContactedAt = DateTimeOffset.UtcNow;
|
||||
_deps.Repository.Update(existing);
|
||||
await _deps.Repository.SaveChangesAsync();
|
||||
_deps.Logger.LogInformation("Master re-registered at {MasterUrl}", masterUrl);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> PushStatusAsync(string apiKey, bool isAvailable, string? disableMessage)
|
||||
{
|
||||
var existing = await _deps.Repository.GetAsync();
|
||||
if (!IsKeyValid(existing, apiKey))
|
||||
{
|
||||
_deps.Logger.LogWarning("Master API key mismatch on {Endpoint}", "POST /api/v1/master/status");
|
||||
return false;
|
||||
}
|
||||
|
||||
_masterIsAvailable = isAvailable;
|
||||
_masterDisableMessage = disableMessage;
|
||||
|
||||
existing!.LastContactedAt = DateTimeOffset.UtcNow;
|
||||
_deps.Repository.Update(existing);
|
||||
await _deps.Repository.SaveChangesAsync();
|
||||
_deps.Logger.LogInformation(
|
||||
"Master status update received: IsAvailable={IsAvailable}, DisableMessage={DisableMessage}",
|
||||
isAvailable, disableMessage);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<string?> GetRegisteredUrlAsync(string apiKey)
|
||||
{
|
||||
var existing = await _deps.Repository.GetAsync();
|
||||
if (!IsKeyValid(existing, apiKey))
|
||||
{
|
||||
_deps.Logger.LogWarning("Master API key mismatch on {Endpoint}", "GET /api/v1/master/registered-url");
|
||||
return null;
|
||||
}
|
||||
|
||||
existing!.LastContactedAt = DateTimeOffset.UtcNow;
|
||||
_deps.Repository.Update(existing);
|
||||
await _deps.Repository.SaveChangesAsync();
|
||||
_deps.Logger.LogDebug("Get-registered-url called");
|
||||
return existing.MasterUrl;
|
||||
}
|
||||
|
||||
private bool IsKeyValid(MasterRegistration? registration, string apiKey)
|
||||
{
|
||||
if (registration is null) return false;
|
||||
var stored = _deps.KeyProtector.Unprotect(registration.ApiKey);
|
||||
return stored is not null && stored == apiKey;
|
||||
}
|
||||
|
||||
internal static void ResetStaticCacheForTest()
|
||||
{
|
||||
_masterIsAvailable = true;
|
||||
_masterDisableMessage = null;
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SlpModularCms.Modules.Availability.Repositories;
|
||||
|
||||
namespace SlpModularCms.Modules.Availability.Services;
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
public record MasterAvailabilityServiceDependencies(
|
||||
IMasterRegistrationRepository Repository,
|
||||
IMasterApiKeyProtector KeyProtector,
|
||||
ILogger<MasterAvailabilityService> Logger
|
||||
);
|
||||
@@ -0,0 +1,6 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace SlpModularCms.Modules.Availability.Services;
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
public record MasterGateStatus(bool IsAvailable, string? DisableMessage);
|
||||
@@ -1,13 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\SlpModularCms.Core\SlpModularCms.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
|
||||
<_Parameter1>SlpModularCms.Modules.Availability.Tests</_Parameter1>
|
||||
</AssemblyAttribute>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\SlpModularCms.Core\SlpModularCms.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user