Adds 2 units and docs for unit 3. nfr-requirements plan

This commit is contained in:
2026-06-29 22:18:37 +02:00
parent 0e01ca1e1c
commit c156107cb1
126 changed files with 15204 additions and 80199 deletions
@@ -0,0 +1,99 @@
# NFR Requirements — Unit 2: slave-availability-extension
## Security
### SEC-01: ApiKey encrypted at rest (Q1=C)
The `MasterRegistration.ApiKey` field is **not** stored in plain text. The slave encrypts the key using ASP.NET Core Data Protection before writing to the DB, and decrypts it before comparison on each incoming request.
| Aspect | Decision |
|--------|----------|
| Mechanism | ASP.NET Core Data Protection (`IDataProtectionProvider`) |
| Wrapper type | `IMasterApiKeyProtector` / `MasterApiKeyProtector` |
| Purpose string | `"SlpModularCms.Availability.MasterApiKey"` |
| Comparison | Decrypt stored key → compare with incoming header value (plain-text equality) |
| On decryption failure | Return `null` (key is unreadable) → treat as mismatch → 401 |
**Rationale**: Consistent with Unit 1's `IApiKeyProtector` pattern. Protects the key if the database is accessed directly (e.g., backup file, DB dump). The purpose string isolates it from Unit 1's protector scope.
### SEC-02: Missing or empty header → 401
An absent or empty `X-Master-Api-Key` header on any master endpoint (register, status, registered-url) immediately returns 401 without touching the database. No information leaked about whether a registration exists.
### SEC-03: Unauthenticated controller, API key validated in service
The three master endpoints carry no `[Authorize]` attribute — authentication is done via the custom header in `MasterAvailabilityService`. The endpoints are in the `/api/v1/master/` bypass prefix so the availability gate cannot block them.
---
## Performance
### PERF-01: Master gate check is synchronous
The `AvailabilityMiddleware` reads `_masterIsAvailable` from a static volatile field — zero async overhead, zero DB call per request. Only status pushes touch the DB.
### PERF-02: No DB call on gate evaluation
The master gate evaluates solely from `volatile` static fields. The local gate still calls `IAvailabilityService.IsAvailableAsync()` (one DB read with 1s cache per `AvailabilityOptions.StatusCacheSeconds` — unchanged from existing behaviour).
---
## Reliability
### REL-01: Fail-open on process startup (Q2=A, Q4=A from FD)
`_masterIsAvailable` defaults to `true` at process startup. A slave that restarts before the master pushes status again is immediately accessible. The master's `IntegrityCheckBackgroundService` will push status again within `IntegrityCheckIntervalMinutes`.
### REL-02: No expiry on cached status (Q4=A from FD)
The static cache has no TTL. If the master goes offline permanently, the last pushed status is used indefinitely. For a slave last told "unavailable", it remains unavailable until either:
- The master recovers and pushes "available" again, or
- An operator manually calls `POST /api/v1/Availability/status` locally (existing `AvailabilityController` endpoint, Owner-only).
### REL-03: Static field thread safety via `volatile` (Q2=A)
`private static volatile bool _masterIsAvailable` and `private static volatile string? _masterDisableMessage`. Writes to reference types and bools are atomic in .NET; `volatile` ensures cross-thread visibility without a lock on every request. Consistent with `PersistentAvailabilityService`'s `_lastErrorTime` pattern.
---
## Maintainability
### MAINT-01: Test coverage ≥ 80% (Q3=B)
**Excluded** from coverage:
- `AvailabilityModule` (service registration changes + `Database.MigrateAsync()` call)
- EF Core migration files (auto-generated)
- DTO / request / response record classes (`[ExcludeFromCodeCoverage]`)
**Included** (must reach ≥ 80%):
- `MasterController`
- `MasterAvailabilityService` (all 3 public methods)
- `MasterApiKeyProtector`
- `AvailabilityMiddleware` (extended paths — master gate logic)
- `IMasterRegistrationRepository` / `MasterRegistrationRepository`
### MAINT-02: Structured logging (Q4=B)
| Scenario | Level | Structured Fields |
|----------|-------|------------------|
| First master registration | `Information` | `masterUrl` |
| Re-registration (existing key match) | `Information` | `masterUrl` |
| API key mismatch on any endpoint | `Warning` | `endpoint` (do NOT log the key itself) |
| Missing X-Master-Api-Key header | `Warning` | `endpoint` |
| Status update received | `Information` | `isAvailable`, `disableMessage` |
| Master gate blocked a request | `Warning` | `path`, `disableMessage` |
| Get-registered-url called | `Debug` | — |
| Decryption failure on stored key | `Error` | (no key value) |
---
## Test Framework (unchanged from Unit 1)
| Aspect | Decision |
|--------|----------|
| Framework | xUnit |
| Mocking | NSubstitute 5.x |
| Assertions | FluentAssertions 8.x |
| EF Core testing | EF Core InMemory provider |
| Data Protection testing | `EphemeralDataProtectionProvider` |
| Project name | `SlpModularCms.Modules.Availability.Master.Tests` |
@@ -0,0 +1,88 @@
# Tech Stack Decisions — Unit 2: slave-availability-extension
## Data Protection
### Decision: `IMasterApiKeyProtector` wrapping ASP.NET Core Data Protection
| Aspect | Decision | Rationale |
|--------|----------|-----------|
| Interface | `IMasterApiKeyProtector` with `Protect(string)` / `Unprotect(string)` | Testable; mirrors Unit 1's `IApiKeyProtector` pattern |
| Implementation | `MasterApiKeyProtector : IMasterApiKeyProtector` | Wraps `IDataProtectionProvider`; purpose-scoped |
| Purpose string | `"SlpModularCms.Availability.MasterApiKey"` | Prevents cross-module decryption with Unit 1's scope |
| On `Unprotect` failure | Catch `CryptographicException`, return `null` | Service treats null as key mismatch → 401; logs Error |
| Key ring | Default file system (inherits app-level `AddDataProtection()` setup) | No extra configuration needed; same caveat as Unit 1 regarding containerized deployments |
**Production note**: Same as Unit 1 — for multi-instance or containerized deployments, configure a shared key ring (`PersistKeysToDbContext`, `PersistKeysToAzureBlobStorage`, etc.). Without it, a restarted container cannot decrypt keys stored by the previous instance.
---
## Static Cache
### Decision: `volatile` static fields in `MasterAvailabilityService`
| Aspect | Decision | Rationale |
|--------|----------|-----------|
| `_masterIsAvailable` | `private static volatile bool` | Atomic read/write for bool; `volatile` ensures CPU cache flush visibility |
| `_masterDisableMessage` | `private static volatile string?` | Reference assignment is atomic in .NET; `volatile` ensures visibility |
| Default | `_masterIsAvailable = true`, `_masterDisableMessage = null` | Fail-open: process startup = Available |
| Write location | `MasterAvailabilityService.PushStatusAsync` only | Single write point; no other code modifies cache |
| Read location | `AvailabilityMiddleware.InvokeAsync` only | Single read point; no async overhead |
**Why not `lock`**: No multi-field invariant to protect (fields are read/written independently). `volatile` matches the existing pattern in `PersistentAvailabilityService` (`_lastErrorTime`).
---
## EF Core / Database
### Decision: New `AvailabilityDbContext` with own migrations
| Aspect | Decision |
|--------|----------|
| DbContext class | `AvailabilityDbContext : DbContext` in `SlpModularCms.Modules.Availability` |
| Migration assembly | `SlpModularCms.Modules.Availability` (same project) |
| Migration application | `app.ApplicationServices.CreateScope()``AvailabilityDbContext.Database.MigrateAsync()` in `AvailabilityModule.UseModule(IApplicationBuilder)` |
| DbSet | `DbSet<MasterRegistration> MasterRegistrations` |
| Table name | `AvailabilityMasterRegistrations` |
| Connection string | Reuses `ConnectionStrings:DefaultConnection` (same as `ApplicationDbContext` and `MasterDbContext`) |
| Registration | `services.AddDbContext<AvailabilityDbContext>((sp, options) => ...)` using `IConfiguration` from service provider |
**Singleton enforcement**: `MasterRegistration.Id` is always `new Guid("00000000-0000-0000-0000-000000000001")`. EF `AddOrUpdate` via `ExecuteUpdateAsync` / find-by-id pattern.
---
## Repository
### Decision: `IMasterRegistrationRepository` / `MasterRegistrationRepository`
| Method | Signature | Notes |
|--------|-----------|-------|
| `GetAsync` | `Task<MasterRegistration?>` | Loads singleton by fixed Id; returns null if not exists |
| `UpsertAsync` | `Task UpsertAsync(MasterRegistration registration)` | Add or Update based on whether row exists |
| `SaveChangesAsync` | `Task SaveChangesAsync()` | Explicit save; keeps service in control of transaction boundary |
---
## Controller
### Decision: New `MasterController` in Availability module
| Aspect | Decision |
|--------|----------|
| Class | `MasterController : ControllerBase` in `SlpModularCms.Modules.Availability.Controllers` |
| Route | `[Route("[controller]")]``/api/v1/master` via `ApiPrefixConvention("api/v1")` |
| Auth | No `[Authorize]` — API key validated in service layer |
| Response on 401 | `Unauthorized()` (HTTP 401) — no `ProblemDetails` body to avoid leaking info |
| Response on success | `Ok()` for register/status; `Ok(new { MasterUrl })` for registered-url |
---
## New Dependencies
| Package | Already present? | Notes |
|---------|-----------------|-------|
| `Microsoft.AspNetCore.DataProtection` | Yes (shared framework) | No NuGet addition needed |
| EF Core SqlServer | Yes (via Core project) | No addition needed |
| xUnit / NSubstitute / FluentAssertions | Yes (existing test projects) | Reference same versions as `Availability.Tests` |
| EF Core InMemory | Likely yes | Confirm in `Availability.Tests.csproj` |
**Net new NuGet packages required**: None.