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 |
| Reviewer | claude-agent |
| Commit reviewed | `1eb6e97` |
| Open findings | 7 |
| Open findings | 6 |
## Summary
@@ -836,7 +836,7 @@ _Unresolved._
|--|--|
| Severity | Medium |
| 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` |
**Description**
@@ -867,7 +867,22 @@ audit `SendGetAsync` already populates.
**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
+25 -2
View File
@@ -8,7 +8,7 @@
| Last reviewed | 2026-05-28 |
| Reviewer | claude-agent |
| Commit reviewed | `1eb6e97` |
| Open findings | 8 |
| Open findings | 7 |
## Summary
@@ -1341,7 +1341,7 @@ at least one representative page so the helper's continued use is enforced.
|--|--|
| Severity | High |
| 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` |
**Description**
@@ -1378,6 +1378,29 @@ Add `Site_ScopedDeploymentUser_OnlySeesPermittedRows` and
`Site_ScopedDeploymentUser_CannotRetryRowOnNonPermittedSite` regression tests modelled
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
| | |
+37 -3
View File
@@ -8,7 +8,7 @@
| Last reviewed | 2026-05-28 |
| Reviewer | claude-agent |
| Commit reviewed | `1eb6e97` |
| Open findings | 6 (1 Deferred — see ManagementService-012) |
| Open findings | 4 (1 Deferred — see ManagementService-012) |
## Summary
@@ -796,7 +796,7 @@ Deployment user and an Admin user, in- and out-of-scope
|--|--|
| Severity | High |
| Category | Security |
| Status | Open |
| Status | Resolved |
| Location | `src/ScadaLink.ManagementService/ManagementActor.cs:153``:207`, `:336`, `:1302` |
**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
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
| | |
|--|--|
| Severity | Medium |
| Category | Security |
| Status | Open |
| Status | Resolved |
| Location | `src/ScadaLink.ManagementService/AuditEndpoints.cs:358``:368`, `:397``:437` |
**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
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
| | |
+11 -19
View File
@@ -40,18 +40,18 @@ module file and counted in **Total**.
| Severity | Open findings |
|----------|---------------|
| Critical | 0 |
| High | 18 |
| Medium | 62 |
| Low | 92 |
| **Total** | **172** |
| High | 16 |
| Medium | 58 |
| Low | 90 |
| **Total** | **164** |
## Module Status
| 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 |
| [CLI](CLI/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/3/4 | 7 | 23 |
| [CentralUI](CentralUI/findings.md) | 2026-05-28 | `1eb6e97` | 0/1/2/5 | 8 | 33 |
| [CLI](CLI/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/2/4 | 6 | 23 |
| [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 |
| [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 |
@@ -62,10 +62,10 @@ module file and counted in **Total**.
| [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 |
| [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 |
| [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 |
| [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 |
@@ -84,18 +84,16 @@ description, location, recommendation — lives in the module's `findings.md`.
_None open._
### High (18)
### High (16)
| 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 |
| 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 |
| 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 |
| 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-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 |
@@ -107,7 +105,7 @@ _None open._
| 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 |
### Medium (62)
### Medium (58)
| 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-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-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 |
| 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` |
@@ -141,7 +138,6 @@ _None open._
| 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-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-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 |
@@ -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 |
| 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 |
| 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-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 |
@@ -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-010 | [Transport](Transport/findings.md) | Critical Overwrite + cross-cutting paths uncovered by tests |
### Low (92)
### Low (90)
| 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-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 |
| 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-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 |
+65 -5
View File
@@ -8,7 +8,7 @@
| Last reviewed | 2026-05-28 |
| Reviewer | claude-agent |
| Commit reviewed | `1eb6e97` |
| Open findings | 6 (1 deferred Security-008) |
| Open findings | 2 (Security-020, Security-021); 1 deferred (Security-008) |
## Summary
@@ -706,7 +706,7 @@ use the single canonical identity. Regression tests
|--|--|
| Severity | Medium |
| Category | Correctness & logic bugs |
| Status | Open |
| Status | Resolved |
| Location | `src/ScadaLink.Security/RoleMapper.cs:30-31`, `:41-55`, `:59` |
**Description**
@@ -742,13 +742,29 @@ 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`.
**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
| | |
|--|--|
| Severity | Medium |
| Category | Design-document adherence |
| Status | Open |
| Status | Resolved |
| Location | `src/ScadaLink.Security/SiteScopeAuthorizationHandler.cs:8-58`; `src/ScadaLink.Security/AuthorizationPolicies.cs:113-143` |
**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
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`
| | |
|--|--|
| Severity | Low |
| 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` |
**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
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
| | |
|--|--|
| Severity | Low |
| Category | Error handling & resilience |
| Status | Open |
| Status | Resolved |
| Location | `src/ScadaLink.Security/LdapAuthService.cs:85-89`, `:147-151` |
**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
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`)
| | |
@@ -1,5 +1,6 @@
using System.Globalization;
using System.Net;
using System.Text.Json;
namespace ScadaLink.CLI.Commands;
@@ -147,10 +148,18 @@ public static class AuditExportHelpers
if (!response.IsSuccessStatusCode)
{
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(
string.IsNullOrWhiteSpace(message) ? $"Export failed (HTTP {(int)response.StatusCode})." : message,
"ERROR");
return 1;
errorCode ?? "ERROR");
return isAuthFailure ? 2 : 1;
}
await using var source = await response.Content.ReadAsStreamAsync();
@@ -163,4 +172,32 @@ public static class AuditExportHelpers
output.WriteLine($"Exported audit log to {args.Output}");
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(
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);
+1 -1
View File
@@ -164,7 +164,7 @@ internal static class CommandHelpers
/// both channels are honoured. (Authentication failure — HTTP 401 / bad credentials
/// — is deliberately <em>not</em> treated as authorization failure; it is exit 1.)
/// </summary>
private static bool IsAuthorizationFailure(ManagementResponse response)
internal static bool IsAuthorizationFailure(ManagementResponse response)
{
if (response.StatusCode == 403)
return true;
@@ -88,8 +88,9 @@ public sealed class SiteScopeService
ids.Add(id);
}
// No SiteId claims => system-wide. This mirrors SiteScopeAuthorizationHandler:
// absence of scope rules means an unrestricted deployer.
// No SiteId claims => system-wide. Absence of scope rules means an
// 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);
_cached = result;
return result;
@@ -1,11 +1,13 @@
@page "/notifications/report"
@attribute [Authorize(Policy = ScadaLink.Security.AuthorizationPolicies.RequireDeployment)]
@using ScadaLink.CentralUI.Auth
@using ScadaLink.Commons.Entities.Sites
@using ScadaLink.Commons.Interfaces.Repositories
@using ScadaLink.Commons.Messages.Notification
@using ScadaLink.Communication
@inject CommunicationService CommunicationService
@inject ISiteRepository SiteRepository
@inject SiteScopeService SiteScope
@inject IDialogService Dialog
@inject ILogger<NotificationReport> Logger
@@ -366,6 +368,12 @@
private ToastNotification _toast = default!;
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
private List<NotificationSummary>? _notifications;
@@ -396,7 +404,13 @@
{
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)
{
@@ -444,7 +458,12 @@
var response = await CommunicationService.QueryNotificationOutboxAsync(request);
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;
}
else
@@ -461,6 +480,15 @@
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(
"Retry notification",
$"Re-queue notification {ShortId(n.NotificationId)} (\"{n.Subject}\") for delivery?");
@@ -490,6 +518,12 @@
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(
"Discard notification",
$"Permanently discard notification {ShortId(n.NotificationId)} (\"{n.Subject}\")? This cannot be undone.",
@@ -650,4 +684,50 @@
"Discarded" => "bg-secondary",
_ => "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"
@attribute [Authorize(Policy = ScadaLink.Security.AuthorizationPolicies.RequireDeployment)]
@using ScadaLink.CentralUI.Auth
@using ScadaLink.Commons.Entities.Sites
@using ScadaLink.Commons.Interfaces.Repositories
@using ScadaLink.Commons.Messages.Audit
@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Logging;
using ScadaLink.CentralUI.Auth;
using ScadaLink.CentralUI.Components.Shared;
using ScadaLink.Commons.Entities.Sites;
using ScadaLink.Commons.Messages.Audit;
@@ -44,6 +45,7 @@ public partial class SiteCallsReport
private const int PageSize = 50;
[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 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 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
private List<SiteCallSummary>? _siteCalls;
@@ -94,7 +101,12 @@ public partial class SiteCallsReport
{
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)
{
@@ -212,7 +224,10 @@ public partial class SiteCallsReport
var response = await CommunicationService.QuerySiteCallsAsync(request);
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;
// 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)
{
// 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(
"Retry cached call",
$"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)
{
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(
"Discard cached call",
$"Relay a discard of cached call {ShortId(c.TrackedOperationId)} (\"{c.Target}\") " +
@@ -448,4 +478,42 @@ public partial class SiteCallsReport
"Discarded" => "bg-secondary",
_ => "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 restricted = ApplySiteScope(filter, auth.User!);
if (restricted is null)
{
return Forbidden("OperationalAudit");
}
filter = restricted;
var paging = ParsePaging(context.Request.Query);
var repo = context.RequestServices.GetRequiredService<IAuditLogRepository>();
@@ -189,6 +195,12 @@ public static class AuditEndpoints
}
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 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(
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
// ─────────────────────────────────────────────────────────────────────
@@ -158,7 +158,15 @@ public class ManagementActor : ReceiveActor
or UpdateRoleMappingCommand or DeleteRoleMappingCommand
or ListApiKeysCommand or CreateApiKeyCommand or DeleteApiKeyCommand
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
CreateAreaCommand or DeleteAreaCommand
@@ -92,7 +92,7 @@ public static class AuthorizationPolicies
/// rather than the ASP.NET authorization-policy pipeline — can reuse the
/// exact same role set the <see cref="OperationalAudit"/> policy enforces.
/// </remarks>
public static readonly string[] OperationalAuditRoles = { "Admin", "Audit", "AuditReadOnly" };
public static readonly string[] OperationalAuditRoles = { Roles.Admin, Roles.Audit, Roles.AuditReadOnly };
/// <summary>
/// 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
/// against this set directly.
/// </remarks>
public static readonly string[] AuditExportRoles = { "Admin", "Audit" };
public static readonly string[] AuditExportRoles = { Roles.Admin, Roles.Audit };
/// <summary>
/// Registers the ScadaLink authorization policies (Admin, Design, Deployment, OperationalAudit, AuditExport).
@@ -115,13 +115,13 @@ public static class AuthorizationPolicies
services.AddAuthorization(options =>
{
options.AddPolicy(RequireAdmin, policy =>
policy.RequireClaim(JwtTokenService.RoleClaimType, "Admin"));
policy.RequireClaim(JwtTokenService.RoleClaimType, Roles.Admin));
options.AddPolicy(RequireDesign, policy =>
policy.RequireClaim(JwtTokenService.RoleClaimType, "Design"));
policy.RequireClaim(JwtTokenService.RoleClaimType, Roles.Design));
options.AddPolicy(RequireDeployment, policy =>
policy.RequireClaim(JwtTokenService.RoleClaimType, "Deployment"));
policy.RequireClaim(JwtTokenService.RoleClaimType, Roles.Deployment));
// Multi-role permission policies — the policy succeeds when the
// principal holds ANY of the mapped roles. RequireClaim with
@@ -137,8 +137,6 @@ public static class AuthorizationPolicies
policy.RequireClaim(JwtTokenService.RoleClaimType, AuditExportRoles));
});
services.AddSingleton<IAuthorizationHandler, SiteScopeAuthorizationHandler>();
return services;
}
}
+44 -6
View File
@@ -81,11 +81,14 @@ public class LdapAuthService
var bindDn = await ResolveUserDnAsync(connection, username, 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))
{
await Task.Run(() =>
connection.Bind(_options.LdapServiceAccountDn, _options.LdapServiceAccountPassword), ct);
await BindServiceAccountAsync(connection, ct);
}
// Query for user attributes and group memberships
@@ -144,6 +147,16 @@ public class LdapAuthService
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)
{
_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>
/// Applies <see cref="SecurityOptions.LdapConnectionTimeoutMs"/> to both the socket
/// connect timeout and the per-operation (bind/search) time limit, so a hung or
@@ -183,11 +219,13 @@ public class LdapAuthService
/// </summary>
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))
{
await Task.Run(() =>
connection.Bind(_options.LdapServiceAccountDn, _options.LdapServiceAccountPassword), ct);
await BindServiceAccountAsync(connection, ct);
var searchFilter = $"({_options.LdapUserIdAttribute}={EscapeLdapFilter(username)})";
var searchResults = await Task.Run(() =>
+23 -6
View File
@@ -28,7 +28,8 @@ public class RoleMapper
var matchedRoles = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var permittedSiteIds = new HashSet<string>();
var hasDeploymentRole = false;
var hasDeploymentWithScopeRules = false;
var hasScopedDeploymentMapping = false;
var hasUnscopedDeploymentMapping = false;
foreach (var mapping in allMappings)
{
@@ -38,25 +39,41 @@ public class RoleMapper
matchedRoles.Add(mapping.Role);
if (mapping.Role.Equals("Deployment", StringComparison.OrdinalIgnoreCase))
if (mapping.Role.Equals(Roles.Deployment, StringComparison.OrdinalIgnoreCase))
{
hasDeploymentRole = true;
// Check for site scope rules
var scopeRules = await _securityRepository.GetScopeRulesForMappingAsync(mapping.Id, ct);
if (scopeRules.Count > 0)
{
hasDeploymentWithScopeRules = true;
hasScopedDeploymentMapping = true;
foreach (var rule in scopeRules)
{
permittedSiteIds.Add(rule.SiteId.ToString());
}
}
else
{
hasUnscopedDeploymentMapping = true;
}
}
}
// System-wide deployment: user has Deployment role but no site scope rules restrict them
var isSystemWide = hasDeploymentRole && !hasDeploymentWithScopeRules;
// Union semantics (Security-016): a Deployment user is system-wide iff
// *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(
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]
public async Task RunExport_Parquet501_PrintsServerMessageAndReturnsNonZero()
{
@@ -281,6 +281,55 @@ public class AuditQueryCommandTests
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
{
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 -------------------------------------------------------
[Fact]
@@ -93,6 +93,7 @@ public class NotificationReportDetailModalTests : BunitContext
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
Services.AddScoped<ScadaLink.CentralUI.Auth.SiteScopeService>();
}
[Fact]
@@ -79,6 +79,10 @@ public class NotificationReportPageTests : BunitContext
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
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]
@@ -94,6 +94,7 @@ public class SiteCallsReportPageTests : BunitContext
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
Services.AddScoped<ScadaLink.CentralUI.Auth.SiteScopeService>();
}
[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)
{
if (disposing)
@@ -530,4 +530,81 @@ public class AuditEndpointsTests
Assert.Null(paging.AfterEventId);
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);
}
[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
// ========================================================================
+37 -76
View File
@@ -314,6 +314,29 @@ public class RoleMapperTests : IDisposable
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]
public async Task MapGroupsToRoles_SystemWideDeployment_NoScopeRules()
{
@@ -1030,6 +1053,20 @@ public class Security012GroupLookupFailureTests
Assert.True(result.Success);
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
@@ -1132,82 +1169,6 @@ public class AuthorizationPolicyTests
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)
{
var claims = new List<Claim>();