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:
Joseph Doherty
2026-05-28 02:55:47 -04:00
parent 1eb6e972b0
commit f93b7b99bb
25 changed files with 8793 additions and 115 deletions
+271 -3
View File
@@ -5,10 +5,10 @@
| Module | `src/ScadaLink.Security` |
| Design doc | `docs/requirements/Component-Security.md` |
| Status | Reviewed |
| Last reviewed | 2026-05-17 |
| Last reviewed | 2026-05-28 |
| Reviewer | claude-agent |
| Commit reviewed | `39d737e` |
| Open findings | 0 (1 deferred — Security-008) |
| Commit reviewed | `1eb6e97` |
| Open findings | 6 (1 deferred — Security-008) |
## Summary
@@ -48,6 +48,36 @@ omits the separate idle check (Security-014). The two Low findings concern fragi
DN parsing of group names containing escaped commas and an un-trimmed username flowing
into the LDAP filter, fallback DN, and JWT claims.
#### Re-review 2026-05-28 (commit `1eb6e97`)
Re-reviewed the module on a fresh baseline. All Security-001..007, 009..015 fixes remain
in place; the only Open carry-over is Security-008 (still correctly **Deferred**
`ISecurityRepository` still exposes no per-set scope-rule query, so the N+1 in
`RoleMapper` cannot be removed from within this module). The original
Security-014 fix is now load-bearing: `RefreshToken` calls `IsIdleTimedOut` before
re-issuing, and the new cookie sliding-expiry tests in `SecurityReviewRegressionTests`
pin CentralUI-005's Security-side contract. This pass surfaced **6 new findings**
(Security-016..021): one Medium correctness/security defect, one Medium design-adherence
defect, and four Low. The most consequential is **Security-016** — when a user is
mapped to *both* a system-wide Deployment LDAP group (e.g. `SCADA-Deploy-All`) and a
site-scoped Deployment LDAP group (e.g. `SCADA-Deploy-SiteA`), `RoleMapper` silently
treats the union as site-scoped (the system-wide grant is dropped); the design's
"multiple groups grant multiple independent roles" intent is not honoured for this
mix-and-match case. **Security-017** is the cross-module partner of CentralUI-028:
`SiteScopeRequirement` / `SiteScopeAuthorizationHandler` are declared and registered
but no production caller ever instantiates them — `[Authorize(Policy = RequireDeployment)]`
*does not* enforce the documented site scoping, callers must remember to inject
`SiteScopeService` and re-check `IsSiteAllowedAsync` themselves (which the two new
report pages flagged by CentralUI-028 forgot to do). The remaining Lows are: role names
are magic strings duplicated across `RoleMapper`, `SiteScopeAuthorizationHandler`, and
`AuthorizationPolicies` (Security-018); a service-account-rebind failure is reported
to the user as "Invalid username or password" — masking a misconfiguration as a
user-credential error (Security-019); required `SecurityOptions` fields
(`LdapServer`, `LdapSearchBase`) have no `IValidateOptions` startup check, so empty
values silently surface only on first login (Security-020); and the
`RequireHttpsCookie=false` dev opt-out emits no warning, so an HTTP production
deployment silently transmits the JWT bearer credential in cleartext (Security-021).
## Checklist coverage
| # | Category | Examined | Notes |
@@ -63,6 +93,21 @@ into the LDAP filter, fallback DN, and JWT claims.
| 9 | Testing coverage | ☑ | No tests for `RoleMapper` N+1 behavior, DN-injection inputs, StartTLS path, or idle-timeout-after-refresh. Insecure-config combinations under-tested (Security-011). |
| 10 | Documentation & comments | ☑ | `SecurityOptions` XML docs say direct bind uses `cn={username}` while the search filter uses `uid=` — comment is misleading (covered under Security-004). |
_Re-review (2026-05-28, `1eb6e97`):_
| # | Category | Examined | Notes |
|---|----------|----------|-------|
| 1 | Correctness & logic bugs | ☑ | `RoleMapper` drops a system-wide Deployment grant when the user is also in any site-scoped Deployment group (Security-016); hard-coded role-name string `"Deployment"` in two separate places allows a refactor to silently break site scoping (Security-018). |
| 2 | Akka.NET conventions | ☑ | No actors. `AddSecurityActors` is still a registration placeholder. No issues. |
| 3 | Concurrency & thread safety | ☑ | Services stateless; LDAP sync calls wrapped in `Task.Run` with the now-bounded timeout (Security-009 resolution holds). No issues found. |
| 4 | Error handling & resilience | ☑ | A service-account-rebind failure inside `AuthenticateAsync` is reported as "Invalid username or password", masking a misconfiguration as a user-credential error (Security-019). LDAP-failure rule + partial-outage path remain correctly enforced post-Security-012. |
| 5 | Security | ☑ | `SiteScopeRequirement` / `SiteScopeAuthorizationHandler` are dead code — no policy is registered that uses them and no production caller instantiates them, so declarative `[Authorize]` does not enforce site scoping (Security-017, cross-module partner of CentralUI-028). `RequireHttpsCookie=false` dev opt-out has no warning path — a production misconfiguration silently transmits the JWT bearer credential over HTTP (Security-021). |
| 6 | Performance & resource management | ☑ | Security-008 N+1 remains correctly Deferred (still gated on `ISecurityRepository`). No new perf issues. |
| 7 | Design-document adherence | ☑ | `RoleMapper`'s drop-system-wide-on-any-scoped behaviour (Security-016) contradicts the design's "A user can hold multiple roles simultaneously … roles are independent — there is no implied hierarchy" rule for the union case; `SiteScopeRequirement` advertises a site-scope authorization pattern the implementation does not actually wire up (Security-017). |
| 8 | Code organization & conventions | ☑ | Role-name strings are duplicated as magic literals across `RoleMapper.cs`, `SiteScopeAuthorizationHandler.cs`, and `AuthorizationPolicies.cs` — only the audit roles have a single source of truth via `OperationalAuditRoles` / `AuditExportRoles` (Security-018). `SecurityOptions` defaults pass through to runtime with no `IValidateOptions` for required fields like `LdapServer` / `LdapSearchBase` (Security-020). |
| 9 | Testing coverage | ☑ | No test covers a user mapped to both a system-wide AND a site-scoped Deployment LDAP group (the Security-016 case). No test covers the `SiteScopeRequirement` cross-page integration — tests evaluate the handler in isolation, not the absence of a policy that uses it (Security-017). |
| 10 | Documentation & comments | ☑ | `SiteScopeAuthorizationHandler` XML doc describes a permission model no caller actually invokes (Security-017). Otherwise stable. |
## Findings
### Security-001 — StartTLS upgrade path is unreachable dead code
@@ -654,3 +699,226 @@ use the single canonical identity. Regression tests
`NormalizeUsername_TrimsLeadingAndTrailingWhitespace`,
`BuildFallbackUserDn_TrimmedUsername_NoLeadingTrailingSpace`,
`AuthenticateAsync_UsernameWithSurroundingWhitespace_StillRejectedForInsecure`.
### Security-016 — `RoleMapper` silently drops the system-wide Deployment grant when a user is also in any site-scoped Deployment group
| | |
|--|--|
| Severity | Medium |
| Category | Correctness & logic bugs |
| Status | Open |
| Location | `src/ScadaLink.Security/RoleMapper.cs:30-31`, `:41-55`, `:59` |
**Description**
`MapGroupsToRolesAsync` resolves the Deployment role's site scope as a single
`isSystemWide = hasDeploymentRole && !hasDeploymentWithScopeRules` flag computed across
ALL matched Deployment mappings. If a user is a member of both a system-wide Deployment
group (e.g. `SCADA-Deploy-All`, no scope rules) AND a site-scoped Deployment group
(e.g. `SCADA-Deploy-SiteA`, one scope rule for Site A), the second mapping sets
`hasDeploymentWithScopeRules = true`, so the final `isSystemWide` becomes `false` and
the returned `PermittedSiteIds` is just `[SiteA]`. The system-wide grant from
`SCADA-Deploy-All` is silently dropped — the user loses access to every other site, even
though one of their LDAP groups was intended to grant them system-wide reach. This
contradicts the design's "A user can hold multiple roles simultaneously … roles are
independent — there is no implied hierarchy" intent: the union of grants should be the
broadest grant in the set, not the narrowest. The mistake is also non-obvious to an
operator: from the Admin → LDAP Mappings page nothing flags that adding a site-scoped
Deployment mapping for a user already in `SCADA-Deploy-All` *removes* sites from their
effective grant. The downstream `SiteScopeService.IsSystemWideAsync` / `FilterSitesAsync`
faithfully reproduce this narrowing, so the user can no longer see or act on sites
outside `[SiteA]`.
**Recommendation**
Track the union semantics explicitly: if any matched Deployment mapping has no scope
rules, the user is system-wide regardless of what other mappings have. The simplest
change is to set `hasDeploymentWithScopeRules` only when the mapping has scope rules
AND another flag `hasUnscopedDeploymentMapping` is false; then compute
`isSystemWide = hasUnscopedDeploymentMapping || (hasDeploymentRole && !hasDeploymentWithScopeRules)`.
Equivalently: collect per-mapping `(hasRules, scopedSiteIds)` first, then
`isSystemWide = any mapping has hasRules==false`, and `permittedSiteIds = union of all
scopedSiteIds` (left empty for system-wide users). Add a regression test
`MapGroupsToRoles_UserInBothSystemWideAndScopedDeploymentGroup_IsSystemWide` covering
the design's example pair `SCADA-Deploy-All` + `SCADA-Deploy-SiteA`.
### Security-017 — `SiteScopeRequirement` / `SiteScopeAuthorizationHandler` are dead code from production callers — `[Authorize(Policy = RequireDeployment)]` does NOT enforce site scoping
| | |
|--|--|
| Severity | Medium |
| Category | Design-document adherence |
| Status | Open |
| Location | `src/ScadaLink.Security/SiteScopeAuthorizationHandler.cs:8-58`; `src/ScadaLink.Security/AuthorizationPolicies.cs:113-143` |
**Description**
The module declares `SiteScopeRequirement` (an `IAuthorizationRequirement` carrying a
`TargetSiteId`) and the matching `SiteScopeAuthorizationHandler` that combines the
Deployment role claim with the `SiteId` claims to enforce the design's site-scoping
rule. The handler is registered in `AddScadaLinkAuthorization`
(`services.AddSingleton<IAuthorizationHandler, SiteScopeAuthorizationHandler>()`). But
no `AddPolicy` call ever wires the requirement to a named policy, and a grep across
`src/ScadaLink.CentralUI` and `src/ScadaLink.ManagementService` confirms that **no
production code ever instantiates `new SiteScopeRequirement(...)` or calls
`AuthorizeAsync(...)` with one** — the only callers are the unit tests in
`SecurityTests.cs:1146,1166,1185,1203`. The design + CLAUDE.md state that "Deployment
and Monitoring pages must filter every site/instance list through `FilterSitesAsync`
and re-check `IsSiteAllowedAsync` before any cross-site command", and the
CentralUI-028 finding (High, Open) confirms this is exactly the contract two new
report pages forgot — because there is no declarative `[Authorize(Policy = ...)]`
shortcut, callers must remember to inject `SiteScopeService` and write the check by
hand, and any new page that forgets is a silent regression with no compile-time or
test-time signal. The module's published surface advertises an authorization-handler
pattern that is, in practice, unwired plumbing.
**Recommendation**
Either (a) **delete** `SiteScopeRequirement` and `SiteScopeAuthorizationHandler` (and
the dead `IAuthorizationHandler` registration) and document `SiteScopeService` as the
sole site-scoping mechanism — this is the smaller change and matches what the codebase
actually does today; or, preferably, (b) **finish the wiring**: add a `RequireSiteScope`
policy that uses `SiteScopeRequirement` and provide a small helper / source generator
or analyzer that flags Deployment-policy-attributed pages without a site-scope check.
Either way, address the cross-module gap: CentralUI-028 stays open until production
pages reliably enforce the rule. If (b) is chosen, a route-parameter-aware
`IAuthorizationPolicyProvider` is needed so the policy can read the target site id from
the request — that is a meaningful design extension and would need to be planned
alongside the Central UI's existing `SiteScopeService` usage rather than replacing it
piecemeal.
### Security-018 — Role names are hard-coded magic strings duplicated across `RoleMapper`, `SiteScopeAuthorizationHandler`, and `AuthorizationPolicies`
| | |
|--|--|
| Severity | Low |
| Category | Code organization & conventions |
| Status | Open |
| Location | `src/ScadaLink.Security/RoleMapper.cs:41`; `src/ScadaLink.Security/SiteScopeAuthorizationHandler.cs:36`; `src/ScadaLink.Security/AuthorizationPolicies.cs:118,121,124,95,107` |
**Description**
The role-name literals `"Admin"`, `"Design"`, `"Deployment"`, `"Audit"`, and
`"AuditReadOnly"` are duplicated as magic strings across three separate files:
`RoleMapper.cs:41` hard-codes `"Deployment"` to detect the site-scope branch;
`SiteScopeAuthorizationHandler.cs:36` independently hard-codes `"Deployment"` to gate
the handler; and `AuthorizationPolicies.cs:118,121,124` hard-code the four role names
as the policy `RequireClaim` values. Only the audit roles have a single source of truth
(via the `OperationalAuditRoles` / `AuditExportRoles` arrays on
`AuthorizationPolicies`). A future rename or addition of a role that misses any one of
these call sites silently breaks the system: e.g. renaming "Deployment" → "Deployer"
in `RoleMapper` alone would leave the policy still requiring `"Deployment"` (logins
get the new role name but the policy never matches), while changing it in the policy
alone would leave `RoleMapper` failing to populate scope rules for the renamed role.
The bug class is "string drift" — exactly the kind the `OperationalAuditRoles` constant
was introduced to prevent.
**Recommendation**
Introduce a `public static class Roles { public const string Admin = "Admin"; public const
string Design = "Design"; public const string Deployment = "Deployment"; public const string
Audit = "Audit"; public const string AuditReadOnly = "AuditReadOnly"; }` in the Security
project and replace every magic-string occurrence — including the elements of
`OperationalAuditRoles` and `AuditExportRoles` — with the constants. A single rename
will then either succeed everywhere or fail to compile.
### Security-019 — Service-account rebind failure is reported as "Invalid username or password" — masks misconfiguration as a user-credential error
| | |
|--|--|
| Severity | Low |
| Category | Error handling & resilience |
| Status | Open |
| Location | `src/ScadaLink.Security/LdapAuthService.cs:85-89`, `:147-151` |
**Description**
After the user's credentials bind successfully, `AuthenticateAsync` re-binds as the
configured service account to perform the group/attribute search
(`connection.Bind(_options.LdapServiceAccountDn, _options.LdapServiceAccountPassword)`).
A failure of this second bind — wrong service-account password, deleted/disabled
service-account, locked-out service-account — throws `LdapException` which is caught by
the broad outer `catch (LdapException)` and returned as
`new LdapAuthResult(false, null, username, null, "Invalid username or password.")`.
The user sees an "invalid credentials" message for *their* credentials even though
their bind succeeded and the failure was in the system's own service-account
configuration. Worse, every user attempting to log in sees the same incorrect message
during a service-account outage, which routes operators down the wrong incident path
(reset the user's password) instead of the right one (check the service-account
credentials). The successful user bind itself is also not auditable as a discrete
event because the result is "Invalid username or password" — indistinguishable from a
genuine bad-password attempt.
**Recommendation**
Wrap the service-account rebind in its own `try`/`catch (LdapException)` and surface a
distinct error: log `_logger.LogError(ex, "Service-account rebind failed; check
LdapServiceAccountDn / LdapServiceAccountPassword configuration")` and return
`new LdapAuthResult(false, null, username, null, "Authentication service is misconfigured. Contact an administrator.")`.
Add a regression test that exercises the service-account-bind failure path (a mocked
or seamed `LdapConnection.Bind` that throws on the second call) and asserts the
distinct error message.
### Security-020 — `SecurityOptions` has no startup validation for required fields (`LdapServer`, `LdapSearchBase`)
| | |
|--|--|
| Severity | Low |
| Category | Error handling & resilience |
| Status | Open |
| Location | `src/ScadaLink.Security/SecurityOptions.cs:6-7`, `:36-37`; `src/ScadaLink.Security/ServiceCollectionExtensions.cs:13-30` |
**Description**
`SecurityOptions.JwtSigningKey` correctly fails fast at `JwtTokenService` construction
(Security-003 fix), but the LDAP-side required fields — `LdapServer` (default
`string.Empty`) and `LdapSearchBase` (default `string.Empty`) — have no equivalent
guard. `AddSecurity` does not register an `IValidateOptions<SecurityOptions>`. A
deployment that fails to set `LdapServer` (a typo in the appsettings.json section name,
a missing environment-variable substitution, a misconfigured Docker compose file)
starts cleanly — the Central UI comes up, the login page loads, and only the first
authentication attempt fails with `LdapConnection.Connect("")` throwing a low-level
exception that bubbles up as the generic "An unexpected error occurred during
authentication." message. The misconfiguration surfaces minutes or hours into the
deploy, on the first real user login, rather than at startup where it is cheap to
diagnose.
**Recommendation**
Add an `IValidateOptions<SecurityOptions>` registered via
`services.AddOptions<SecurityOptions>().ValidateOnStart()` that fails when
`LdapServer` is null/whitespace, `LdapSearchBase` is null/whitespace, or
`LdapPort <= 0`. Combine with the existing `JwtTokenService` constructor check so
every required `SecurityOptions` field is enforced at startup, not at first use.
### Security-021 — `RequireHttpsCookie=false` dev opt-out has no warning path — an HTTP production deployment silently transmits the JWT bearer credential in cleartext
| | |
|--|--|
| Severity | Low |
| Category | Security |
| Status | Open |
| Location | `src/ScadaLink.Security/SecurityOptions.cs:100-108`; `src/ScadaLink.Security/ServiceCollectionExtensions.cs:54-59` |
**Description**
The Security-002 fix added `RequireHttpsCookie` (default `true`) so the auth cookie's
`SecurePolicy` is `Always` in production. The current Docker dev cluster sets
`RequireHttpsCookie=false` in both central nodes' `appsettings.Central.json`, downgrading
to `SameAsRequest` so the local HTTP cluster works. The downgrade is documented in the
XML doc but is silent at runtime: no log line warns that the cookie carrying the JWT
bearer credential is being sent over an HTTP-only path. A production deployment that
inherits a dev-derived appsettings — or that copy-pastes the docker config and forgets
to flip the flag — transmits the session token in cleartext with no diagnostic signal.
The default is correct; the gap is that the unsafe override has no operational guard.
**Recommendation**
In the `PostConfigure` block in `AddSecurity`, when `RequireHttpsCookie == false`, log
a single startup warning along the lines of `_logger.LogWarning("RequireHttpsCookie is
DISABLED — auth cookie SecurePolicy is SameAsRequest. The cookie-embedded JWT will be
transmitted over plain HTTP. This setting is intended for local dev only — set
SecurityOptions:RequireHttpsCookie=true in production.")`. Optionally, also fail
startup when `RequireHttpsCookie=false` AND `ASPNETCORE_ENVIRONMENT=Production`. Add a
regression test that asserts the warning is emitted when the flag is disabled and not
when it is enabled.