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.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.
|
||||
|
||||
Reference in New Issue
Block a user