docs(code-reviews): re-review batch 2 at 39d737e — ConfigurationDatabase, DataConnectionLayer, DeploymentManager, ExternalSystemGateway, HealthMonitoring
17 new findings: ConfigurationDatabase-012..014, DataConnectionLayer-014..017, DeploymentManager-015..017, ExternalSystemGateway-015..017, HealthMonitoring-013..016.
This commit is contained in:
@@ -5,10 +5,10 @@
|
||||
| Module | `src/ScadaLink.ConfigurationDatabase` |
|
||||
| Design doc | `docs/requirements/Component-ConfigurationDatabase.md` |
|
||||
| Status | Reviewed |
|
||||
| Last reviewed | 2026-05-16 |
|
||||
| Last reviewed | 2026-05-17 |
|
||||
| Reviewer | claude-agent |
|
||||
| Commit reviewed | `9c60592` |
|
||||
| Open findings | 0 |
|
||||
| Commit reviewed | `39d737e` |
|
||||
| Open findings | 3 |
|
||||
|
||||
## Summary
|
||||
|
||||
@@ -37,6 +37,28 @@ repositories (`TemplateEngineRepository`, `DeploymentManagerRepository`,
|
||||
`ExternalSystemRepository`, `InboundApiRepository`, `NotificationRepository`,
|
||||
`SiteRepository`, `InstanceLocator`) have little or no direct coverage.
|
||||
|
||||
#### Re-review 2026-05-17 (commit `39d737e`)
|
||||
|
||||
Re-reviewed the module at commit `39d737e`. All eleven findings from the initial
|
||||
review (`9c60592`) remain `Resolved` — the secret-column encryption
|
||||
(`EncryptedStringConverter` + `ApplySecretColumnEncryption`), the fail-fast no-arg
|
||||
DI overload, the `AsSplitQuery` conversions, the audit cycle-safe serializer, and the
|
||||
added repository test coverage all verified present and consistent with their
|
||||
resolutions. Three new findings were recorded. The most material is that the
|
||||
encryption work done for CD-004 left one bearer credential out of scope:
|
||||
`ApiKey.KeyValue` — the inbound-API authentication secret — is still persisted in
|
||||
plaintext (`ConfigurationDatabase-012`); it cannot use the same non-deterministic
|
||||
Data Protection converter because authentication looks the key up *by value*, so it
|
||||
needs a hash-based scheme instead. The second is a resilience gap in the encryption
|
||||
plumbing itself: `ApplySecretColumnEncryption` silently substitutes a throwaway
|
||||
`EphemeralDataProtectionProvider` whenever no provider is supplied, so any context
|
||||
constructed via the single-argument constructor on a write path would encrypt
|
||||
secrets with a key discarded at process exit, yielding permanently undecryptable
|
||||
ciphertext with no error (`ConfigurationDatabase-013`). The third is a minor
|
||||
inconsistency — a redundant cast on one of the three `HasConversion` calls
|
||||
(`ConfigurationDatabase-014`). The module is otherwise healthy and the prior fixes
|
||||
hold up well.
|
||||
|
||||
## Checklist coverage
|
||||
|
||||
| # | Category | Examined | Notes |
|
||||
@@ -44,11 +66,11 @@ repositories (`TemplateEngineRepository`, `DeploymentManagerRepository`,
|
||||
| 1 | Correctness & logic bugs | ✓ | `GetTemplateWithChildrenAsync` discards loaded children (CD-001); `GetApprovedKeysForMethodAsync` CSV parsing is brittle (CD-008). |
|
||||
| 2 | Akka.NET conventions | ✓ | No actors in this module; data-access layer only. No issues found. |
|
||||
| 3 | Concurrency & thread safety | ✓ | DbContext correctly scoped; optimistic concurrency on `DeploymentRecord` correct. Repositories hold no shared mutable state. No issues found. |
|
||||
| 4 | Error handling & resilience | ✓ | `WaitForDatabaseReadyAsync` is sound. No-arg DI overload fails late and silently (CD-003); audit JSON serialization failure handling (CD-007). |
|
||||
| 5 | Security | ✓ | Hardcoded `sa` credential literal (CD-002); SMTP/DB-connection/auth secrets stored unencrypted (CD-004). |
|
||||
| 6 | Performance & resource management | ✓ | `GetAllTemplatesAsync` / `GetTemplateTreeAsync` eager-load multiple collections without `AsSplitQuery` (CD-009). No N+1 in audited paths. |
|
||||
| 7 | Design-document adherence | ✓ | Audit `Id` type mismatch vs design doc (CD-005); seed data uses `HasData` consistent with design. |
|
||||
| 8 | Code organization & conventions | ✓ | Mostly clean. `Grpc*` address columns unbounded (CD-006); inconsistent null-guard on injected context (CD-011). |
|
||||
| 4 | Error handling & resilience | ✓ | `WaitForDatabaseReadyAsync` is sound. No-arg DI overload fails late and silently (CD-003, resolved); audit JSON serialization failure handling (CD-007, resolved). Re-review: ephemeral Data Protection fallback can silently produce undecryptable ciphertext (CD-013). |
|
||||
| 5 | Security | ✓ | Hardcoded `sa` credential literal (CD-002, resolved); SMTP/DB-connection/auth secrets unencrypted (CD-004, resolved). Re-review: `ApiKey.KeyValue` bearer credential still stored in plaintext (CD-012). |
|
||||
| 6 | Performance & resource management | ✓ | `GetAllTemplatesAsync` / `GetTemplateTreeAsync` eager-load multiple collections without `AsSplitQuery` (CD-009, resolved). No N+1 in audited paths. Re-review: no new issues. |
|
||||
| 7 | Design-document adherence | ✓ | Audit `Id` type mismatch vs design doc (CD-005, resolved); seed data uses `HasData` consistent with design. Re-review: no new issues. |
|
||||
| 8 | Code organization & conventions | ✓ | Mostly clean. `Grpc*` address columns unbounded (CD-006, resolved); inconsistent null-guard on injected context (CD-011, resolved). Re-review: redundant/inconsistent cast on one `HasConversion` call (CD-014). |
|
||||
| 9 | Testing coverage | ✓ | Several repositories and `InstanceLocator` lack direct tests (CD-010). |
|
||||
| 10 | Documentation & comments | ✓ | `DeploymentManagerRepository` "WP-24 stub" XML comment is stale; noted in module context but not raised as a standalone finding. No issues found beyond items above. |
|
||||
|
||||
@@ -570,3 +592,128 @@ every data-access type behaves uniformly and a hand-constructed instance fails w
|
||||
informative exception at construction rather than a later `NullReferenceException`.
|
||||
Regression: `Constructor_NullContext_Throws` tests were added for all four affected types
|
||||
(`InboundApiRepositoryTests.cs`, `RepositoryCoverageTests.cs`).
|
||||
|
||||
### ConfigurationDatabase-012 — Inbound-API `ApiKey.KeyValue` bearer credential stored in plaintext
|
||||
|
||||
| | |
|
||||
|--|--|
|
||||
| Severity | Medium |
|
||||
| Category | Security |
|
||||
| Status | Open |
|
||||
| Location | `src/ScadaLink.ConfigurationDatabase/Configurations/InboundApiConfiguration.cs:17-19` |
|
||||
|
||||
**Description**
|
||||
|
||||
`ApiKey.KeyValue` is the bearer credential presented in the `X-API-Key` header to
|
||||
authenticate Inbound API requests (HighLevelReqs §7.2–7.3). It is mapped as an
|
||||
ordinary `nvarchar(500)` column with a unique index and persisted verbatim. Anyone
|
||||
with read access to the configuration database — or to any `AuditLogEntry.AfterStateJson`
|
||||
into which an `ApiKey` entity is serialized — obtains live API credentials in cleartext.
|
||||
|
||||
`ConfigurationDatabase-004` introduced encryption-at-rest for the other secret-bearing
|
||||
columns (SMTP credentials, external-system auth config, database connection strings)
|
||||
but explicitly scoped `ApiKey.KeyValue` out. The omission is genuine: the
|
||||
`EncryptedStringConverter` built for CD-004 is backed by ASP.NET Data Protection, which
|
||||
is **non-deterministic** — the same plaintext encrypts to different ciphertext each
|
||||
time — so it cannot be applied here, because `GetApprovedKeysForMethodAsync` and the
|
||||
authentication path resolve a key by its value (`GetApiKeyByValueAsync` does
|
||||
`FirstOrDefaultAsync(k => k.KeyValue == keyValue)`). A non-deterministic converter would
|
||||
break that equality lookup. The result is that the one credential most exposed to
|
||||
external callers is the one credential left unprotected.
|
||||
|
||||
**Recommendation**
|
||||
|
||||
Store a salted cryptographic hash of the key value instead of the plaintext (or
|
||||
ciphertext): hash on create, and authenticate by hashing the presented key and
|
||||
comparing. This keeps the equality lookup working (the hash is deterministic) while
|
||||
ensuring the database never holds a usable credential. The plaintext key would then
|
||||
only ever be shown once, at creation time, to the Admin who created it. This requires
|
||||
a coordinated change with the Inbound API / Security components and the `ApiKey`
|
||||
entity in Commons; record the chosen scheme in
|
||||
`docs/requirements/Component-ConfigurationDatabase.md` and the Inbound API design doc.
|
||||
At minimum, ensure `ApiKey` entities are never passed to `IAuditService` without the
|
||||
key value redacted.
|
||||
|
||||
**Resolution**
|
||||
|
||||
_Unresolved._
|
||||
|
||||
### ConfigurationDatabase-013 — Secret-column encryption silently falls back to an ephemeral (throwaway) key
|
||||
|
||||
| | |
|
||||
|--|--|
|
||||
| Severity | Medium |
|
||||
| Category | Error handling & resilience |
|
||||
| Status | Open |
|
||||
| Location | `src/ScadaLink.ConfigurationDatabase/ScadaLinkDbContext.cs:107-124` |
|
||||
|
||||
**Description**
|
||||
|
||||
`ApplySecretColumnEncryption` resolves the Data Protection provider as
|
||||
`_dataProtectionProvider ?? new EphemeralDataProtectionProvider()`. The `??` fallback
|
||||
is reached whenever the context is constructed via the single-argument
|
||||
`ScadaLinkDbContext(DbContextOptions)` constructor — i.e. whenever no provider was
|
||||
injected. An `EphemeralDataProtectionProvider` generates a key ring that lives only in
|
||||
process memory and is discarded at process exit.
|
||||
|
||||
For design-time `dotnet ef` tooling this is harmless (the XML remark correctly notes
|
||||
it only emits schema). The risk is on a *runtime write path*. The runtime currently
|
||||
gets the provider-bearing context only because `AddConfigurationDatabase` adds an
|
||||
`AddScoped` factory registration that overrides EF's activator-based registration.
|
||||
That override is the single thing standing between correct behaviour and silent data
|
||||
corruption: any future change that resolves a `ScadaLinkDbContext` through a path the
|
||||
override does not cover — an `AddPooledDbContextFactory`/`IDbContextFactory<ScadaLinkDbContext>`
|
||||
registration, a second `AddDbContext` call, a hand-constructed context in server code —
|
||||
would construct the context with the single-arg constructor, encrypt secret columns
|
||||
with a throwaway key, and persist ciphertext that becomes **permanently undecryptable
|
||||
the moment the process restarts**. There is no exception, no warning; the failure only
|
||||
surfaces later as `CryptographicException` on read (mis-attributed by
|
||||
`EncryptedStringConverter` to "the stored value was not written by this system").
|
||||
|
||||
**Recommendation**
|
||||
|
||||
Do not silently substitute an ephemeral provider for write-capable contexts. Either:
|
||||
(a) require the provider unconditionally and have design-time tooling pass an explicit
|
||||
ephemeral provider so the intent is visible at the call site; or (b) keep the
|
||||
single-arg constructor but mark contexts built without a real provider as
|
||||
schema-only — e.g. record a flag and have the encrypting converter throw a clear
|
||||
`InvalidOperationException` ("secret columns cannot be written without a configured
|
||||
Data Protection key ring") on the first `Protect`, instead of producing throwaway
|
||||
ciphertext. Also harden the DI wiring so a `ScadaLinkDbContext` cannot be resolved
|
||||
through the EF-activator registration at all (e.g. register only the factory, or use
|
||||
`AddDbContextFactory` with the explicit constructor).
|
||||
|
||||
**Resolution**
|
||||
|
||||
_Unresolved._
|
||||
|
||||
### ConfigurationDatabase-014 — Redundant, inconsistent cast on one `HasConversion` call
|
||||
|
||||
| | |
|
||||
|--|--|
|
||||
| Severity | Low |
|
||||
| Category | Code organization & conventions |
|
||||
| Status | Open |
|
||||
| Location | `src/ScadaLink.ConfigurationDatabase/ScadaLinkDbContext.cs:121-123` |
|
||||
|
||||
**Description**
|
||||
|
||||
`ApplySecretColumnEncryption` calls `HasConversion(converter)` three times. The first
|
||||
two (`SmtpConfiguration.Credentials`, `ExternalSystemDefinition.AuthConfiguration`)
|
||||
pass `converter` directly; the third (`DatabaseConnectionDefinition.ConnectionString`)
|
||||
casts it to `(Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter)`.
|
||||
`EncryptedStringConverter` already derives from `ValueConverter<string?, string?>`
|
||||
(itself a `ValueConverter`), and the first two call sites compile fine without the
|
||||
cast, so the cast is redundant. The inconsistency makes the code look as though the
|
||||
third call needs special handling when it does not, and the fully-qualified type name
|
||||
inline adds noise.
|
||||
|
||||
**Recommendation**
|
||||
|
||||
Remove the cast so all three calls read identically as `HasConversion(converter)`.
|
||||
If a `ValueConverter`-typed reference is genuinely wanted, give it a local variable of
|
||||
that type once and use it for all three.
|
||||
|
||||
**Resolution**
|
||||
|
||||
_Unresolved._
|
||||
|
||||
Reference in New Issue
Block a user