Adds 2 units and docs for unit 3. nfr-requirements plan
This commit is contained in:
+302
@@ -0,0 +1,302 @@
|
||||
# Logical Components — Unit 2: slave-availability-extension
|
||||
|
||||
## Component Overview
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph Controllers
|
||||
MC["MasterController\n(new)"]
|
||||
end
|
||||
|
||||
subgraph Services
|
||||
IMAS["IMasterAvailabilityService"]
|
||||
MAS["MasterAvailabilityService\n(volatile static cache)"]
|
||||
IMAKP["IMasterApiKeyProtector"]
|
||||
MAKP["MasterApiKeyProtector"]
|
||||
MASD["MasterAvailabilityServiceDependencies\n(record)"]
|
||||
end
|
||||
|
||||
subgraph Repositories
|
||||
IMRR["IMasterRegistrationRepository"]
|
||||
MRR["MasterRegistrationRepository"]
|
||||
end
|
||||
|
||||
subgraph Data
|
||||
AVDBCTX["AvailabilityDbContext\n(new)"]
|
||||
MR["MasterRegistration\n(entity)"]
|
||||
end
|
||||
|
||||
subgraph Middleware
|
||||
AVMW["AvailabilityMiddleware\n(extended)"]
|
||||
end
|
||||
|
||||
subgraph External
|
||||
DP["IDataProtectionProvider\n(ASP.NET Core)"]
|
||||
end
|
||||
|
||||
MC -->|delegates| IMAS
|
||||
MAS -.->|implements| IMAS
|
||||
MAS -->|uses| MASD
|
||||
MASD -->|contains| IMRR
|
||||
MASD -->|contains| IMAKP
|
||||
MRR -.->|implements| IMRR
|
||||
MAKP -.->|implements| IMAKP
|
||||
MRR -->|reads/writes| AVDBCTX
|
||||
AVDBCTX -->|owns| MR
|
||||
MAKP -->|wraps| DP
|
||||
AVMW -->|InvokeAsync param| IMAS
|
||||
|
||||
classDef interface fill:#fff,stroke:#63b3ed,stroke-width:2px,color:#2b6cb0
|
||||
classDef impl fill:#63b3ed,stroke:#2b6cb0,color:#fff
|
||||
classDef entity fill:#FFC107,stroke:#F57F17,color:#000
|
||||
classDef infra fill:#9ae6b4,stroke:#2f855a,color:#000
|
||||
classDef middleware fill:#CE93D8,stroke:#6A1B9A,color:#000
|
||||
classDef external fill:#eee,stroke:#999,color:#333
|
||||
|
||||
class IMAS,IMRR,IMAKP interface
|
||||
class MAS,MRR,MAKP,MASD impl
|
||||
class MR,AVDBCTX entity
|
||||
class MC infra
|
||||
class AVMW middleware
|
||||
class DP external
|
||||
```
|
||||
|
||||
Text alternative: `MasterController` delegates to `IMasterAvailabilityService`. `MasterAvailabilityService` uses `MasterAvailabilityServiceDependencies` which holds `IMasterRegistrationRepository` and `IMasterApiKeyProtector`. Repository uses `AvailabilityDbContext`. `MasterApiKeyProtector` wraps `IDataProtectionProvider`. `AvailabilityMiddleware` receives `IMasterAvailabilityService` as third `InvokeAsync` parameter.
|
||||
|
||||
---
|
||||
|
||||
## Component Specifications
|
||||
|
||||
### 1. `MasterRegistration` (Entity)
|
||||
|
||||
**Namespace**: `SlpModularCms.Modules.Availability.Data.Entities`
|
||||
|
||||
| Property | Type | Notes |
|
||||
|----------|------|-------|
|
||||
| `Id` | `Guid` | PK; always `new Guid("00000000-0000-0000-0000-000000000001")` |
|
||||
| `MasterUrl` | `string` | Required; max 500 |
|
||||
| `ApiKey` | `string` | Required; max 2000 (encrypted via Data Protection) |
|
||||
| `RegisteredAt` | `DateTimeOffset` | Set once on creation |
|
||||
| `LastContactedAt` | `DateTimeOffset?` | Updated on every valid master call |
|
||||
|
||||
---
|
||||
|
||||
### 2. `AvailabilityDbContext`
|
||||
|
||||
**Namespace**: `SlpModularCms.Modules.Availability.Data`
|
||||
|
||||
```csharp
|
||||
public class AvailabilityDbContext : DbContext
|
||||
{
|
||||
public DbSet<MasterRegistration> MasterRegistrations => Set<MasterRegistration>();
|
||||
}
|
||||
```
|
||||
|
||||
| Aspect | Decision |
|
||||
|--------|----------|
|
||||
| Migration assembly | `SlpModularCms.Modules.Availability` |
|
||||
| Table | `AvailabilityMasterRegistrations` |
|
||||
| Applied at | `AvailabilityModule.UseModule` → `MigrateAsync()` |
|
||||
| Connection string | `ConnectionStrings:DefaultConnection` (same as other DbContexts) |
|
||||
|
||||
---
|
||||
|
||||
### 3. `IMasterRegistrationRepository` / `MasterRegistrationRepository`
|
||||
|
||||
**Namespace**: `SlpModularCms.Modules.Availability.Repositories`
|
||||
|
||||
```csharp
|
||||
public interface IMasterRegistrationRepository
|
||||
{
|
||||
Task<MasterRegistration?> GetAsync();
|
||||
Task UpsertAsync(MasterRegistration registration);
|
||||
Task SaveChangesAsync();
|
||||
}
|
||||
```
|
||||
|
||||
| Method | Notes |
|
||||
|--------|-------|
|
||||
| `GetAsync()` | Loads singleton by fixed Id; returns `null` if row does not exist |
|
||||
| `UpsertAsync(registration)` | `Add` if not tracked; `Update` if tracked or found by Id |
|
||||
| `SaveChangesAsync()` | Explicit save; service controls transaction boundary |
|
||||
|
||||
**Registration**: `services.AddScoped<IMasterRegistrationRepository, MasterRegistrationRepository>()`
|
||||
|
||||
---
|
||||
|
||||
### 4. `IMasterApiKeyProtector` / `MasterApiKeyProtector`
|
||||
|
||||
**Namespace**: `SlpModularCms.Modules.Availability.Services`
|
||||
|
||||
```csharp
|
||||
public interface IMasterApiKeyProtector
|
||||
{
|
||||
string Protect(string plainApiKey);
|
||||
string? Unprotect(string encryptedApiKey); // null on CryptographicException
|
||||
}
|
||||
```
|
||||
|
||||
**Registration**: `services.AddSingleton<IMasterApiKeyProtector, MasterApiKeyProtector>()`
|
||||
|
||||
---
|
||||
|
||||
### 5. `MasterAvailabilityServiceDependencies` (Record)
|
||||
|
||||
**Namespace**: `SlpModularCms.Modules.Availability.Services`
|
||||
|
||||
```csharp
|
||||
[ExcludeFromCodeCoverage]
|
||||
public record MasterAvailabilityServiceDependencies(
|
||||
IMasterRegistrationRepository Repository,
|
||||
IMasterApiKeyProtector KeyProtector,
|
||||
ILogger<MasterAvailabilityService> Logger
|
||||
);
|
||||
```
|
||||
|
||||
**Registration**: `services.AddScoped<MasterAvailabilityServiceDependencies>()`
|
||||
|
||||
---
|
||||
|
||||
### 6. `IMasterAvailabilityService` / `MasterAvailabilityService`
|
||||
|
||||
**Namespace**: `SlpModularCms.Modules.Availability.Services`
|
||||
|
||||
```csharp
|
||||
public interface IMasterAvailabilityService
|
||||
{
|
||||
Task RegisterAsync(string masterUrl, string apiKey);
|
||||
Task PushStatusAsync(string apiKey, bool isAvailable, string? disableMessage);
|
||||
Task<string?> GetRegisteredUrlAsync(string apiKey);
|
||||
MasterGateStatus GetMasterStatus();
|
||||
}
|
||||
```
|
||||
|
||||
**Static fields** (in implementation):
|
||||
```csharp
|
||||
private static volatile bool _masterIsAvailable = true;
|
||||
private static volatile string? _masterDisableMessage = null;
|
||||
```
|
||||
|
||||
**Key validation helper** (private, reused across all 3 write-path methods):
|
||||
```csharp
|
||||
private async Task<MasterRegistration?> ValidateApiKeyAsync(string apiKey)
|
||||
{
|
||||
var registration = await _deps.Repository.GetAsync();
|
||||
if (registration is null) return null;
|
||||
var stored = _deps.KeyProtector.Unprotect(registration.ApiKey);
|
||||
return stored == apiKey ? registration : null;
|
||||
}
|
||||
```
|
||||
|
||||
**Registration**: `services.AddScoped<IMasterAvailabilityService, MasterAvailabilityService>()`
|
||||
|
||||
---
|
||||
|
||||
### 7. `MasterGateStatus` (Record)
|
||||
|
||||
**Namespace**: `SlpModularCms.Modules.Availability.Services`
|
||||
|
||||
```csharp
|
||||
[ExcludeFromCodeCoverage]
|
||||
public record MasterGateStatus(bool IsAvailable, string? DisableMessage);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 8. `MasterController`
|
||||
|
||||
**Namespace**: `SlpModularCms.Modules.Availability.Controllers`
|
||||
|
||||
```csharp
|
||||
[ApiController]
|
||||
[Route("[controller]")] // → /api/v1/master via ApiPrefixConvention
|
||||
public class MasterController : ControllerBase
|
||||
{
|
||||
[HttpPost("register")] // POST /api/v1/master/register
|
||||
[HttpPost("status")] // POST /api/v1/master/status
|
||||
[HttpGet("registered-url")] // GET /api/v1/master/registered-url
|
||||
}
|
||||
```
|
||||
|
||||
**Constructor**: `MasterController(IMasterAvailabilityService svc)` — single dependency, no record wrapper needed.
|
||||
|
||||
**Auth**: No `[Authorize]` attribute — API key validated in `MasterAvailabilityService`.
|
||||
|
||||
**Response on 401**: `Unauthorized()` — no body to avoid leaking registration state.
|
||||
|
||||
---
|
||||
|
||||
### 9. `AvailabilityMiddleware` (Extended)
|
||||
|
||||
**Extended fields** (added to existing class):
|
||||
```csharp
|
||||
// Updated bypass prefix list
|
||||
private static readonly string[] _bypassPrefixes =
|
||||
[
|
||||
"/api/v1/Availability/status",
|
||||
"/api/v1/Auth/",
|
||||
"/api/v1/Setup/status",
|
||||
"/api/v1/master/" // NEW
|
||||
];
|
||||
```
|
||||
|
||||
**Updated `InvokeAsync` signature**:
|
||||
```csharp
|
||||
public async Task InvokeAsync(
|
||||
HttpContext context,
|
||||
IAvailabilityService localSvc,
|
||||
IMasterAvailabilityService masterSvc)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Dependency Registration Summary
|
||||
|
||||
All new registrations added to `AvailabilityModule.RegisterServices`:
|
||||
|
||||
```csharp
|
||||
// Data
|
||||
services.AddDbContext<AvailabilityDbContext>((sp, options) =>
|
||||
options.UseSqlServer(sp.GetRequiredService<IConfiguration>()
|
||||
.GetConnectionString("DefaultConnection")));
|
||||
|
||||
// Security
|
||||
services.AddDataProtection();
|
||||
services.AddSingleton<IMasterApiKeyProtector, MasterApiKeyProtector>();
|
||||
|
||||
// Repositories
|
||||
services.AddScoped<IMasterRegistrationRepository, MasterRegistrationRepository>();
|
||||
|
||||
// Services
|
||||
services.AddScoped<MasterAvailabilityServiceDependencies>();
|
||||
services.AddScoped<IMasterAvailabilityService, MasterAvailabilityService>();
|
||||
```
|
||||
|
||||
And in `AvailabilityModule.UseModule`:
|
||||
```csharp
|
||||
using var scope = app.ApplicationServices.CreateScope();
|
||||
await scope.ServiceProvider
|
||||
.GetRequiredService<AvailabilityDbContext>()
|
||||
.Database.MigrateAsync();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## New Files Summary
|
||||
|
||||
| File | Project | Type |
|
||||
|------|---------|------|
|
||||
| `Data/Entities/MasterRegistration.cs` | Availability | Entity |
|
||||
| `Data/AvailabilityDbContext.cs` | Availability | DbContext |
|
||||
| `Repositories/IMasterRegistrationRepository.cs` | Availability | Interface |
|
||||
| `Repositories/MasterRegistrationRepository.cs` | Availability | Implementation |
|
||||
| `Services/IMasterApiKeyProtector.cs` | Availability | Interface |
|
||||
| `Services/MasterApiKeyProtector.cs` | Availability | Implementation |
|
||||
| `Services/MasterGateStatus.cs` | Availability | Record |
|
||||
| `Services/MasterAvailabilityServiceDependencies.cs` | Availability | Record |
|
||||
| `Services/IMasterAvailabilityService.cs` | Availability | Interface |
|
||||
| `Services/MasterAvailabilityService.cs` | Availability | Implementation |
|
||||
| `Controllers/MasterController.cs` | Availability | Controller |
|
||||
| `Middleware/AvailabilityMiddleware.cs` | Availability | Modified (extended) |
|
||||
| `AvailabilityModule.cs` | Availability | Modified (registration + migration) |
|
||||
| `Data/Migrations/*` | Availability | EF Core auto-generated |
|
||||
+232
@@ -0,0 +1,232 @@
|
||||
# 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**:
|
||||
```csharp
|
||||
public interface IMasterApiKeyProtector
|
||||
{
|
||||
string Protect(string plainApiKey);
|
||||
string? Unprotect(string encryptedApiKey); // null on CryptographicException
|
||||
}
|
||||
```
|
||||
|
||||
**Implementation**:
|
||||
```csharp
|
||||
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`):
|
||||
```csharp
|
||||
services.AddDataProtection();
|
||||
services.AddSingleton<IMasterApiKeyProtector, MasterApiKeyProtector>();
|
||||
```
|
||||
|
||||
**Usage in tests**:
|
||||
```csharp
|
||||
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**:
|
||||
```csharp
|
||||
private static volatile bool _masterIsAvailable = true;
|
||||
private static volatile string? _masterDisableMessage = null;
|
||||
```
|
||||
|
||||
**Return type**:
|
||||
```csharp
|
||||
public record MasterGateStatus(bool IsAvailable, string? DisableMessage);
|
||||
```
|
||||
|
||||
**Interface method** (sync — no `Task`):
|
||||
```csharp
|
||||
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**:
|
||||
```csharp
|
||||
public MasterGateStatus GetMasterStatus()
|
||||
=> new(_masterIsAvailable, _masterDisableMessage);
|
||||
```
|
||||
|
||||
**Write point** (only in `PushStatusAsync`):
|
||||
```csharp
|
||||
_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**:
|
||||
```csharp
|
||||
[ExcludeFromCodeCoverage]
|
||||
public record MasterAvailabilityServiceDependencies(
|
||||
IMasterRegistrationRepository Repository,
|
||||
IMasterApiKeyProtector KeyProtector,
|
||||
ILogger<MasterAvailabilityService> Logger
|
||||
);
|
||||
```
|
||||
|
||||
**Registration**:
|
||||
```csharp
|
||||
services.AddScoped<MasterAvailabilityServiceDependencies>();
|
||||
services.AddScoped<IMasterAvailabilityService, MasterAvailabilityService>();
|
||||
```
|
||||
|
||||
**`MasterAvailabilityService` constructor**:
|
||||
```csharp
|
||||
public MasterAvailabilityService(MasterAvailabilityServiceDependencies deps)
|
||||
{
|
||||
_deps = deps;
|
||||
}
|
||||
```
|
||||
|
||||
**Test construction** (no DI container):
|
||||
```csharp
|
||||
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**:
|
||||
```csharp
|
||||
public async Task InvokeAsync(
|
||||
HttpContext context,
|
||||
IAvailabilityService localSvc,
|
||||
IMasterAvailabilityService masterSvc)
|
||||
```
|
||||
|
||||
**Evaluation order**:
|
||||
|
||||
```mermaid
|
||||
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):
|
||||
```csharp
|
||||
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`):
|
||||
```csharp
|
||||
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`):
|
||||
```csharp
|
||||
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**:
|
||||
```csharp
|
||||
_logger.LogWarning(
|
||||
"Master API key mismatch on {Endpoint} — returning 401",
|
||||
"POST /api/v1/master/status");
|
||||
```
|
||||
Reference in New Issue
Block a user