Inbound-API bearer credentials are no longer persisted in plaintext. ApiKey now holds a KeyHash (peppered HMAC-SHA256); the key is shown once at creation and only its hash is stored. Lookup and validation hash the presented candidate. Cross-module: Commons (ApiKey, ApiKeyHasher), ConfigurationDatabase (mapping + HashApiKeyValue migration), InboundAPI (ApiKeyValidator), ManagementService (key creation), CentralUI (ApiKeys.razor). Existing keys must be re-issued.
819 lines
47 KiB
Markdown
819 lines
47 KiB
Markdown
# Code Review — ConfigurationDatabase
|
||
|
||
| Field | Value |
|
||
|-------|-------|
|
||
| Module | `src/ScadaLink.ConfigurationDatabase` |
|
||
| Design doc | `docs/requirements/Component-ConfigurationDatabase.md` |
|
||
| Status | Reviewed |
|
||
| Last reviewed | 2026-05-17 |
|
||
| Reviewer | claude-agent |
|
||
| Commit reviewed | `39d737e` |
|
||
| Open findings | 0 |
|
||
|
||
## Summary
|
||
|
||
The ConfigurationDatabase module is a focused, conventional EF Core data-access layer:
|
||
a single `ScadaLinkDbContext`, Fluent API entity configurations, eight repository
|
||
implementations of Commons-defined interfaces, an `IAuditService` implementation, an
|
||
`IInstanceLocator`, environment-aware migration handling, and design-time tooling
|
||
support. Overall structure adheres well to the design doc and the CLAUDE.md "Code
|
||
Organization" decisions — POCO entities and interfaces live in Commons, EF mappings and
|
||
implementations live here, Fluent API only, and optimistic concurrency is correctly
|
||
applied to `DeploymentRecord` via `rowversion`. The module is generally healthy.
|
||
|
||
The main themes across findings are: (1) a genuine logic bug in
|
||
`GetTemplateWithChildrenAsync`, which loads child templates and then discards them, so
|
||
the method does not deliver what its name implies; (2) secret-bearing columns (SMTP
|
||
credentials, external-system auth config, database connection strings) persisted in
|
||
plaintext with no encryption-at-rest; (3) a hardcoded SQL `sa` connection string with a
|
||
password literal embedded in `DesignTimeDbContextFactory`; (4) the no-arg
|
||
`AddConfigurationDatabase()` overload, which silently registers nothing, making a
|
||
misconfigured central node fail late and opaquely; and (5) audit-trail robustness gaps —
|
||
`AuditService` can throw on serializing entities with navigation cycles, rolling back
|
||
the whole business operation, and the design doc's claim that audit `Id` is `Long/GUID`
|
||
disagrees with the `int` entity. Test coverage is good for the repositories that have
|
||
tests (Security, CentralUI, audit, concurrency, seed data, data protection) but several
|
||
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 |
|
||
|---|----------|----------|-------|
|
||
| 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, 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. |
|
||
|
||
## Findings
|
||
|
||
### ConfigurationDatabase-001 — `GetTemplateWithChildrenAsync` loads child templates then discards them
|
||
|
||
| | |
|
||
|--|--|
|
||
| Severity | High |
|
||
| Category | Correctness & logic bugs |
|
||
| Status | Resolved |
|
||
| Location | `src/ScadaLink.ConfigurationDatabase/Repositories/TemplateEngineRepository.cs:30-41` |
|
||
|
||
**Description**
|
||
|
||
`GetTemplateWithChildrenAsync` queries for all templates whose `ParentTemplateId`
|
||
equals the requested id, assigns the result to the local variable `children`, and
|
||
then returns `template` — the `children` list is never used, attached to the returned
|
||
object, or otherwise exposed. The method is therefore behaviourally identical to
|
||
`GetTemplateByIdAsync` but issues an extra database round-trip. Any caller relying on
|
||
the method name to obtain a template with its derived/child templates populated will
|
||
silently receive a template with no children, leading to incorrect template-resolution
|
||
or UI behaviour with no error.
|
||
|
||
**Recommendation**
|
||
|
||
Either populate the children onto the returned aggregate (e.g. project into a result
|
||
type that carries the children, or load them into a navigation collection that is
|
||
actually returned), or remove the dead query and the misleading method if children are
|
||
not in fact needed. If the navigation does not exist on the `Template` entity, add an
|
||
explicit result tuple/DTO so the loaded data reaches the caller.
|
||
|
||
**Resolution**
|
||
|
||
Resolved 2026-05-16 (commit `<pending>`). Root cause confirmed against source: the
|
||
method ran a `Where(t => t.ParentTemplateId == id)` query, assigned the result to a
|
||
local `children` variable, and never used it — a misleading no-op that also issued an
|
||
extra database round-trip per call.
|
||
|
||
Triage of the three callers (`FlatteningPipeline.BuildTemplateChainAsync`,
|
||
`ManagementActor.HandleGetTemplate`, `ManagementActor.HandleValidateTemplate`) showed
|
||
none consume derived/sub-templates; they all need the template's *member* collections
|
||
(Attributes/Alarms/Scripts/Compositions), which `GetTemplateByIdAsync` already
|
||
eager-loads. The `Template` entity has no child-templates navigation collection, and
|
||
adding one (plus changing the interface signature) would require editing
|
||
`ScadaLink.Commons`, which is outside this module's scope.
|
||
|
||
Fix applied the recommendation's secondary option: removed the dead query so the
|
||
method no longer misleads or wastes a round-trip, and added an XML doc comment
|
||
clarifying that "children" means the template's member collections. The method now
|
||
honestly delegates to `GetTemplateByIdAsync`. Regression tests added in
|
||
`TemplateEngineRepositoryTests.cs`:
|
||
`GetTemplateWithChildrenAsync_ReturnsTemplateWithAllMemberCollectionsPopulated`,
|
||
`GetTemplateWithChildrenAsync_PreservesParentTemplateId_ForInheritanceChainWalk`, and
|
||
`GetTemplateWithChildrenAsync_ReturnsNull_WhenTemplateDoesNotExist` — pinning the
|
||
template-aggregate contract the callers depend on.
|
||
|
||
### ConfigurationDatabase-002 — Hardcoded `sa` connection string with embedded password literal
|
||
|
||
| | |
|
||
|--|--|
|
||
| Severity | Medium |
|
||
| Category | Security |
|
||
| Status | Resolved |
|
||
| Location | `src/ScadaLink.ConfigurationDatabase/DesignTimeDbContextFactory.cs:21-22` |
|
||
|
||
**Description**
|
||
|
||
`DesignTimeDbContextFactory` falls back to a literal connection string
|
||
`"Server=localhost,1433;Database=ScadaLink_Config;User Id=sa;Password=YourPassword;TrustServerCertificate=True"`
|
||
when no configured connection string is found. Embedding a credential literal (even a
|
||
placeholder) in source code is a poor pattern: it is committed to version control,
|
||
encourages copy-paste of `sa`/`TrustServerCertificate=True` into real environments, and
|
||
the fallback can mask a genuine misconfiguration during `dotnet ef` operations by
|
||
silently pointing tooling at an unintended database.
|
||
|
||
**Recommendation**
|
||
|
||
Remove the hardcoded fallback. If no connection string is resolved from configuration
|
||
or environment, throw a clear `InvalidOperationException` instructing the developer to
|
||
set `ScadaLink:Database:ConfigurationDb` (or an environment variable). At minimum, read
|
||
the design-time connection string from an environment variable rather than a literal,
|
||
and never use `sa`.
|
||
|
||
**Resolution**
|
||
|
||
Resolved 2026-05-16 (commit pending). Root cause confirmed against source: the factory
|
||
fell back to a literal `User Id=sa;Password=YourPassword;...` connection string when no
|
||
configured value was found. Removed the hardcoded fallback entirely. The factory now
|
||
resolves the connection string from the Host's appsettings files or, when those are not
|
||
present, from the `SCADALINK_DESIGNTIME_CONNECTIONSTRING` environment variable, and
|
||
throws a clear `InvalidOperationException` (naming both the config key and the env var)
|
||
when neither yields a value. Also hardened `SetBasePath` to be applied only when the
|
||
`ScadaLink.Host` directory exists, so the factory degrades cleanly instead of throwing
|
||
`DirectoryNotFoundException` when run from a context without a sibling Host folder.
|
||
Regression tests added in `DesignTimeDbContextFactoryTests.cs`:
|
||
`CreateDbContext_NoConnectionStringConfigured_ThrowsClearException`,
|
||
`CreateDbContext_ConnectionStringFromEnvironmentVariable_IsUsed`, and
|
||
`DesignTimeDbContextFactory_SourceContainsNoHardcodedSaCredential`.
|
||
|
||
### ConfigurationDatabase-003 — No-arg `AddConfigurationDatabase()` silently registers nothing
|
||
|
||
| | |
|
||
|--|--|
|
||
| Severity | Medium |
|
||
| Category | Error handling & resilience |
|
||
| Status | Resolved |
|
||
| Location | `src/ScadaLink.ConfigurationDatabase/ServiceCollectionExtensions.cs:44-49` |
|
||
|
||
**Description**
|
||
|
||
The parameterless `AddConfigurationDatabase()` overload is a deliberate no-op "retained
|
||
for backward compatibility during migration." If a central node is wired up with this
|
||
overload by mistake, no `ScadaLinkDbContext`, repositories, `IAuditService`, or
|
||
`IInstanceLocator` are registered. The failure does not surface at startup; it surfaces
|
||
much later as opaque DI resolution exceptions the first time any consumer requests a
|
||
repository — far from the actual misconfiguration. The XML comment also refers to
|
||
"Phase 0 stubs," which is stale relative to the current state of the module.
|
||
|
||
**Recommendation**
|
||
|
||
Either delete the no-op overload now that the connection-string overload exists, or
|
||
mark it `[Obsolete]` with an error-level message so misuse is a compile-time failure.
|
||
If a true "site node" no-op is genuinely required, give it an explicit, self-documenting
|
||
name (e.g. `AddConfigurationDatabaseNoOp()`), and remove the stale "Phase 0" wording.
|
||
|
||
**Resolution**
|
||
|
||
Resolved 2026-05-16 (commit pending). Root cause confirmed against source: the
|
||
parameterless `AddConfigurationDatabase()` overload returned `services` unchanged,
|
||
registering no `DbContext`, repositories, `IAuditService`, or `IInstanceLocator`.
|
||
Applied the recommendation's first option: the overload is now marked
|
||
`[Obsolete(..., error: true)]` so any source reference is a compile-time failure, and
|
||
its body throws `InvalidOperationException` with an actionable message as
|
||
defence-in-depth (covering reflection-based invocation or suppressed warnings). The
|
||
stale "Phase 0 stubs / backward compatibility" XML comment was replaced with one
|
||
explaining the obsoletion. The pre-existing
|
||
`ServiceRegistrationTests.AddConfigurationDatabase_NoArgs_DoesNotThrow` test in
|
||
`UnitTest1.cs`, which encoded the old buggy no-op contract, was updated to
|
||
`AddConfigurationDatabase_NoArgs_FailsFast` to assert the corrected behaviour.
|
||
New regression tests added in `ServiceCollectionExtensionsTests.cs`:
|
||
`AddConfigurationDatabase_NoArgOverload_FailsFastWithClearMessage` and
|
||
`AddConfigurationDatabase_NoArgOverload_IsMarkedObsoleteAsError`.
|
||
|
||
### ConfigurationDatabase-004 — Secret-bearing columns stored in plaintext with no protection
|
||
|
||
| | |
|
||
|--|--|
|
||
| Severity | Medium |
|
||
| Category | Security |
|
||
| Status | Resolved |
|
||
| Location | `src/ScadaLink.ConfigurationDatabase/Configurations/NotificationConfiguration.cs:56-57`, `src/ScadaLink.ConfigurationDatabase/Configurations/ExternalSystemConfiguration.cs:25-26,75-77` |
|
||
|
||
**Description**
|
||
|
||
`SmtpConfiguration.Credentials`, `ExternalSystemDefinition.AuthConfiguration`, and
|
||
`DatabaseConnectionDefinition.ConnectionString` all hold authentication secrets (SMTP
|
||
OAuth2 client secrets / passwords, external-system API keys or Basic Auth credentials,
|
||
and database passwords respectively). They are mapped as ordinary string columns and
|
||
persisted verbatim. Anyone with read access to the configuration database — including
|
||
audit-log JSON if these entities are serialized into `AfterStateJson` — obtains the
|
||
plaintext secrets. The design doc does not call out encryption-at-rest for these
|
||
fields, so the design is also silent on a real risk.
|
||
|
||
**Recommendation**
|
||
|
||
Apply encryption to these fields, e.g. an EF Core value converter backed by ASP.NET
|
||
Data Protection (the module already configures `IDataProtectionKeyContext`), or rely on
|
||
SQL Server Always Encrypted / column encryption. Separately, ensure `IAuditService`
|
||
callers never pass these secret-bearing entities (or that the serializer redacts the
|
||
fields) so secrets do not leak into `AuditLogEntry.AfterStateJson`. Update the design
|
||
doc to state the chosen at-rest protection.
|
||
|
||
**Resolution**
|
||
|
||
Resolved 2026-05-16 (commit pending). Root cause confirmed against source:
|
||
`SmtpConfiguration.Credentials`, `ExternalSystemDefinition.AuthConfiguration`, and
|
||
`DatabaseConnectionDefinition.ConnectionString` were mapped as ordinary `nvarchar(4000)`
|
||
columns and persisted verbatim.
|
||
|
||
Implemented the recommendation's first option — an in-module EF Core value converter
|
||
backed by ASP.NET Data Protection, which the module already uses
|
||
(`IDataProtectionKeyContext`, `AddDataProtection().PersistKeysToDbContext`). Added
|
||
`EncryptedStringConverter` (purpose-scoped `IDataProtector`; `Protect` on write,
|
||
`Unprotect` on read; null-safe; surfaces a clear message on a `CryptographicException`).
|
||
`ScadaLinkDbContext` gained an `(options, IDataProtectionProvider)` constructor and
|
||
applies the converter to the three secret columns in `OnModelCreating`; the DI
|
||
registration in `ServiceCollectionExtensions` now constructs the context with the
|
||
registered provider. The secret columns were widened to `HasMaxLength(8000)` (EF maps
|
||
this to `nvarchar(max)` on SQL Server) so ciphertext expansion cannot truncate the
|
||
value; migration `20260517010521_EncryptSecretColumns` carries the column-type change.
|
||
Regression tests added in `SecretEncryptionTests.cs` verify the raw column value is
|
||
never the plaintext secret and that EF transparently decrypts on read, for all three
|
||
columns plus a null round-trip.
|
||
|
||
The encryption scheme itself is fully in-module; the only remaining cross-cutting item
|
||
is a documentation gap — the design doc does not yet state encryption-at-rest for these
|
||
fields. That doc update is outside this module's editable scope (constraint: edit only
|
||
`src/ScadaLink.ConfigurationDatabase`, the tests, and this file) and is surfaced here
|
||
for a follow-up to `docs/requirements/Component-ConfigurationDatabase.md`. The audit
|
||
secret-leak concern is mitigated separately by CD-007's serializer hardening; whether
|
||
callers should additionally redact secret-bearing entities before passing them to
|
||
`IAuditService` is a caller-side concern in other modules and is also surfaced for
|
||
follow-up. The code fix in this module is complete.
|
||
|
||
### ConfigurationDatabase-005 — Audit `Id` type disagrees with the design doc
|
||
|
||
| | |
|
||
|--|--|
|
||
| Severity | Low |
|
||
| Category | Design-document adherence |
|
||
| Status | Resolved |
|
||
| Location | `src/ScadaLink.ConfigurationDatabase/Configurations/AuditConfiguration.cs:11` (entity `src/ScadaLink.Commons/Entities/Audit/AuditLogEntry.cs`) |
|
||
|
||
**Description**
|
||
|
||
The design doc's Audit Entry Schema table specifies `Id` as `Long / GUID`, and notes
|
||
the audit table is append-only and retained indefinitely. The actual `AuditLogEntry`
|
||
entity uses an `int` identity key. For a never-purged, append-only table that
|
||
accumulates one row per save operation across the system lifetime, a 32-bit identity
|
||
risks overflow over a long deployment horizon, and the code drifts from the documented
|
||
schema.
|
||
|
||
**Recommendation**
|
||
|
||
Change `AuditLogEntry.Id` to `long` (and the corresponding migration column to
|
||
`bigint`) to match the design doc and remove the overflow risk, or — if `int` is
|
||
intentional — update the design doc's schema table to say `int` and justify it.
|
||
Resolve the discrepancy in one direction.
|
||
|
||
**Resolution**
|
||
|
||
Resolved 2026-05-16 (commit pending). Root cause confirmed against source: the
|
||
`AuditLogEntry` entity declares `int Id`, while the design doc's Audit Entry Schema
|
||
table said `Long / GUID`. The entity lives in `ScadaLink.Commons`
|
||
(`src/ScadaLink.Commons/Entities/Audit/AuditLogEntry.cs`), which is outside this
|
||
module's editable scope, so the discrepancy was resolved by aligning the design doc to
|
||
the code — the recommendation's second option. The schema table now records `Id` as
|
||
`int (identity)` with an explicit justification: a 32-bit identity matches the key type
|
||
of every other entity in the schema (uniform repository/query code), and at a sustained
|
||
100 rows/second the `int` range is not exhausted for roughly 680 years, so the
|
||
indefinite-retention policy poses no realistic overflow risk; if a future deployment
|
||
ever approaches the limit the column can be widened to `bigint` via a migration without
|
||
a schema redesign. No regression test is meaningful for a documentation alignment; the
|
||
existing `AuditConfiguration` (`HasKey(a => a.Id)`) and the audit repository tests
|
||
already exercise the `int` key end to end.
|
||
|
||
### ConfigurationDatabase-006 — `Site.GrpcNodeAAddress` / `GrpcNodeBAddress` columns are unbounded
|
||
|
||
| | |
|
||
|--|--|
|
||
| Severity | Low |
|
||
| Category | Code organization & conventions |
|
||
| Status | Resolved |
|
||
| Location | `src/ScadaLink.ConfigurationDatabase/Configurations/SiteConfiguration.cs:24-25` |
|
||
|
||
**Description**
|
||
|
||
`SiteConfiguration` explicitly sets `HasMaxLength(500)` for `NodeAAddress` and
|
||
`NodeBAddress`, but the entity also has `GrpcNodeAAddress` and `GrpcNodeBAddress`
|
||
(added per the gRPC streaming design decision) which are not configured at all. With no
|
||
length set, EF Core maps them to `nvarchar(max)`. This is inconsistent with the sibling
|
||
address columns, wastes the opportunity to constrain input, and `nvarchar(max)` columns
|
||
cannot be indexed and have different storage/performance characteristics.
|
||
|
||
**Recommendation**
|
||
|
||
Add `builder.Property(s => s.GrpcNodeAAddress).HasMaxLength(500);` and the same for
|
||
`GrpcNodeBAddress`, matching the existing `NodeAAddress`/`NodeBAddress` mapping, and
|
||
generate a migration to alter the column types.
|
||
|
||
**Resolution**
|
||
|
||
Resolved 2026-05-16 (commit pending). Root cause confirmed against source:
|
||
`SiteConfiguration` configured `NodeAAddress`/`NodeBAddress` with `HasMaxLength(500)` but
|
||
left `GrpcNodeAAddress`/`GrpcNodeBAddress` unconfigured, so EF mapped them to
|
||
`nvarchar(max)` — inconsistent with the sibling columns and non-indexable. Applied the
|
||
recommendation: added `builder.Property(s => s.GrpcNodeAAddress).HasMaxLength(500)` and
|
||
the same for `GrpcNodeBAddress`. Generated migration
|
||
`20260517020720_BoundGrpcNodeAddressLength` altering both columns from `nvarchar(max)`
|
||
to `nvarchar(500)` (the model snapshot was updated to match). Regression tests added in
|
||
`SchemaConfigurationTests.cs`:
|
||
`GrpcNodeAddressColumns_AreLengthBoundedTo500` (theory over both columns, asserting the
|
||
EF model metadata reports `MaxLength == 500`) and
|
||
`GrpcNodeAddressColumns_MatchSiblingNodeAddressBounds` (asserting the gRPC columns share
|
||
the bound of the `NodeAAddress`/`NodeBAddress` siblings).
|
||
|
||
### ConfigurationDatabase-007 — `AuditService` does not handle JSON-serialization failure of arbitrary `afterState`
|
||
|
||
| | |
|
||
|--|--|
|
||
| Severity | Medium |
|
||
| Category | Error handling & resilience |
|
||
| Status | Resolved |
|
||
| Location | `src/ScadaLink.ConfigurationDatabase/Services/AuditService.cs:28-30` |
|
||
|
||
**Description**
|
||
|
||
`LogAsync` serializes the caller-supplied `afterState` object with
|
||
`JsonSerializer.Serialize(afterState)` using default options. EF entity POCOs commonly
|
||
have navigation properties; serializing an entity that has loaded navigations (e.g. a
|
||
`Template` with `Attributes`/`Scripts`, or any entity with a cycle) will throw
|
||
`JsonException` for a reference cycle or produce a very large payload. Because audit
|
||
writes are designed to commit in the same transaction as the change, a serialization
|
||
exception thrown here will roll back the *entire* business operation — a template
|
||
update fails because its audit entry could not be serialized. This couples audit
|
||
robustness to the shape of every entity passed in.
|
||
|
||
**Recommendation**
|
||
|
||
Configure `JsonSerializerOptions` with `ReferenceHandler.IgnoreCycles` (or
|
||
`Preserve`) and a sensible `MaxDepth`, and consider serializing a projected
|
||
DTO/snapshot rather than the live tracked entity. Decide explicitly whether an audit
|
||
serialization failure should fail the operation or be logged and degraded gracefully,
|
||
and document that decision against the design doc's transactional-guarantee section.
|
||
|
||
**Resolution**
|
||
|
||
Resolved 2026-05-16 (commit pending). Root cause confirmed against source: `LogAsync`
|
||
called `JsonSerializer.Serialize(afterState)` with default options, so any `afterState`
|
||
graph containing a reference cycle threw `JsonException` — and because the audit entry
|
||
commits in the same transaction as the change it records, that exception rolled back
|
||
the entire business operation.
|
||
|
||
Fix applied per the recommendation: `AuditService` now serializes via a static
|
||
`JsonSerializerOptions` configured with `ReferenceHandler.IgnoreCycles` and
|
||
`MaxDepth = 32`. The serialization is additionally wrapped in a `SerializeAfterState`
|
||
helper that catches a residual `JsonException`/`NotSupportedException` and substitutes a
|
||
small diagnostic placeholder JSON (`AuditSerializationError` + `StateType`) — an explicit
|
||
decision that an audit-serialization failure must **degrade gracefully** and never roll
|
||
back the audited operation. The audit entry is always recorded; the design doc's
|
||
transactional-guarantee section ("if the change succeeds, the audit entry is always
|
||
recorded") is thereby honoured even for pathological state objects. Regression test
|
||
added in `AuditServiceTests.cs`:
|
||
`LogAsync_AfterStateWithReferenceCycle_DoesNotThrow_AndDoesNotRollBackOperation`.
|
||
|
||
### ConfigurationDatabase-008 — `GetApprovedKeysForMethodAsync` CSV parsing silently drops malformed ids
|
||
|
||
| | |
|
||
|--|--|
|
||
| Severity | Low |
|
||
| Category | Correctness & logic bugs |
|
||
| Status | Resolved |
|
||
| Location | `src/ScadaLink.ConfigurationDatabase/Repositories/InboundApiRepository.cs:46-58` |
|
||
|
||
**Description**
|
||
|
||
`ApiMethod.ApprovedApiKeyIds` is stored as a comma-separated string of integer ids.
|
||
`GetApprovedKeysForMethodAsync` splits it, maps each token with
|
||
`int.TryParse(...) ? id : -1`, then filters with `id > 0`. Any token that fails to
|
||
parse, or a legitimately negative/zero id, is silently discarded. If `ApprovedApiKeyIds`
|
||
becomes corrupt (e.g. a stray name instead of an id), the method quietly returns fewer
|
||
approved keys than expected, which for an API-key authorization path means a method may
|
||
unexpectedly reject a key that should be approved. Storing a relational many-to-many as
|
||
a CSV string in a column is itself fragile (no FK integrity, no cascade on key delete).
|
||
|
||
**Recommendation**
|
||
|
||
Short term: log a warning when a token fails to parse instead of silently dropping it,
|
||
so corruption is observable. Longer term: replace the CSV column with a proper join
|
||
table (`ApiMethodApprovedKey`) with foreign keys to `ApiMethod` and `ApiKey`, which
|
||
gives referential integrity and correct cascade behaviour when an API key is deleted.
|
||
|
||
**Resolution**
|
||
|
||
Resolved 2026-05-16 (commit pending). Root cause confirmed against source:
|
||
`GetApprovedKeysForMethodAsync` mapped each CSV token with
|
||
`int.TryParse(...) ? id : -1` then filtered `id > 0`, so any unparseable (or
|
||
non-positive) token was discarded with no signal — a corrupt `ApprovedApiKeyIds` value
|
||
silently approves fewer keys than intended, an authorization-relevant outcome.
|
||
|
||
Applied the recommendation's short-term fix: the parse loop was rewritten to log a
|
||
warning for every token that fails to parse to a positive integer, naming the method id
|
||
and the offending token, so corruption is observable in logs. Valid ids still resolve
|
||
normally. `InboundApiRepository` gained an optional `ILogger<InboundApiRepository>`
|
||
constructor parameter (defaulting to `NullLogger`, matching the `MigrationHelper`
|
||
pattern) and the project now references `Microsoft.Extensions.Logging.Abstractions`. The
|
||
longer-term join-table redesign would change the `ApiMethod` entity / schema and the
|
||
`IInboundApiRepository` contract (Commons, out of this module's scope) and is left as a
|
||
future schema-design item. Regression tests added in `InboundApiRepositoryTests.cs`:
|
||
`GetApprovedKeysForMethod_WithMalformedCsvToken_LogsWarningAndDropsToken`,
|
||
`GetApprovedKeysForMethod_WithValidCsv_ReturnsAllKeys`, and
|
||
`GetApprovedKeysForMethod_WithNullOrEmptyCsv_ReturnsEmptyWithoutWarning` (using a
|
||
capturing `ILogger` to assert the warning is emitted only on malformed input).
|
||
|
||
### ConfigurationDatabase-009 — Multi-collection eager loads issue cartesian-product queries
|
||
|
||
| | |
|
||
|--|--|
|
||
| Severity | Low |
|
||
| Category | Performance & resource management |
|
||
| Status | Resolved |
|
||
| Location | `src/ScadaLink.ConfigurationDatabase/Repositories/TemplateEngineRepository.cs:43-51,53-61`, `src/ScadaLink.ConfigurationDatabase/Repositories/CentralUiRepository.cs:45-55` |
|
||
|
||
**Description**
|
||
|
||
`GetAllTemplatesAsync`, `GetTemplatesComposingAsync`, and `GetTemplateTreeAsync` each
|
||
`Include` three-to-four sibling collections (`Attributes`, `Alarms`, `Scripts`,
|
||
`Compositions`) in a single query. EF Core's default single-query strategy produces a
|
||
cartesian-product join across those collections, so a template with N attributes, M
|
||
alarms, and K scripts yields N×M×K rows that EF must then de-duplicate. For templates
|
||
with many members this materially inflates the result set and query time.
|
||
`GetInstanceByIdAsync`/`GetAllInstancesAsync` have the same shape with three
|
||
collections.
|
||
|
||
**Recommendation**
|
||
|
||
Add `.AsSplitQuery()` to these multi-collection-include queries (or set
|
||
`UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery)` globally in
|
||
`AddConfigurationDatabase`) so each collection is loaded with a separate query and the
|
||
cartesian explosion is avoided.
|
||
|
||
**Resolution**
|
||
|
||
Resolved 2026-05-16 (commit pending). Root cause confirmed against source:
|
||
`GetAllTemplatesAsync` and `GetTemplatesComposingAsync` (`TemplateEngineRepository`) and
|
||
`GetTemplateTreeAsync` (`CentralUiRepository`) each `Include` three-to-four sibling
|
||
collections in a single query, producing a cartesian-product join. The same shape was
|
||
also present in `GetTemplateByIdAsync`, `GetInstanceByIdAsync`, `GetAllInstancesAsync`,
|
||
`GetInstancesBySiteIdAsync`, and `GetInstanceByUniqueNameAsync`.
|
||
|
||
Applied the recommendation's per-query option: `.AsSplitQuery()` was added to every
|
||
multi-collection-include query in `TemplateEngineRepository` (eight call sites) and to
|
||
`GetTemplateTreeAsync` in `CentralUiRepository`, so each collection loads with its own
|
||
query and the cartesian explosion is avoided. Per-query `AsSplitQuery()` was preferred
|
||
over a global `UseQuerySplittingBehavior` so single-collection queries elsewhere keep
|
||
the cheaper single-query plan. Split queries change query *shape* only, not results;
|
||
regression tests added in `SchemaConfigurationTests.cs` pin that behaviour:
|
||
`GetAllTemplatesAsync_WithMultipleMembersPerCollection_LoadsAllWithoutDuplication`
|
||
(a template with 3 attributes, 2 alarms, 4 scripts must return exactly those counts —
|
||
not a 24-row cartesian product) and
|
||
`GetTemplateByIdAsync_WithMultipleMembers_LoadsAllCollections`.
|
||
|
||
### ConfigurationDatabase-010 — Several repositories and `InstanceLocator` lack direct test coverage
|
||
|
||
| | |
|
||
|--|--|
|
||
| Severity | Low |
|
||
| Category | Testing coverage |
|
||
| Status | Resolved |
|
||
| Location | `src/ScadaLink.ConfigurationDatabase/Repositories/TemplateEngineRepository.cs`, `Repositories/DeploymentManagerRepository.cs`, `Repositories/ExternalSystemRepository.cs`, `Repositories/InboundApiRepository.cs`, `Repositories/NotificationRepository.cs`, `Repositories/SiteRepository.cs`, `Services/InstanceLocator.cs` |
|
||
|
||
**Description**
|
||
|
||
The test project covers `SecurityRepository`, `CentralUiRepository`, `AuditService`,
|
||
optimistic concurrency, seed data, and Data Protection persistence. There are no direct
|
||
tests for `TemplateEngineRepository` (the largest repository, and the one with the
|
||
CD-001 bug, which a test would have caught), `DeploymentManagerRepository` (including
|
||
its `Local`-then-stub delete fallback and the `DeleteInstanceAsync`
|
||
restrict-FK-cleanup logic), `ExternalSystemRepository`, `InboundApiRepository` (notably
|
||
`GetApprovedKeysForMethodAsync` CSV parsing — CD-008), `NotificationRepository`,
|
||
`SiteRepository` (including its stub-attach delete path), or `InstanceLocator`.
|
||
|
||
**Recommendation**
|
||
|
||
Add repository-level tests using the existing `SqliteTestHelper` pattern, covering at
|
||
minimum: CRUD round-trips, the stub-attach delete fallbacks in
|
||
`DeploymentManagerRepository`/`SiteRepository`, `DeleteInstanceAsync`'s explicit
|
||
deployment-record cleanup, `GetApprovedKeysForMethodAsync` with valid/malformed CSV,
|
||
and `InstanceLocator.GetSiteIdForInstanceAsync` for found/not-found cases.
|
||
|
||
**Resolution**
|
||
|
||
Resolved 2026-05-16 (commit pending). Direct repository/service tests were added using
|
||
the existing `SqliteTestHelper` pattern. `InboundApiRepositoryTests.cs` covers
|
||
`InboundApiRepository` (API-key/method CRUD round-trips and the
|
||
`GetApprovedKeysForMethodAsync` valid/malformed/empty-CSV cases — see CD-008).
|
||
`RepositoryCoverageTests.cs` adds `ExternalSystemRepositoryTests` (definition/method CRUD,
|
||
parent-filtered method query, database-connection delete), `NotificationRepositoryTests`
|
||
(notification-list-with-recipients and SMTP-configuration round-trips, list delete),
|
||
`SiteRepositoryTests` (site/identifier round-trip plus the stub-attach delete fallback
|
||
exercised for both `DeleteSiteAsync` and `DeleteDataConnectionAsync` by clearing the
|
||
ChangeTracker, and the site-filtered instance query), `DeploymentManagerRepositoryTests`
|
||
(deployment-record CRUD and `GetCurrentDeploymentStatusAsync` ordering, the stub-attach
|
||
`DeleteDeploymentRecordAsync` fallback, and `DeleteInstanceAsync`'s explicit
|
||
Restrict-FK deployment-record cleanup), and `InstanceLocatorTests`
|
||
(`GetSiteIdForInstanceAsync` for the found and not-found cases). `TemplateEngineRepository`
|
||
gained the CD-001 and CD-009 regression tests
|
||
(`TemplateEngineRepositoryTests.cs`, `SchemaConfigurationTests.cs`). A constructor
|
||
null-guard test was added for each of the five repositories/services covered, doubling
|
||
as the CD-011 regression guard. The full module suite is green.
|
||
|
||
### ConfigurationDatabase-011 — Inconsistent constructor null-guarding across repositories/services
|
||
|
||
| | |
|
||
|--|--|
|
||
| Severity | Low |
|
||
| Category | Code organization & conventions |
|
||
| Status | Resolved |
|
||
| Location | `src/ScadaLink.ConfigurationDatabase/Repositories/ExternalSystemRepository.cs:11-14`, `Repositories/InboundApiRepository.cs:11-14`, `Repositories/NotificationRepository.cs:11-14`, `Services/InstanceLocator.cs:13-16` |
|
||
|
||
**Description**
|
||
|
||
`SecurityRepository`, `CentralUiRepository`, `TemplateEngineRepository`,
|
||
`DeploymentManagerRepository`, `SiteRepository`, and `AuditService` all guard their
|
||
injected `ScadaLinkDbContext` with `?? throw new ArgumentNullException(...)`.
|
||
`ExternalSystemRepository`, `InboundApiRepository`, `NotificationRepository`, and
|
||
`InstanceLocator` assign the constructor argument directly with no guard. This is a
|
||
minor consistency/maintainability issue: although the DI container will not normally
|
||
supply null, the divergence makes the codebase look unfinished and means a future
|
||
hand-constructed instance fails with a less informative `NullReferenceException` later.
|
||
|
||
**Recommendation**
|
||
|
||
Apply the same `?? throw new ArgumentNullException(nameof(context))` guard in the four
|
||
inconsistent constructors so all data-access types behave uniformly.
|
||
|
||
**Resolution**
|
||
|
||
Resolved 2026-05-16 (commit pending). Root cause confirmed against source:
|
||
`ExternalSystemRepository`, `InboundApiRepository`, `NotificationRepository`, and
|
||
`InstanceLocator` assigned the injected `ScadaLinkDbContext` directly with no null
|
||
guard, diverging from `SecurityRepository`/`CentralUiRepository`/`TemplateEngineRepository`/
|
||
`DeploymentManagerRepository`/`SiteRepository`/`AuditService`. Applied the recommendation:
|
||
all four constructors now use `context ?? throw new ArgumentNullException(nameof(context))`
|
||
(`InboundApiRepository`'s guard was added as part of its CD-008 constructor change), so
|
||
every data-access type behaves uniformly and a hand-constructed instance fails with an
|
||
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 | Resolved |
|
||
| 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**
|
||
|
||
Resolved 2026-05-17 (commit `<pending>`) — approved cross-module change spanning
|
||
Commons + ConfigurationDatabase + InboundAPI + ManagementService. The inbound-API
|
||
bearer credential is now persisted as a deterministic HMAC-SHA256 keyed hash, never
|
||
as plaintext, so a configuration-database dump no longer yields usable API keys
|
||
while the constant-time by-value lookup still works.
|
||
|
||
Root cause confirmed against source: `ApiKey.KeyValue` was a plaintext
|
||
`nvarchar(500)` column with a unique index, holding the live `X-API-Key` credential.
|
||
|
||
Design implemented — **deterministic keyed hash** (the recommendation's first option):
|
||
|
||
- **Hash scheme.** HMAC-SHA256 over the UTF-8 key bytes, keyed with a server-side
|
||
*pepper*; the digest is stored Base64-encoded. No per-row random salt — an API key
|
||
is already a high-entropy random token, and a random salt would break the
|
||
deterministic by-value lookup the authentication path relies on. The pepper instead
|
||
binds every hash to the deployment. Implemented as `IApiKeyHasher` / `ApiKeyHasher`
|
||
in `ScadaLink.Commons` (`Types/InboundApi/ApiKeyHasher.cs`); the constructor
|
||
rejects a missing or weak (`< 16`-char) pepper with `ArgumentException` — fail-fast.
|
||
- **Where the pepper lives.** `InboundApiOptions.ApiKeyPepper`, a component-owned
|
||
Options class already bound from the `ScadaLink:InboundApi` configuration section
|
||
(Options pattern); it is never hard-coded. `AddInboundAPI` registers `IApiKeyHasher`
|
||
via a factory that reads the bound options, so a missing/weak pepper fails the
|
||
deployment fast rather than degrading silently. (Operators must supply the pepper
|
||
via configuration / a secret store; `appsettings*.json` is intentionally left
|
||
without a value.)
|
||
- **Commons.** `ApiKey` drops the persisted `KeyValue` property and gains a persisted
|
||
`KeyHash` — the plaintext is never a field on the entity. `ApiKey.FromHash(name,
|
||
keyHash)` creates an entity from a precomputed hash (the production create-path);
|
||
the existing `ApiKey(name, keyValue)` constructor is retained as a convenience that
|
||
hashes its input with the unpeppered default hasher.
|
||
- **ConfigurationDatabase.** EF mapping maps `KeyHash` (`nvarchar(256)`, required,
|
||
unique index `IX_ApiKeys_KeyHash`); migration `20260517073000_HashApiKeyValue`
|
||
drops `KeyValue`/`IX_ApiKeys_KeyValue`, adds `KeyHash`, and deletes existing
|
||
`ApiKeys` rows (a one-way hash cannot recover plaintext). `GetApiKeyByValueAsync`
|
||
now hashes the supplied value and looks up by `KeyHash`.
|
||
- **InboundAPI.** `ApiKeyValidator` takes an `IApiKeyHasher`; `FindKeyConstantTime`
|
||
hashes the presented candidate and compares hashes via
|
||
`CryptographicOperations.FixedTimeEquals` — the comparison stays constant-time.
|
||
- **ManagementService.** `HandleCreateApiKey` generates a random 32-byte key, stores
|
||
only its peppered hash, and returns the plaintext to the caller exactly once;
|
||
`HandleListApiKeys` no longer exposes the hash (returns id/name/enabled only).
|
||
|
||
TDD regression tests (written first, confirmed failing, then passing):
|
||
`ApiKeyHasherTests` + `ApiKeyTests` (Commons — determinism, pepper fail-fast, no
|
||
plaintext property), `ApiKeyHashValidationTests` (InboundAPI — auth succeeds via
|
||
hash, wrong key / wrong-pepper fail), `ApiKeyCreationTests` (ManagementService — only
|
||
the hash is persisted, plaintext returned once), and `SchemaConfigurationTests`
|
||
additions (ConfigurationDatabase — `KeyHash` mapped/uniquely-indexed, no `KeyValue`
|
||
column). `dotnet build` of all five touched modules is clean and their test suites
|
||
are green.
|
||
|
||
**Operational consequence: every existing API key must be re-issued.** A hash is
|
||
one-way, so the `HashApiKeyValue` migration deletes existing key rows; after applying
|
||
it, operators must create new keys (CLI / Management API / Central UI), distribute
|
||
the one-time plaintext to callers, and re-approve the new keys on their methods.
|
||
|
||
Follow-up (outside this task's editable scope): record the hash scheme in
|
||
`docs/requirements/Component-ConfigurationDatabase.md` and the Inbound API design
|
||
doc, and update the Central UI API-keys page, which previously displayed a masked
|
||
`KeyValue`.
|
||
|
||
### ConfigurationDatabase-013 — Secret-column encryption silently falls back to an ephemeral (throwaway) key
|
||
|
||
| | |
|
||
|--|--|
|
||
| Severity | Medium |
|
||
| Category | Error handling & resilience |
|
||
| Status | Resolved |
|
||
| 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**
|
||
|
||
Resolved 2026-05-17 (commit pending). Root cause confirmed against source:
|
||
`ApplySecretColumnEncryption` resolved the provider as
|
||
`_dataProtectionProvider ?? new EphemeralDataProtectionProvider()`, so a context built
|
||
via the single-argument constructor would silently encrypt secret columns with a
|
||
throwaway key and persist permanently undecryptable ciphertext with no error.
|
||
|
||
Applied the recommendation — a combination of options (a) and (b). The silent
|
||
ephemeral substitution was removed: a no-provider context now builds its model with an
|
||
internal `SchemaOnlyDataProtector` that satisfies schema generation but throws a clear
|
||
`InvalidOperationException` if it is ever asked to `Protect`/`Unprotect`. On top of
|
||
that, `SaveChanges`/`SaveChangesAsync` were overridden with a `GuardSecretWritesHaveAKeyRing`
|
||
check that fails fast — before any database round-trip — with a clear
|
||
`InvalidOperationException` (naming the offending `Entity.Property`) whenever a
|
||
no-provider context has a pending non-null write to one of the three secret-bearing
|
||
columns. A custom `SecretAwareModelCacheKeyFactory` (registered via
|
||
`OnConfiguring`/`ReplaceService`) folds the presence of a real provider into EF's model
|
||
cache key, so a write-capable and a schema-only context can no longer share one cached
|
||
model. Design-time `dotnet ef` tooling (which only emits schema) and the test fixture
|
||
both keep working — `SqliteTestDbContext` now passes an explicit
|
||
`EphemeralDataProtectionProvider`, making the test intent visible at the call site as
|
||
recommendation (a) asks. Regression tests added in `EphemeralEncryptionFallbackTests.cs`:
|
||
`SingleArgConstructor_WritingSecretColumn_FailsFast_DoesNotPersistThrowawayCiphertext`,
|
||
`SingleArgConstructor_WritingNonSecretColumn_Succeeds`, and
|
||
`ProviderConstructor_WritingSecretColumn_StillSucceeds`. The DI-hardening sub-point
|
||
(register only the factory) was not adopted — `AddConfigurationDatabase` already
|
||
overrides the EF-activator registration with a provider-bearing factory, and the
|
||
fail-fast guard now closes the residual gap for any other resolution path.
|
||
|
||
### ConfigurationDatabase-014 — Redundant, inconsistent cast on one `HasConversion` call
|
||
|
||
| | |
|
||
|--|--|
|
||
| Severity | Low |
|
||
| Category | Code organization & conventions |
|
||
| Status | Resolved |
|
||
| 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**
|
||
|
||
Resolved 2026-05-17 (commit pending). Partially re-triaged: the finding is correct
|
||
that the inline fully-qualified cast was inconsistent and noisy, but its premise that
|
||
the cast is *purely redundant* is wrong. `DatabaseConnectionDefinition.ConnectionString`
|
||
is a non-nullable `string`; `EncryptedStringConverter` is `ValueConverter<string?,
|
||
string?>`, so calling `HasConversion(converter)` with `converter` typed as
|
||
`EncryptedStringConverter` on that property raises a real `CS8620` nullability
|
||
error (verified by build). The cast to the non-generic `ValueConverter` base was
|
||
suppressing that — load-bearing, not noise. Applied the recommendation's second
|
||
option: the converter is now held once in a single `ValueConverter`-typed local
|
||
(with an explanatory comment), and all three `HasConversion(converter)` calls read
|
||
identically with no inline cast or fully-qualified name. Behaviour is unchanged, so
|
||
no behavioural regression test is meaningful (cf. CD-005); a forward guard was added
|
||
in `SchemaConfigurationTests.cs` —
|
||
`SecretColumns_AllHaveEncryptedStringConverterApplied` (theory over all three secret
|
||
columns) — asserting each column keeps an `EncryptedStringConverter`.
|