code-review: 2026-05-28 baseline re-review of all 23 modules at 1eb6e97
Re-applies the full 10-category checklist to every src/ project — including
first-time reviews of the four newer components (AuditLog, NotificationOutbox,
SiteCallAudit, Transport) — so the code-reviews/ index reflects today's
codebase rather than the 2026-05-16 baseline. 172 new Open findings (0
Critical, 18 High, 62 Medium, 92 Low); 481 findings total across 23 modules.
regen-readme.py now derives each module's Last reviewed + Commit from its
findings.md header instead of hard-coding 2026-05-16 / 9c60592, so future
single-module re-reviews show their own date in the Module Status table.
This commit is contained in:
@@ -5,10 +5,10 @@
|
||||
| Module | `src/ScadaLink.ConfigurationDatabase` |
|
||||
| Design doc | `docs/requirements/Component-ConfigurationDatabase.md` |
|
||||
| Status | Reviewed |
|
||||
| Last reviewed | 2026-05-17 |
|
||||
| Last reviewed | 2026-05-28 |
|
||||
| Reviewer | claude-agent |
|
||||
| Commit reviewed | `39d737e` |
|
||||
| Open findings | 0 |
|
||||
| Commit reviewed | `1eb6e97` |
|
||||
| Open findings | 10 |
|
||||
|
||||
## Summary
|
||||
|
||||
@@ -59,6 +59,59 @@ 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.
|
||||
|
||||
#### Re-review 2026-05-28 (commit `1eb6e97`)
|
||||
|
||||
Re-reviewed the module at commit `1eb6e97`. All fourteen prior findings remain
|
||||
`Resolved`; their fixes still hold (encryption converter, fail-fast guard,
|
||||
peppered API-key hash, ephemeral-fallback hardening, etc.). The module has
|
||||
grown since the last review — new code includes Audit Log (#23) raw-SQL
|
||||
paths in `AuditLogRepository` (partition-switch purge, recursive
|
||||
execution-tree CTE, KPI snapshot, partition-boundary discovery), the
|
||||
`AuditLogPartitionMaintenance` SPLIT-RANGE roll-forward implementation, the
|
||||
`AuditCorrelationContext` scoped service that stamps `BundleImportId`, the
|
||||
`SiteCallAuditRepository` monotonic-rank upsert, and the
|
||||
`NotificationOutboxRepository` per-site KPI surface — and most of the new
|
||||
findings are concentrated in those raw-SQL paths and in latent gaps left
|
||||
behind by the CD-012 hash migration.
|
||||
|
||||
Ten new findings were recorded. The most material is
|
||||
`ConfigurationDatabase-015`: a check-then-act race in
|
||||
`NotificationOutboxRepository.InsertIfNotExistsAsync` with no duplicate-key
|
||||
catch — unlike the sibling Audit Log / Site Call ingest paths, a concurrent
|
||||
ack-after-persist on the same `NotificationId` will surface as an
|
||||
unhandled `DbUpdateException` and break the at-least-once site→central
|
||||
handoff. `ConfigurationDatabase-016` flags that
|
||||
`InboundApiRepository.GetApiKeyByValueAsync` hashes the candidate with
|
||||
`ApiKeyHasher.Default` (unpeppered) while the production create-path uses
|
||||
the configured peppered hasher — any future caller (or test that exercises
|
||||
the method) will silently fail to find a real key; the production
|
||||
`ApiKeyValidator` happens not to call it, but the method is a publicly
|
||||
exposed `IInboundApiRepository` member and a latent bug.
|
||||
`ConfigurationDatabase-017` records that the `DeleteDeploymentRecordAsync`
|
||||
stub-attach delete bypasses the documented optimistic-concurrency rule on
|
||||
`DeploymentRecord.RowVersion` — the SQLite tests pass because the test
|
||||
fixture re-maps `RowVersion` as a nullable concurrency token, but in
|
||||
production this is likely to throw `DbUpdateConcurrencyException`.
|
||||
`ConfigurationDatabase-018` records the `DateTime`-typed `*Utc` columns on
|
||||
`AuditEvent` and `SiteCall` re-emerge as `Kind=Unspecified` on read; the
|
||||
sibling Commons module flagged the same pattern as Commons-019, and
|
||||
`AuditLogPartitionMaintenance.GetMaxBoundaryAsync` already defends against
|
||||
it with an explicit `SpecifyKind(Utc)` — but `GetPartitionBoundariesOlderThanAsync`
|
||||
does not (`ConfigurationDatabase-020`). `ConfigurationDatabase-019` is the
|
||||
SPLIT-RANGE loop in `AuditLogPartitionMaintenance.EnsureLookaheadAsync`
|
||||
swallowing every `SqlException` as a Warning and continuing — a genuine
|
||||
failure (permissions, deadlock, transient) leaves a missing boundary and
|
||||
the next iteration cheerfully splits the following month, creating a hole.
|
||||
`ConfigurationDatabase-021` is a low-severity hardening concern around
|
||||
`SwitchOutPartitionAsync`'s raw-SQL interpolation of `monthBoundaryStr` /
|
||||
`stagingTableName` (currently safe by construction, but truncates fractional
|
||||
seconds). `ConfigurationDatabase-022` is the stale "WP-24 stub" XML comment
|
||||
on `DeploymentManagerRepository`. `ConfigurationDatabase-023` is a
|
||||
design-doc-adherence drift on `IX_AuditLog_CorrelationId` (design says
|
||||
`IX_AuditLog_Correlation`). `ConfigurationDatabase-024` is missing test
|
||||
coverage for the SPLIT-RANGE failure-continuation behaviour and for the
|
||||
production-shape stub-attach delete with a real rowversion.
|
||||
|
||||
## Checklist coverage
|
||||
|
||||
| # | Category | Examined | Notes |
|
||||
@@ -74,6 +127,21 @@ hold up well.
|
||||
| 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. |
|
||||
|
||||
_Re-review (2026-05-28, `1eb6e97`):_
|
||||
|
||||
| # | Category | Examined | Notes |
|
||||
|---|----------|----------|-------|
|
||||
| 1 | Correctness & logic bugs | ✓ | `GetPartitionBoundariesOlderThanAsync` returns `DateTimeKind.Unspecified` (CD-020). `GetApiKeyByValueAsync` hashes with the unpeppered default (CD-016). |
|
||||
| 2 | Akka.NET conventions | ✓ | No actors in this module. No issues found. |
|
||||
| 3 | Concurrency & thread safety | ✓ | `NotificationOutboxRepository.InsertIfNotExistsAsync` check-then-act has no duplicate-key catch (CD-015). Stub-attach delete bypasses documented optimistic concurrency on `DeploymentRecord.RowVersion` (CD-017). |
|
||||
| 4 | Error handling & resilience | ✓ | `AuditLogPartitionMaintenance.EnsureLookaheadAsync` swallows non-idempotent SPLIT failures and continues (CD-019). |
|
||||
| 5 | Security | ✓ | `SwitchOutPartitionAsync` interpolates a `DateTime` string and a GUID-suffixed identifier into raw SQL — safe by construction but pattern is risky (CD-021). |
|
||||
| 6 | Performance & resource management | ✓ | No new issues found. |
|
||||
| 7 | Design-document adherence | ✓ | Index name drift: design says `IX_AuditLog_Correlation`, code uses `IX_AuditLog_CorrelationId` (CD-023). |
|
||||
| 8 | Code organization & conventions | ✓ | `DateTime *Utc` columns on `AuditEvent` / `SiteCall` carry no `DateTimeKind` enforcement (CD-018). |
|
||||
| 9 | Testing coverage | ✓ | No tests for SPLIT failure continuation and no production-shape rowversion stub-attach test (CD-024). |
|
||||
| 10 | Documentation & comments | ✓ | Stale "WP-24 stub" XML comment on `DeploymentManagerRepository` (CD-022). |
|
||||
|
||||
## Findings
|
||||
|
||||
### ConfigurationDatabase-001 — `GetTemplateWithChildrenAsync` loads child templates then discards them
|
||||
@@ -816,3 +884,411 @@ no behavioural regression test is meaningful (cf. CD-005); a forward guard was a
|
||||
in `SchemaConfigurationTests.cs` —
|
||||
`SecretColumns_AllHaveEncryptedStringConverterApplied` (theory over all three secret
|
||||
columns) — asserting each column keeps an `EncryptedStringConverter`.
|
||||
|
||||
### ConfigurationDatabase-015 — `NotificationOutboxRepository.InsertIfNotExistsAsync` is a check-then-act race with no duplicate-key catch
|
||||
|
||||
| | |
|
||||
|--|--|
|
||||
| Severity | High |
|
||||
| Category | Concurrency & thread safety |
|
||||
| Status | Open |
|
||||
| Location | `src/ScadaLink.ConfigurationDatabase/Repositories/NotificationOutboxRepository.cs:33-45` |
|
||||
|
||||
**Description**
|
||||
|
||||
`InsertIfNotExistsAsync` does `AnyAsync(x => x.NotificationId == n.NotificationId)`,
|
||||
then — if false — `AddAsync` + `SaveChangesAsync`. There is a check-then-act window
|
||||
between the two operations: two sessions can both pass the `AnyAsync` check and both
|
||||
attempt the INSERT, and the loser surfaces as a uniqueness violation on the
|
||||
`NotificationId` primary key wrapped in a `DbUpdateException` / `SqlException` (error
|
||||
2627). The site→central handoff for notifications is documented as **at-least-once
|
||||
with ack-after-persist plus insert-if-not-exists**; collisions on the same
|
||||
`NotificationId` are therefore not a "should never happen" but the *expected* contention
|
||||
mode. As written, the second concurrent ack throws, fails the site→central
|
||||
acknowledgement, and the site retries the same row again on its next forward — a
|
||||
livelock if the contending pair keeps racing.
|
||||
|
||||
The sibling raw-SQL `IF NOT EXISTS … INSERT` paths in `AuditLogRepository.InsertIfNotExistsAsync`
|
||||
(see SqlErrorUniqueIndexViolation / SqlErrorPrimaryKeyViolation handling at
|
||||
`AuditLogRepository.cs:74-89`) and `SiteCallAuditRepository.UpsertAsync`
|
||||
(`SiteCallAuditRepository.cs:87-96`) explicitly catch errors 2601/2627 and treat the
|
||||
loser as a no-op — exactly the right pattern for "first-write-wins idempotent ingest".
|
||||
This repository alone does not.
|
||||
|
||||
**Recommendation**
|
||||
|
||||
Either (a) rewrite the body as a single raw-SQL `IF NOT EXISTS … INSERT` and apply the
|
||||
same 2601/2627 catch-and-log-Debug pattern the AuditLog and SiteCall repositories use,
|
||||
or (b) wrap the existing flow in a try/catch around `SaveChangesAsync` that inspects
|
||||
the inner `SqlException.Number` and returns `false` (i.e. "another writer won the race")
|
||||
on 2601/2627. Option (a) is preferable because it collapses the two round-trips to one
|
||||
and matches the established idempotent-ingest pattern used elsewhere in the module.
|
||||
Add a regression test that simulates two concurrent `InsertIfNotExistsAsync` calls
|
||||
(using two open contexts) for the same `NotificationId` and asserts neither call
|
||||
throws and exactly one row lands.
|
||||
|
||||
### ConfigurationDatabase-016 — `InboundApiRepository.GetApiKeyByValueAsync` hashes the candidate with the unpeppered `ApiKeyHasher.Default`
|
||||
|
||||
| | |
|
||||
|--|--|
|
||||
| Severity | Medium |
|
||||
| Category | Security |
|
||||
| Status | Open |
|
||||
| Location | `src/ScadaLink.ConfigurationDatabase/Repositories/InboundApiRepository.cs:35-39` |
|
||||
|
||||
**Description**
|
||||
|
||||
`GetApiKeyByValueAsync` resolves an API key by its presented plaintext value by hashing
|
||||
the candidate and looking up `KeyHash`. The hash, however, is computed with the static
|
||||
`ApiKeyHasher.Default` (the fixed, deployment-independent unpeppered hasher used for
|
||||
tests). Production key creation uses the DI-registered, *peppered* `IApiKeyHasher`
|
||||
constructed from `InboundApiOptions.ApiKeyPepper` (see CD-012 resolution and
|
||||
`ApiKeyHasher.ctor(string pepper)`), so the stored `KeyHash` of any real key was
|
||||
produced under the deployment pepper. Hashing the candidate with the unpeppered
|
||||
`Default` yields a different digest, and the `WHERE KeyHash = @hash` lookup will never
|
||||
match a real key.
|
||||
|
||||
The production `ApiKeyValidator` (InboundAPI module) deliberately does NOT call this
|
||||
method — it fetches all keys and runs a constant-time comparison via the
|
||||
DI-registered hasher (`ApiKeyValidator.cs:53-64`) — so the immediate
|
||||
authentication path is unaffected. But `GetApiKeyByValueAsync` remains a publicly
|
||||
exposed `IInboundApiRepository` member; any new caller (a future admin tool, a CLI
|
||||
command, a test) that uses it under a peppered configuration will silently get a
|
||||
`null` result for an existing, valid key, and almost certainly mis-route the failure
|
||||
as "key not found".
|
||||
|
||||
**Recommendation**
|
||||
|
||||
Either (a) take `IApiKeyHasher` via constructor injection — alongside the existing
|
||||
`ScadaLinkDbContext` and optional `ILogger` — and use it here so the repository
|
||||
participates in the same peppered scheme as the rest of the system; or (b) delete
|
||||
the method from both the implementation and `IInboundApiRepository` (Commons) on the
|
||||
grounds that the production authentication path correctly avoids it for timing
|
||||
reasons and there is no remaining valid caller. Add a regression test that constructs
|
||||
the repository under a real `ApiKeyHasher("a-strong-pepper-value")`, inserts an
|
||||
`ApiKey.FromHash(...)` using the same hasher, and asserts `GetApiKeyByValueAsync`
|
||||
returns the row — under option (a) it should pass; under option (b) the method no
|
||||
longer exists.
|
||||
|
||||
### ConfigurationDatabase-017 — Stub-attach delete on `DeploymentRecord` bypasses optimistic concurrency
|
||||
|
||||
| | |
|
||||
|--|--|
|
||||
| Severity | Medium |
|
||||
| Category | Concurrency & thread safety |
|
||||
| Status | Open |
|
||||
| Location | `src/ScadaLink.ConfigurationDatabase/Repositories/DeploymentManagerRepository.cs:83-97` |
|
||||
|
||||
**Description**
|
||||
|
||||
`DeploymentRecord` carries a SQL Server `rowversion` concurrency token (declared
|
||||
in `DeploymentConfiguration` and confirmed by `ConcurrencyTests`), per the design
|
||||
doc's "Optimistic concurrency is used on deployment status records". When
|
||||
`DeleteDeploymentRecordAsync` falls into its stub-attach branch (no tracked entity
|
||||
in `_dbContext.DeploymentRecords.Local` for the given id), it constructs
|
||||
`new DeploymentRecord("stub", "stub") { Id = id }`, `Attach`es it, and `Remove`s it.
|
||||
The stub's `RowVersion` is left at its default `null` (or `byte[0]`).
|
||||
|
||||
EF Core's SQL Server provider generates the delete as
|
||||
`DELETE FROM DeploymentRecords WHERE Id = @id AND RowVersion = @stubRowVersion` — and
|
||||
the stub rowversion is not the row's real rowversion, so on a real SQL Server (with
|
||||
`IsRowVersion()` auto-populating the column) the WHERE never matches and `SaveChanges`
|
||||
throws `DbUpdateConcurrencyException`. The path is exercised by
|
||||
`RepositoryCoverageTests.DeleteDeploymentRecord_ViaStubAttachPath_RemovesEntity` —
|
||||
but the test fixture remaps `RowVersion` as a nullable `IsConcurrencyToken()` column
|
||||
without auto-population (`SqliteTestHelper.ConfigureForTests`), so the stored
|
||||
RowVersion is null AND the stub's RowVersion is null AND the SQLite delete matches.
|
||||
Production-shape behaviour is the opposite.
|
||||
|
||||
The same stub-attach pattern is used on `SystemArtifactDeploymentRecord`,
|
||||
`Site`, and `DataConnection`. Those entities have no rowversion token, so the
|
||||
production behaviour is correct for them — the issue is specific to
|
||||
`DeploymentRecord`.
|
||||
|
||||
**Recommendation**
|
||||
|
||||
Replace the stub-attach branch in `DeleteDeploymentRecordAsync` with a real lookup —
|
||||
`await _dbContext.DeploymentRecords.FindAsync([id], ct)` then `Remove` if non-null —
|
||||
mirroring `DeleteInstanceAttributeOverrideAsync` and `DeleteDeployedSnapshotAsync`.
|
||||
This loses the "delete by id without a read" micro-optimisation (a real concern only
|
||||
in batched-delete loops) but restores the documented concurrency contract. If the
|
||||
optimisation is genuinely required, attach a `DeploymentRecord` with the *caller's*
|
||||
known RowVersion (the caller had to fetch the row at some point) and accept the
|
||||
`DbUpdateConcurrencyException` as the correct concurrency signal. Add a regression
|
||||
test under MS SQL (extend `RepositoryCoverageTests` with a SQL-Server-flavoured
|
||||
fixture, or use `MsSqlMigrationFixture`) that asserts the stub-attach delete works
|
||||
when the real RowVersion is supplied.
|
||||
|
||||
### ConfigurationDatabase-018 — `DateTime`-typed `*Utc` columns on `AuditEvent` / `SiteCall` carry no `DateTimeKind` enforcement
|
||||
|
||||
| | |
|
||||
|--|--|
|
||||
| Severity | Medium |
|
||||
| Category | Code organization & conventions |
|
||||
| Status | Open |
|
||||
| Location | `src/ScadaLink.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs`, `Configurations/SiteCallEntityTypeConfiguration.cs` (mappings for `OccurredAtUtc`, `IngestedAtUtc`, `CreatedAtUtc`, `UpdatedAtUtc`, `TerminalAtUtc`) |
|
||||
|
||||
**Description**
|
||||
|
||||
`AuditEvent.OccurredAtUtc` / `IngestedAtUtc` and `SiteCall.CreatedAtUtc` /
|
||||
`UpdatedAtUtc` / `TerminalAtUtc` / `IngestedAtUtc` are declared as `DateTime` (not
|
||||
`DateTimeOffset`) per the Audit Log #23 spec, with a UTC suffix convention. SQL Server's
|
||||
`datetime2` provider strips the `Kind` flag on the wire — values inserted with
|
||||
`DateTimeKind.Utc` round-trip as `DateTimeKind.Unspecified` on read. The EF mappings
|
||||
add no `HasConversion(...)` to normalise the kind. The sibling Commons module just
|
||||
flagged the same pattern as `Commons-019`; in this module the consequence is concrete:
|
||||
|
||||
- `AuditLogPartitionMaintenance.GetMaxBoundaryAsync` already defends with an explicit
|
||||
`DateTime.SpecifyKind(dt, DateTimeKind.Utc)` (see `AuditLogPartitionMaintenance.cs:103-104`).
|
||||
That defence is necessary precisely because the EF mapping does not enforce it.
|
||||
- `AuditLogRepository.GetPartitionBoundariesOlderThanAsync` does NOT defend — it
|
||||
returns `reader.GetDateTime(0)` directly with `Kind=Unspecified` (separate finding
|
||||
CD-020).
|
||||
- Downstream comparisons like `DateTime.UtcNow` (Kind=Utc) against a re-read
|
||||
`OccurredAtUtc` (Kind=Unspecified) do not produce a runtime error, but any code
|
||||
path that converts via `.ToLocalTime()` or `.ToUniversalTime()` will silently
|
||||
interpret an unspecified-kind value as local time and produce wrong results.
|
||||
|
||||
**Recommendation**
|
||||
|
||||
Apply a value converter on every `DateTime`-typed `*Utc` column that re-tags the
|
||||
`Kind` to `Utc` on read (and asserts/`SpecifyKind` on write to defend against an
|
||||
accidental local-kind write). EF Core's built-in
|
||||
`UtcValueConverter`-style pattern is a single line per column:
|
||||
|
||||
```csharp
|
||||
builder.Property(e => e.OccurredAtUtc)
|
||||
.HasConversion(
|
||||
v => v,
|
||||
v => DateTime.SpecifyKind(v, DateTimeKind.Utc));
|
||||
```
|
||||
|
||||
Apply uniformly to `AuditEvent` (OccurredAtUtc, IngestedAtUtc), `SiteCall`
|
||||
(CreatedAtUtc, UpdatedAtUtc, TerminalAtUtc, IngestedAtUtc), and any other
|
||||
`DateTime *Utc` columns added later. Add a regression test that inserts a UTC row,
|
||||
re-reads it in a fresh context, and asserts `Kind == DateTimeKind.Utc`. Coordinate
|
||||
with the sibling `Commons-019` finding so the resolution is consistent across both
|
||||
modules.
|
||||
|
||||
### ConfigurationDatabase-019 — `EnsureLookaheadAsync` swallows non-idempotent SPLIT failures and continues, creating partition holes
|
||||
|
||||
| | |
|
||||
|--|--|
|
||||
| Severity | Medium |
|
||||
| Category | Error handling & resilience |
|
||||
| Status | Open |
|
||||
| Location | `src/ScadaLink.ConfigurationDatabase/Maintenance/AuditLogPartitionMaintenance.cs:181-199` |
|
||||
|
||||
**Description**
|
||||
|
||||
`EnsureLookaheadAsync` loops one month at a time from `next` up to `horizon` and
|
||||
issues `ALTER PARTITION SCHEME … NEXT USED` + `ALTER PARTITION FUNCTION … SPLIT RANGE`
|
||||
per month. The class doc says idempotency is guaranteed by reading the max-boundary
|
||||
first and only issuing SPLITs for strictly-greater months — so "boundary already
|
||||
exists" (SQL Server msg 7708/7711) cannot occur by construction. Yet the loop wraps
|
||||
each iteration in `catch (SqlException ex) { _logger.LogWarning(...); }` and
|
||||
continues, with the rationale "the desired end state (boundary present) is satisfied
|
||||
by either path."
|
||||
|
||||
That rationale is correct only for an "already-exists" error — which the pre-check
|
||||
makes impossible. Any *other* `SqlException` — a permissions failure (the
|
||||
`scadalink_audit_purger` role's `ALTER ON SCHEMA::dbo` revoked or not granted), a
|
||||
deadlock victim, a transient connection drop, a transaction log full, an underlying
|
||||
filegroup full — leaves the boundary genuinely **not** created, logs a Warning
|
||||
(quiet by default in most appenders), and the next iteration tries to SPLIT the
|
||||
following month. That split *can* succeed (it is a different range value), creating
|
||||
a permanent **hole** in the partition layout: month N never had a partition created,
|
||||
month N+1 does, so any future row in month N lands in the partition that previously
|
||||
spanned both months and partition-switch purge for month N becomes impossible.
|
||||
|
||||
The class is the central singleton's daily-tick partition roll-forward, so the hole
|
||||
persists until an operator notices it and rebuilds manually — by which point months
|
||||
of audit retention may be locked behind the unsplit range.
|
||||
|
||||
**Recommendation**
|
||||
|
||||
Either (a) drop the `try/catch` entirely so any SPLIT failure aborts the loop and
|
||||
surfaces to the hosted service (the next tick retries — at-least-once with no holes),
|
||||
or (b) keep the catch but narrow it to ONLY the
|
||||
"boundary-already-exists" errors (SQL Server msg 7708 and 7711) and log at Debug,
|
||||
mirroring how `AuditLogRepository.InsertIfNotExistsAsync` narrowly catches 2601/2627.
|
||||
Option (a) is preferable: by class-doc construction the catch should never fire, so
|
||||
its only effect is to mask the real-failure case. Add tests that simulate a SPLIT
|
||||
failure (e.g. a permission denial via a constrained test login) and assert the loop
|
||||
aborts after the first failure with no further SPLITs.
|
||||
|
||||
### ConfigurationDatabase-020 — `GetPartitionBoundariesOlderThanAsync` returns `DateTime` with `Kind=Unspecified`
|
||||
|
||||
| | |
|
||||
|--|--|
|
||||
| Severity | Low |
|
||||
| Category | Correctness & logic bugs |
|
||||
| Status | Open |
|
||||
| Location | `src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs:378-387` |
|
||||
|
||||
**Description**
|
||||
|
||||
`GetPartitionBoundariesOlderThanAsync` reads `reader.GetDateTime(0)` and adds the
|
||||
raw value to the returned list. SQL Server's `datetime2` materialises as
|
||||
`DateTimeKind.Unspecified` on the ADO.NET side (see CD-019), so every returned
|
||||
boundary has `Kind=Unspecified`. The sibling `AuditLogPartitionMaintenance.GetMaxBoundaryAsync`
|
||||
(`AuditLogPartitionMaintenance.cs:103-104`) explicitly defends against this exact
|
||||
issue by calling `DateTime.SpecifyKind(dt, DateTimeKind.Utc)` — exactly because EF /
|
||||
ADO.NET strips the kind — but the repository method does not. Callers (the
|
||||
`AuditLogPurgeActor`) that compare a returned boundary to `DateTime.UtcNow` get a
|
||||
silently wrong comparison if they ever serialise to/from a string with a local-kind
|
||||
assumption in between.
|
||||
|
||||
**Recommendation**
|
||||
|
||||
Wrap the read with `DateTime.SpecifyKind(reader.GetDateTime(0), DateTimeKind.Utc)`,
|
||||
matching the explicit defensive pattern already in
|
||||
`AuditLogPartitionMaintenance.GetMaxBoundaryAsync`. Better still: fix CD-019 (a value
|
||||
converter on the column) so the defence at the read site is no longer required.
|
||||
|
||||
### ConfigurationDatabase-021 — `SwitchOutPartitionAsync` interpolates `monthBoundary` / staging table name into raw SQL
|
||||
|
||||
| | |
|
||||
|--|--|
|
||||
| Severity | Low |
|
||||
| Category | Security |
|
||||
| Status | Open |
|
||||
| Location | `src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs:192-338` |
|
||||
|
||||
**Description**
|
||||
|
||||
`SwitchOutPartitionAsync` builds two large SQL batches via interpolated strings
|
||||
(`sampleSql` and `sql`) that include `{monthBoundaryStr}` and `{stagingTableName}`
|
||||
directly in the SQL text, and executes them via `ExecuteSqlRawAsync` /
|
||||
`cmd.ExecuteScalarAsync`. Both values are constructed inside the method —
|
||||
`monthBoundaryStr = monthBoundary.ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss")`
|
||||
and `stagingTableName = $"AuditLog_Staging_{Guid.NewGuid():N}"` — and the formats are
|
||||
fully controlled. SQL injection is therefore not possible as the code stands.
|
||||
|
||||
Two related concerns:
|
||||
|
||||
1. The format string `"yyyy-MM-dd HH:mm:ss"` truncates fractional seconds. The
|
||||
partition function is seeded at `T00:00:00` exactly, so truncation happens to
|
||||
produce the right boundary value today. A future change that adds a sub-second
|
||||
boundary (or invokes `SwitchOutPartitionAsync` with a non-midnight value) would
|
||||
silently round to the wrong partition with no error — and SWITCH PARTITION would
|
||||
either fail loudly or succeed against the wrong month. Use
|
||||
`"yyyy-MM-dd HH:mm:ss.fffffff"` to match the precision the migration seeds at,
|
||||
and the rounding ambiguity disappears.
|
||||
2. The pattern of "build a multi-statement DDL batch by string concatenation" is
|
||||
robust today only by inspection. A code review tripwire — the CLAUDE.md note "the
|
||||
data-access layer must not concatenate SQL" — would catch the pattern earlier;
|
||||
converting the batch to a parameterised `sp_executesql` invocation (the inner
|
||||
`EXEC sp_executesql @sql` already exists for the SWITCH itself) is the textbook
|
||||
safe form even when the input is internally controlled.
|
||||
|
||||
**Recommendation**
|
||||
|
||||
(1) Switch `monthBoundaryStr`'s format to `"yyyy-MM-dd HH:mm:ss.fffffff"`. (2)
|
||||
Optionally migrate the two batches to fully parameterised `sp_executesql` form so
|
||||
the `monthBoundary` value flows as a typed `@boundary datetime2(7)` parameter
|
||||
rather than as interpolated text — the only piece that genuinely *cannot* be
|
||||
parameterised is the staging table identifier (DDL identifiers are not parameterisable
|
||||
in T-SQL), but a server-side `QUOTENAME(@stagingTable)` wrapper covers it. Add a
|
||||
regression test that supplies a non-midnight `monthBoundary` value and asserts the
|
||||
boundary lookup resolves to the expected partition.
|
||||
|
||||
### ConfigurationDatabase-022 — Stale "WP-24 Stub level sufficient for diff/staleness support" XML comment on `DeploymentManagerRepository`
|
||||
|
||||
| | |
|
||||
|--|--|
|
||||
| Severity | Low |
|
||||
| Category | Documentation & comments |
|
||||
| Status | Open |
|
||||
| Location | `src/ScadaLink.ConfigurationDatabase/Repositories/DeploymentManagerRepository.cs:8-14` |
|
||||
|
||||
**Description**
|
||||
|
||||
The class-level XML doc on `DeploymentManagerRepository` reads "WP-24: Stub level
|
||||
sufficient for diff/staleness support." WP-24 (Deployment Manager work-package) shipped
|
||||
long ago; the repository now covers full `DeploymentRecord` CRUD,
|
||||
`SystemArtifactDeploymentRecord` CRUD, `DeployedConfigSnapshot` CRUD, and an
|
||||
`Instance` deletion path with explicit Restrict-FK cleanup
|
||||
(`DeleteInstanceAsync` at line 210-229). The comment misleads a reader into
|
||||
thinking the repository is incomplete and tempts them not to investigate further
|
||||
before adding new behaviour. The same module-context observation was noted but
|
||||
not raised in the prior review.
|
||||
|
||||
**Recommendation**
|
||||
|
||||
Remove the WP-24 line and rewrite the class doc to describe what the repository
|
||||
actually does today: EF Core implementation of `IDeploymentManagerRepository`
|
||||
covering deployment records, system-artifact deployment records, deployed config
|
||||
snapshots, and the Restrict-FK-aware `DeleteInstanceAsync` for the
|
||||
deployment pipeline. Cross-reference the optimistic-concurrency contract on
|
||||
`DeploymentRecord.RowVersion`.
|
||||
|
||||
### ConfigurationDatabase-023 — `AuditLog` correlation-index name drifts from design doc (`IX_AuditLog_CorrelationId` vs `IX_AuditLog_Correlation`)
|
||||
|
||||
| | |
|
||||
|--|--|
|
||||
| Severity | Low |
|
||||
| Category | Design-document adherence |
|
||||
| Status | Open |
|
||||
| Location | `src/ScadaLink.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs:99-101`, `Migrations/20260520142214_AddAuditLogTable.cs:103-107` |
|
||||
|
||||
**Description**
|
||||
|
||||
The Component-ConfigurationDatabase design doc lists the AuditLog indexes by name —
|
||||
including `IX_AuditLog_Correlation (CorrelationId)` for the "drilldown from a single
|
||||
operation" use case. The implemented index name is `IX_AuditLog_CorrelationId` (the
|
||||
fluent-config `HasDatabaseName` call and the matching DDL in the migration both use
|
||||
the `Id`-suffixed form). The names are syntactically valid SQL Server index names and
|
||||
the index does the right work; the drift is cosmetic but it breaks scripted
|
||||
maintenance ops that grep for the documented name (e.g. a runbook reindex script).
|
||||
|
||||
The other four documented index names (`IX_AuditLog_OccurredAtUtc`,
|
||||
`IX_AuditLog_Site_Occurred`, `IX_AuditLog_Channel_Status_Occurred`,
|
||||
`IX_AuditLog_Target_Occurred`, plus the post-design additions
|
||||
`IX_AuditLog_Execution`, `IX_AuditLog_ParentExecution`, `IX_AuditLog_Node_Occurred`)
|
||||
agree with the code.
|
||||
|
||||
**Recommendation**
|
||||
|
||||
Pick one direction. Updating the design doc to match the code is cheap (one word) and
|
||||
preserves the existing migration; renaming the index in the database requires a new
|
||||
migration that does `sp_rename`. Document-aligning is the lower-cost option and
|
||||
matches the resolution pattern used for CD-005.
|
||||
|
||||
### ConfigurationDatabase-024 — Missing test coverage for SPLIT-RANGE failure-continuation and production-shape rowversion delete
|
||||
|
||||
| | |
|
||||
|--|--|
|
||||
| Severity | Low |
|
||||
| Category | Testing coverage |
|
||||
| Status | Open |
|
||||
| Location | `tests/ScadaLink.ConfigurationDatabase.Tests/Maintenance/AuditLogPartitionMaintenanceTests.cs`, `tests/.../RepositoryCoverageTests.cs:855-869` |
|
||||
|
||||
**Description**
|
||||
|
||||
`AuditLogPartitionMaintenanceTests` exercises the happy-path SPLIT-RANGE behaviour
|
||||
(no-op, single-month, three-month, already-exists idempotency) but never simulates a
|
||||
SPLIT *failure* — so the catch-and-continue behaviour flagged in CD-019 is
|
||||
behaviourally untested. The class is a central singleton driving daily audit purge;
|
||||
a regression that turned the failure path into a permanent hole would not surface in
|
||||
the test suite.
|
||||
|
||||
Separately, `RepositoryCoverageTests.DeleteDeploymentRecord_ViaStubAttachPath_RemovesEntity`
|
||||
covers the stub-attach delete path under the SQLite test fixture, but the fixture
|
||||
remaps `RowVersion` as a nullable concurrency token (`SqliteTestHelper`), so it does
|
||||
not exercise the production-shape `IsRowVersion()` auto-population — the actual
|
||||
concurrency-token bug flagged in CD-018 cannot show up. There is an
|
||||
`MsSqlMigrationFixture` in the test project already (used by the Audit Log migration
|
||||
tests); the stub-attach delete deserves a parallel MS-SQL-flavoured test.
|
||||
|
||||
**Recommendation**
|
||||
|
||||
(1) Add an `AuditLogPartitionMaintenanceTests` case that constructs a context against
|
||||
a constrained login (no `ALTER ON SCHEMA::dbo`), invokes `EnsureLookaheadAsync` for a
|
||||
three-month gap, and asserts: only the partition boundaries created BEFORE the
|
||||
permissions failure landed remain, and the call aborts cleanly without continuing to
|
||||
later months. This pins down the resolution of CD-019. (2) Add a
|
||||
`RepositoryCoverageTests` case that uses `MsSqlMigrationFixture` to insert a
|
||||
`DeploymentRecord`, clear the change tracker, call `DeleteDeploymentRecordAsync`,
|
||||
and assert the row is gone — pinning the resolution of CD-018. Both tests should be
|
||||
`[SkippableFact]` so the suite still passes when no MS SQL Server is available.
|
||||
|
||||
Reference in New Issue
Block a user