fix(security): close auth & site-scoping gaps across 8 findings

Resolves the auth-theme batch from the 2026-05-28 baseline review (8 findings
across Security/CentralUI/ManagementService/CLI). The most consequential gaps:
NotificationReport + SiteCallsReport now route through SiteScopeService so a
site-scoped Deployment user cannot see or act on other sites' rows (CUI-028);
QueryAuditLogCommand is no longer "any authenticated user" — gated Admin-only
to match /api/audit/query's strictness (MS-018); RoleMapper preserves the
broader grant when a user is in both an unscoped and scoped Deployment LDAP
group, instead of silently narrowing to the scoped set (Sec-016); and the
dead SiteScopeRequirement/Handler are deleted so SiteScopeService is
unambiguously the sole site-scoping mechanism (Sec-017). Pending findings:
172 → 164.
This commit is contained in:
Joseph Doherty
2026-05-28 03:35:29 -04:00
parent f93b7b99bb
commit e536178323
28 changed files with 814 additions and 196 deletions
+18 -3
View File
@@ -8,7 +8,7 @@
| Last reviewed | 2026-05-28 | | Last reviewed | 2026-05-28 |
| Reviewer | claude-agent | | Reviewer | claude-agent |
| Commit reviewed | `1eb6e97` | | Commit reviewed | `1eb6e97` |
| Open findings | 7 | | Open findings | 6 |
## Summary ## Summary
@@ -836,7 +836,7 @@ _Unresolved._
|--|--| |--|--|
| Severity | Medium | | Severity | Medium |
| Category | Error handling & resilience | | Category | Error handling & resilience |
| Status | Open | | Status | Resolved |
| Location | `src/ScadaLink.CLI/Commands/AuditQueryHelpers.cs:186-193`, `src/ScadaLink.CLI/Commands/AuditExportHelpers.cs:147-153` | | Location | `src/ScadaLink.CLI/Commands/AuditQueryHelpers.cs:186-193`, `src/ScadaLink.CLI/Commands/AuditExportHelpers.cs:147-153` |
**Description** **Description**
@@ -867,7 +867,22 @@ audit `SendGetAsync` already populates.
**Resolution** **Resolution**
_Unresolved._ Resolved 2026-05-28 (commit pending). Promoted `CommandHelpers.IsAuthorizationFailure`
from `private` to `internal` so both helpers can reuse the same auth-failure rule
(HTTP 403 OR `FORBIDDEN`/`UNAUTHORIZED` error code, case-insensitive).
`AuditQueryHelpers.RunQueryAsync` now returns
`CommandHelpers.IsAuthorizationFailure(response) ? 2 : 1` on the error path instead
of an unconditional 1. `AuditExportHelpers.RunExportAsync` doesn't ride
`ManagementResponse` (it streams directly via `SendGetStreamAsync`), so a new
`AuditExportHelpers.TryExtractErrorCode` helper parses the server's JSON error
envelope to extract `code`, and the `!IsSuccessStatusCode` branch returns exit 2 on
either HTTP 403 or a `FORBIDDEN`/`UNAUTHORIZED` envelope code. Regression tests:
`AuditQueryCommandTests.RunQuery_Http403_ReturnsExitCode2`,
`..._UnauthorizedCodeOnNon403_ReturnsExitCode2`,
`..._GenericServerError_ReturnsExitCode1` (negative guard);
`AuditExportCommandTests.RunExport_Http403_ReturnsExitCode2`,
`..._UnauthorizedCodeOnNon403_ReturnsExitCode2`. All five fail on the pre-fix code
and pass after.
### CLI-019 — `bundle export` decodes the entire base64 bundle into memory before writing ### CLI-019 — `bundle export` decodes the entire base64 bundle into memory before writing
+25 -2
View File
@@ -8,7 +8,7 @@
| Last reviewed | 2026-05-28 | | Last reviewed | 2026-05-28 |
| Reviewer | claude-agent | | Reviewer | claude-agent |
| Commit reviewed | `1eb6e97` | | Commit reviewed | `1eb6e97` |
| Open findings | 8 | | Open findings | 7 |
## Summary ## Summary
@@ -1341,7 +1341,7 @@ at least one representative page so the helper's continued use is enforced.
|--|--| |--|--|
| Severity | High | | Severity | High |
| Category | Security | | Category | Security |
| Status | Open | | Status | Resolved |
| Location | `src/ScadaLink.CentralUI/Components/Pages/Notifications/NotificationReport.razor:2,434,472,502`; `src/ScadaLink.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor:2,52-59`; `src/ScadaLink.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor.cs:97-110,201,250-251,278-279` | | Location | `src/ScadaLink.CentralUI/Components/Pages/Notifications/NotificationReport.razor:2,434,472,502`; `src/ScadaLink.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor:2,52-59`; `src/ScadaLink.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor.cs:97-110,201,250-251,278-279` |
**Description** **Description**
@@ -1378,6 +1378,29 @@ Add `Site_ScopedDeploymentUser_OnlySeesPermittedRows` and
`Site_ScopedDeploymentUser_CannotRetryRowOnNonPermittedSite` regression tests modelled `Site_ScopedDeploymentUser_CannotRetryRowOnNonPermittedSite` regression tests modelled
on `TopologyPageTests.SiteScoping_*`. on `TopologyPageTests.SiteScoping_*`.
**Resolution**
Resolved 2026-05-28 (commit pending). Both pages now inject `SiteScopeService` and apply
three layers of restriction. (1) `OnInitializedAsync` keeps an unfiltered `_allSites`
list as the source of truth for site-identifier → Site.Id lookups, runs the dropdown
through `SiteScope.FilterSitesAsync`, and caches `IsSystemWideAsync` + permitted-site
ids so the row-level filter is synchronous. (2) The query response is run through a new
`FilterPermittedAsync` helper that drops any row whose `SourceSiteId` / `SourceSite`
resolves (via the unfiltered list) to a Site.Id outside the permitted set — a stale
source-site identifier not present in the loaded list defaults to allowed, mirroring
the existing tolerance for deleted-site rows. (3) `RetryNotification` /
`DiscardNotification` / `RetrySiteCall` / `DiscardSiteCall` each re-check
`IsRowSiteAllowedAsync` against the row's site BEFORE relaying, surfacing
"You are not permitted to act on …" via toast on failure. Cross-module partner
Security-017 was resolved in the same batch (the dead `SiteScopeAuthorizationHandler`
was deleted; `SiteScopeService` is now documented as the sole site-scoping mechanism).
Regression test `SiteCallsReportPageTests.SiteScoping_ScopedDeploymentUser_HidesOutOfScopeRows`
seeds a Deployment user with a single `SiteId=1` claim, asserts only the Plant-A row
renders, and verifies the Plant-B row is dropped (the page's row count drops from 2 to
1). All three existing report-page test fixtures register `SiteScopeService` so the
default system-wide path is unaffected — the full `ScadaLink.CentralUI.Tests` suite
still passes (568 / 568).
### CentralUI-029 — `ConfigurationAuditLog` uses `JS.InvokeAsync<int>("eval", ...)` instead of a dedicated JS module ### CentralUI-029 — `ConfigurationAuditLog` uses `JS.InvokeAsync<int>("eval", ...)` instead of a dedicated JS module
| | | | | |
+37 -3
View File
@@ -8,7 +8,7 @@
| Last reviewed | 2026-05-28 | | Last reviewed | 2026-05-28 |
| Reviewer | claude-agent | | Reviewer | claude-agent |
| Commit reviewed | `1eb6e97` | | Commit reviewed | `1eb6e97` |
| Open findings | 6 (1 Deferred — see ManagementService-012) | | Open findings | 4 (1 Deferred — see ManagementService-012) |
## Summary ## Summary
@@ -796,7 +796,7 @@ Deployment user and an Admin user, in- and out-of-scope
|--|--| |--|--|
| Severity | High | | Severity | High |
| Category | Security | | Category | Security |
| Status | Open | | Status | Resolved |
| Location | `src/ScadaLink.ManagementService/ManagementActor.cs:153``:207`, `:336`, `:1302` | | Location | `src/ScadaLink.ManagementService/ManagementActor.cs:153``:207`, `:336`, `:1302` |
**Description** **Description**
@@ -838,13 +838,26 @@ Recommended: option 1 plus a deprecation comment on `QueryAuditLogCommand` point
so the ManagementActor route is redundant. Add a regression test asserting that a so the ManagementActor route is redundant. Add a regression test asserting that a
no-role / `Deployment`-only caller gets `ManagementUnauthorized` for `QueryAuditLogCommand`. no-role / `Deployment`-only caller gets `ManagementUnauthorized` for `QueryAuditLogCommand`.
**Resolution**
Resolved 2026-05-28 (commit pending) per recommendation option 1. `QueryAuditLogCommand`
was added to the Admin-required group in `GetRequiredRole`, with an inline comment
documenting the deliberate strictness vs. the keyset-paged `/api/audit/query`
(`OperationalAuditRoles`) and pointing new audit consumers at the REST endpoint.
The CentralUI `ConfigurationAuditLog` page reads via `ICentralUiRepository` directly
(not through this command), so the gate tightening does not break any UI flow. Two
regression tests pin the new behaviour:
`QueryAuditLogCommand_WithNoRoles_ReturnsUnauthorized` and
`QueryAuditLogCommand_WithDeploymentRole_ReturnsUnauthorized` — both fail on the
pre-fix code (the command fell through to "any authenticated user") and pass after.
### ManagementService-019 — AuditEndpoints builds PermittedSiteIds but never enforces them ### ManagementService-019 — AuditEndpoints builds PermittedSiteIds but never enforces them
| | | | | |
|--|--| |--|--|
| Severity | Medium | | Severity | Medium |
| Category | Security | | Category | Security |
| Status | Open | | Status | Resolved |
| Location | `src/ScadaLink.ManagementService/AuditEndpoints.cs:358``:368`, `:397``:437` | | Location | `src/ScadaLink.ManagementService/AuditEndpoints.cs:358``:368`, `:397``:437` |
**Description** **Description**
@@ -887,6 +900,27 @@ Recommended: option 1, mirroring the `ManagementActor` pattern — same security
across both surfaces. Add a regression test that a site-scoped `AuditReadOnly` user across both surfaces. Add a regression test that a site-scoped `AuditReadOnly` user
filtering on an out-of-scope site gets a 403 (or an empty page). filtering on an out-of-scope site gets a 403 (or an empty page).
**Resolution**
Resolved 2026-05-28 (commit pending) per recommendation option 1. Added a public
helper `AuditEndpoints.ApplySiteScope(AuditLogQueryFilter, AuthenticatedUser)` that
returns the restricted filter (or `null` when the caller explicitly asks for an
out-of-scope site). Three cases:
- Empty `PermittedSiteIds` (Admin or any unscoped role) → filter returned unchanged.
- Scoped user with empty caller filter → `SourceSiteIds` set to the permitted set.
- Scoped user with explicit `SourceSiteIds` → intersected with the permitted set;
empty intersection returns `null` so `HandleQuery` / `HandleExport` emit a 403
rather than silently producing an empty page.
Both `HandleQuery` and `HandleExport` now call the helper after the role check and
short-circuit to `Forbidden("OperationalAudit"|"AuditExport")` on a `null` result.
Audit roles remain non-site-scoped by design (the design doc unchanged), but the
helper honours scope rules if an operator attaches them via the LDAP-mapping UI,
matching the existing `ManagementActor` pattern. Regression tests added in
`AuditEndpointsTests.ApplySiteScope_*` (5 tests): system-wide unchanged,
empty-caller-filter restricted, in-scope kept verbatim, out-of-scope returns null,
mixed-set intersected.
### ManagementService-020 — UpdateSmtpConfig returns and audits the SMTP Credentials field verbatim ### ManagementService-020 — UpdateSmtpConfig returns and audits the SMTP Credentials field verbatim
| | | | | |
+11 -19
View File
@@ -40,18 +40,18 @@ module file and counted in **Total**.
| Severity | Open findings | | Severity | Open findings |
|----------|---------------| |----------|---------------|
| Critical | 0 | | Critical | 0 |
| High | 18 | | High | 16 |
| Medium | 62 | | Medium | 58 |
| Low | 92 | | Low | 90 |
| **Total** | **172** | | **Total** | **164** |
## Module Status ## Module Status
| Module | Last reviewed | Commit | Open (C/H/M/L) | Open | Total | | Module | Last reviewed | Commit | Open (C/H/M/L) | Open | Total |
|--------|---------------|--------|----------------|------|-------| |--------|---------------|--------|----------------|------|-------|
| [AuditLog](AuditLog/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/3/8 | 11 | 11 | | [AuditLog](AuditLog/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/3/8 | 11 | 11 |
| [CLI](CLI/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/3/4 | 7 | 23 | | [CLI](CLI/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/2/4 | 6 | 23 |
| [CentralUI](CentralUI/findings.md) | 2026-05-28 | `1eb6e97` | 0/1/2/5 | 8 | 33 | | [CentralUI](CentralUI/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/2/5 | 7 | 33 |
| [ClusterInfrastructure](ClusterInfrastructure/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/4 | 4 | 14 | | [ClusterInfrastructure](ClusterInfrastructure/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/4 | 4 | 14 |
| [Commons](Commons/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/3/6 | 9 | 23 | | [Commons](Commons/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/3/6 | 9 | 23 |
| [Communication](Communication/findings.md) | 2026-05-28 | `1eb6e97` | 0/1/1/5 | 7 | 22 | | [Communication](Communication/findings.md) | 2026-05-28 | `1eb6e97` | 0/1/1/5 | 7 | 22 |
@@ -62,10 +62,10 @@ module file and counted in **Total**.
| [HealthMonitoring](HealthMonitoring/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/2/5 | 7 | 23 | | [HealthMonitoring](HealthMonitoring/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/2/5 | 7 | 23 |
| [Host](Host/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/2/5 | 7 | 22 | | [Host](Host/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/2/5 | 7 | 22 |
| [InboundAPI](InboundAPI/findings.md) | 2026-05-28 | `1eb6e97` | 0/1/3/4 | 8 | 25 | | [InboundAPI](InboundAPI/findings.md) | 2026-05-28 | `1eb6e97` | 0/1/3/4 | 8 | 25 |
| [ManagementService](ManagementService/findings.md) | 2026-05-28 | `1eb6e97` | 0/1/3/2 | 6 | 23 | | [ManagementService](ManagementService/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/2/2 | 4 | 23 |
| [NotificationOutbox](NotificationOutbox/findings.md) | 2026-05-28 | `1eb6e97` | 0/2/5/3 | 10 | 10 | | [NotificationOutbox](NotificationOutbox/findings.md) | 2026-05-28 | `1eb6e97` | 0/2/5/3 | 10 | 10 |
| [NotificationService](NotificationService/findings.md) | 2026-05-28 | `1eb6e97` | 0/2/2/3 | 7 | 25 | | [NotificationService](NotificationService/findings.md) | 2026-05-28 | `1eb6e97` | 0/2/2/3 | 7 | 25 |
| [Security](Security/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/2/4 | 6 | 21 | | [Security](Security/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/2 | 2 | 21 |
| [SiteCallAudit](SiteCallAudit/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/2/4 | 6 | 6 | | [SiteCallAudit](SiteCallAudit/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/2/4 | 6 | 6 |
| [SiteEventLogging](SiteEventLogging/findings.md) | 2026-05-28 | `1eb6e97` | 0/1/2/6 | 9 | 23 | | [SiteEventLogging](SiteEventLogging/findings.md) | 2026-05-28 | `1eb6e97` | 0/1/2/6 | 9 | 23 |
| [SiteRuntime](SiteRuntime/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/4/3 | 7 | 26 | | [SiteRuntime](SiteRuntime/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/4/3 | 7 | 26 |
@@ -84,18 +84,16 @@ description, location, recommendation — lives in the module's `findings.md`.
_None open._ _None open._
### High (18) ### High (16)
| ID | Module | Title | | ID | Module | Title |
|----|--------|-------| |----|--------|-------|
| CentralUI-028 | [CentralUI](CentralUI/findings.md) | `NotificationReport` and `SiteCallsReport` bypass `SiteScopeService` — Deployment role site-scoping defeated on the two new central-mirror pages |
| Communication-016 | [Communication](Communication/findings.md) | `HandleConnectionStateChanged` is dead code — the documented disconnect-cleanup workflow never fires | | Communication-016 | [Communication](Communication/findings.md) | `HandleConnectionStateChanged` is dead code — the documented disconnect-cleanup workflow never fires |
| ConfigurationDatabase-015 | [ConfigurationDatabase](ConfigurationDatabase/findings.md) | `NotificationOutboxRepository.InsertIfNotExistsAsync` is a check-then-act race with no duplicate-key catch | | ConfigurationDatabase-015 | [ConfigurationDatabase](ConfigurationDatabase/findings.md) | `NotificationOutboxRepository.InsertIfNotExistsAsync` is a check-then-act race with no duplicate-key catch |
| DataConnectionLayer-018 | [DataConnectionLayer](DataConnectionLayer/findings.md) | Concurrent subscribes for the same tag from different instances orphan an adapter subscription handle | | DataConnectionLayer-018 | [DataConnectionLayer](DataConnectionLayer/findings.md) | Concurrent subscribes for the same tag from different instances orphan an adapter subscription handle |
| DeploymentManager-018 | [DeploymentManager](DeploymentManager/findings.md) | Reconciliation force-sets `Enabled`, overwriting an intentional `Disabled` after central failover | | DeploymentManager-018 | [DeploymentManager](DeploymentManager/findings.md) | Reconciliation force-sets `Enabled`, overwriting an intentional `Disabled` after central failover |
| ExternalSystemGateway-018 | [ExternalSystemGateway](ExternalSystemGateway/findings.md) | `DeliverBufferedAsync` lets `JsonException` propagate, turning a corrupt buffered row into a permanent retry-forever poison message | | ExternalSystemGateway-018 | [ExternalSystemGateway](ExternalSystemGateway/findings.md) | `DeliverBufferedAsync` lets `JsonException` propagate, turning a corrupt buffered row into a permanent retry-forever poison message |
| InboundAPI-022 | [InboundAPI](InboundAPI/findings.md) | `IActiveNodeGate` has no production registration in Host — standby-node gating is silently disabled in production | | InboundAPI-022 | [InboundAPI](InboundAPI/findings.md) | `IActiveNodeGate` has no production registration in Host — standby-node gating is silently disabled in production |
| ManagementService-018 | [ManagementService](ManagementService/findings.md) | QueryAuditLogCommand has no role gate |
| NotificationOutbox-001 | [NotificationOutbox](NotificationOutbox/findings.md) | `EmailNotificationDeliveryAdapter` inherits the OAuth2 empty-user SASL bug (NS-021) on the M365 send path | | NotificationOutbox-001 | [NotificationOutbox](NotificationOutbox/findings.md) | `EmailNotificationDeliveryAdapter` inherits the OAuth2 empty-user SASL bug (NS-021) on the M365 send path |
| NotificationOutbox-002 | [NotificationOutbox](NotificationOutbox/findings.md) | Dispatcher parks on first transient failure when `SmtpConfiguration.MaxRetries == 0` | | NotificationOutbox-002 | [NotificationOutbox](NotificationOutbox/findings.md) | Dispatcher parks on first transient failure when `SmtpConfiguration.MaxRetries == 0` |
| NotificationService-019 | [NotificationService](NotificationService/findings.md) | `NotificationDeliveryService` and `INotificationDeliveryService` are orphaned by the central-only redesign | | NotificationService-019 | [NotificationService](NotificationService/findings.md) | `NotificationDeliveryService` and `INotificationDeliveryService` are orphaned by the central-only redesign |
@@ -107,7 +105,7 @@ _None open._
| Transport-002 | [Transport](Transport/findings.md) | ExternalSystem Overwrite never syncs methods | | Transport-002 | [Transport](Transport/findings.md) | ExternalSystem Overwrite never syncs methods |
| Transport-003 | [Transport](Transport/findings.md) | Unlock lockout is enforced only client-side; server session is never marked Locked | | Transport-003 | [Transport](Transport/findings.md) | Unlock lockout is enforced only client-side; server session is never marked Locked |
### Medium (62) ### Medium (58)
| ID | Module | Title | | ID | Module | Title |
|----|--------|-------| |----|--------|-------|
@@ -115,7 +113,6 @@ _None open._
| AuditLog-004 | [AuditLog](AuditLog/findings.md) | `SiteAuditReconciliationActor` advances cursor even on per-row insert failure, silently abandoning permanently-failing rows | | AuditLog-004 | [AuditLog](AuditLog/findings.md) | `SiteAuditReconciliationActor` advances cursor even on per-row insert failure, silently abandoning permanently-failing rows |
| AuditLog-005 | [AuditLog](AuditLog/findings.md) | `GetBacklogStatsAsync` holds the SQLite hot-path write lock for the full COUNT+MIN scan | | AuditLog-005 | [AuditLog](AuditLog/findings.md) | `GetBacklogStatsAsync` holds the SQLite hot-path write lock for the full COUNT+MIN scan |
| CLI-017 | [CLI](CLI/findings.md) | `BundleCommands.RunBundleCommandAsync` duplicates `ExecuteCommandAsync` and breaks the auth exit-code contract | | CLI-017 | [CLI](CLI/findings.md) | `BundleCommands.RunBundleCommandAsync` duplicates `ExecuteCommandAsync` and breaks the auth exit-code contract |
| CLI-018 | [CLI](CLI/findings.md) | `audit query` and `audit export` never return exit 2 for an authorization failure |
| CLI-019 | [CLI](CLI/findings.md) | `bundle export` decodes the entire base64 bundle into memory before writing | | CLI-019 | [CLI](CLI/findings.md) | `bundle export` decodes the entire base64 bundle into memory before writing |
| CentralUI-026 | [CentralUI](CentralUI/findings.md) | `AuditFilterBar` From/To filters treat browser-local datetimes as UTC | | CentralUI-026 | [CentralUI](CentralUI/findings.md) | `AuditFilterBar` From/To filters treat browser-local datetimes as UTC |
| CentralUI-027 | [CentralUI](CentralUI/findings.md) | Same UTC misinterpretation in `SiteCallsReport`, `NotificationReport`, and `EventLogs` | | CentralUI-027 | [CentralUI](CentralUI/findings.md) | Same UTC misinterpretation in `SiteCallsReport`, `NotificationReport`, and `EventLogs` |
@@ -141,7 +138,6 @@ _None open._
| InboundAPI-018 | [InboundAPI](InboundAPI/findings.md) | `AuditWriteMiddleware` fires `WriteAsync` as `_ = task` — faulted async writes are unobserved | | InboundAPI-018 | [InboundAPI](InboundAPI/findings.md) | `AuditWriteMiddleware` fires `WriteAsync` as `_ = task` — faulted async writes are unobserved |
| InboundAPI-021 | [InboundAPI](InboundAPI/findings.md) | `ParentExecutionId` correlation flows only through `Call`; attribute reads/writes lose the inbound→site execution-tree link | | InboundAPI-021 | [InboundAPI](InboundAPI/findings.md) | `ParentExecutionId` correlation flows only through `Call`; attribute reads/writes lose the inbound→site execution-tree link |
| InboundAPI-025 | [InboundAPI](InboundAPI/findings.md) | `AuditWriteMiddleware` runs against the entire `/api/*` branch — emits spurious `ApiInbound` audit rows for `/api/audit/query` and `/api/audit/export` | | InboundAPI-025 | [InboundAPI](InboundAPI/findings.md) | `AuditWriteMiddleware` runs against the entire `/api/*` branch — emits spurious `ApiInbound` audit rows for `/api/audit/query` and `/api/audit/export` |
| ManagementService-019 | [ManagementService](ManagementService/findings.md) | AuditEndpoints builds PermittedSiteIds but never enforces them |
| ManagementService-020 | [ManagementService](ManagementService/findings.md) | UpdateSmtpConfig returns and audits the SMTP Credentials field verbatim | | ManagementService-020 | [ManagementService](ManagementService/findings.md) | UpdateSmtpConfig returns and audits the SMTP Credentials field verbatim |
| ManagementService-021 | [ManagementService](ManagementService/findings.md) | Transport bundle handlers have zero test coverage | | ManagementService-021 | [ManagementService](ManagementService/findings.md) | Transport bundle handlers have zero test coverage |
| NotificationOutbox-003 | [NotificationOutbox](NotificationOutbox/findings.md) | Dispatcher does not propagate a `CancellationToken` into delivery; in-flight SMTP sends cannot be cancelled on shutdown | | NotificationOutbox-003 | [NotificationOutbox](NotificationOutbox/findings.md) | Dispatcher does not propagate a `CancellationToken` into delivery; in-flight SMTP sends cannot be cancelled on shutdown |
@@ -151,8 +147,6 @@ _None open._
| NotificationOutbox-010 | [NotificationOutbox](NotificationOutbox/findings.md) | Comment claims `PipeTo` is not used "because the writer never throws"; the surrounding try/catch is dead-letter for the documented failure mode | | NotificationOutbox-010 | [NotificationOutbox](NotificationOutbox/findings.md) | Comment claims `PipeTo` is not used "because the writer never throws"; the surrounding try/catch is dead-letter for the documented failure mode |
| NotificationService-020 | [NotificationService](NotificationService/findings.md) | NS-001 fix superseded; `AkkaHostedService` would register two competing `Notification` S&F handlers if both code paths ran | | NotificationService-020 | [NotificationService](NotificationService/findings.md) | NS-001 fix superseded; `AkkaHostedService` would register two competing `Notification` S&F handlers if both code paths ran |
| NotificationService-024 | [NotificationService](NotificationService/findings.md) | No test affirms the central-only invariant; the orphaned-path tests give a false coverage signal | | NotificationService-024 | [NotificationService](NotificationService/findings.md) | No test affirms the central-only invariant; the orphaned-path tests give a false coverage signal |
| Security-016 | [Security](Security/findings.md) | `RoleMapper` silently drops the system-wide Deployment grant when a user is also in any site-scoped Deployment group |
| Security-017 | [Security](Security/findings.md) | `SiteScopeRequirement` / `SiteScopeAuthorizationHandler` are dead code from production callers — `[Authorize(Policy = RequireDeployment)]` does NOT enforce site scoping |
| SiteCallAudit-001 | [SiteCallAudit](SiteCallAudit/findings.md) | SupervisorStrategy override is dead code; XML claims Resume that is not enforced | | SiteCallAudit-001 | [SiteCallAudit](SiteCallAudit/findings.md) | SupervisorStrategy override is dead code; XML claims Resume that is not enforced |
| SiteCallAudit-003 | [SiteCallAudit](SiteCallAudit/findings.md) | `OnUpsertAsync` does not refresh `IngestedAtUtc`; direct-write callers must remember to stamp it | | SiteCallAudit-003 | [SiteCallAudit](SiteCallAudit/findings.md) | `OnUpsertAsync` does not refresh `IngestedAtUtc`; direct-write callers must remember to stamp it |
| SiteEventLogging-015 | [SiteEventLogging](SiteEventLogging/findings.md) | Background write queue is unbounded; can grow without limit under sustained writer slowness | | SiteEventLogging-015 | [SiteEventLogging](SiteEventLogging/findings.md) | Background write queue is unbounded; can grow without limit under sustained writer slowness |
@@ -174,7 +168,7 @@ _None open._
| Transport-007 | [Transport](Transport/findings.md) | Failed import sessions retain decrypted plaintext for the full 30-minute TTL | | Transport-007 | [Transport](Transport/findings.md) | Failed import sessions retain decrypted plaintext for the full 30-minute TTL |
| Transport-010 | [Transport](Transport/findings.md) | Critical Overwrite + cross-cutting paths uncovered by tests | | Transport-010 | [Transport](Transport/findings.md) | Critical Overwrite + cross-cutting paths uncovered by tests |
### Low (92) ### Low (90)
| ID | Module | Title | | ID | Module | Title |
|----|--------|-------| |----|--------|-------|
@@ -245,8 +239,6 @@ _None open._
| NotificationService-022 | [NotificationService](NotificationService/findings.md) | `MailKitSmtpClientWrapper` holds a long-lived `SmtpClient`; combined with per-send factory, the design comment about pooling is contradicted | | NotificationService-022 | [NotificationService](NotificationService/findings.md) | `MailKitSmtpClientWrapper` holds a long-lived `SmtpClient`; combined with per-send factory, the design comment about pooling is contradicted |
| NotificationService-023 | [NotificationService](NotificationService/findings.md) | XML docs on the orphaned classes still describe the removed site-delivery flow; misleading to maintainers | | NotificationService-023 | [NotificationService](NotificationService/findings.md) | XML docs on the orphaned classes still describe the removed site-delivery flow; misleading to maintainers |
| NotificationService-025 | [NotificationService](NotificationService/findings.md) | `CredentialRedactor` over-masks: any 4-character credential component is masked anywhere it appears, including unrelated log text | | NotificationService-025 | [NotificationService](NotificationService/findings.md) | `CredentialRedactor` over-masks: any 4-character credential component is masked anywhere it appears, including unrelated log text |
| Security-018 | [Security](Security/findings.md) | Role names are hard-coded magic strings duplicated across `RoleMapper`, `SiteScopeAuthorizationHandler`, and `AuthorizationPolicies` |
| Security-019 | [Security](Security/findings.md) | Service-account rebind failure is reported as "Invalid username or password" — masks misconfiguration as a user-credential error |
| Security-020 | [Security](Security/findings.md) | `SecurityOptions` has no startup validation for required fields (`LdapServer`, `LdapSearchBase`) | | Security-020 | [Security](Security/findings.md) | `SecurityOptions` has no startup validation for required fields (`LdapServer`, `LdapSearchBase`) |
| Security-021 | [Security](Security/findings.md) | `RequireHttpsCookie=false` dev opt-out has no warning path — an HTTP production deployment silently transmits the JWT bearer credential in cleartext | | Security-021 | [Security](Security/findings.md) | `RequireHttpsCookie=false` dev opt-out has no warning path — an HTTP production deployment silently transmits the JWT bearer credential in cleartext |
| SiteCallAudit-002 | [SiteCallAudit](SiteCallAudit/findings.md) | Singleton failover does not wait for in-flight async upserts | | SiteCallAudit-002 | [SiteCallAudit](SiteCallAudit/findings.md) | Singleton failover does not wait for in-flight async upserts |
+65 -5
View File
@@ -8,7 +8,7 @@
| Last reviewed | 2026-05-28 | | Last reviewed | 2026-05-28 |
| Reviewer | claude-agent | | Reviewer | claude-agent |
| Commit reviewed | `1eb6e97` | | Commit reviewed | `1eb6e97` |
| Open findings | 6 (1 deferred Security-008) | | Open findings | 2 (Security-020, Security-021); 1 deferred (Security-008) |
## Summary ## Summary
@@ -706,7 +706,7 @@ use the single canonical identity. Regression tests
|--|--| |--|--|
| Severity | Medium | | Severity | Medium |
| Category | Correctness & logic bugs | | Category | Correctness & logic bugs |
| Status | Open | | Status | Resolved |
| Location | `src/ScadaLink.Security/RoleMapper.cs:30-31`, `:41-55`, `:59` | | Location | `src/ScadaLink.Security/RoleMapper.cs:30-31`, `:41-55`, `:59` |
**Description** **Description**
@@ -742,13 +742,29 @@ scopedSiteIds` (left empty for system-wide users). Add a regression test
`MapGroupsToRoles_UserInBothSystemWideAndScopedDeploymentGroup_IsSystemWide` covering `MapGroupsToRoles_UserInBothSystemWideAndScopedDeploymentGroup_IsSystemWide` covering
the design's example pair `SCADA-Deploy-All` + `SCADA-Deploy-SiteA`. the design's example pair `SCADA-Deploy-All` + `SCADA-Deploy-SiteA`.
**Resolution**
Resolved 2026-05-28 (commit pending). `RoleMapper.MapGroupsToRolesAsync` now tracks two
independent flags per matched Deployment mapping: `hasUnscopedDeploymentMapping` (any
matched mapping with no scope rules) and `hasScopedDeploymentMapping` (any matched
mapping with scope rules). `isSystemWide` is `hasUnscopedDeploymentMapping ||
(hasDeploymentRole && !hasScopedDeploymentMapping)` — so a user in both
`SCADA-Deploy-All` and `SCADA-Deploy-SiteA` is now correctly system-wide, with the
accumulated scope ids cleared. Magic string `"Deployment"` was replaced with the new
`Roles.Deployment` constant (Security-018). Regression test
`MapGroupsToRoles_UserInBothSystemWideAndScopedDeploymentGroup_IsSystemWide`
(seeds Site A, attaches a scope rule to the seeded `SCADA-Deploy-SiteA` mapping, and
asserts a user mapped via both `SCADA-Deploy-All` and `SCADA-Deploy-SiteA` resolves
to system-wide with empty `PermittedSiteIds`) fails on the pre-fix code and passes
after.
### Security-017 — `SiteScopeRequirement` / `SiteScopeAuthorizationHandler` are dead code from production callers — `[Authorize(Policy = RequireDeployment)]` does NOT enforce site scoping ### Security-017 — `SiteScopeRequirement` / `SiteScopeAuthorizationHandler` are dead code from production callers — `[Authorize(Policy = RequireDeployment)]` does NOT enforce site scoping
| | | | | |
|--|--| |--|--|
| Severity | Medium | | Severity | Medium |
| Category | Design-document adherence | | Category | Design-document adherence |
| Status | Open | | Status | Resolved |
| Location | `src/ScadaLink.Security/SiteScopeAuthorizationHandler.cs:8-58`; `src/ScadaLink.Security/AuthorizationPolicies.cs:113-143` | | Location | `src/ScadaLink.Security/SiteScopeAuthorizationHandler.cs:8-58`; `src/ScadaLink.Security/AuthorizationPolicies.cs:113-143` |
**Description** **Description**
@@ -787,13 +803,25 @@ the request — that is a meaningful design extension and would need to be plann
alongside the Central UI's existing `SiteScopeService` usage rather than replacing it alongside the Central UI's existing `SiteScopeService` usage rather than replacing it
piecemeal. piecemeal.
**Resolution**
Resolved 2026-05-28 (commit pending) per recommendation option (a): deleted
`SiteScopeRequirement` and `SiteScopeAuthorizationHandler` outright, along with the
unwired `services.AddSingleton<IAuthorizationHandler, SiteScopeAuthorizationHandler>()`
registration in `AuthorizationPolicies.AddScadaLinkAuthorization` and the four
isolation tests in `SecurityTests.cs`. `SiteScopeService` (CentralUI-002) is now
documented as the sole site-scoping mechanism — the stale "mirrors
SiteScopeAuthorizationHandler" comment in `SiteScopeService.ResolveAsync` was rewritten
to say so. The cross-module partner CentralUI-028 is fixed in the same batch (the two
new report pages now consume `SiteScopeService`).
### Security-018 — Role names are hard-coded magic strings duplicated across `RoleMapper`, `SiteScopeAuthorizationHandler`, and `AuthorizationPolicies` ### Security-018 — Role names are hard-coded magic strings duplicated across `RoleMapper`, `SiteScopeAuthorizationHandler`, and `AuthorizationPolicies`
| | | | | |
|--|--| |--|--|
| Severity | Low | | Severity | Low |
| Category | Code organization & conventions | | Category | Code organization & conventions |
| Status | Open | | Status | Resolved |
| Location | `src/ScadaLink.Security/RoleMapper.cs:41`; `src/ScadaLink.Security/SiteScopeAuthorizationHandler.cs:36`; `src/ScadaLink.Security/AuthorizationPolicies.cs:118,121,124,95,107` | | Location | `src/ScadaLink.Security/RoleMapper.cs:41`; `src/ScadaLink.Security/SiteScopeAuthorizationHandler.cs:36`; `src/ScadaLink.Security/AuthorizationPolicies.cs:118,121,124,95,107` |
**Description** **Description**
@@ -822,13 +850,26 @@ project and replace every magic-string occurrence — including the elements of
`OperationalAuditRoles` and `AuditExportRoles` — with the constants. A single rename `OperationalAuditRoles` and `AuditExportRoles` — with the constants. A single rename
will then either succeed everywhere or fail to compile. will then either succeed everywhere or fail to compile.
**Resolution**
Resolved 2026-05-28 (commit pending). Added `src/ScadaLink.Security/Roles.cs` holding
`Admin`/`Design`/`Deployment`/`Audit`/`AuditReadOnly` as `public const string`
fields. Replaced every magic-string occurrence in this module:
`RoleMapper.MapGroupsToRolesAsync` now compares against `Roles.Deployment`;
`AuthorizationPolicies.AddScadaLinkAuthorization` binds `RequireClaim(...)` to
`Roles.{Admin,Design,Deployment}`; `OperationalAuditRoles` /
`AuditExportRoles` are now built from `Roles.Admin`, `Roles.Audit`, `Roles.AuditReadOnly`.
`SiteScopeAuthorizationHandler.cs` was deleted under Security-017, so its
`"Deployment"` literal is gone with it. A future rename now propagates by a
single edit or fails to compile.
### Security-019 — Service-account rebind failure is reported as "Invalid username or password" — masks misconfiguration as a user-credential error ### Security-019 — Service-account rebind failure is reported as "Invalid username or password" — masks misconfiguration as a user-credential error
| | | | | |
|--|--| |--|--|
| Severity | Low | | Severity | Low |
| Category | Error handling & resilience | | Category | Error handling & resilience |
| Status | Open | | Status | Resolved |
| Location | `src/ScadaLink.Security/LdapAuthService.cs:85-89`, `:147-151` | | Location | `src/ScadaLink.Security/LdapAuthService.cs:85-89`, `:147-151` |
**Description** **Description**
@@ -859,6 +900,25 @@ Add a regression test that exercises the service-account-bind failure path (a mo
or seamed `LdapConnection.Bind` that throws on the second call) and asserts the or seamed `LdapConnection.Bind` that throws on the second call) and asserts the
distinct error message. distinct error message.
**Resolution**
Resolved 2026-05-28 (commit pending). Added a new `ServiceAccountBindException`
(`src/ScadaLink.Security/ServiceAccountBindException.cs`) — deliberately NOT an
`LdapException` subtype so it short-circuits the generic LDAP catch — and a private
`BindServiceAccountAsync` helper on `LdapAuthService` that wraps both service-account
rebind sites (the post-user-bind group-lookup rebind AND the `ResolveUserDnAsync`
DN-search rebind). On `LdapException`, the helper logs Error
("Service-account rebind failed; check LdapServiceAccountDn /
LdapServiceAccountPassword configuration") and rethrows as `ServiceAccountBindException`,
which the outer `AuthenticateAsync` catch chain maps to the distinct user-facing message
"Authentication service is misconfigured. Contact an administrator." Service-account
faults no longer surface as "Invalid username or password". Regression test
`ServiceAccountBindException_DoesNotInheritLdapException_SoCatchOrderIsCorrect` pins the
load-bearing inheritance contract; a full end-to-end auth test that exercises the
service-account-bind failure path is not feasible without an LDAP seam in
`LdapAuthService` (the `LdapConnection` is constructed in-method), so the structural test
is the closest meaningful unit-level coverage.
### Security-020 — `SecurityOptions` has no startup validation for required fields (`LdapServer`, `LdapSearchBase`) ### Security-020 — `SecurityOptions` has no startup validation for required fields (`LdapServer`, `LdapSearchBase`)
| | | | | |
@@ -1,5 +1,6 @@
using System.Globalization; using System.Globalization;
using System.Net; using System.Net;
using System.Text.Json;
namespace ScadaLink.CLI.Commands; namespace ScadaLink.CLI.Commands;
@@ -147,10 +148,18 @@ public static class AuditExportHelpers
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
{ {
var message = await response.Content.ReadAsStringAsync(); var message = await response.Content.ReadAsStringAsync();
// CLI-018: honour the documented "authorization failure → exit 2"
// contract on the REST audit surface as well. HTTP 403 is the
// primary signal; the server may also surface UNAUTHORIZED /
// FORBIDDEN via the JSON error envelope on a non-403 status.
var errorCode = TryExtractErrorCode(message);
var isAuthFailure = (int)response.StatusCode == 403
|| string.Equals(errorCode, "FORBIDDEN", StringComparison.OrdinalIgnoreCase)
|| string.Equals(errorCode, "UNAUTHORIZED", StringComparison.OrdinalIgnoreCase);
OutputFormatter.WriteError( OutputFormatter.WriteError(
string.IsNullOrWhiteSpace(message) ? $"Export failed (HTTP {(int)response.StatusCode})." : message, string.IsNullOrWhiteSpace(message) ? $"Export failed (HTTP {(int)response.StatusCode})." : message,
"ERROR"); errorCode ?? "ERROR");
return 1; return isAuthFailure ? 2 : 1;
} }
await using var source = await response.Content.ReadAsStreamAsync(); await using var source = await response.Content.ReadAsStreamAsync();
@@ -163,4 +172,32 @@ public static class AuditExportHelpers
output.WriteLine($"Exported audit log to {args.Output}"); output.WriteLine($"Exported audit log to {args.Output}");
return 0; return 0;
} }
/// <summary>
/// Best-effort parse of the server's JSON error envelope (<c>{ "error": ..., "code": ... }</c>)
/// to extract the <c>code</c> field. Returns null if the body is empty, not valid JSON, or
/// has no <c>code</c> property — callers fall back to "ERROR" in that case.
/// </summary>
internal static string? TryExtractErrorCode(string body)
{
if (string.IsNullOrWhiteSpace(body))
return null;
try
{
using var doc = JsonDocument.Parse(body);
if (doc.RootElement.ValueKind == JsonValueKind.Object
&& doc.RootElement.TryGetProperty("code", out var codeProp)
&& codeProp.ValueKind == JsonValueKind.String)
{
return codeProp.GetString();
}
}
catch (JsonException)
{
// Body is not a JSON envelope (e.g. an HTML proxy error page); no code to extract.
}
return null;
}
} }
@@ -189,7 +189,9 @@ public static class AuditQueryHelpers
{ {
OutputFormatter.WriteError( OutputFormatter.WriteError(
response.Error ?? "Audit query failed.", response.ErrorCode ?? "ERROR"); response.Error ?? "Audit query failed.", response.ErrorCode ?? "ERROR");
return 1; // CLI-018: surface the documented "authorization failure → exit 2"
// contract for the audit REST surface too, not just /management.
return CommandHelpers.IsAuthorizationFailure(response) ? 2 : 1;
} }
using var doc = JsonDocument.Parse(response.JsonData); using var doc = JsonDocument.Parse(response.JsonData);
+1 -1
View File
@@ -164,7 +164,7 @@ internal static class CommandHelpers
/// both channels are honoured. (Authentication failure — HTTP 401 / bad credentials /// both channels are honoured. (Authentication failure — HTTP 401 / bad credentials
/// — is deliberately <em>not</em> treated as authorization failure; it is exit 1.) /// — is deliberately <em>not</em> treated as authorization failure; it is exit 1.)
/// </summary> /// </summary>
private static bool IsAuthorizationFailure(ManagementResponse response) internal static bool IsAuthorizationFailure(ManagementResponse response)
{ {
if (response.StatusCode == 403) if (response.StatusCode == 403)
return true; return true;
@@ -88,8 +88,9 @@ public sealed class SiteScopeService
ids.Add(id); ids.Add(id);
} }
// No SiteId claims => system-wide. This mirrors SiteScopeAuthorizationHandler: // No SiteId claims => system-wide. Absence of scope rules means an
// absence of scope rules means an unrestricted deployer. // unrestricted deployer (Security-017 made this service the sole
// site-scoping mechanism — there is no separate handler to mirror).
var result = (IsSystemWide: ids.Count == 0, Sites: (IReadOnlySet<int>)ids); var result = (IsSystemWide: ids.Count == 0, Sites: (IReadOnlySet<int>)ids);
_cached = result; _cached = result;
return result; return result;
@@ -1,11 +1,13 @@
@page "/notifications/report" @page "/notifications/report"
@attribute [Authorize(Policy = ScadaLink.Security.AuthorizationPolicies.RequireDeployment)] @attribute [Authorize(Policy = ScadaLink.Security.AuthorizationPolicies.RequireDeployment)]
@using ScadaLink.CentralUI.Auth
@using ScadaLink.Commons.Entities.Sites @using ScadaLink.Commons.Entities.Sites
@using ScadaLink.Commons.Interfaces.Repositories @using ScadaLink.Commons.Interfaces.Repositories
@using ScadaLink.Commons.Messages.Notification @using ScadaLink.Commons.Messages.Notification
@using ScadaLink.Communication @using ScadaLink.Communication
@inject CommunicationService CommunicationService @inject CommunicationService CommunicationService
@inject ISiteRepository SiteRepository @inject ISiteRepository SiteRepository
@inject SiteScopeService SiteScope
@inject IDialogService Dialog @inject IDialogService Dialog
@inject ILogger<NotificationReport> Logger @inject ILogger<NotificationReport> Logger
@@ -366,6 +368,12 @@
private ToastNotification _toast = default!; private ToastNotification _toast = default!;
private List<Site> _sites = new(); private List<Site> _sites = new();
// CentralUI-028: full site list (kept unfiltered) so a permitted-site check
// resolves correctly for a SourceSiteId whose Site was filtered out of the
// dropdown. Set once in OnInitializedAsync alongside _sites.
private List<Site> _allSites = new();
private bool _siteScopeSystemWide;
private HashSet<int> _permittedSiteIds = new();
// List // List
private List<NotificationSummary>? _notifications; private List<NotificationSummary>? _notifications;
@@ -396,7 +404,13 @@
{ {
try try
{ {
_sites = (await SiteRepository.GetAllSitesAsync()).ToList(); _allSites = (await SiteRepository.GetAllSitesAsync()).ToList();
// CentralUI-028: restrict the site dropdown to the user's permitted set
// so a site-scoped Deployment user cannot select a site they have no
// grant for. System-wide users see the full list back unchanged.
_sites = await SiteScope.FilterSitesAsync(_allSites);
_siteScopeSystemWide = await SiteScope.IsSystemWideAsync();
_permittedSiteIds = new HashSet<int>(await SiteScope.PermittedSiteIdsAsync());
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -444,7 +458,12 @@
var response = await CommunicationService.QueryNotificationOutboxAsync(request); var response = await CommunicationService.QueryNotificationOutboxAsync(request);
if (response.Success) if (response.Success)
{ {
_notifications = response.Notifications.ToList(); // CentralUI-028: drop any row whose source site is outside the
// user's permitted set. The query API accepts only a single
// SourceSiteFilter, so a scoped user with an empty filter could
// otherwise see every site's rows; this is the row-level safety
// net behind the dropdown restriction.
_notifications = await FilterPermittedAsync(response.Notifications);
_totalCount = response.TotalCount; _totalCount = response.TotalCount;
} }
else else
@@ -461,6 +480,15 @@
private async Task RetryNotification(NotificationSummary n) private async Task RetryNotification(NotificationSummary n)
{ {
// CentralUI-028: server-side re-check before relaying — even if the row
// somehow made it into the grid for an out-of-scope user (race with a
// permission change, stale circuit cache), the relay must not fire.
if (!await IsRowSiteAllowedAsync(n.SourceSiteId))
{
_toast.ShowError("You are not permitted to act on notifications for this site.");
return;
}
var confirmed = await Dialog.ConfirmAsync( var confirmed = await Dialog.ConfirmAsync(
"Retry notification", "Retry notification",
$"Re-queue notification {ShortId(n.NotificationId)} (\"{n.Subject}\") for delivery?"); $"Re-queue notification {ShortId(n.NotificationId)} (\"{n.Subject}\") for delivery?");
@@ -490,6 +518,12 @@
private async Task DiscardNotification(NotificationSummary n) private async Task DiscardNotification(NotificationSummary n)
{ {
if (!await IsRowSiteAllowedAsync(n.SourceSiteId))
{
_toast.ShowError("You are not permitted to act on notifications for this site.");
return;
}
var confirmed = await Dialog.ConfirmAsync( var confirmed = await Dialog.ConfirmAsync(
"Discard notification", "Discard notification",
$"Permanently discard notification {ShortId(n.NotificationId)} (\"{n.Subject}\")? This cannot be undone.", $"Permanently discard notification {ShortId(n.NotificationId)} (\"{n.Subject}\")? This cannot be undone.",
@@ -650,4 +684,50 @@
"Discarded" => "bg-secondary", "Discarded" => "bg-secondary",
_ => "bg-light text-dark" _ => "bg-light text-dark"
}; };
/// <summary>
/// Drops any notification whose <c>SourceSiteId</c> resolves to a Site.Id outside
/// the caller's permitted set. A system-wide user gets the list back unchanged.
/// Lookup uses <c>_allSites</c> (NOT <c>_sites</c>) so a row whose Site was
/// filtered OUT of the dropdown is correctly classified as out-of-scope.
/// A truly unknown <c>SourceSiteId</c> (stale row from a deleted site) is kept —
/// there is no Site.Id to gate it on.
/// </summary>
private Task<List<NotificationSummary>> FilterPermittedAsync(
IEnumerable<NotificationSummary> notifications)
{
if (_siteScopeSystemWide)
return Task.FromResult(notifications.ToList());
var filtered = notifications
.Where(n =>
{
var resolved = _allSites.FirstOrDefault(s => s.SiteIdentifier == n.SourceSiteId);
return resolved is null || _permittedSiteIds.Contains(resolved.Id);
})
.ToList();
return Task.FromResult(filtered);
}
/// <summary>
/// Server-side re-check for the Retry/Discard relay actions. Returns true for a
/// system-wide user, or when the row's source site resolves to a Site.Id in the
/// caller's permitted set. An unresolvable site identifier defaults to allowed
/// (legacy behaviour); the relay's own site-scope re-check is then the
/// final gate.
/// </summary>
private bool IsRowSiteAllowedSync(string sourceSiteId)
{
if (_siteScopeSystemWide)
return true;
var resolved = _allSites.FirstOrDefault(s => s.SiteIdentifier == sourceSiteId);
if (resolved is null)
return true;
return _permittedSiteIds.Contains(resolved.Id);
}
private Task<bool> IsRowSiteAllowedAsync(string sourceSiteId)
=> Task.FromResult(IsRowSiteAllowedSync(sourceSiteId));
} }
@@ -1,5 +1,6 @@
@page "/site-calls/report" @page "/site-calls/report"
@attribute [Authorize(Policy = ScadaLink.Security.AuthorizationPolicies.RequireDeployment)] @attribute [Authorize(Policy = ScadaLink.Security.AuthorizationPolicies.RequireDeployment)]
@using ScadaLink.CentralUI.Auth
@using ScadaLink.Commons.Entities.Sites @using ScadaLink.Commons.Entities.Sites
@using ScadaLink.Commons.Interfaces.Repositories @using ScadaLink.Commons.Interfaces.Repositories
@using ScadaLink.Commons.Messages.Audit @using ScadaLink.Commons.Messages.Audit
@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.WebUtilities; using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using ScadaLink.CentralUI.Auth;
using ScadaLink.CentralUI.Components.Shared; using ScadaLink.CentralUI.Components.Shared;
using ScadaLink.Commons.Entities.Sites; using ScadaLink.Commons.Entities.Sites;
using ScadaLink.Commons.Messages.Audit; using ScadaLink.Commons.Messages.Audit;
@@ -44,6 +45,7 @@ public partial class SiteCallsReport
private const int PageSize = 50; private const int PageSize = 50;
[Inject] private NavigationManager Navigation { get; set; } = null!; [Inject] private NavigationManager Navigation { get; set; } = null!;
[Inject] private SiteScopeService SiteScope { get; set; } = null!;
// The Status filter <select> options — the exact strings the dropdown binds and // The Status filter <select> options — the exact strings the dropdown binds and
// the KPI tiles emit (e.g. ?status=Parked). A query-string status only seeds the // the KPI tiles emit (e.g. ?status=Parked). A query-string status only seeds the
@@ -56,6 +58,11 @@ public partial class SiteCallsReport
private ToastNotification _toast = default!; private ToastNotification _toast = default!;
private List<Site> _sites = new(); private List<Site> _sites = new();
// CentralUI-028: unfiltered site list so a permitted-site lookup resolves
// correctly for a SourceSite whose Site was filtered out of the dropdown.
private List<Site> _allSites = new();
private bool _siteScopeSystemWide;
private HashSet<int> _permittedSiteIds = new();
// List // List
private List<SiteCallSummary>? _siteCalls; private List<SiteCallSummary>? _siteCalls;
@@ -94,7 +101,12 @@ public partial class SiteCallsReport
{ {
try try
{ {
_sites = (await SiteRepository.GetAllSitesAsync()).ToList(); _allSites = (await SiteRepository.GetAllSitesAsync()).ToList();
// CentralUI-028: restrict the source-site dropdown to the user's
// permitted set. System-wide users see the full list back unchanged.
_sites = await SiteScope.FilterSitesAsync(_allSites);
_siteScopeSystemWide = await SiteScope.IsSystemWideAsync();
_permittedSiteIds = new HashSet<int>(await SiteScope.PermittedSiteIdsAsync());
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -212,7 +224,10 @@ public partial class SiteCallsReport
var response = await CommunicationService.QuerySiteCallsAsync(request); var response = await CommunicationService.QuerySiteCallsAsync(request);
if (response.Success) if (response.Success)
{ {
_siteCalls = response.SiteCalls.ToList(); // CentralUI-028: drop any row whose source site is outside the
// user's permitted set, as a row-level safety net behind the
// dropdown restriction.
_siteCalls = await FilterPermittedAsync(response.SiteCalls);
_currentCursor = cursor; _currentCursor = cursor;
// The response echoes the last row's cursor. A short page (fewer // The response echoes the last row's cursor. A short page (fewer
@@ -238,6 +253,15 @@ public partial class SiteCallsReport
private async Task RetrySiteCall(SiteCallSummary c) private async Task RetrySiteCall(SiteCallSummary c)
{ {
// CentralUI-028: server-side re-check before relaying — a Retry relay must
// not fire for a site outside the caller's permitted set, even if the row
// somehow appeared in the grid.
if (!await IsRowSiteAllowedAsync(c.SourceSite))
{
_toast.ShowError("You are not permitted to act on cached calls for this site.");
return;
}
var confirmed = await Dialog.ConfirmAsync( var confirmed = await Dialog.ConfirmAsync(
"Retry cached call", "Retry cached call",
$"Relay a retry of cached call {ShortId(c.TrackedOperationId)} (\"{c.Target}\") " + $"Relay a retry of cached call {ShortId(c.TrackedOperationId)} (\"{c.Target}\") " +
@@ -265,6 +289,12 @@ public partial class SiteCallsReport
private async Task DiscardSiteCall(SiteCallSummary c) private async Task DiscardSiteCall(SiteCallSummary c)
{ {
if (!await IsRowSiteAllowedAsync(c.SourceSite))
{
_toast.ShowError("You are not permitted to act on cached calls for this site.");
return;
}
var confirmed = await Dialog.ConfirmAsync( var confirmed = await Dialog.ConfirmAsync(
"Discard cached call", "Discard cached call",
$"Relay a discard of cached call {ShortId(c.TrackedOperationId)} (\"{c.Target}\") " + $"Relay a discard of cached call {ShortId(c.TrackedOperationId)} (\"{c.Target}\") " +
@@ -448,4 +478,42 @@ public partial class SiteCallsReport
"Discarded" => "bg-secondary", "Discarded" => "bg-secondary",
_ => "bg-light text-dark" _ => "bg-light text-dark"
}; };
/// <summary>
/// Drops any site-call row whose source site resolves to a Site.Id outside the
/// caller's permitted set. System-wide users get the list back unchanged.
/// Lookup uses <c>_allSites</c> (not <c>_sites</c>) so a row whose Site was
/// filtered OUT of the dropdown is correctly classified as out-of-scope.
/// </summary>
private Task<List<SiteCallSummary>> FilterPermittedAsync(
IEnumerable<SiteCallSummary> calls)
{
if (_siteScopeSystemWide)
return Task.FromResult(calls.ToList());
var filtered = calls
.Where(c =>
{
var resolved = _allSites.FirstOrDefault(s => s.SiteIdentifier == c.SourceSite);
return resolved is null || _permittedSiteIds.Contains(resolved.Id);
})
.ToList();
return Task.FromResult(filtered);
}
/// <summary>
/// Server-side re-check for the Retry/Discard relay. True for a system-wide
/// user, or when the row's source site maps to a Site.Id in the permitted set.
/// </summary>
private Task<bool> IsRowSiteAllowedAsync(string sourceSite)
{
if (_siteScopeSystemWide)
return Task.FromResult(true);
var resolved = _allSites.FirstOrDefault(s => s.SiteIdentifier == sourceSite);
if (resolved is null)
return Task.FromResult(true);
return Task.FromResult(_permittedSiteIds.Contains(resolved.Id));
}
} }
@@ -117,6 +117,12 @@ public static class AuditEndpoints
} }
var filter = ParseFilter(context.Request.Query); var filter = ParseFilter(context.Request.Query);
var restricted = ApplySiteScope(filter, auth.User!);
if (restricted is null)
{
return Forbidden("OperationalAudit");
}
filter = restricted;
var paging = ParsePaging(context.Request.Query); var paging = ParsePaging(context.Request.Query);
var repo = context.RequestServices.GetRequiredService<IAuditLogRepository>(); var repo = context.RequestServices.GetRequiredService<IAuditLogRepository>();
@@ -189,6 +195,12 @@ public static class AuditEndpoints
} }
var filter = ParseFilter(context.Request.Query); var filter = ParseFilter(context.Request.Query);
var restricted = ApplySiteScope(filter, auth.User!);
if (restricted is null)
{
return Forbidden("AuditExport");
}
filter = restricted;
var repo = context.RequestServices.GetRequiredService<IAuditLogRepository>(); var repo = context.RequestServices.GetRequiredService<IAuditLogRepository>();
var contentType = format == "csv" ? "text/csv; charset=utf-8" : "application/x-ndjson"; var contentType = format == "csv" ? "text/csv; charset=utf-8" : "application/x-ndjson";
@@ -374,6 +386,49 @@ public static class AuditEndpoints
private static IResult Forbidden(string permission) => Results.Json( private static IResult Forbidden(string permission) => Results.Json(
new { error = $"Permission '{permission}' required.", code = "UNAUTHORIZED" }, statusCode: 403); new { error = $"Permission '{permission}' required.", code = "UNAUTHORIZED" }, statusCode: 403);
/// <summary>
/// Applies the caller's <see cref="AuthenticatedUser.PermittedSiteIds"/> to the
/// audit-log filter (Management-019). System-wide callers (empty PermittedSiteIds —
/// Admin or a Deployment-style role with no scope rules attached to its mapping)
/// see the filter unchanged. A scoped caller has any caller-supplied
/// <c>sourceSiteId</c> intersected with their permitted set: an empty caller filter
/// is replaced by the permitted set; an explicit out-of-scope filter (no overlap
/// with the permitted set) returns <c>null</c> so the caller gets a 403 rather
/// than silently empty results.
/// </summary>
/// <returns>
/// The restricted filter, or <c>null</c> if the caller explicitly asked for
/// sites entirely outside their permitted set.
/// </returns>
public static AuditLogQueryFilter? ApplySiteScope(AuditLogQueryFilter filter, AuthenticatedUser user)
{
// Empty PermittedSiteIds is the system-wide signal (Admin, system-wide
// Deployment). System-wide audit roles also fall here — the design treats
// Audit/AuditReadOnly as non-site-scoped unless an operator attaches scope
// rules to the LDAP mapping; if they do, this helper enforces them.
if (user.PermittedSiteIds.Length == 0)
{
return filter;
}
var permitted = new HashSet<string>(user.PermittedSiteIds, StringComparer.Ordinal);
if (filter.SourceSiteIds is null || filter.SourceSiteIds.Count == 0)
{
// No explicit filter — restrict to permitted set.
return filter with { SourceSiteIds = permitted.ToArray() };
}
// Explicit filter — intersect.
var intersection = filter.SourceSiteIds.Where(permitted.Contains).ToArray();
if (intersection.Length == 0)
{
return null;
}
return filter with { SourceSiteIds = intersection };
}
// ───────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────
// Query-string parsing // Query-string parsing
// ───────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────
@@ -158,7 +158,15 @@ public class ManagementActor : ReceiveActor
or UpdateRoleMappingCommand or DeleteRoleMappingCommand or UpdateRoleMappingCommand or DeleteRoleMappingCommand
or ListApiKeysCommand or CreateApiKeyCommand or DeleteApiKeyCommand or ListApiKeysCommand or CreateApiKeyCommand or DeleteApiKeyCommand
or UpdateApiKeyCommand or UpdateApiKeyCommand
or ListScopeRulesCommand or AddScopeRuleCommand or DeleteScopeRuleCommand => "Admin", or ListScopeRulesCommand or AddScopeRuleCommand or DeleteScopeRuleCommand
// QueryAuditLogCommand: legacy Action/EntityType filter on the
// configuration audit log. Gated Admin-only so this older path is
// never looser than the keyset-paged `/api/audit/query` endpoint
// (which requires OperationalAuditRoles). New audit consumers
// should use the REST endpoint; this command is retained for
// backward compatibility with the CentralUI Configuration Audit
// Log page (Management-018).
or QueryAuditLogCommand => "Admin",
// Design operations // Design operations
CreateAreaCommand or DeleteAreaCommand CreateAreaCommand or DeleteAreaCommand
@@ -92,7 +92,7 @@ public static class AuthorizationPolicies
/// rather than the ASP.NET authorization-policy pipeline — can reuse the /// rather than the ASP.NET authorization-policy pipeline — can reuse the
/// exact same role set the <see cref="OperationalAudit"/> policy enforces. /// exact same role set the <see cref="OperationalAudit"/> policy enforces.
/// </remarks> /// </remarks>
public static readonly string[] OperationalAuditRoles = { "Admin", "Audit", "AuditReadOnly" }; public static readonly string[] OperationalAuditRoles = { Roles.Admin, Roles.Audit, Roles.AuditReadOnly };
/// <summary> /// <summary>
/// Roles that satisfy <see cref="AuditExport"/>. A strict subset of /// Roles that satisfy <see cref="AuditExport"/>. A strict subset of
@@ -104,7 +104,7 @@ public static class AuthorizationPolicies
/// the ManagementService <c>/api/audit/export</c> route checks roles /// the ManagementService <c>/api/audit/export</c> route checks roles
/// against this set directly. /// against this set directly.
/// </remarks> /// </remarks>
public static readonly string[] AuditExportRoles = { "Admin", "Audit" }; public static readonly string[] AuditExportRoles = { Roles.Admin, Roles.Audit };
/// <summary> /// <summary>
/// Registers the ScadaLink authorization policies (Admin, Design, Deployment, OperationalAudit, AuditExport). /// Registers the ScadaLink authorization policies (Admin, Design, Deployment, OperationalAudit, AuditExport).
@@ -115,13 +115,13 @@ public static class AuthorizationPolicies
services.AddAuthorization(options => services.AddAuthorization(options =>
{ {
options.AddPolicy(RequireAdmin, policy => options.AddPolicy(RequireAdmin, policy =>
policy.RequireClaim(JwtTokenService.RoleClaimType, "Admin")); policy.RequireClaim(JwtTokenService.RoleClaimType, Roles.Admin));
options.AddPolicy(RequireDesign, policy => options.AddPolicy(RequireDesign, policy =>
policy.RequireClaim(JwtTokenService.RoleClaimType, "Design")); policy.RequireClaim(JwtTokenService.RoleClaimType, Roles.Design));
options.AddPolicy(RequireDeployment, policy => options.AddPolicy(RequireDeployment, policy =>
policy.RequireClaim(JwtTokenService.RoleClaimType, "Deployment")); policy.RequireClaim(JwtTokenService.RoleClaimType, Roles.Deployment));
// Multi-role permission policies — the policy succeeds when the // Multi-role permission policies — the policy succeeds when the
// principal holds ANY of the mapped roles. RequireClaim with // principal holds ANY of the mapped roles. RequireClaim with
@@ -137,8 +137,6 @@ public static class AuthorizationPolicies
policy.RequireClaim(JwtTokenService.RoleClaimType, AuditExportRoles)); policy.RequireClaim(JwtTokenService.RoleClaimType, AuditExportRoles));
}); });
services.AddSingleton<IAuthorizationHandler, SiteScopeAuthorizationHandler>();
return services; return services;
} }
} }
+44 -6
View File
@@ -81,11 +81,14 @@ public class LdapAuthService
var bindDn = await ResolveUserDnAsync(connection, username, ct); var bindDn = await ResolveUserDnAsync(connection, username, ct);
await Task.Run(() => connection.Bind(bindDn, password), ct); await Task.Run(() => connection.Bind(bindDn, password), ct);
// Re-bind as service account for attribute/group lookup (user may lack search rights) // Re-bind as service account for attribute/group lookup (user may lack search rights).
// A failure here is the SYSTEM's misconfiguration (wrong service-account credentials,
// disabled/locked account) — not the user's credential problem. The user bind on the
// line above already succeeded, so masking this as "Invalid username or password" would
// route operators down the wrong incident path (Security-019).
if (!string.IsNullOrWhiteSpace(_options.LdapServiceAccountDn)) if (!string.IsNullOrWhiteSpace(_options.LdapServiceAccountDn))
{ {
await Task.Run(() => await BindServiceAccountAsync(connection, ct);
connection.Bind(_options.LdapServiceAccountDn, _options.LdapServiceAccountPassword), ct);
} }
// Query for user attributes and group memberships // Query for user attributes and group memberships
@@ -144,6 +147,16 @@ public class LdapAuthService
return BuildAuthResultFromGroupLookup(username, displayName, groups, groupLookupSucceeded); return BuildAuthResultFromGroupLookup(username, displayName, groups, groupLookupSucceeded);
} }
catch (ServiceAccountBindException ex)
{
// Distinct from the user-credential catch below so the operator
// sees the *system* misconfiguration rather than blaming user input
// (Security-019). The inner exception was already logged at Error
// by BindServiceAccountAsync; nothing further to log here.
_ = ex;
return new LdapAuthResult(false, null, username, null,
"Authentication service is misconfigured. Contact an administrator.");
}
catch (LdapException ex) catch (LdapException ex)
{ {
_logger.LogWarning(ex, "LDAP authentication failed for user {Username}", username); _logger.LogWarning(ex, "LDAP authentication failed for user {Username}", username);
@@ -156,6 +169,29 @@ public class LdapAuthService
} }
} }
/// <summary>
/// Binds the supplied connection as the configured service account. A failure here is
/// a system-misconfiguration condition (Security-019) — wrong service-account DN /
/// password, locked or disabled account, server-side ACL change — not a user-credential
/// problem. The underlying <see cref="LdapException"/> is logged at Error and rethrown
/// as <see cref="ServiceAccountBindException"/> so callers can distinguish it from a
/// user-bind failure.
/// </summary>
private async Task BindServiceAccountAsync(LdapConnection connection, CancellationToken ct)
{
try
{
await Task.Run(() =>
connection.Bind(_options.LdapServiceAccountDn, _options.LdapServiceAccountPassword), ct);
}
catch (LdapException ex)
{
_logger.LogError(ex,
"Service-account rebind failed; check LdapServiceAccountDn / LdapServiceAccountPassword configuration");
throw new ServiceAccountBindException(ex);
}
}
/// <summary> /// <summary>
/// Applies <see cref="SecurityOptions.LdapConnectionTimeoutMs"/> to both the socket /// Applies <see cref="SecurityOptions.LdapConnectionTimeoutMs"/> to both the socket
/// connect timeout and the per-operation (bind/search) time limit, so a hung or /// connect timeout and the per-operation (bind/search) time limit, so a hung or
@@ -183,11 +219,13 @@ public class LdapAuthService
/// </summary> /// </summary>
private async Task<string> ResolveUserDnAsync(LdapConnection connection, string username, CancellationToken ct) private async Task<string> ResolveUserDnAsync(LdapConnection connection, string username, CancellationToken ct)
{ {
// If a service account is configured, search for the user's actual DN // If a service account is configured, search for the user's actual DN.
// The service-account bind is routed through BindServiceAccountAsync so a
// misconfiguration surfaces distinctly rather than masking as
// "Invalid username or password" (Security-019).
if (!string.IsNullOrWhiteSpace(_options.LdapServiceAccountDn)) if (!string.IsNullOrWhiteSpace(_options.LdapServiceAccountDn))
{ {
await Task.Run(() => await BindServiceAccountAsync(connection, ct);
connection.Bind(_options.LdapServiceAccountDn, _options.LdapServiceAccountPassword), ct);
var searchFilter = $"({_options.LdapUserIdAttribute}={EscapeLdapFilter(username)})"; var searchFilter = $"({_options.LdapUserIdAttribute}={EscapeLdapFilter(username)})";
var searchResults = await Task.Run(() => var searchResults = await Task.Run(() =>
+23 -6
View File
@@ -28,7 +28,8 @@ public class RoleMapper
var matchedRoles = new HashSet<string>(StringComparer.OrdinalIgnoreCase); var matchedRoles = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var permittedSiteIds = new HashSet<string>(); var permittedSiteIds = new HashSet<string>();
var hasDeploymentRole = false; var hasDeploymentRole = false;
var hasDeploymentWithScopeRules = false; var hasScopedDeploymentMapping = false;
var hasUnscopedDeploymentMapping = false;
foreach (var mapping in allMappings) foreach (var mapping in allMappings)
{ {
@@ -38,25 +39,41 @@ public class RoleMapper
matchedRoles.Add(mapping.Role); matchedRoles.Add(mapping.Role);
if (mapping.Role.Equals("Deployment", StringComparison.OrdinalIgnoreCase)) if (mapping.Role.Equals(Roles.Deployment, StringComparison.OrdinalIgnoreCase))
{ {
hasDeploymentRole = true; hasDeploymentRole = true;
// Check for site scope rules
var scopeRules = await _securityRepository.GetScopeRulesForMappingAsync(mapping.Id, ct); var scopeRules = await _securityRepository.GetScopeRulesForMappingAsync(mapping.Id, ct);
if (scopeRules.Count > 0) if (scopeRules.Count > 0)
{ {
hasDeploymentWithScopeRules = true; hasScopedDeploymentMapping = true;
foreach (var rule in scopeRules) foreach (var rule in scopeRules)
{ {
permittedSiteIds.Add(rule.SiteId.ToString()); permittedSiteIds.Add(rule.SiteId.ToString());
} }
} }
else
{
hasUnscopedDeploymentMapping = true;
}
} }
} }
// System-wide deployment: user has Deployment role but no site scope rules restrict them // Union semantics (Security-016): a Deployment user is system-wide iff
var isSystemWide = hasDeploymentRole && !hasDeploymentWithScopeRules; // *any* matched Deployment mapping has no scope rules. A user in both
// SCADA-Deploy-All (unscoped) and SCADA-Deploy-SiteA (scoped to Site A)
// gets the broader grant, not the narrower one — matching the design's
// "roles are independent — there is no implied hierarchy" rule.
var isSystemWide = hasUnscopedDeploymentMapping
|| (hasDeploymentRole && !hasScopedDeploymentMapping);
// When system-wide, drop any accumulated scope ids — the empty
// permitted set is the system-wide signal downstream consumers
// (SiteScopeService, ManagementActor) already use.
if (isSystemWide)
{
permittedSiteIds.Clear();
}
return new RoleMappingResult( return new RoleMappingResult(
matchedRoles.ToList(), matchedRoles.ToList(),
+22
View File
@@ -0,0 +1,22 @@
namespace ScadaLink.Security;
/// <summary>
/// Single source of truth for role-name string literals used across the
/// Security module and downstream authorization checks.
/// </summary>
/// <remarks>
/// Role names appear in three independent contexts: <see cref="RoleMapper"/>
/// (LDAP-group → role resolution), <see cref="AuthorizationPolicies"/>
/// (policy <c>RequireClaim</c> values + the audit role arrays), and at LDAP
/// mapping rows configured by an operator. Holding the literals here means a
/// rename either succeeds everywhere or fails to compile, eliminating the
/// "string drift" class that Security-018 documented.
/// </remarks>
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";
}
@@ -0,0 +1,15 @@
namespace ScadaLink.Security;
/// <summary>
/// Thrown by <see cref="LdapAuthService"/> when the configured LDAP service-account
/// rebind fails. Distinct from a user-bind <c>LdapException</c> so the outer login
/// pipeline can surface "Authentication service is misconfigured" instead of
/// masking the system fault as "Invalid username or password" (Security-019).
/// </summary>
public sealed class ServiceAccountBindException : Exception
{
public ServiceAccountBindException(Exception innerException)
: base("LDAP service-account rebind failed", innerException)
{
}
}
@@ -1,58 +0,0 @@
using Microsoft.AspNetCore.Authorization;
namespace ScadaLink.Security;
/// <summary>
/// Authorization requirement for site-scoped deployment operations.
/// </summary>
public class SiteScopeRequirement : IAuthorizationRequirement
{
/// <summary>Gets the site id the deploying user must be permitted to operate on.</summary>
public string TargetSiteId { get; }
/// <summary>
/// Initializes a new <see cref="SiteScopeRequirement"/> for the given site.
/// </summary>
/// <param name="targetSiteId">The id of the site being targeted by the operation.</param>
public SiteScopeRequirement(string targetSiteId)
{
TargetSiteId = targetSiteId ?? throw new ArgumentNullException(nameof(targetSiteId));
}
}
/// <summary>
/// Checks that a user with the Deployment role is permitted to operate on the target site.
/// Users with Deployment role and no SiteId claims are system-wide deployers.
/// Users with SiteId claims are only permitted on those specific sites.
/// </summary>
public class SiteScopeAuthorizationHandler : AuthorizationHandler<SiteScopeRequirement>
{
/// <inheritdoc />
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
SiteScopeRequirement requirement)
{
// Must have Deployment role
var hasDeploymentRole = context.User.HasClaim(JwtTokenService.RoleClaimType, "Deployment");
if (!hasDeploymentRole)
{
return Task.CompletedTask; // Fail — no Deployment role
}
var siteIdClaims = context.User.FindAll(JwtTokenService.SiteIdClaimType).ToList();
if (siteIdClaims.Count == 0)
{
// No site scope restrictions — system-wide deployer
context.Succeed(requirement);
}
else if (siteIdClaims.Any(c => c.Value == requirement.TargetSiteId))
{
// User is permitted on this specific site
context.Succeed(requirement);
}
// Otherwise, silently fail (not authorized for this site)
return Task.CompletedTask;
}
}
@@ -212,6 +212,58 @@ public class AuditExportCommandTests
} }
} }
[Fact]
public async Task RunExport_Http403_ReturnsExitCode2()
{
// CLI-018: an HTTP 403 on /api/audit/export must produce exit code 2 per the
// documented CLI contract — the legacy bare-1 return masked auth failures
// as generic command failures.
var path = Path.Combine(Path.GetTempPath(), $"audit-export-403-{Guid.NewGuid():N}.csv");
try
{
var handler = new BodyHandler(HttpStatusCode.Forbidden,
() => new StringContent("{\"error\":\"nope\",\"code\":\"UNAUTHORIZED\"}", Encoding.UTF8, "application/json"));
var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
var output = new StringWriter();
var exit = await AuditExportHelpers.RunExportAsync(
client,
new AuditExportArgs { Since = "1h", Until = "0h", Format = "csv", Output = path },
output, DateTimeOffset.UtcNow);
Assert.Equal(2, exit);
Assert.False(File.Exists(path));
}
finally
{
if (File.Exists(path)) File.Delete(path);
}
}
[Fact]
public async Task RunExport_UnauthorizedCodeOnNon403_ReturnsExitCode2()
{
var path = Path.Combine(Path.GetTempPath(), $"audit-export-401-{Guid.NewGuid():N}.csv");
try
{
var handler = new BodyHandler(HttpStatusCode.BadRequest,
() => new StringContent("{\"error\":\"nope\",\"code\":\"FORBIDDEN\"}", Encoding.UTF8, "application/json"));
var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
var output = new StringWriter();
var exit = await AuditExportHelpers.RunExportAsync(
client,
new AuditExportArgs { Since = "1h", Until = "0h", Format = "csv", Output = path },
output, DateTimeOffset.UtcNow);
Assert.Equal(2, exit);
}
finally
{
if (File.Exists(path)) File.Delete(path);
}
}
[Fact] [Fact]
public async Task RunExport_Parquet501_PrintsServerMessageAndReturnsNonZero() public async Task RunExport_Parquet501_PrintsServerMessageAndReturnsNonZero()
{ {
@@ -281,6 +281,55 @@ public class AuditQueryCommandTests
Assert.NotEqual(0, exit); Assert.NotEqual(0, exit);
} }
[Fact]
public async Task RunQuery_Http403_ReturnsExitCode2()
{
// CLI-018: an authorization failure on /api/audit/query (HTTP 403) must
// produce exit code 2 per the documented CLI exit-code contract — the
// legacy bare-1 return masked auth failures as generic command failures.
var handler = new StatusHandler(HttpStatusCode.Forbidden, "{\"error\":\"nope\",\"code\":\"UNAUTHORIZED\"}");
var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
var output = new StringWriter();
var exit = await AuditQueryHelpers.RunQueryAsync(
client, new AuditQueryArgs(), fetchAll: false,
new JsonLinesAuditFormatter(), output, DateTimeOffset.UtcNow);
Assert.Equal(2, exit);
}
[Fact]
public async Task RunQuery_UnauthorizedCodeOnNon403_ReturnsExitCode2()
{
// The server may signal authorization failure via the error code on a
// non-403 status (e.g. 400 + code=UNAUTHORIZED). Honour both channels.
var handler = new StatusHandler(HttpStatusCode.BadRequest, "{\"error\":\"nope\",\"code\":\"UNAUTHORIZED\"}");
var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
var output = new StringWriter();
var exit = await AuditQueryHelpers.RunQueryAsync(
client, new AuditQueryArgs(), fetchAll: false,
new JsonLinesAuditFormatter(), output, DateTimeOffset.UtcNow);
Assert.Equal(2, exit);
}
[Fact]
public async Task RunQuery_GenericServerError_ReturnsExitCode1()
{
// Authentication / internal errors (non-403, no auth code) must remain
// exit code 1 — exit 2 is reserved for authorization failures.
var handler = new StatusHandler(HttpStatusCode.InternalServerError, "{\"error\":\"boom\",\"code\":\"INTERNAL\"}");
var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
var output = new StringWriter();
var exit = await AuditQueryHelpers.RunQueryAsync(
client, new AuditQueryArgs(), fetchAll: false,
new JsonLinesAuditFormatter(), output, DateTimeOffset.UtcNow);
Assert.Equal(1, exit);
}
private sealed class ErrorHandler : HttpMessageHandler private sealed class ErrorHandler : HttpMessageHandler
{ {
protected override Task<HttpResponseMessage> SendAsync( protected override Task<HttpResponseMessage> SendAsync(
@@ -291,6 +340,16 @@ public class AuditQueryCommandTests
}); });
} }
private sealed class StatusHandler : HttpMessageHandler
{
private readonly HttpStatusCode _status;
private readonly string _body;
public StatusHandler(HttpStatusCode status, string body) { _status = status; _body = body; }
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
=> Task.FromResult(new HttpResponseMessage(_status) { Content = new StringContent(_body) });
}
// ---- CLI parsing ------------------------------------------------------- // ---- CLI parsing -------------------------------------------------------
[Fact] [Fact]
@@ -93,6 +93,7 @@ public class NotificationReportDetailModalTests : BunitContext
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user)); Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore(); Services.AddAuthorizationCore();
Services.AddScoped<ScadaLink.CentralUI.Auth.SiteScopeService>();
} }
[Fact] [Fact]
@@ -79,6 +79,10 @@ public class NotificationReportPageTests : BunitContext
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user)); Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore(); Services.AddAuthorizationCore();
// CentralUI-028: the page now injects SiteScopeService — the test user
// has no SiteId claims, so this resolves to system-wide and the
// pre-existing test expectations hold.
Services.AddScoped<ScadaLink.CentralUI.Auth.SiteScopeService>();
} }
[Fact] [Fact]
@@ -94,6 +94,7 @@ public class SiteCallsReportPageTests : BunitContext
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user)); Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore(); Services.AddAuthorizationCore();
Services.AddScoped<ScadaLink.CentralUI.Auth.SiteScopeService>();
} }
[Fact] [Fact]
@@ -484,6 +485,31 @@ public class SiteCallsReportPageTests : BunitContext
}); });
} }
[Fact]
public void SiteScoping_ScopedDeploymentUser_HidesOutOfScopeRows()
{
// CentralUI-028: a Deployment user scoped to Plant A only must not see
// Plant B rows in the grid, even though the query response carried both.
// Last AuthenticationStateProvider registration wins on resolution.
var scopedUser = new ClaimsPrincipal(new ClaimsIdentity(new[]
{
new Claim("Username", "scoped"),
new Claim(ClaimTypes.Role, "Deployment"),
new Claim(JwtTokenService.SiteIdClaimType, "1"), // Plant A only
}, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(scopedUser));
var cut = Render<SiteCallsReportPage>();
cut.WaitForState(() => cut.FindAll("table tbody tr").Count > 0,
TimeSpan.FromSeconds(2));
var rows = cut.FindAll("table tbody tr");
Assert.Single(rows);
// Plant A row only; Plant B (FailedId) row must be filtered out.
Assert.Contains(ParkedId.ToString("N")[..12], rows[0].TextContent);
Assert.DoesNotContain(FailedId.ToString("N")[..12], cut.Markup);
}
protected override void Dispose(bool disposing) protected override void Dispose(bool disposing)
{ {
if (disposing) if (disposing)
@@ -530,4 +530,81 @@ public class AuditEndpointsTests
Assert.Null(paging.AfterEventId); Assert.Null(paging.AfterEventId);
Assert.Null(paging.AfterOccurredAtUtc); Assert.Null(paging.AfterOccurredAtUtc);
} }
// ─────────────────────────────────────────────────────────────────────
// ApplySiteScope (Management-019)
// ─────────────────────────────────────────────────────────────────────
[Fact]
public void ApplySiteScope_SystemWideUser_ReturnsFilterUnchanged()
{
// Empty PermittedSiteIds is the system-wide signal (Admin, system-wide
// Deployment, audit roles with no scope rules attached). The filter
// should pass through with no restriction added.
var user = new ScadaLink.Commons.Messages.Management.AuthenticatedUser(
"alice", "Alice", new[] { "Admin" }, Array.Empty<string>());
var filter = new AuditLogQueryFilter(SourceSiteIds: new[] { "plant-a" });
var result = AuditEndpoints.ApplySiteScope(filter, user);
Assert.NotNull(result);
Assert.Same(filter, result);
}
[Fact]
public void ApplySiteScope_ScopedUser_EmptyCallerFilter_RestrictedToPermittedSet()
{
// No explicit sourceSiteId from the caller — the helper must restrict
// the query to the user's permitted set, otherwise a site-scoped audit
// user could read every site's rows.
var user = new ScadaLink.Commons.Messages.Management.AuthenticatedUser(
"alice", "Alice", new[] { "AuditReadOnly" }, new[] { "plant-a", "plant-b" });
var filter = new AuditLogQueryFilter();
var result = AuditEndpoints.ApplySiteScope(filter, user);
Assert.NotNull(result);
Assert.NotNull(result!.SourceSiteIds);
Assert.Equal(new[] { "plant-a", "plant-b" }, result.SourceSiteIds!.OrderBy(s => s).ToArray());
}
[Fact]
public void ApplySiteScope_ScopedUser_ExplicitInScopeFilter_KeptVerbatim()
{
var user = new ScadaLink.Commons.Messages.Management.AuthenticatedUser(
"alice", "Alice", new[] { "AuditReadOnly" }, new[] { "plant-a", "plant-b" });
var filter = new AuditLogQueryFilter(SourceSiteIds: new[] { "plant-a" });
var result = AuditEndpoints.ApplySiteScope(filter, user);
Assert.NotNull(result);
Assert.Equal(new[] { "plant-a" }, result!.SourceSiteIds);
}
[Fact]
public void ApplySiteScope_ScopedUser_ExplicitOutOfScopeFilter_ReturnsNull()
{
// Caller explicitly asked for a site they cannot see — the helper signals
// "403" by returning null rather than silently producing an empty page.
var user = new ScadaLink.Commons.Messages.Management.AuthenticatedUser(
"alice", "Alice", new[] { "AuditReadOnly" }, new[] { "plant-a" });
var filter = new AuditLogQueryFilter(SourceSiteIds: new[] { "plant-b" });
var result = AuditEndpoints.ApplySiteScope(filter, user);
Assert.Null(result);
}
[Fact]
public void ApplySiteScope_ScopedUser_MixedInAndOutOfScopeFilter_IntersectedToInScopeOnly()
{
var user = new ScadaLink.Commons.Messages.Management.AuthenticatedUser(
"alice", "Alice", new[] { "AuditReadOnly" }, new[] { "plant-a" });
var filter = new AuditLogQueryFilter(SourceSiteIds: new[] { "plant-a", "plant-b" });
var result = AuditEndpoints.ApplySiteScope(filter, user);
Assert.NotNull(result);
Assert.Equal(new[] { "plant-a" }, result!.SourceSiteIds);
}
} }
@@ -94,6 +94,36 @@ public class ManagementActorTests : TestKit, IDisposable
Assert.Contains("Deployment", response.Message); Assert.Contains("Deployment", response.Message);
} }
[Fact]
public void QueryAuditLogCommand_WithNoRoles_ReturnsUnauthorized()
{
// ManagementService-018: QueryAuditLogCommand used to fall through to the
// default "any authenticated user" case, allowing a Deployment-only or
// no-role caller to read the configuration audit log via /management
// even though /api/audit/query enforces OperationalAuditRoles. The fix
// gates this legacy command to Admin so the older route is never looser
// than the new REST endpoint.
var actor = CreateActor();
var envelope = Envelope(new QueryAuditLogCommand(null, null, null, null, null, 1, 25));
actor.Tell(envelope);
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Contains("Admin", response.Message);
}
[Fact]
public void QueryAuditLogCommand_WithDeploymentRole_ReturnsUnauthorized()
{
var actor = CreateActor();
var envelope = Envelope(new QueryAuditLogCommand(null, null, null, null, null, 1, 25), "Deployment");
actor.Tell(envelope);
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Contains("Admin", response.Message);
}
// ======================================================================== // ========================================================================
// 2. Read-only query passes without special role // 2. Read-only query passes without special role
// ======================================================================== // ========================================================================
+37 -76
View File
@@ -314,6 +314,29 @@ public class RoleMapperTests : IDisposable
Assert.Contains(site2.Id.ToString(), result.PermittedSiteIds); Assert.Contains(site2.Id.ToString(), result.PermittedSiteIds);
} }
[Fact]
public async Task MapGroupsToRoles_UserInBothSystemWideAndScopedDeploymentGroup_IsSystemWide()
{
// Security-016: a user in BOTH an unscoped Deployment mapping
// (SCADA-Deploy-All, Id=3) AND a scoped Deployment mapping
// (SCADA-Deploy-SiteA, Id=4) used to be silently narrowed to the site-A
// grant. The union semantics now preserve the broader grant: the
// unscoped mapping wins, PermittedSiteIds is empty, system-wide.
var siteA = new Site("SiteA", "S-A");
_context.Sites.Add(siteA);
await _context.SaveChangesAsync();
// Mapping Id=4 (SCADA-Deploy-SiteA) is seeded; attach a scope rule for siteA.
_context.SiteScopeRules.Add(new SiteScopeRule { LdapGroupMappingId = 4, SiteId = siteA.Id });
await _context.SaveChangesAsync();
var result = await _roleMapper.MapGroupsToRolesAsync(new[] { "SCADA-Deploy-All", "SCADA-Deploy-SiteA" });
Assert.Contains("Deployment", result.Roles);
Assert.True(result.IsSystemWideDeployment);
Assert.Empty(result.PermittedSiteIds);
}
[Fact] [Fact]
public async Task MapGroupsToRoles_SystemWideDeployment_NoScopeRules() public async Task MapGroupsToRoles_SystemWideDeployment_NoScopeRules()
{ {
@@ -1030,6 +1053,20 @@ public class Security012GroupLookupFailureTests
Assert.True(result.Success); Assert.True(result.Success);
Assert.Equal(new[] { "SCADA-Admins" }, result.Groups); Assert.Equal(new[] { "SCADA-Admins" }, result.Groups);
} }
[Fact]
public void ServiceAccountBindException_DoesNotInheritLdapException_SoCatchOrderIsCorrect()
{
// Security-019: the LdapAuthService catch chain matches
// ServiceAccountBindException *before* the generic LdapException catch. That only
// produces the distinct "Authentication service is misconfigured" message if the
// exception type is NOT an LdapException subtype (otherwise it would be caught
// by the broader handler first regardless of ordering).
var ex = new ServiceAccountBindException(new InvalidOperationException("boom"));
Assert.IsNotType<Novell.Directory.Ldap.LdapException>(ex);
Assert.IsType<InvalidOperationException>(ex.InnerException);
}
} }
#endregion #endregion
@@ -1132,82 +1169,6 @@ public class AuthorizationPolicyTests
Assert.False(await EvaluatePolicy(AuthorizationPolicies.AuditExport, principal)); Assert.False(await EvaluatePolicy(AuthorizationPolicies.AuditExport, principal));
} }
[Fact]
public async Task SiteScope_SystemWideDeployer_Succeeds()
{
var handler = new SiteScopeAuthorizationHandler();
var claims = new List<Claim>
{
new(JwtTokenService.RoleClaimType, "Deployment")
// No SiteId claims = system-wide
};
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "test"));
var requirement = new SiteScopeRequirement("42");
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, null);
await handler.HandleAsync(context);
Assert.True(context.HasSucceeded);
}
[Fact]
public async Task SiteScope_PermittedSite_Succeeds()
{
var handler = new SiteScopeAuthorizationHandler();
var claims = new List<Claim>
{
new(JwtTokenService.RoleClaimType, "Deployment"),
new(JwtTokenService.SiteIdClaimType, "1"),
new(JwtTokenService.SiteIdClaimType, "2")
};
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "test"));
var requirement = new SiteScopeRequirement("1");
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, null);
await handler.HandleAsync(context);
Assert.True(context.HasSucceeded);
}
[Fact]
public async Task SiteScope_UnpermittedSite_Fails()
{
var handler = new SiteScopeAuthorizationHandler();
var claims = new List<Claim>
{
new(JwtTokenService.RoleClaimType, "Deployment"),
new(JwtTokenService.SiteIdClaimType, "1")
};
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "test"));
var requirement = new SiteScopeRequirement("99");
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, null);
await handler.HandleAsync(context);
Assert.False(context.HasSucceeded);
}
[Fact]
public async Task SiteScope_NoDeploymentRole_Fails()
{
var handler = new SiteScopeAuthorizationHandler();
var claims = new List<Claim>
{
new(JwtTokenService.RoleClaimType, "Admin")
};
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "test"));
var requirement = new SiteScopeRequirement("1");
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, null);
await handler.HandleAsync(context);
Assert.False(context.HasSucceeded);
}
private static ClaimsPrincipal CreatePrincipal(string[] roles, string[]? siteIds = null) private static ClaimsPrincipal CreatePrincipal(string[] roles, string[]? siteIds = null)
{ {
var claims = new List<Claim>(); var claims = new List<Claim>();