7.4 KiB
NFR Design Patterns — Unit 2: slave-availability-extension
Pattern 1 — Security: IMasterApiKeyProtector Wrapper
NFR: ApiKey encrypted at rest (Q1=C NFR Requirements); decrypted only for comparison; never logged
Pattern: Thin wrapper interface over ASP.NET Core Data Protection. Mirrors Unit 1's IApiKeyProtector pattern but with a distinct purpose string to prevent cross-module decryption.
Interface:
public interface IMasterApiKeyProtector
{
string Protect(string plainApiKey);
string? Unprotect(string encryptedApiKey); // null on CryptographicException
}
Implementation:
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; }
}
}
Registration (in AvailabilityModule.RegisterServices):
services.AddDataProtection();
services.AddSingleton<IMasterApiKeyProtector, MasterApiKeyProtector>();
Usage in tests:
var protector = Substitute.For<IMasterApiKeyProtector>();
protector.Protect(Arg.Any<string>()).Returns(s => $"enc:{s.ArgAt<string>(0)}");
protector.Unprotect(Arg.Any<string>()).Returns(s => s.ArgAt<string>(0).Replace("enc:", ""));
Pattern 2 — Performance: volatile Static Cache + Sync GetMasterStatus()
NFR: Master gate check is synchronous; zero DB call per request (PERF-01/02)
Pattern: Volatile static fields in MasterAvailabilityService updated only on status push. Exposed via a synchronous interface method so AvailabilityMiddleware can mock it in tests without accessing static state directly.
Static fields:
private static volatile bool _masterIsAvailable = true;
private static volatile string? _masterDisableMessage = null;
Return type:
public record MasterGateStatus(bool IsAvailable, string? DisableMessage);
Interface method (sync — no Task):
public interface IMasterAvailabilityService
{
Task RegisterAsync(string masterUrl, string apiKey);
Task PushStatusAsync(string apiKey, bool isAvailable, string? disableMessage);
Task<string?> GetRegisteredUrlAsync(string apiKey);
MasterGateStatus GetMasterStatus(); // sync; reads volatile fields
}
Implementation:
public MasterGateStatus GetMasterStatus()
=> new(_masterIsAvailable, _masterDisableMessage);
Write point (only in PushStatusAsync):
_masterIsAvailable = isAvailable;
_masterDisableMessage = disableMessage;
Why volatile: bool and string reference assignments are already atomic in .NET. volatile adds the memory barrier to ensure other threads see the updated value without a lock. Consistent with PersistentAvailabilityService's _lastErrorTime pattern.
Pattern 3 — Constructor Aggregation: MasterAvailabilityServiceDependencies
NFR: Consistent with Unit 1's MasterServiceDependencies pattern (Q2=B)
Record definition:
[ExcludeFromCodeCoverage]
public record MasterAvailabilityServiceDependencies(
IMasterRegistrationRepository Repository,
IMasterApiKeyProtector KeyProtector,
ILogger<MasterAvailabilityService> Logger
);
Registration:
services.AddScoped<MasterAvailabilityServiceDependencies>();
services.AddScoped<IMasterAvailabilityService, MasterAvailabilityService>();
MasterAvailabilityService constructor:
public MasterAvailabilityService(MasterAvailabilityServiceDependencies deps)
{
_deps = deps;
}
Test construction (no DI container):
var deps = new MasterAvailabilityServiceDependencies(
Substitute.For<IMasterRegistrationRepository>(),
Substitute.For<IMasterApiKeyProtector>(),
NullLogger<MasterAvailabilityService>.Instance
);
var svc = new MasterAvailabilityService(deps);
Pattern 4 — Middleware Extension: InvokeAsync Third Parameter (Q1=A)
NFR: Master gate is outer gate; evaluated before local gate; admin bypass applies to both
Pattern: AvailabilityMiddleware.InvokeAsync receives IMasterAvailabilityService as a third DI-resolved parameter. ASP.NET Core middleware supports per-request parameter injection on InvokeAsync.
Extended InvokeAsync signature:
public async Task InvokeAsync(
HttpContext context,
IAvailabilityService localSvc,
IMasterAvailabilityService masterSvc)
Evaluation order:
graph TD
A[Request] --> B{Bypass path?}
B -->|Yes| Z[Pass through]
B -->|No| C{Admin JWT?}
C -->|Yes| Z
C -->|No| D{masterSvc.GetMasterStatus\n.IsAvailable?}
D -->|true| E{localSvc\n.IsAvailableAsync?}
D -->|false| F[503 with master\nDisableMessage]
E -->|Available| Z
E -->|Unavailable| G[503 with local\ndisable message]
classDef decision fill:#FFC107,stroke:#F57F17,color:#000
classDef pass fill:#9ae6b4,stroke:#2f855a,color:#000
classDef fail fill:#FC8181,stroke:#C53030,color:#000
class B,C,D,E decision
class Z pass
class F,G fail
Text alternative: Request hits bypass path check, then admin JWT check. If both fail, master gate evaluates (sync). If master blocks: 503 with master message. If master passes: local gate evaluates (async). If local blocks: 503 with local message. Otherwise pass through.
Extended bypass prefix (added to _bypassPrefixes array):
private static readonly string[] _bypassPrefixes =
[
"/api/v1/Availability/status",
"/api/v1/Auth/",
"/api/v1/Setup/status",
"/api/v1/master/" // NEW — master can always reach slave
];
503 response for master gate (uses ProblemDetails):
var masterStatus = masterSvc.GetMasterStatus();
if (!masterStatus.IsAvailable)
{
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
await context.Response.WriteAsJsonAsync(new ProblemDetails
{
Status = 503,
Title = "Service Unavailable",
Detail = masterStatus.DisableMessage
});
return;
}
Test pattern (mock IMasterAvailabilityService):
var masterSvc = Substitute.For<IMasterAvailabilityService>();
masterSvc.GetMasterStatus().Returns(new MasterGateStatus(false, "Maintenance"));
// invoke middleware — assert 503
Pattern 5 — Structured Logging
Pattern: Structured fields per event; never log key material.
| Scenario | Level | Structured Fields |
|---|---|---|
| First master registration | Information |
{MasterUrl} |
| Re-registration (key match) | Information |
{MasterUrl} |
| API key mismatch | Warning |
{Endpoint} — do NOT log key |
| Missing header | Warning |
{Endpoint} |
| Status update received | Information |
{IsAvailable}, {DisableMessage} |
| Master gate blocked request | Warning |
{Path}, {DisableMessage} |
| Get-registered-url called | Debug |
— |
| Key decryption failure | Error |
— (no key material) |
Example:
_logger.LogWarning(
"Master API key mismatch on {Endpoint} — returning 401",
"POST /api/v1/master/status");