Files
scadalink-design/code-reviews/ConfigurationDatabase/findings.md
Joseph Doherty 977d7369a7 docs: add code review process and baseline review of all 19 modules
Establishes a per-module code review workflow under code-reviews/ and
records the 2026-05-16 baseline review (commit 9c60592): 241 findings
across all src/ modules (6 Critical, 46 High, 100 Medium, 89 Low).
This is the clean starting point for remediation work.
2026-05-16 18:09:09 -04:00

18 KiB
Raw Blame History

Code Review — ConfigurationDatabase

Field Value
Module src/ScadaLink.ConfigurationDatabase
Design doc docs/requirements/Component-ConfigurationDatabase.md
Status Reviewed
Last reviewed 2026-05-16
Reviewer claude-agent
Commit reviewed 9c60592
Open findings 11

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.

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); audit JSON serialization failure handling (CD-007).
5 Security Hardcoded sa credential literal (CD-002); SMTP/DB-connection/auth secrets stored unencrypted (CD-004).
6 Performance & resource management GetAllTemplatesAsync / GetTemplateTreeAsync eager-load multiple collections without AsSplitQuery (CD-009). No N+1 in audited paths.
7 Design-document adherence Audit Id type mismatch vs design doc (CD-005); seed data uses HasData consistent with design.
8 Code organization & conventions Mostly clean. Grpc* address columns unbounded (CD-006); inconsistent null-guard on injected context (CD-011).
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 Open
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

Unresolved.

ConfigurationDatabase-002 — Hardcoded sa connection string with embedded password literal

Severity Medium
Category Security
Status Open
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

Unresolved.

ConfigurationDatabase-003 — No-arg AddConfigurationDatabase() silently registers nothing

Severity Medium
Category Error handling & resilience
Status Open
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

Unresolved.

ConfigurationDatabase-004 — Secret-bearing columns stored in plaintext with no protection

Severity Medium
Category Security
Status Open
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

Unresolved.

ConfigurationDatabase-005 — Audit Id type disagrees with the design doc

Severity Low
Category Design-document adherence
Status Open
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

Unresolved.

ConfigurationDatabase-006 — Site.GrpcNodeAAddress / GrpcNodeBAddress columns are unbounded

Severity Low
Category Code organization & conventions
Status Open
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

Unresolved.

ConfigurationDatabase-007 — AuditService does not handle JSON-serialization failure of arbitrary afterState

Severity Medium
Category Error handling & resilience
Status Open
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

Unresolved.

ConfigurationDatabase-008 — GetApprovedKeysForMethodAsync CSV parsing silently drops malformed ids

Severity Low
Category Correctness & logic bugs
Status Open
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

Unresolved.

ConfigurationDatabase-009 — Multi-collection eager loads issue cartesian-product queries

Severity Low
Category Performance & resource management
Status Open
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

Unresolved.

ConfigurationDatabase-010 — Several repositories and InstanceLocator lack direct test coverage

Severity Low
Category Testing coverage
Status Open
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

Unresolved.

ConfigurationDatabase-011 — Inconsistent constructor null-guarding across repositories/services

Severity Low
Category Code organization & conventions
Status Open
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

Unresolved.