# 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 | 1 | ## 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 ``). 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` 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 | 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** _Open — requires a cross-module design decision that cannot be made within this module's editable scope._ Root cause confirmed against source. `ApiKey.KeyValue` (`src/ScadaLink.Commons/Entities/InboundApi/ApiKey.cs`) is mapped as a plaintext `nvarchar(500)` column with a unique index (`InboundApiConfiguration.cs:17-22`) and is the bearer credential for Inbound API requests. The CD-004 `EncryptedStringConverter` is correctly inapplicable here: it is non-deterministic, and the key is resolved by value. The recommended fix — store a deterministic salted/peppered hash instead of the plaintext — cannot be completed within this module alone, for three concrete reasons: 1. **Commons entity change (out of scope).** Hashing changes the semantics of `ApiKey.KeyValue`: after creation it can only ever hold the hash, never the live key. The proper shape is a dedicated `KeyHash` field on the `ApiKey` entity (in `ScadaLink.Commons`), which this task may not edit. 2. **Cross-module consumer change (out of scope).** `ScadaLink.InboundAPI`'s `ApiKeyValidator.FindKeyConstantTime` reads `key.KeyValue` and does a constant-time byte comparison against the *raw presented secret*. If the column becomes a hash, the validator must hash the presented candidate before comparing — an InboundAPI change. The CLI (`SecurityCommands.cs`) and Management Service also create/read `ApiKey` and would need the "plaintext shown once at creation" model. 3. **Irreversible data migration / operational decision.** A hash is one-way, so a migration cannot convert existing plaintext keys to hashes without the originals — every existing API key must be re-issued. Whether and how to do that is an operational/design call, not a mechanical fix. **Decision needed (for a follow-up spanning Commons + InboundAPI + ManagementService + CLI):** (a) add an `ApiKey.KeyHash` column and a per-key salt (or a system pepper); (b) hash on create and authenticate by hashing the presented key; (c) decide the hash algorithm (e.g. PBKDF2/Argon2 vs. a fast salted SHA-256 — note the constant-time lookup in `ApiKeyValidator` already mitigates timing, so a slow KDF is the safer choice if the key space is small); (d) plan the one-time re-issue of existing keys; (e) record the scheme in `docs/requirements/Component-ConfigurationDatabase.md` and the Inbound API design doc. Until that decision is made the finding stays **Open**. ### 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` 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` (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`, 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`.