From 146a13238ffbdf927cb4d50811edc950044695a0 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 9 Sep 2026 16:13:58 -0400 Subject: [PATCH] Resolve all high-severity architecture review findings --- docs/plans/2026-09-09-arch-review.md | 83 +++++- docs/requirements/Component-AuditLog.md | 17 ++ docs/requirements/Component-CentralUI.md | 8 + docs/requirements/Component-Communication.md | 30 ++ .../Component-ConfigurationDatabase.md | 4 + .../Component-DataConnectionLayer.md | 20 +- .../Component-ManagementService.md | 4 + .../Component-NotificationOutbox.md | 9 + docs/requirements/Component-ScriptAnalysis.md | 2 + docs/requirements/Component-Security.md | 12 +- docs/requirements/Component-SiteCallAudit.md | 2 + docs/requirements/Component-SiteRuntime.md | 6 +- .../Central/GrpcPullAuditEventsClient.cs | 2 +- .../Central/GrpcPullSiteCallsClient.cs | 2 +- .../Central/GrpcPullTraceEventsClient.cs | 2 +- .../Site/Telemetry/SiteAuditTelemetryActor.cs | 6 +- .../Auth/AuthEndpoints.cs | 26 ++ .../Auth/ClaimsPrincipalExtensions.cs | 14 + .../Auth/CookieAuthenticationStateProvider.cs | 85 ++++-- .../Auth/SiteScopeService.cs | 10 +- .../Health/CentralFailoverControl.razor | 4 +- .../Components/Pages/Admin/ApiKeyForm.razor | 3 + .../Components/Pages/Admin/ApiKeys.razor | 4 + .../Pages/Admin/LdapMappingForm.razor | 26 +- .../Components/Pages/Admin/LdapMappings.razor | 12 +- .../Components/Pages/Admin/SiteForm.razor | 38 +-- .../Components/Pages/Admin/Sites.razor | 8 +- .../Pages/Deployment/InstanceConfigure.razor | 80 ++--- .../Pages/Deployment/InstanceCreate.razor | 10 +- .../Pages/Deployment/Topology.razor | 63 +++- .../Pages/Design/ApiMethodForm.razor | 38 +-- .../Pages/Design/DataConnectionForm.razor | 26 +- .../Pages/Design/DataConnections.razor | 7 +- .../Pages/Design/DbConnectionForm.razor | 24 +- .../Pages/Design/ExternalSystemForm.razor | 29 +- .../Pages/Design/ExternalSystems.razor | 9 +- .../Pages/Design/SharedScriptForm.razor | 2 +- .../Pages/Design/SharedScripts.razor | 2 +- .../Pages/Design/TemplateCreate.razor | 2 +- .../Pages/Design/TemplateEdit.razor | 23 +- .../Components/Pages/Design/Templates.razor | 2 +- .../Pages/Design/TransportExport.razor.cs | 2 +- .../Pages/Design/TransportImport.razor.cs | 4 +- .../Pages/Monitoring/ParkedMessages.razor | 18 +- .../Notifications/NotificationListForm.razor | 27 +- .../Notifications/NotificationLists.razor | 6 +- .../Notifications/NotificationReport.razor | 13 +- .../Notifications/SmsConfiguration.razor | 53 +--- .../Notifications/SmtpConfiguration.razor | 35 +-- .../Pages/SiteCalls/SiteCallsReport.razor.cs | 18 +- .../Components/Shared/SessionExpiry.razor | 33 +-- .../ServiceCollectionExtensions.cs | 1 + .../Services/BindingTester.cs | 3 +- .../Services/BrowseService.cs | 5 +- .../Services/CertManagementService.cs | 3 +- .../Services/ConfigurationCommandService.cs | 57 ++++ .../Services/DataConnectionMoveService.cs | 3 +- .../Services/EndpointVerificationService.cs | 3 +- .../Services/FlowDebugService.cs | 4 +- .../Services/SchemaLibraryService.cs | 3 +- .../Services/SecuredWriteService.cs | 3 +- .../TemplateInheritanceQueryService.cs | 3 +- .../wwwroot/js/session-expiry.js | 21 ++ .../IConfigurationCommandTransaction.cs | 22 ++ .../DatabaseConnectionDefCommands.cs | 4 +- .../Messages/Management/InstanceCommands.cs | 3 + .../Management/NotificationCommands.cs | 15 +- .../Notification/NotificationMessages.cs | 3 +- .../Types/Alarms/NativeAlarmTransition.cs | 6 +- .../Types/Enums/AlarmTransitionKind.cs | 4 +- .../Grpc/AuditWireBudget.cs | 30 ++ .../Grpc/CentralChannelProvider.cs | 2 +- .../Grpc/CentralControlGrpcService.cs | 11 +- .../Grpc/CentralControlSiteIdentity.cs | 50 ++++ .../Grpc/GrpcCentralTransport.cs | 64 ++-- .../Grpc/GrpcMessageLimits.cs | 23 ++ .../Grpc/GrpcRetrySafety.cs | 23 ++ .../Grpc/GrpcSiteTransport.cs | 13 +- .../Grpc/SiteCommandGrpcService.cs | 3 +- .../Grpc/SitePairChannelProvider.cs | 36 ++- .../Grpc/SiteStreamGrpcClient.cs | 2 +- .../Grpc/SiteStreamGrpcServer.cs | 14 +- .../Repositories/SiteCallAuditRepository.cs | 11 +- .../ServiceCollectionExtensions.cs | 4 + .../ConfigurationCommandTransaction.cs | 56 ++++ .../Actors/DataConnectionActor.cs | 277 +++++++++++------- .../Adapters/MxGatewayDataConnection.cs | 4 +- .../Adapters/RealMxGatewayClient.cs | 4 +- .../Adapters/RealOpcUaClient.cs | 23 +- .../CentralControlAuthInterceptor.cs | 3 + src/ZB.MOM.WW.ScadaBridge.Host/Program.cs | 4 + .../InboundScriptExecutor.cs | 8 + .../ManagementActor.cs | 202 ++++++++++--- .../NotificationOutboxActor.cs | 3 +- .../ScriptTrustPolicy.cs | 3 + .../CookieSessionValidator.cs | 48 +-- .../ServiceCollectionExtensions.cs | 18 +- .../SessionActivityTracker.cs | 41 +++ .../SessionClaimBuilder.cs | 13 +- .../Actors/InstanceActor.cs | 4 + .../Actors/NativeAlarmActor.cs | 24 ++ .../Scripts/AuditingDbCommand.cs | 10 +- .../Scripts/AuditingDbConnection.cs | 15 +- .../Scripts/AuditingDbTransaction.cs | 70 +++++ .../Scripts/ScopeAccessors.cs | 4 +- .../Scripts/ScriptRuntimeContext.cs | 14 +- .../Telemetry/SiteAuditTelemetryActorTests.cs | 40 +++ .../SessionActivityTrustedInputTests.cs | 66 +++++ .../Admin/ApiKeyFormAuditDrillinTests.cs | 1 + .../Admin/ApiKeysListPageTests.cs | 1 + .../Admin/SiteFormAuditDrillinTests.cs | 1 + .../Audit/FlowDebuggerDrillinTests.cs | 4 +- .../Auth/AuthEndpointsCsrfTests.cs | 2 + .../Auth/AuthPingEndpointTests.cs | 2 + .../Auth/CircuitRevocationRegressionTests.cs | 74 +++++ .../ParkedMessagesMutationRegressionTests.cs | 99 +++++++ .../Auth/UiAuditRegressionTests.cs | 95 ++++++ .../Components/ConnectionCertificatesTests.cs | 1 + .../Components/OpcUaEndpointVerifyTests.cs | 1 + .../Components/SecuredWritesTests.cs | 1 + .../ConfigurationMutationTestExtensions.cs | 25 ++ .../DataConnectionFormTests.cs | 3 +- .../DataConnectionsPageTests.cs | 1 + .../Deployment/FlowDebuggerPageTests.cs | 1 + .../InstanceConfigureAuditDrillinTests.cs | 1 + .../InstanceConfigureBindingBehaviourTests.cs | 1 + .../InstanceConfigureListOverrideTests.cs | 2 +- .../InstanceConfigureNativeAlarmTests.cs | 10 +- .../InstanceCreateTemplateFieldTests.cs | 1 + .../TopologyDisableConfirmLabelTests.cs | 1 + .../ExternalSystemFormAuditDrillinTests.cs | 1 + ...ernalSystemsConnectionStringHidingTests.cs | 1 + .../Design/SchemaLibraryPageTests.cs | 1 + .../Design/ScriptFormSandboxDisposalTests.cs | 2 + .../Design/TemplateEditInheritedSetTests.cs | 1 + .../TemplateNativeAlarmSourceEditorTests.cs | 8 +- .../Layout/NavMenuTests.cs | 1 + .../Monitoring/AlarmSummaryRenderTests.cs | 1 + .../Monitoring/AlarmSummaryVirtualizeTests.cs | 1 + .../Monitoring/FailoverConfirmLabelTests.cs | 1 + .../Monitoring/HealthFailoverButtonTests.cs | 1 + .../Pages/AuditLogPagePermissionTests.cs | 1 + .../Pages/AuditLogPageScaffoldTests.cs | 2 + .../Pages/AuditLogPageTrendTests.cs | 1 + .../Pages/Design/TransportExportPageTests.cs | 1 + .../Pages/Design/TransportImportPageTests.cs | 1 + .../TransportImportSessionDisposeRaceTests.cs | 1 + .../Pages/ExecutionTreePageTests.cs | 1 + .../Pages/HealthPageTests.cs | 1 + .../Pages/NotificationKpisPageTests.cs | 1 + .../Pages/NotificationListFormTests.cs | 1 + .../Pages/NotificationListsPageTests.cs | 1 + .../NotificationReportDetailModalTests.cs | 1 + .../Pages/NotificationReportPageTests.cs | 1 + .../Pages/QueryStringDrillInTests.cs | 3 + .../Pages/SiteCallsReportPageTests.cs | 1 + .../Pages/SmsConfigurationPageTests.cs | 1 + .../Pages/SmtpConfigurationPageTests.cs | 4 + .../TemplatesPageTests.cs | 1 + ...opologyLiveUpdatesTimerDisposeRaceTests.cs | 1 + .../TopologyPageTests.cs | 40 +++ .../Grpc/AuditWireBudgetTests.cs | 39 +++ .../SitePairChannelProviderDisposalTests.cs | 55 ++++ .../SiteStreamPullAuditEventsTests.cs | 28 ++ .../SiteCallAuditRepositoryTests.cs | 22 +- .../Actors/HighReviewRegressionTests.cs | 247 ++++++++++++++++ .../Adapters/MxGatewayBatchSeamTests.cs | 24 ++ .../Adapters/RealOpcUaSnapshotScopeTests.cs | 32 ++ .../DataConnectionActorTests.cs | 12 +- .../OpcUaAlarmLiveSmokeTests.cs | 60 +++- .../CentralControlAuthInterceptorTests.cs | 45 +++ .../CentralControlEndToEndTests.cs | 44 ++- .../ControlPlaneAuthEndToEndTests.cs | 19 +- .../GrpcCentralTransportTests.cs | 42 ++- .../GrpcSiteTransportFailoverTests.cs | 29 +- .../SessionActivityHttpIntegrationTests.cs | 136 +++++++++ .../ApiMethodTraceAllCallsTests.cs | 2 +- .../ConfigurationTransactionTestExtensions.cs | 21 ++ .../ManagementActorTests.cs | 2 +- .../RequiredRoleMatrixTests.cs | 6 + .../RoleMappingValidationTests.cs | 2 +- .../SchemaLibraryHandlerTests.cs | 2 +- .../SecretProjectionTests.cs | 2 +- .../SecuredWriteHandlerTests.cs | 2 +- .../TransportGateTests.cs | 2 +- .../NotificationOutboxActorQueryTests.cs | 13 + .../ScriptTrustValidatorTests.cs | 19 ++ .../PassiveSessionRegressionTests.cs | 66 +++++ .../Actors/InstanceActorTests.cs | 11 + .../Actors/NativeAlarmActorTests.cs | 43 +++ .../AttributeSchedulerRegressionTests.cs | 48 +++ .../Scripts/AuditingDbTransactionTests.cs | 55 ++++ 192 files changed, 3160 insertions(+), 753 deletions(-) create mode 100644 src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/ConfigurationCommandService.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Services/IConfigurationCommandTransaction.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/AuditWireBudget.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/CentralControlSiteIdentity.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/GrpcMessageLimits.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/GrpcRetrySafety.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Services/ConfigurationCommandTransaction.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.Security/SessionActivityTracker.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/AuditingDbTransaction.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/SessionActivityTrustedInputTests.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Auth/CircuitRevocationRegressionTests.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Auth/ParkedMessagesMutationRegressionTests.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Auth/UiAuditRegressionTests.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ConfigurationMutationTestExtensions.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/AuditWireBudgetTests.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/HighReviewRegressionTests.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaSnapshotScopeTests.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SessionActivityHttpIntegrationTests.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ConfigurationTransactionTestExtensions.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.Security.Tests/PassiveSessionRegressionTests.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/AttributeSchedulerRegressionTests.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/AuditingDbTransactionTests.cs diff --git a/docs/plans/2026-09-09-arch-review.md b/docs/plans/2026-09-09-arch-review.md index 089146d8..ae4a6e01 100644 --- a/docs/plans/2026-09-09-arch-review.md +++ b/docs/plans/2026-09-09-arch-review.md @@ -1,6 +1,6 @@ # ScadaBridge — architectural / code review (whole system) -**Date:** 2026-09-09 · **Status:** SECOND-PASS REVIEW REPORT (documentation only; no production code changed) +**Date:** 2026-09-09 · **Status:** ALL 13 HIGH FINDINGS COMPLETED (see §9) **Reviewed revision:** `main` @ `c078bc8f`. Its only difference from the first review's `d8229916` is the original findings document; production source/configuration is identical. **Scope:** all 26 `src/` projects, 32 test/support projects, both Docker rig configurations, @@ -13,7 +13,8 @@ rechecked all High source paths, ran build/test/format/package checks, and execu probes. Each area revalidated its existing IDs and searched for additional defects. Source-traced mechanisms, runtime probes, latent risks, and unproven operational impact are distinguished below. This is a focused whole-system review, not a claim that every line or production topology was -exercised. No rig rebuild, device writes, failover drill, or remote QA/production inspection ran. +exercised. The review phase ran no rig rebuild, device writes, failover drill, or remote QA/production inspection. +Sections 1–8 preserve that reviewed baseline; §9 records the subsequent implementation and local rig verification. **Second-pass changes:** 11 new findings; UI-3 withdrawn as unsupported; XC-6 merged into CM-5; UI-4/6/7 reclassified as Low hardening gaps. Existing IDs are preserved for traceability. The @@ -36,9 +37,11 @@ references; every mapped gRPC service is behind a fail-closed constant-time inte contract changes since August are additive; all eight migrations are sound and the model snapshot matches. The test suite is broad; fresh execution evidence and its limits are in §7. -The updated register has **92 unique active findings: 13 High, 33 Medium, 46 Low**. +The reviewed baseline contained **92 unique findings: 13 High, 33 Medium, 46 Low**. +**Remediation status: all 13 High findings completed; 79 findings remain open (33 Medium, 46 Low).** +Completion evidence and limits are recorded in §9. This includes low-priority test/documentation gaps and latent risks, not just reproduced failures. -The High-priority findings are: +The reviewed High-priority findings were: | # | Sev | Finding | Area | |---|---|---|---| @@ -821,3 +824,75 @@ A `pass` is limited to the cited check, not a blanket claim that the component i Add these to the repository's chosen CI system with existing build/test gates; keep formatting cleanup and framework/package migration in separate maintenance work so they do not obscure the correctness/security remediation. + + +## 9. High-severity remediation completion + +**Completed 2026-09-09. All 13 High findings are resolved and verified.** Implementation +started from `8bfe6551` on `codex/resolve-high-review-findings`, with parallel device/runtime, +transport, and UI/security agents plus primary-agent integration and independent reviews. +The findings above preserve the reviewed baseline; this section supersedes their open status. +Medium/Low findings remain outside this remediation unless a change was required for a High fix. + +| Finding | Status | Implementation / verification | +|---|---|---| +| DC-1 | **Completed** | Owner-checked PostStop unsubscribe and DeathWatch release dead demand. Shared/replacement demand survives old-owner termination and in-flight completion. Deterministic lifecycle/race regressions pass. | +| DC-2 | **Completed** | Initial subscribe and resolution exclude each other’s in-flight tags; equal live handles are never released. Mx reverse-map removal is atomic and identity-checked. Adapter and controlled-race regressions pass. | +| DC-3 | **Completed** | Connecting/reconnecting reject single and batch writes immediately; subscription retries are deduplicated by owner/source. No stale setpoint replay after reconnect. Full DCL suite: 365 passed. | +| DC-12 | **Completed** | OPC snapshot start/entries/end retain source and epoch; NativeAlarmActor ignores unrelated/duplicate ends and drops partial snapshots on disconnect. Legacy Mx framing remains supported. Actor tests prove standing-alarm preservation; live two-boiler feed verifies distinct wire scopes and epochs. | +| CM-1 | **Completed** | Servers and all six application channels use explicit 32 MiB send/receive limits. Protobuf-exact 2 MiB audit budgets cover pending/cached drain and reconciliation paging. Both 256×64 KiB lanes drain without loss; real gRPC 17 MiB response passes. Oversize-row policy is explicit below. | +| CM-9 | **Completed** | Authenticated site identity guards RPC payloads, audit rows and notification status. SQL upsert rejects another site’s stored operation ID, including duplicate-insert retry. Real gRPC denial, outbox ownership and 29 SQL Server lifecycle/ownership tests pass. | +| CM-10 | **Completed** | Reads/idempotent ingests may retry; mutations retry only after explicit server pre-dispatch rejection. Ambiguous primary/secondary response loss returns `OUTCOME_UNKNOWN` without replay. Real transport and effect-count tests pass. | +| UI-1 | **Completed** | All 16 configuration repository-bypass pages use typed commands. One transaction spans entity saves and final scrubbed audit flush, including generated IDs; cache publication follows commit. Direct instance/area paths retain domain checks within the same boundary. LDAP CRUD and audit-failure rollback regressions pass; deployed browser CRUD passes. | +| UI-2 | **Completed** | Passive ping/Blazor validation neither slides cookies nor advances activity. Trusted browser input posts with antiforgery; per-login activity reaches existing circuits. Stable NameIdentifier preserves antiforgery across role refresh. Real HTTP fake-clock tests and deployed trusted/synthetic-input browser gate pass. | +| UI-13 | **Completed** | Circuit authority remaps stored groups in fresh scopes; mutations require current roles and persisted-site scope. API keys, reports, failover, parked messages, instances and Topology recheck before dispatch. Revoked-scope and authorized-positive tests pass, including nonnumeric site identifiers. Explicit server-configured auto-login retains synthetic grants under its existing startup guard. | +| SR-1 | **Completed** | Attribute get/set/wait/batch continuations use `ConfigureAwait(false)`. Saturated two-worker synchronous read/write regressions pass without watchdog assistance; wait/trace regression coverage remains green. | +| SR-11 | **Completed** | Sync/async transaction and command connection/transaction readback retain auditing wrappers. SQLite regressions prove persisted writes and exactly one audit through each former escape. | +| MG-1 | **Completed** | Trust gate denies Unsafe, RuntimeHelpers and Runtime.Loader, including alias/static imports, while preserving async and CallerMemberName. ScriptAnalysis suite: 81 passed. | + +### Final verification + +| Gate | Result / evidence | +|---|---| +| Final solution build | `dotnet build ZB.MOM.WW.ScadaBridge.slnx --no-restore`: **0 warnings, 0 errors**. `/tmp/high-final-build3.log` | +| Selected full solution | `dotnet test ZB.MOM.WW.ScadaBridge.slnx --no-build --no-restore --filter 'FullyQualifiedName!~PlaywrightTests' --logger trx --results-directory /tmp/scadabridge-high-final-green`: **9,310 passed, 0 failed, 0 skipped**; exit 0. `/tmp/high-final-tests2.log` | +| Final UI follow-up | After the final auto-login compatibility fix: **1,392 passed, 0 failed, 0 skipped** in the full CentralUI suite. This reruns the 1,391 UI cases included above plus the new regression; counts are not additive. `/tmp/high-ui-final.log` | +| Deployed browser | LDAP create/edit/delete round trip and trusted-input/passive-input/antiforgery/throttle scenario: **2 passed, 0 failed, 0 skipped**. `/tmp/high-browser-final.log` | +| Independent reviews | Runtime/trust, transport/ownership, and UI transaction/authorization reviews passed after corrections; final auto-login guard follow-up also passed. `/tmp/scadabridge-ui-independent-review.md` | +| Local rig | Final image `sha256:b9d6b60ab77093ee537470992fb891f78cc9888170d290021855c902c0115eb6` on **all eight nodes**. All ready (200), one active per pair (200) and four standbys (503), two members per pair, zero unreachable/restarts, all five central singletons reachable. Site-A replication connected with backlog 0. `/tmp/scadabridge-final-loginfix-postdeploy-health.json` | +| Change hygiene | `git diff --check` clean. Canonical original-repository config/log mounts and named data volumes preserved; env2 remains untouched. Component requirements updated with implementation. | + +The local image was built with `bash docker/build.sh` from the implementation worktree, then +all eight nodes were recreated using the canonical original repository’s Compose file. This +keeps existing configuration, logs and data in place rather than rebinding them to the worktree. +No device writes, failover drill, remote deployment, commit or push was performed by this remediation. + +### Regression evidence and limits + +- SR-1/SR-11 reproduced **five failures before** their fixes; the runtime follow-up passed 39 + targeted tests. MG-1 reproduced five forbidden-call failures while two benign cases passed; + all 81 ScriptAnalysis cases pass afterward. Full SiteRuntime: **774 passed**. +- CM-1’s 17 MiB response failed at the previous default limit. CM-9’s stored-owner SQL regression + failed before its guard. Full Communication **756**, ConfigurationDatabase **411**, AuditLog + **396**, Host **590**, and integration **117** tests pass. Two historical Host tests expected + blind mutation retry on `Unavailable`; they now model explicit pre-dispatch rejection, with + a separate real-transport test proving ambiguous failure never dispatches to the peer. +- Device tests control owner death, replacement, shared-tag demand, subscribe/resolve races, + disconnected writes and interleaved snapshots. A stale failover test assertion was corrected: + backup demand must be restored while an original subscribe remains pending. The full DCL + suite subsequently passed. The local two-boiler snapshots were empty; standing-condition + preservation is proven by deterministic actor tests, **not by live raised alarms**. +- UI revocation and Topology denied-mutation tests reproduced failures before correction. + The first LDAP baseline test stopped at an incorrect seeded-row count, so it does **not** + establish a before-fix missing-audit reproduction. Corrected real-SQLite CRUD/audit/rollback + tests pass. A real HTTP regression found antiforgery failure after role refresh; stable user + identity fixed it. The deployed browser found the synthetic auto-login role-loss regression; + the explicit trusted-configuration guard passed its red/green test and repeat live CRUD gate. +- A single audit row above 2 MiB travels alone up to the 32 MiB wire ceiling. A larger row remains + durable/unacknowledged with an explicit operator-action error, without truncation or silent + loss. Stored LDAP groups are remapped live; directory group-membership changes still require + login. Pre-upgrade cookies without stable NameIdentifier may require a fresh sign-in. +- No separate formatter cleanup or framework/package migration was included. The original + Medium/Low register and baseline formatting debt remain. The local rig has an existing + `zztest-conn-5d93ea81` test connection retrying unreachable `localhost:4840`; healthy cluster + readiness does not assert that every configured device endpoint is reachable. diff --git a/docs/requirements/Component-AuditLog.md b/docs/requirements/Component-AuditLog.md index f2dd5052..8086ca77 100644 --- a/docs/requirements/Component-AuditLog.md +++ b/docs/requirements/Component-AuditLog.md @@ -881,3 +881,20 @@ orphaned entries) and in the CLI's `audit tree` output. reusing the existing audit-KPI reads; the resulting `totalEventsLastHour` / `errorEventsLastHour` / `backlogTotal` series render as trends on the Audit Log page via `KpiTrendChart`. + +### Serialized-byte budgets for telemetry and reconciliation (2026-09-09) + +The audit-only drain, combined cached telemetry drain, and site reconciliation responses use +`AuditWireBudget`: the complete protobuf row (including error message/detail, summaries, extra, +provenance and nested operational fields), repeated-message tag/length prefixes, five-byte gRPC +frame and the continuation flag must fit a normal **2 MiB** budget. A drain sends a prefix and +marks only acknowledged rows forwarded. A byte-limited pull returns `MoreAvailable = true`; +its cursor advances from the last returned row, so unreturned rows remain available. + +A single row exceeding 2 MiB is sent alone if it fits the coordinated **32 MiB** channel ceiling. +A row exceeding that ceiling is never truncated, falsely acknowledged or silently skipped: +the operation reports `ResourceExhausted` with its row identifier and required byte count, and +the row remains durable locally. Good rows preceding it can still advance. Operators must +reduce that oversized payload or deploy coordinated larger limits to resume past it; repeated +drain failures are logged. A lost response retains the existing at-least-once retry/cursor +semantics. The separate Flow Debugger trace pull remains governed by its own contract. diff --git a/docs/requirements/Component-CentralUI.md b/docs/requirements/Component-CentralUI.md index 6b5dcd3d..9b619328 100644 --- a/docs/requirements/Component-CentralUI.md +++ b/docs/requirements/Component-CentralUI.md @@ -330,3 +330,11 @@ Per-leaf alarm rendering (leaf nodes are individual conditions for native alarms - **Site Call Audit**: Serves the `SiteCalls` table queries and relays Retry/Discard actions to sites for the Site Calls page. - **Audit Log (#23)**: Serves all `AuditLog` table queries (filter / grid / drilldown / CSV export) for the new Audit Log page and the drill-in surfaces on Notifications, Site Calls, External Systems, Inbound API keys, Sites, and Instances. Payload capture, redaction, and per-site authorization follow the Audit Log component's "Payload Capture Policy" and "Security & Tamper-Evidence" sections. - **KPI History (#26)**: The Central UI hosts the `KpiHistoryQueryService` (scoped-repository read over `IKpiHistoryRepository.GetRawSeriesAsync` + `KpiSeriesBucketer`, dual-ctor test seam) and renders the reusable custom-SVG `KpiTrendChart` fed by it. Trend sections appear on the Notification Outbox, Site Calls, and Audit Log pages and in a per-site panel on the Health Monitoring dashboard; a query failure degrades to an unavailable-chart placeholder rather than breaking the page. See [Component-KpiHistory.md](Component-KpiHistory.md). + +### Configuration mutations and live circuit authority (2026-09-09) + +Page configuration handlers use `ConfigurationCommandService` and the guarded management command boundary. Database changes and their scrubbed audit entries commit in one transaction, including generated entity IDs. Existing instance service operations execute through the same transaction seam and retain their domain validation and audit ownership. Circuit read tracking is cleared after a separate transaction scope changes configuration. + +Cookie circuits retain their initial principal without retaining `HttpContext`; authoritative reads re-map stored directory groups against current grants. Mutation handlers recheck role and persisted target site immediately before dispatch. Passive session pings do not count as user activity. Trusted browser input posts an antiforgery-protected activity signal; the server activity tracker also makes it visible to the existing circuit. + +The explicit server-configured development auto-login mode preserves its synthetic all-role principal. It uses the Cookies scheme but has no LDAP group claims; it must not enter directory re-mapping. This exception follows validated `AuthDisableLoginOptions`, never a caller-supplied claim. diff --git a/docs/requirements/Component-Communication.md b/docs/requirements/Component-Communication.md index ca6edbd4..be10987e 100644 --- a/docs/requirements/Component-Communication.md +++ b/docs/requirements/Component-Communication.md @@ -452,3 +452,33 @@ Disconnect is detected at the **transport layer**, never via an application-leve - **Audit Log (#23)**: Sites forward audit-event telemetry (push) and serve the `PullAuditEvents` gRPC reconciliation pull from their `ISiteAuditQueue`; the central `AuditLogIngestActor` is the ingest target for both the push path and the combined cached-call telemetry packet. - **Site Event Logging**: Event log queries are routed through communication. - **Management Service**: The ManagementActor runs at `/user/management` on central nodes and is reached **in-process** via `ManagementActorHolder`. The CLI communicates with it over the central HTTP `/management` endpoints (HTTP Basic), not over any cluster transport. It is no longer registered with ClusterClientReceptionist (that registration, and the whole ClusterClient surface, is gone). + +### Control-plane ownership, retry safety and message limits (2026-09-09) + +Both gRPC servers and every application client channel explicitly set maximum send and receive +message sizes to **32 MiB** through `GrpcMessageLimits`. Audit push and reconciliation normally +use **2 MiB** batches measured from the complete protobuf message fields, repeated-message +length/tag prefixes and gRPC framing. The shared limits must move together on both peers. + +On the central control service, the PSK interceptor stores a verified site identity in the +server call context. Before dispatch it compares that identity to notification source site, +reconciliation site identifier, health report and heartbeat site, **each** audit row's source +site and **both** halves of every cached telemetry packet. A mismatch or missing owner denies +the request with `PermissionDenied` before any handler or partial batch runs. Execution and +parent-execution linkage may cross sites; those identifiers are not ownership fields. +Notification status queries carry the verified identity to the outbox, which returns no result +for a notification stored under another site. An unrecognized protobuf request is denied until +its ownership rule is explicitly implemented. + +`Unavailable` alone does not prove a command was unsent: it can represent a connection loss +after a device write, deployment or script call ran. The site transport automatically retries +only an explicit read-only command whitelist, or a server readiness rejection carrying the +`scadabridge-dispatch: not-started` trailer emitted before actor dispatch. An ambiguous mutation +returns `Aborted` with `OUTCOME_UNKNOWN`; the caller must inspect the operation before retrying. +The provider selects the peer for subsequent independent calls, without replaying the failed +mutation. A secondary attempt that loses its response has the same uncertain-outcome behavior. + +Site-to-central notification submission and audit ingestion can retry because their durable +identifiers are idempotent; status reads and sequence-guarded health reports can retry as well. +Reconciliation mints fetch tokens and only retries a proven pre-dispatch rejection. Attempts +share the original absolute deadline. No transport retries `DeadlineExceeded` across nodes. diff --git a/docs/requirements/Component-ConfigurationDatabase.md b/docs/requirements/Component-ConfigurationDatabase.md index 699de7d1..a5598839 100644 --- a/docs/requirements/Component-ConfigurationDatabase.md +++ b/docs/requirements/Component-ConfigurationDatabase.md @@ -460,3 +460,7 @@ The `AuditLog` table is append-only and grows by every script-trust-boundary eve - **Central UI**: Uses `ICentralUiRepository` for read-oriented queries across domain areas, including config-audit queries for the Configuration Audit Log Viewer (the operational Audit Log page is owned by #23). - **All central components that modify state**: Call `IAuditService.LogAsync()` after successful operations to record audit entries within the same transaction. - **Host**: Provides database connection configuration. Registers DbContext, repository implementations, and `IAuditService` implementation in the DI container. Triggers auto-migration in development or validates schema version in production. + +### Configuration command transactions (2026-09-09) + +`ConfigurationCommandTransaction` creates a fresh dependency scope and EF context for each execution-strategy attempt. Entity changes and staged audit entries share one transaction, including intermediate saves needed for generated keys. A failure before commit rolls back both. An uncertain commit is not replayed, preventing duplicate creates; the caller must reconcile its result. Postcommit cache actions execute while their dependency scope remains alive; failures are logged without replaying the committed operation. diff --git a/docs/requirements/Component-DataConnectionLayer.md b/docs/requirements/Component-DataConnectionLayer.md index 3cd9ebc8..ea1612ff 100644 --- a/docs/requirements/Component-DataConnectionLayer.md +++ b/docs/requirements/Component-DataConnectionLayer.md @@ -304,7 +304,7 @@ The `AlarmTransitionCallback` delivers a protocol-neutral `NativeAlarmTransition ### Protocol Adapters -- **OPC UA** (`OpcUaDataConnection` + `RealOpcUaClient`): a single **event MonitoredItem** (`AttributeId = EventNotifier`) on the Server object, with an `EventFilter` selecting `EventType` / `SourceNode` / `Severity` plus the `ConditionType` / `AcknowledgeableConditionType` / `AlarmConditionType` state fields. `ConditionRefresh` is invoked on subscribe to replay active conditions as the snapshot. The OPC UA field → `NativeAlarmTransition` mapping is isolated in the pure helper `OpcUaAlarmMapper`, unit-testable without a live server. +- **OPC UA** (`OpcUaDataConnection` + `RealOpcUaClient`): one **event MonitoredItem per source binding** (`AttributeId = EventNotifier`), sharing an event subscription (the empty binding selects the Server object), with an `EventFilter` selecting `EventType` / `SourceNode` / `Severity` plus the `ConditionType` / `AcknowledgeableConditionType` / `AlarmConditionType` state fields. `ConditionRefresh` is invoked on subscribe to replay active conditions as the snapshot. The OPC UA field → `NativeAlarmTransition` mapping is isolated in the pure helper `OpcUaAlarmMapper`, unit-testable without a live server. - **MxGateway** (`MxGatewayDataConnection` + `RealMxGatewayClient`): mirrors over the gateway package's `StreamAlarmsAsync` — a resumable background stream whose reconnect re-sends a snapshot. The field mapping lives in `MxGatewayAlarmMapper`. Other/custom protocols do not implement the capability; a subscribe request against such a connection is replied to with a failure (`SubscribeAlarmsResponse.Success = false`). @@ -315,11 +315,11 @@ The `DataConnectionActor` opens **one alarm feed per source** (not per subscribe The registered `SourceReference` is a **NodeId** for OPC UA (the picker, CSV and config all store NodeIds), so the transition's routing identity must live in the same space or nothing matches. **Gitea #17**: the OPC UA adapter previously set `SourceObjectReference` from the event's `SourceName` (a plain name — the RawPath for OtOpcUa), which never prefix-matches a NodeId binding, so every native OPC UA alarm transition was silently dropped. Because each OPC UA feed is opened for exactly one binding, the adapter now tags every transition on that feed with the **binding string verbatim** (via the pure `OpcUaAlarmMapper.BuildIdentity`), making the routing key an exact match independent of whether the binding is stored as `ns=` or the durable `nsu=` form. The Server-object aggregate feed (empty binding) keeps an empty routing identity, so it reaches only "mirror everything" subscribers and never leaks into a specific-node binding. The per-condition `SourceReference` key stays the human-readable `SourceName.ConditionName`, so persistence and display are unchanged. MxGateway is unaffected — its bindings are object names and its mapper already emits matching names. -- **State gating**: `SubscribeAlarmsRequest` is handled only in the **Connected** state; requests arriving while **Connecting**/**Reconnecting** are stashed (standard Become/Stash) and processed on entering Connected. +- **State gating**: `SubscribeAlarmsRequest` is handled only in the **Connected** state; requests arriving while **Connecting**/**Reconnecting** are coalesced to the latest request per subscriber actor/source and processed on entering Connected. Unsubscribes and subscriber termination are handled in every state. - **Capability check**: if `_adapter is not IAlarmSubscribableConnection`, the actor replies `SubscribeAlarmsResponse(Success = false, ...)`. - **Reconnect handling**: on entering **Reconnecting**, the actor pushes a `NativeAlarmSourceUnavailable` to every alarm subscriber (consumers mark mirrored alarms uncertain rather than clearing them). On successful reconnection it re-subscribes the feed; the adapter re-emits a snapshot, reconciling state. - **Shared condition filter (last-subscriber-wins)**: because the feed is opened once per source, it carries a single condition filter. A second subscriber that registers a *different* filter for the same source overwrites it (last writer wins) and the actor logs a warning — co-subscribers to one source are expected to agree on the filter. -- **Routing index**: subscribed sources are bucketed by the **first path segment** of their reference (everything before the first `.`), and a transition is matched only against the bucket for its own first segment, plus a residue list of sources that carry no separator at all (a prefix shorter than one segment, which can match other buckets). This is sound because a source reference containing a separator can only prefix a reference sharing its entire first segment. The per-source `StartsWith` test and the condition-type gate inside the bucket are unchanged, so routing decisions are identical to the previous linear scan over every subscribed source; the `SnapshotComplete` sentinel still bypasses the index entirely and is broadcast to every subscriber. +- **Routing index**: subscribed sources are bucketed by the **first path segment** of their reference (everything before the first `.`), and a transition is matched only against the bucket for its own first segment, plus a residue list of sources that carry no separator at all (a prefix shorter than one segment, which can match other buckets). This is sound because a source reference containing a separator can only prefix a reference sharing its entire first segment. The per-source `StartsWith` test and the condition-type gate inside the bucket are unchanged, so routing decisions are identical to the previous linear scan over every subscribed source; snapshot boundaries bypass the condition filter, but OPC UA boundaries are sent only to the exact source binding. Only legacy connection-wide Mx boundaries broadcast to all subscribers. - **Gateway union filter (MxGateway)**: `StreamAlarms` carries a single `alarm_filter_prefix`, so the adapter opens the stream on the **longest common prefix** of the currently subscribed source references and restarts it only when a NEW source falls outside that prefix (the source then replays a fresh snapshot, which `NativeAlarmActor` already handles). An unsubscribe never restarts the stream — the prefix it leaves behind is at worst too broad, which costs bandwidth, not correctness. The prefix is a bandwidth optimisation only; the actor's per-source + condition-type gate remains authoritative, mirroring the OPC UA server-side WhereClause stance. ### Protocol-Neutral Types & Messages @@ -330,7 +330,7 @@ All defined in Commons so the feed is identical across protocols: |------|-------| | `NativeAlarmTransition` | `SourceReference`, `SourceObjectReference`, `AlarmTypeName`, `Kind`, `Condition`, `Category`, `Description`, `Message`, `OperatorUser`, `OperatorComment`, `OriginalRaiseTime?`, `TransitionTime`, `CurrentValue`, `LimitValue`, `AckTime?` | | `AlarmConditionState` | `Active`, `Acknowledged`, `Confirmed?` (null when not confirmable), `Shelve`, `Suppressed`, `Severity` (0–1000) | -| `AlarmTransitionKind` (enum) | `Snapshot`, `SnapshotComplete`, `Raise`, `Acknowledge`, `Clear`, `Retrigger`, `StateChange` | +| `AlarmTransitionKind` (enum) | `Snapshot`, `SnapshotComplete`, `Raise`, `Acknowledge`, `Clear`, `Retrigger`, `StateChange`, `SnapshotStart` (appended) | `OperatorUser` / `OperatorComment` and `CurrentValue` / `LimitValue` are display-only mirrors from the source. @@ -377,11 +377,15 @@ Each value update delivered to an Instance Actor includes: > > **Stop-and-recreate was rejected, and the reason is worth keeping.** Subscriptions are held inside `DataConnectionActor`, and `InstanceActor` cancels its per-connection subscribe-retry timer on the first success — so a rebuilt connection actor would have *no* subscribers and nothing would ever re-subscribe. That trades "stale endpoint, data flowing" for "correct endpoint, no data until redeploy". See *In-place reconfiguration* below for what the actor does instead. -Each data connection is managed by a dedicated connection actor that uses the Akka.NET **Become/Stash** pattern to model its lifecycle as a state machine: +Each data connection is managed by a dedicated connection actor that uses the Akka.NET **Become** pattern to model its lifecycle as a state machine: -- **Connecting**: The actor attempts to establish the connection. Subscription requests and write commands received during this phase are **stashed** (buffered in the actor's stash). -- **Connected**: The actor is actively servicing subscriptions. On entering this state, all stashed messages are unstashed and processed. -- **Reconnecting**: The connection was lost. The actor transitions back to a connecting-like state, stashing new requests while it retries. +- **Connecting**: The actor attempts to establish the connection. It retains only the latest tag-subscribe request per instance and alarm-subscribe request per subscriber/source. Single and batch device writes receive an immediate failed response; they are never retained. +- **Connected**: The actor actively services subscriptions and processes the retained latest subscription requests. +- **Reconnecting**: The connection was lost. Existing subscribers receive bad quality, the latest subscription requests are coalesced while the actor retries, and single/batch writes fail immediately. A caller timeout never leaves a queued setpoint to replay on recovery. + +Instance Actors and NativeAlarmActors send explicit unsubscribe requests from `PostStop`, with their own actor references as sender. DCL also watches every subscriber. A stopped actor releases only its own registrations; a delayed unsubscribe or termination from an old actor cannot remove the replacement actor with the same instance name. A narrower replacement drops tags it no longer requests. Completion of outstanding device subscribe/resolution I/O adopts handles only for tags still requested and releases orphan handles. Existing identical Mx handles are never released as redundant, and adapter reverse-map removals compare the handle atomically before removing a tag mapping. + +OPC UA refreshes carry an exact `SnapshotSourceReference` (including the empty Server binding) and a `SnapshotId` shared by `SnapshotStart`, entries and `SnapshotComplete`. Each NativeAlarmActor replaces its buffer on a new start and commits only a matching end; duplicate or superseded ends cannot clear a committed snapshot. The adapter tags each monitored item's refresh separately even though ConditionRefresh targets the shared event subscription. Mx snapshots retain their connection-wide framing. An interrupted refresh buffer is discarded on disconnect. This pattern ensures no messages are lost during connection transitions and is the standard Akka.NET approach for actors with I/O lifecycle dependencies. diff --git a/docs/requirements/Component-ManagementService.md b/docs/requirements/Component-ManagementService.md index a0fff021..80b76b81 100644 --- a/docs/requirements/Component-ManagementService.md +++ b/docs/requirements/Component-ManagementService.md @@ -309,3 +309,7 @@ The ManagementActor receives the following services and repositories via DI (inj - **Communication Layer**: Deployment commands and remote site queries flow through communication actors. - **Configuration Database (via IAuditService)**: All configuration changes are audited. - **Security & Auth**: The ManagementActor enforces authorization using user identity passed in messages. For HTTP API access, the Management endpoint authenticates the user via LDAP and resolves roles before dispatching to the ManagementActor. + +### Atomic configuration command boundary (2026-09-09) + +Database-only configuration commands may execute through `ExecuteConfigurationCommandAsync` from the actor or Central UI. The boundary requires role authorization and `IConfigurationCommandTransaction`; missing transaction configuration fails closed. Repository mutations may flush generated keys within the transaction; a final flush persists the audit before commit. Existing service-owned audits are retained. Site cache refresh and inbound API handler publication run only after a successful commit. diff --git a/docs/requirements/Component-NotificationOutbox.md b/docs/requirements/Component-NotificationOutbox.md index b9e1fe32..aaea7f75 100644 --- a/docs/requirements/Component-NotificationOutbox.md +++ b/docs/requirements/Component-NotificationOutbox.md @@ -199,3 +199,12 @@ Delivery max-retry-count and retry interval are not part of `NotificationOutboxO - **Central UI**: Queries the `Notifications` table for the Notification Outbox page and issues operator Retry/Discard actions on parked notifications. - **Health Monitoring**: Polls the outbox for KPI tiles on the health dashboard. - **KPI History (#26)**: Emits `IKpiSampleSource` (`NotificationOutboxKpiSampleSource`, Global + per-Site + per-Node) consumed by the KpiHistory recorder (#26), reusing the existing `Compute…KpisAsync` reads; the resulting `queueDepth` / `stuckCount` / `parkedCount` / `deliveredLastInterval` / `oldestPendingAgeSeconds` series render as trends on the Notification Outbox page via `KpiTrendChart`. See [Component-KpiHistory.md](Component-KpiHistory.md). + +### Site-scoped delivery-status queries (2026-09-09) + +The central gRPC ingress adds its credential-verified site identity to the internal status +query; the site cannot supply or override this field on the wire. The outbox compares it to +the stored notification's `SourceSiteId` before returning delivery state or error details. +A foreign or unknown notification returns the same `Found = false` response. Existing trusted +in-process central callers may omit the site constraint. Notification ingest remains +idempotent on `NotificationId`; this does not imply row-level dispatch claiming. diff --git a/docs/requirements/Component-ScriptAnalysis.md b/docs/requirements/Component-ScriptAnalysis.md index 4e2688e4..507081de 100644 --- a/docs/requirements/Component-ScriptAnalysis.md +++ b/docs/requirements/Component-ScriptAnalysis.md @@ -38,6 +38,8 @@ The following namespace/type prefixes are forbidden in all scripts: | `System.Reflection` | Reflection — forbidden entirely | | `System.Net` | Raw network access — forbidden entirely (scripts must use `ExternalSystem.Call`) | | `System.Runtime.InteropServices` | Native interop — forbidden entirely | +| `System.Runtime.CompilerServices.Unsafe`, `System.Runtime.CompilerServices.RuntimeHelpers` | Type-punning and constructor/runtime bypass — forbidden types; compiler attributes and async support remain allowed | +| `System.Runtime.Loader` | Assembly loading and load-context control — forbidden entirely | | `Microsoft.Win32` | Win32 API access — forbidden entirely | | `System.Environment` | Whole type — forbidden entirely; exposes process control (`Exit`/`FailFast`), the host environment, and secrets via environment variables (e.g. `SCADABRIDGE_API_KEY`). Scripts that need a line break use `"\n"` instead of `Environment.NewLine`. | | `System.GC` | Whole type — forbidden entirely; GC control (`Collect`, `KeepAlive`, memory-pressure knobs) has no legitimate script use. | diff --git a/docs/requirements/Component-Security.md b/docs/requirements/Component-Security.md index 92049438..12518d8a 100644 --- a/docs/requirements/Component-Security.md +++ b/docs/requirements/Component-Security.md @@ -51,12 +51,12 @@ Set in a local or docker-dev environment via the environment variable `ScadaBrid > intent and is the mechanism for any non-cookie bearer surface (e.g. `/auth/token`), but > it is **not** the transport for the cookie principal. -- **Idle timeout**: Configurable, default **30 minutes**. If no requests are made within the idle window, the session is rejected and the user must re-login. Tracked via a `LastActivity` last-activity timestamp claim. The cookie's `ExpireTimeSpan` is set to the idle timeout and `SlidingExpiration` renews it on activity, so the cookie window and the explicit `OnValidatePrincipal` idle check use the **same** value and cannot contradict each other. -- **Role-mapping refresh (LDAP-free)**: Configurable, default **15 minutes** (`SecurityOptions.RoleRefreshThresholdMinutes`). At login the session stores the user's raw LDAP groups (one `zb:group` claim each) plus a `zb:lastrolerefresh` anchor. Once the anchor is older than the threshold, `OnValidatePrincipal` re-runs the **DB-backed** `RoleMapper` on the stored groups — **with no LDAP call** — rebuilds the role/scope claims via the shared claim-builder, advances the anchor, and re-issues the cookie. Central role-mapping (DB) changes — including a **revoked** mapping that drops the user's roles, and changed site-scope rules — take effect within this window. Roles derived from central mappings are never more than ~15 minutes stale. +- **Idle timeout**: Configurable, default **30 minutes**. Without eligible activity within the idle window, the session is rejected and the user must re-login. Passive `/auth/ping`, activity prevalidation, and Blazor transport requests preserve the activity anchor and suppress sliding renewal; the activity endpoint renews only after authentication and antiforgery validation. Tracked via a `LastActivity` last-activity timestamp claim. The cookie's `ExpireTimeSpan` is set to the idle timeout and `SlidingExpiration` renews it on activity, so the cookie window and the explicit `OnValidatePrincipal` idle check use the **same** value and cannot contradict each other. +- **Role-mapping refresh (LDAP-free)**: Configurable, default **15 minutes** (`SecurityOptions.RoleRefreshThresholdMinutes`). At login the session stores the user's raw LDAP groups (one `zb:group` claim each) plus a `zb:lastrolerefresh` anchor. Once the anchor is older than the threshold, `OnValidatePrincipal` re-runs the **DB-backed** `RoleMapper` on the stored groups — **with no LDAP call** — rebuilds the role/scope claims via the shared claim-builder, advances the anchor, and re-issues the cookie. Central role-mapping (DB) changes — including a **revoked** mapping that drops the user's roles, and changed site-scope rules — take effect within this window. Ordinary HTTP validation uses this threshold. A live circuit refreshes mappings in fresh scopes on authentication-state reads, and mutation entrypoints require a successful current mapping before dispatch. #### Session Validation (`OnValidatePrincipal`) - The cookie principal is built at login by a **single shared claim-builder** (`SessionClaimBuilder`). The `OnValidatePrincipal` role-refresh path rebuilds the principal through the **same** builder, so the login and refresh claim shapes cannot drift. -- **Failure policy**: the refresh is best-effort. Any error during the refresh (e.g. the configuration database is unreachable) **keeps the existing principal with its current roles** — it never signs the user out and never throws out of the request pipeline. This mirrors the **Active sessions** stance under *LDAP Connection Failure* below. Only the explicit idle-timeout path rejects the principal. +- **Failure policy**: ordinary cookie validation and display-state refresh retain existing grants on a mapping lookup error. Authoritative circuit mutation checks fail closed on the same error, so a mutation cannot proceed using grants that could not be refreshed. Idle timeout rejects the principal. > **Residual limitation — live LDAP group-membership changes (follow-up).** The > mid-session refresh re-maps the **stored** groups against the central database; it does @@ -66,7 +66,7 @@ Set in a local or docker-dev environment via the environment variable `ScadaBrid > shared `ZB.MOM.WW.Auth.Ldap` library, which is an **external NuGet package** and exposes > only `AuthenticateAsync(username, password, ct)` (no standalone group search). Adding > that method is tracked as a follow-up. Until then: central role-mapping/scope changes are -> reflected within ~15 minutes; directory group-membership changes require re-login. +> reflected during HTTP refresh and immediately at circuit mutation checks; directory group-membership changes require re-login. ### Load Balancer Compatibility - The authentication cookie carries a self-contained JWT — no server-side session state. A load balancer in front of the central cluster can route requests to either node without sticky sessions or a shared session store. @@ -213,3 +213,7 @@ To blunt online password-spray and brute-force against the directory, every LDAP - **Proxy topologies and the username-lockout DoS.** The key is deliberately `{username}|{IP}`, not `{username}` alone: keying on username alone would let any network-adjacent actor lock any operator out of a SCADA control surface with five wrong passwords, repeatable indefinitely. That isolation only exists when the throttle sees real client IPs — behind a proxy **without** ForwardedHeaders trust, all clients collapse onto the proxy's IP and the DoS returns. Mitigations: (a) the shipped Traefik topologies enable trusted-proxy ForwardedHeaders (so per-IP isolation is real in the documented deployment); (b) the lockout window is short by default (`LoginLockoutMinutes` = 5) and never touches the directory account itself; (c) residual risk — an attacker spraying from their *own* IP locking out a victim username *at that IP only* — is the throttle working as designed. A success-path bypass ("a correct password unlocks") was considered and rejected: it would let an attacker who has the password bypass the lockout entirely, and it converts the throttle into a password oracle during the lockout window. - **Behaviour when locked**: the surface refuses the bind without contacting LDAP. The Basic-Auth surfaces (`ManagementAuthenticator`) and `POST /auth/token` return HTTP `429` with `code = "AUTH_THROTTLED"`; `POST /auth/login` redirects to `/login` with a "Too many failed attempts. Try again later." message. - **Best-effort, per-node**: the throttle is in-memory with no external state. The two central nodes maintain independent counters (a spray hitting both simply burns its budget on each). Memory is bounded — writes prune expired keys and cap the tracked-key count. This is a rate-limit guard, not an account-lockout policy; it never disables the directory account itself. + +### Passive traffic and circuit refresh (2026-09-09) + +`/auth/ping`, `/auth/activity`, and Blazor transport validation do not advance inactivity timestamps or trigger sliding renewal. The explicit activity endpoint validates antiforgery before renewing the cookie and recording activity by protected session ID. A bounded process-local tracker lets a live circuit observe successful activity renewal. Role refresh preserves the session ID and activity timestamp; current circuit mutation checks re-map stored groups with fail-closed lookup behavior. diff --git a/docs/requirements/Component-SiteCallAudit.md b/docs/requirements/Component-SiteCallAudit.md index ee2c2558..a9f38492 100644 --- a/docs/requirements/Component-SiteCallAudit.md +++ b/docs/requirements/Component-SiteCallAudit.md @@ -148,6 +148,8 @@ and the mailbox supplies the memory barrier between consecutive passes. The daily terminal-row purge uses the same shape. (`NotificationOutboxActor`'s dispatch sweep is the in-repo reference.) +**Stored site ownership is immutable.** Authenticated control-plane requests must claim their verified site in both audit and operational halves. An existing `TrackedOperationId` cannot be advanced by another site even when the incoming packet claims its own site consistently: the SQL upsert rejects the owner mismatch before its monotonic update, including the duplicate-insert retry path. The update predicate independently requires an exact owner match. A rejection rolls back the paired audit/operational ingest transaction and produces no acknowledgment. + **The central upsert is one round trip, INSERT-first.** `SiteCallAuditRepository.UpsertAsync` ships both statements in a single command text — `IF NOT EXISTS … INSERT;` then the monotonic `UPDATE` — so a packet costs diff --git a/docs/requirements/Component-SiteRuntime.md b/docs/requirements/Component-SiteRuntime.md index b8fbda15..7abd6959 100644 --- a/docs/requirements/Component-SiteRuntime.md +++ b/docs/requirements/Component-SiteRuntime.md @@ -216,7 +216,7 @@ flowchart TD - **Alarm state changes**: `[InstanceUniqueName].[AlarmName]`, alarm state (active/normal), priority, timestamp. ### GetAttribute / SetAttribute -- **GetAttribute**: Returns the current in-memory value for the requested attribute. +- **GetAttribute**: Returns the current in-memory value for the requested attribute. Attribute read/write/wait helper continuations do not capture the calling scheduler (`ConfigureAwait(false)`), so synchronous `Attributes[key]` access can complete even when every script worker is blocked. AsyncLocal trace attribution still flows with execution context. - **SetAttribute** (for attributes with data source reference): Sends a write request to the Data Connection Layer. The DCL writes to the physical device. If the write fails (connection down, device rejection, timeout), the error is returned synchronously to the calling script for handling. On success, the existing subscription picks up the confirmed value from the device and sends it back as a value update, which then updates the in-memory value. The in-memory value is **not** optimistically updated. - **SetAttribute** (for static attributes): Updates the in-memory value and **persists the override to local SQLite**. On restart (or failover), the Instance Actor loads persisted overrides on top of the deployed configuration, preserving runtime-modified values. A redeployment of the instance resets all persisted overrides to the new deployed configuration values. @@ -379,6 +379,8 @@ that owns the Instance Actor, and central reads the linkage from audit rows. - **`NativeAlarmSourceUnavailable`**: the source connection has dropped. The actor **retains its last-known mirrored conditions** but marks them uncertain rather than purging them, so a transient disconnect does not flap every condition to normal. The set is reconciled against truth by the next reconnect snapshot. ### Transition Handling (`NativeAlarmTransitionUpdate`) +- **Owned subscription cleanup**: Instance Actors send tag unsubscribes for each bound connection in `PostStop`; Native Alarm Actors send their alarm-source unsubscribe with their own actor identity. DCL DeathWatch provides cleanup if an owner terminates without a graceful message. Late cleanup from an old actor cannot remove its same-name replacement. +- **OPC UA refresh scope**: snapshot start, entries and completion preserve the exact source binding and refresh epoch. Native Alarm Actors ignore unrelated feeds and mismatched/duplicate completion epochs, and discard a partial refresh on source disconnect. MxGateway keeps its connection-wide snapshot framing. - **Snapshot / SnapshotComplete (reconnect reconciliation)**: `Snapshot` updates buffer into a staging set; `SnapshotComplete` performs an **atomic swap** of the mirrored set with the staged set. Any condition that was previously mirrored but is **not present** in the new snapshot emits a return-to-normal `AlarmStateChanged` and drops out. This is how the mirror self-corrects after an outage. - **Live transitions** (`Raise` / `Ack` / `Clear` / `Retrigger` / `StateChange`): upsert the condition by `SourceReference`. Updates carrying a `TransitionTime` **older** than the currently held transition are ignored (out-of-order protection). Accepted transitions persist to SQLite and emit an enriched `AlarmStateChanged` upward to the Instance Actor. - **Retention**: a mirrored condition is dropped once it is both inactive **and** acknowledged (`!Active && Acknowledged`) — the alarm has fully run its course at the source and no longer needs mirroring. The drop emits a final state change and deletes the SQLite row. @@ -499,7 +501,7 @@ Available to all script runs and alarm on-trigger runs: - `Notify.Status("trackedOperationId")` — A thin alias of `Tracking.Status(id)` retained for the notification domain. Returns a status record (status, retry count, last error, key timestamps). While the notification is still in the site store-and-forward buffer the site answers locally (status `Forwarding`); once forwarded the query round-trips to central. ### Database Access -- `Database.Connection("connectionName")` — Obtain a raw MS SQL client connection (ADO.NET) for synchronous read/write. +- `Database.Connection("connectionName")` — Obtain an audited ADO.NET connection for read/write. Commands and synchronous/asynchronous transactions retain the auditing connection on public readback; provider transactions are unwrapped only internally for command assignment. Each command execution emits one audit event, including commands reached through `transaction.Connection` or `command.Connection`. Commit, rollback, savepoints and disposal retain provider semantics. - `Database.CachedWrite("connectionName", "sql", parameters)` — Submit a write operation for store-and-forward delivery. Returns a `TrackedOperationId` tracking handle immediately; pass it to `Tracking.Status(id)` to observe delivery progress. ### Operation Tracking diff --git a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/GrpcPullAuditEventsClient.cs b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/GrpcPullAuditEventsClient.cs index 3020e57f..4f553a54 100644 --- a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/GrpcPullAuditEventsClient.cs +++ b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/GrpcPullAuditEventsClient.cs @@ -342,7 +342,7 @@ public sealed class GrpcPullAuditEventsInvoker KeepAlivePingTimeout = _options.GrpcKeepAlivePingTimeout, KeepAlivePingPolicy = HttpKeepAlivePingPolicy.Always, }, - }.WithSiteCredentials(_pskProvider, siteId)); + }.WithMessageLimits().WithSiteCredentials(_pskProvider, siteId)); /// Disposes all cached channels. public void Dispose() diff --git a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/GrpcPullSiteCallsClient.cs b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/GrpcPullSiteCallsClient.cs index d04de05c..4d5179ad 100644 --- a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/GrpcPullSiteCallsClient.cs +++ b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/GrpcPullSiteCallsClient.cs @@ -356,7 +356,7 @@ public sealed class GrpcPullSiteCallsInvoker KeepAlivePingTimeout = _options.GrpcKeepAlivePingTimeout, KeepAlivePingPolicy = HttpKeepAlivePingPolicy.Always, }, - }.WithSiteCredentials(_pskProvider, siteId)); + }.WithMessageLimits().WithSiteCredentials(_pskProvider, siteId)); /// Disposes all cached channels. public void Dispose() diff --git a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/GrpcPullTraceEventsClient.cs b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/GrpcPullTraceEventsClient.cs index 6434d474..153490a3 100644 --- a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/GrpcPullTraceEventsClient.cs +++ b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/GrpcPullTraceEventsClient.cs @@ -209,7 +209,7 @@ public sealed class GrpcPullTraceEventsInvoker : GrpcPullTraceEventsClient.IPull KeepAlivePingTimeout = _options.GrpcKeepAlivePingTimeout, KeepAlivePingPolicy = HttpKeepAlivePingPolicy.Always, }, - }.WithSiteCredentials(_pskProvider, siteId)); + }.WithMessageLimits().WithSiteCredentials(_pskProvider, siteId)); /// public void Dispose() diff --git a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/Telemetry/SiteAuditTelemetryActor.cs b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/Telemetry/SiteAuditTelemetryActor.cs index 5d6d2bf3..4189a257 100644 --- a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/Telemetry/SiteAuditTelemetryActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/Telemetry/SiteAuditTelemetryActor.cs @@ -260,6 +260,7 @@ public class SiteAuditTelemetryActor : ReceiveActor } var batch = new CachedTelemetryBatch(); + var wireBytes = AuditWireBudget.InitialBytes; var emittedEventIds = new List(pending.Count); // Rows whose operational half can never be built. They are marked // Forwarded so they LEAVE this queue — see AbandonUnresolvableAsync @@ -326,7 +327,7 @@ public class SiteAuditTelemetryActor : ReceiveActor } var packet = BuildCachedPacket(auditRow, snapshot); - batch.Packets.Add(packet); + if (!AuditWireBudget.TryAppend(batch.Packets, packet, ref wireBytes, auditRow.EventId.ToString())) break; emittedEventIds.Add(auditRow.EventId); } @@ -452,9 +453,10 @@ public class SiteAuditTelemetryActor : ReceiveActor private static AuditEventBatch BuildBatch(IReadOnlyList events) { var batch = new AuditEventBatch(); + var wireBytes = AuditWireBudget.InitialBytes; foreach (var e in events) { - batch.Events.Add(AuditEventDtoMapper.ToDto(e)); + if (!AuditWireBudget.TryAppend(batch.Events, AuditEventDtoMapper.ToDto(e), ref wireBytes, e.EventId.ToString())) break; } return batch; } diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Auth/AuthEndpoints.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Auth/AuthEndpoints.cs index 6919de1b..d135f290 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Auth/AuthEndpoints.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Auth/AuthEndpoints.cs @@ -1,3 +1,5 @@ +using Microsoft.AspNetCore.Antiforgery; +using System.Security.Claims; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Builder; @@ -198,6 +200,7 @@ public static class AuthEndpoints // page. Allowing anonymous access and returning 200/401 ourselves gives // the client an unambiguous expiry signal. endpoints.MapGet("/auth/ping", HandlePing); + endpoints.MapPost("/auth/activity", HandleActivity); return endpoints; } @@ -217,6 +220,29 @@ public static class AuthEndpoints return Task.CompletedTask; } + /// Records real browser input only after authenticating and validating antiforgery. + public static async Task HandleActivity(HttpContext context, IAntiforgery antiforgery, + SessionActivityTracker tracker, TimeProvider clock) + { + if (context.User.Identity?.IsAuthenticated != true) return Results.Unauthorized(); + try { await antiforgery.ValidateRequestAsync(context); } + catch (AntiforgeryValidationException) { return Results.BadRequest(); } + var now = clock.GetUtcNow(); + var identity = new ClaimsIdentity((ClaimsIdentity)context.User.Identity); + var old = identity.FindFirst(JwtTokenService.LastActivityClaimType); + if (old is not null) identity.RemoveClaim(old); + identity.AddClaim(new Claim(JwtTokenService.LastActivityClaimType, now.ToString("o"))); + var principal = new ClaimsPrincipal(identity); + // Preserve the login's persistence semantics; renew expiry from this actual interaction. + var authenticated = await context.AuthenticateAsync(CookieAuthenticationDefaults.AuthenticationScheme); + var properties = authenticated.Properties ?? BuildSignInProperties(); + properties.IssuedUtc = now; + properties.ExpiresUtc = null; + await context.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal, properties); + tracker.Record(principal, now); + return Results.NoContent(); + } + /// /// Builds the for the login sign-in. /// Deliberately does not set . diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Auth/ClaimsPrincipalExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Auth/ClaimsPrincipalExtensions.cs index 814b102a..05bfc7a9 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Auth/ClaimsPrincipalExtensions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Auth/ClaimsPrincipalExtensions.cs @@ -12,6 +12,20 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Auth; /// public static class ClaimsPrincipalExtensions { + /// Gets fresh mutation authority, failing closed on a mapping error. + public static Task GetAuthoritativeStateAsync(this AuthenticationStateProvider provider) + => provider is CookieAuthenticationStateProvider cookie + ? cookie.GetAuthoritativeStateAsync() : provider.GetAuthenticationStateAsync(); + + /// Checks the current grant before invoking a directly audited application service. + public static async Task RequireRoleAsync(this AuthenticationStateProvider provider, string role) + { + var principal = (await provider.GetAuthoritativeStateAsync()).User; + if (principal.Identity?.IsAuthenticated != true || !principal.HasClaim(JwtTokenService.RoleClaimType, role)) + throw new UnauthorizedAccessException($"The current session requires the {role} role."); + return principal.GetUsername(); + } + /// Fallback returned when no username claim is present. public const string UnknownUser = "unknown"; diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Auth/CookieAuthenticationStateProvider.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Auth/CookieAuthenticationStateProvider.cs index f5d4be26..19c8c493 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Auth/CookieAuthenticationStateProvider.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Auth/CookieAuthenticationStateProvider.cs @@ -1,48 +1,71 @@ +using Microsoft.Extensions.Options; +using ZB.MOM.WW.ScadaBridge.Security.Auth; using System.Security.Claims; +using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Components.Server; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using ZB.MOM.WW.ScadaBridge.Security; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Auth; /// -/// Bridges ASP.NET Core cookie authentication with Blazor Server's auth state. -/// -/// The cookie middleware validates and decrypts the cookie during the initial -/// HTTP request that establishes the Blazor circuit. This provider is registered -/// Scoped, so it is constructed within that request's DI scope while -/// is still valid. We snapshot -/// the authenticated principal once in the constructor and serve that -/// snapshot for the lifetime of the circuit. -/// -/// -/// We must NOT read on every -/// call: for the -/// lifetime of a long-lived SignalR circuit HttpContext is null -/// (or, worse, a stale/foreign context), so a later re-evaluation — -/// e.g. <AuthorizeView> re-rendering — would otherwise see an -/// unauthenticated principal and render the wrong UI. -/// +/// Captures the circuit identity once, then revalidates its stored groups and session +/// against current server state. Fresh scopes avoid sharing the circuit's DbContext. +/// Never reads IHttpContextAccessor after construction. /// public class CookieAuthenticationStateProvider : ServerAuthenticationStateProvider { - private readonly Task _circuitAuthState; + private AuthenticationState _state; + private readonly IServiceScopeFactory? _scopes; + private readonly bool _autoLogin; + private readonly SemaphoreSlim _gate = new(1, 1); - /// - /// Snapshots the authenticated principal from the current HTTP context for use throughout the circuit lifetime. - /// - /// Accessor used to read the initial HTTP context principal. - public CookieAuthenticationStateProvider(IHttpContextAccessor httpContextAccessor) + /// Captures identity and the factory used for subsequent server-side revalidation. + public CookieAuthenticationStateProvider(IHttpContextAccessor httpContextAccessor, IServiceScopeFactory? scopes = null, IOptions? autoLogin = null) { - // Snapshot the principal at circuit-construction time. HttpContext is - // valid here (initial HTTP request) and will not be afterwards. - var user = httpContextAccessor.HttpContext?.User - ?? new ClaimsPrincipal(new ClaimsIdentity()); - - _circuitAuthState = Task.FromResult(new AuthenticationState(user)); + _state = new AuthenticationState(httpContextAccessor.HttpContext?.User ?? new ClaimsPrincipal(new ClaimsIdentity())); + _scopes = scopes; + // Trusted startup configuration, already checked by DisableLoginGuard. The + // auto-login handler deliberately uses the Cookies scheme with no LDAP groups. + _autoLogin = autoLogin?.Value.DisableLogin == true; } /// - public override Task GetAuthenticationStateAsync() - => _circuitAuthState; + public override Task GetAuthenticationStateAsync() => RefreshAsync(failClosed: false); + + /// Requires a current mapping before a mutation; mapping faults deny rather than reuse old grants. + public Task GetAuthoritativeStateAsync() => RefreshAsync(failClosed: true); + + private async Task RefreshAsync(bool failClosed) + { + if (_autoLogin || _scopes is null || _state.User.Identity?.AuthenticationType != CookieAuthenticationDefaults.AuthenticationScheme) + return _state; + AuthenticationState state; + bool changed; + await _gate.WaitAsync(); + try + { + using var scope = _scopes.CreateScope(); + var validator = scope.ServiceProvider.GetRequiredService(); + var result = await validator.ValidateAsync(_state.User, recordActivity: false, forceRefresh: true, failClosed: failClosed); + var user = result.Action switch + { + SessionValidationAction.Replace => result.Principal!, + SessionValidationAction.Reject => new ClaimsPrincipal(new ClaimsIdentity()), + _ => _state.User, + }; + changed = Authority(_state.User) != Authority(user); + _state = state = new AuthenticationState(user); + } + finally { _gate.Release(); } + if (changed) NotifyAuthenticationStateChanged(Task.FromResult(state)); + return state; + } + + private static string Authority(ClaimsPrincipal principal) + => principal.Identity?.IsAuthenticated + "|" + string.Join("|", principal.Claims + .Where(c => c.Type == JwtTokenService.RoleClaimType || c.Type == JwtTokenService.SiteIdClaimType) + .Select(c => c.Type + "=" + c.Value).OrderBy(x => x, StringComparer.Ordinal)); } diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Auth/SiteScopeService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Auth/SiteScopeService.cs index c99c92b4..95fae501 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Auth/SiteScopeService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Auth/SiteScopeService.cs @@ -37,7 +37,6 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Auth; public sealed class SiteScopeService { private readonly AuthenticationStateProvider _authStateProvider; - private (bool IsSystemWide, IReadOnlySet Sites)? _cached; /// Initializes a new instance of . /// The Blazor authentication state provider used to read the current user's claims. @@ -92,10 +91,7 @@ public sealed class SiteScopeService private async Task<(bool IsSystemWide, IReadOnlySet Sites)> ResolveAsync() { - if (_cached is { } cached) - return cached; - - var state = await _authStateProvider.GetAuthenticationStateAsync(); + var state = await _authStateProvider.GetAuthoritativeStateAsync(); // Administrator => system-wide, whatever SiteId claims are present. Read // the role CLAIM rather than IsInRole: the principal's role claim type is @@ -103,6 +99,9 @@ public sealed class SiteScopeService // by SessionClaimBuilder, and the comparison is OrdinalIgnoreCase to match // ManagementActor's — IsInRole compares claim VALUES ordinally, which would // make the two sides disagree on a differently-cased role name. + if (state.User.Identity?.IsAuthenticated != true) + return (false, new HashSet()); + var isAdministrator = state.User .FindAll(JwtTokenService.RoleClaimType) .Any(c => string.Equals(c.Value, Roles.Administrator, StringComparison.OrdinalIgnoreCase)); @@ -126,7 +125,6 @@ public sealed class SiteScopeService // unrestricted deployer (this service is the sole // site-scoping mechanism — there is no separate handler to mirror). var result = (IsSystemWide: isAdministrator || ids.Count == 0, Sites: (IReadOnlySet)ids); - _cached = result; return result; } } diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Health/CentralFailoverControl.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Health/CentralFailoverControl.razor index 33c16242..1aab07ad 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Health/CentralFailoverControl.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Health/CentralFailoverControl.razor @@ -1,3 +1,5 @@ +@using ZB.MOM.WW.ScadaBridge.CentralUI.Auth +@inject Microsoft.AspNetCore.Components.Authorization.AuthenticationStateProvider MutationAuth @using Microsoft.AspNetCore.Components.Authorization @using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared @using ZB.MOM.WW.ScadaBridge.CentralUI.Services @@ -92,7 +94,7 @@ try { - var actor = await ResolveActorAsync(); + var actor = await MutationAuth.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Administrator); if (IsSite) { diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Admin/ApiKeyForm.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Admin/ApiKeyForm.razor index e99e19b1..7ec5ff1e 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Admin/ApiKeyForm.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Admin/ApiKeyForm.razor @@ -1,3 +1,5 @@ +@using ZB.MOM.WW.ScadaBridge.CentralUI.Auth +@inject Microsoft.AspNetCore.Components.Authorization.AuthenticationStateProvider MutationAuth @page "/admin/api-keys/create" @page "/admin/api-keys/{KeyId}/edit" @using ZB.MOM.WW.ScadaBridge.Security @@ -187,6 +189,7 @@ try { + await MutationAuth.RequireRoleAsync(Roles.Administrator); if (_editingKey != null) { // Edit: name is fixed; only the method-scope set is mutable. diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Admin/ApiKeys.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Admin/ApiKeys.razor index 021fb4ca..e7f535e3 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Admin/ApiKeys.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Admin/ApiKeys.razor @@ -1,3 +1,5 @@ +@using ZB.MOM.WW.ScadaBridge.CentralUI.Auth +@inject Microsoft.AspNetCore.Components.Authorization.AuthenticationStateProvider MutationAuth @page "/admin/api-keys" @using ZB.MOM.WW.ScadaBridge.Security @using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Security @@ -151,6 +153,7 @@ { var newEnabled = !key.Enabled; // The seam persists; there is no separate SaveChangesAsync. + await MutationAuth.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Administrator); var ok = await ApiKeyAdmin.SetEnabledAsync(key.KeyId, newEnabled); if (!ok) { @@ -177,6 +180,7 @@ try { + await MutationAuth.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Administrator); var ok = await ApiKeyAdmin.DeleteAsync(key.KeyId); if (!ok) { diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Admin/LdapMappingForm.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Admin/LdapMappingForm.razor index 606019b0..33a1587a 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Admin/LdapMappingForm.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Admin/LdapMappingForm.razor @@ -1,3 +1,6 @@ +@using ZB.MOM.WW.ScadaBridge.CentralUI.Services +@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management +@inject IServiceProvider MutationServices @page "/admin/ldap-mappings/create" @page "/admin/ldap-mappings/{Id:int}/edit" @using ZB.MOM.WW.ScadaBridge.Commons.Entities.Security @@ -163,19 +166,9 @@ try { - if (_editingMapping != null) - { - _editingMapping.LdapGroupName = _formGroupName.Trim(); - _editingMapping.Role = _formRole; - await SecurityRepository.UpdateMappingAsync(_editingMapping); - } - else - { - var mapping = new LdapGroupMapping(_formGroupName.Trim(), _formRole); - await SecurityRepository.AddMappingAsync(mapping); - } - - await SecurityRepository.SaveChangesAsync(); + await MutationServices.ExecuteConfigurationAsync(_editingMapping is not null + ? new UpdateRoleMappingCommand(_editingMapping.Id, _formGroupName.Trim(), _formRole) + : new CreateRoleMappingCommand(_formGroupName.Trim(), _formRole)); NavigationManager.NavigateTo("/admin/ldap-mappings"); } catch (Exception ex) @@ -196,9 +189,7 @@ try { - var rule = new SiteScopeRule { LdapGroupMappingId = Id!.Value, SiteId = _scopeRuleSiteId }; - await SecurityRepository.AddScopeRuleAsync(rule); - await SecurityRepository.SaveChangesAsync(); + await MutationServices.ExecuteConfigurationAsync(new AddScopeRuleCommand(Id!.Value, _scopeRuleSiteId)); _scopeRules = (await SecurityRepository.GetScopeRulesForMappingAsync(Id.Value)).ToList(); _scopeRuleSiteId = 0; } @@ -219,8 +210,7 @@ try { - await SecurityRepository.DeleteScopeRuleAsync(rule.Id); - await SecurityRepository.SaveChangesAsync(); + await MutationServices.ExecuteConfigurationAsync(new DeleteScopeRuleCommand(rule.Id)); _scopeRules = (await SecurityRepository.GetScopeRulesForMappingAsync(Id!.Value)).ToList(); } catch (Exception ex) diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Admin/LdapMappings.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Admin/LdapMappings.razor index 765969b1..120f7ffd 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Admin/LdapMappings.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Admin/LdapMappings.razor @@ -1,3 +1,6 @@ +@using ZB.MOM.WW.ScadaBridge.CentralUI.Services +@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management +@inject IServiceProvider MutationServices @page "/admin/ldap-mappings" @attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)] @using ZB.MOM.WW.ScadaBridge.Commons.Entities.Security @@ -143,14 +146,7 @@ { try { - // Also delete scope rules for this mapping - var rules = await SecurityRepository.GetScopeRulesForMappingAsync(id); - foreach (var rule in rules) - { - await SecurityRepository.DeleteScopeRuleAsync(rule.Id); - } - await SecurityRepository.DeleteMappingAsync(id); - await SecurityRepository.SaveChangesAsync(); + await MutationServices.ExecuteConfigurationAsync(new DeleteRoleMappingCommand(id)); await LoadDataAsync(); } catch (Exception ex) diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Admin/SiteForm.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Admin/SiteForm.razor index b1a96e48..2344ece3 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Admin/SiteForm.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Admin/SiteForm.razor @@ -1,3 +1,6 @@ +@using ZB.MOM.WW.ScadaBridge.CentralUI.Services +@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management +@inject IServiceProvider MutationServices @page "/admin/sites/create" @page "/admin/sites/{Id:int}/edit" @using ZB.MOM.WW.ScadaBridge.Security @@ -158,35 +161,16 @@ try { - if (_editingSite != null) + if (_editingSite is null && string.IsNullOrWhiteSpace(_formIdentifier)) { - _editingSite.Name = _formName.Trim(); - _editingSite.Description = _formDescription?.Trim(); - _editingSite.NodeAAddress = _formNodeAAddress?.Trim(); - _editingSite.NodeBAddress = _formNodeBAddress?.Trim(); - _editingSite.GrpcNodeAAddress = _formGrpcNodeAAddress?.Trim(); - _editingSite.GrpcNodeBAddress = _formGrpcNodeBAddress?.Trim(); - await SiteRepository.UpdateSiteAsync(_editingSite); + _formError = "Identifier is required."; + return; } - else - { - if (string.IsNullOrWhiteSpace(_formIdentifier)) - { - _formError = "Identifier is required."; - return; - } - var site = new Site(_formName.Trim(), _formIdentifier.Trim()) - { - Description = _formDescription?.Trim(), - NodeAAddress = _formNodeAAddress?.Trim(), - NodeBAddress = _formNodeBAddress?.Trim(), - GrpcNodeAAddress = _formGrpcNodeAAddress?.Trim(), - GrpcNodeBAddress = _formGrpcNodeBAddress?.Trim() - }; - await SiteRepository.AddSiteAsync(site); - } - - await SiteRepository.SaveChangesAsync(); + await MutationServices.ExecuteConfigurationAsync(_editingSite is not null + ? new UpdateSiteCommand(_editingSite.Id, _formName.Trim(), _formDescription?.Trim(), + _formNodeAAddress?.Trim(), _formNodeBAddress?.Trim(), _formGrpcNodeAAddress?.Trim(), _formGrpcNodeBAddress?.Trim()) + : new CreateSiteCommand(_formName.Trim(), _formIdentifier.Trim(), _formDescription?.Trim(), + _formNodeAAddress?.Trim(), _formNodeBAddress?.Trim(), _formGrpcNodeAAddress?.Trim(), _formGrpcNodeBAddress?.Trim())); CommunicationService.RefreshSiteAddresses(); NavigationManager.NavigateTo("/admin/sites"); } diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Admin/Sites.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Admin/Sites.razor index 2d154c63..34a34218 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Admin/Sites.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Admin/Sites.razor @@ -1,3 +1,6 @@ +@using ZB.MOM.WW.ScadaBridge.CentralUI.Services +@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management +@inject IServiceProvider MutationServices @* Reference pattern for list pages: card grid (col-lg-6) + flex header + search filter + kebab dropdown + Bootstrap collapse for noisy detail + @key on iterated cards + "No X match the filter." inline + empty-state CTA. Mirror this when building new list pages. *@ @page "/admin/sites" @using ZB.MOM.WW.ScadaBridge.Security @@ -165,7 +168,7 @@ // CentralUI-024: delegates to the shared helper so the claim type stays // resolved through JwtTokenService rather than a duplicated magic string. private Task GetCurrentUserAsync() - => AuthStateProvider.GetCurrentUsernameAsync(); + => AuthStateProvider.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Administrator); private List _sites = new(); private Dictionary> _siteConnections = new(); @@ -221,8 +224,7 @@ try { - await SiteRepository.DeleteSiteAsync(site.Id); - await SiteRepository.SaveChangesAsync(); + await MutationServices.ExecuteConfigurationAsync(new DeleteSiteCommand(site.Id)); CommunicationService.RefreshSiteAddresses(); _toast.ShowSuccess($"Site '{site.Name}' deleted."); await LoadDataAsync(); diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor index b611d82d..7d3ab306 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor @@ -1,3 +1,7 @@ +@using Microsoft.Extensions.DependencyInjection +@using ZB.MOM.WW.ScadaBridge.CentralUI.Services +@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management +@inject IServiceProvider MutationServices @page "/deployment/instances/{Id:int}/configure" @using ZB.MOM.WW.ScadaBridge.Security @using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances @@ -5,14 +9,12 @@ @using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates @using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol @using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories -@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management @using ZB.MOM.WW.ScadaBridge.Commons.Types @using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums @using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening @using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services @using ZB.MOM.WW.ScadaBridge.DeploymentManager @using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Dialogs -@using ZB.MOM.WW.ScadaBridge.CentralUI.Services @using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening @attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)] @inject ITemplateEngineRepository TemplateEngineRepository @@ -963,8 +965,14 @@ // CentralUI-024: delegates to the shared helper so the claim type stays // resolved through JwtTokenService rather than a duplicated magic string. - private Task GetCurrentUserAsync() - => AuthStateProvider.GetCurrentUsernameAsync(); + private async Task GetCurrentUserAsync() + { + var user = await AuthStateProvider.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Deployer); + var current = await TemplateEngineRepository.GetInstanceByIdAsync(Id); + if (current is null || !await SiteScope.IsSiteAllowedAsync(current.SiteId)) + throw new UnauthorizedAccessException("The instance is outside your current site scope."); + return user; + } // ── Bindings ──────────────────────────────────────────── @@ -1243,7 +1251,7 @@ _bindingOverrides.GetValueOrDefault(kv.Key))) .ToList(); var user = await GetCurrentUserAsync(); - var result = await InstanceService.SetConnectionBindingsAsync(Id, bindings, user); + var result = await MutationServices.GetRequiredService().ExecuteInstanceAsync(Id, null, (instanceService, currentUser) => instanceService.SetConnectionBindingsAsync(Id, bindings, currentUser)); if (result.IsSuccess) { SnapshotSavedBindingState(); @@ -1323,8 +1331,7 @@ { if (_existingOverrides.TryGetValue(attrName, out var ovr)) { - await TemplateEngineRepository.DeleteInstanceAttributeOverrideAsync(ovr.Id); - await TemplateEngineRepository.SaveChangesAsync(); + await MutationServices.ExecuteConfigurationAsync(new DeleteInstanceAttributeOverrideCommand(Id, attrName)); _existingOverrides.Remove(attrName); } _overrideValues.Remove(attrName); @@ -1377,7 +1384,7 @@ var failures = new List(); foreach (var (attrName, value) in toSave) { - var result = await InstanceService.SetAttributeOverrideAsync(Id, attrName, value, user); + var result = await MutationServices.GetRequiredService().ExecuteInstanceAsync(Id, null, (instanceService, currentUser) => instanceService.SetAttributeOverrideAsync(Id, attrName, value, currentUser)); if (result.IsSuccess) _existingOverrides[attrName] = result.Value!; else @@ -1454,7 +1461,7 @@ var failures = new List(); foreach (var (attrName, value) in outcome.Overrides) { - var result = await InstanceService.SetAttributeOverrideAsync(Id, attrName, value, user); + var result = await MutationServices.GetRequiredService().ExecuteInstanceAsync(Id, null, (instanceService, currentUser) => instanceService.SetAttributeOverrideAsync(Id, attrName, value, currentUser)); if (result.IsSuccess) { _existingOverrides[attrName] = result.Value!; @@ -1622,7 +1629,7 @@ { if (_existingAlarmOverrides.ContainsKey(alarmName)) { - var del = await InstanceService.DeleteAlarmOverrideAsync(Id, alarmName, user); + var del = await MutationServices.GetRequiredService().ExecuteInstanceAsync(Id, null, (instanceService, currentUser) => instanceService.DeleteAlarmOverrideAsync(Id, alarmName, currentUser)); if (!del.IsSuccess) { _editingError = del.Error; @@ -1639,8 +1646,8 @@ } else { - var result = await InstanceService.SetAlarmOverrideAsync( - Id, alarmName, overrideJson, priority, user); + var result = await MutationServices.GetRequiredService().ExecuteInstanceAsync(Id, null, (instanceService, currentUser) => instanceService.SetAlarmOverrideAsync( + Id, alarmName, overrideJson, priority, currentUser)); if (!result.IsSuccess) { _editingError = result.Error; @@ -1675,7 +1682,7 @@ try { var user = await GetCurrentUserAsync(); - var result = await InstanceService.DeleteAlarmOverrideAsync(Id, alarmName, user); + var result = await MutationServices.GetRequiredService().ExecuteInstanceAsync(Id, null, (instanceService, currentUser) => instanceService.DeleteAlarmOverrideAsync(Id, alarmName, currentUser)); if (result.IsSuccess) { _existingAlarmOverrides.Remove(alarmName); @@ -1718,7 +1725,6 @@ } await UpsertNativeOverrideCore(sourceName, conn, sref, filt); - await TemplateEngineRepository.SaveChangesAsync(); _toast.ShowSuccess($"Saved native alarm source override on '{sourceName}'."); } catch (Exception ex) @@ -1751,27 +1757,8 @@ /// private async Task UpsertNativeOverrideCore(string sourceName, string? conn, string? sref, string? filt) { - var existing = await TemplateEngineRepository.GetNativeAlarmSourceOverrideAsync(Id, sourceName); - if (existing == null) - { - var ovr = new InstanceNativeAlarmSourceOverride(sourceName) - { - InstanceId = Id, - ConnectionNameOverride = conn, - SourceReferenceOverride = sref, - ConditionFilterOverride = filt - }; - await TemplateEngineRepository.AddInstanceNativeAlarmSourceOverrideAsync(ovr); - _existingNativeOverrides[sourceName] = ovr; - } - else - { - existing.ConnectionNameOverride = conn; - existing.SourceReferenceOverride = sref; - existing.ConditionFilterOverride = filt; - await TemplateEngineRepository.UpdateInstanceNativeAlarmSourceOverrideAsync(existing); - _existingNativeOverrides[sourceName] = existing; - } + var result = await MutationServices.ExecuteConfigurationAsync(new SetInstanceNativeAlarmSourceOverrideCommand(Id, sourceName, conn, sref, filt)); + if (result is InstanceNativeAlarmSourceOverride saved) _existingNativeOverrides[sourceName] = saved; // Keep the inline editor showing what was just persisted. _nasConnEdit[sourceName] = conn; @@ -1781,12 +1768,7 @@ private async Task ClearNativeOverrideCore(string sourceName) { - var existing = await TemplateEngineRepository.GetNativeAlarmSourceOverrideAsync(Id, sourceName); - if (existing != null) - { - await TemplateEngineRepository.DeleteInstanceNativeAlarmSourceOverrideAsync(existing.Id); - await TemplateEngineRepository.SaveChangesAsync(); - } + await MutationServices.ExecuteConfigurationAsync(new DeleteInstanceNativeAlarmSourceOverrideCommand(Id, sourceName)); _existingNativeOverrides.Remove(sourceName); _nasConnEdit[sourceName] = null; _nasRefEdit[sourceName] = null; @@ -1849,17 +1831,9 @@ return; } - // Every row was validated up front, so the batch applies as a unit through - // the same repository upsert/clear the inline editor uses. - foreach (var row in outcome.Rows) - { - if (row.IsClear) - await ClearNativeOverrideCore(row.SourceName); - else - await UpsertNativeOverrideCore( - row.SourceName, row.Connection, row.SourceReference, row.Filter); - } - await TemplateEngineRepository.SaveChangesAsync(); + await MutationServices.ExecuteConfigurationAsync(new SetInstanceNativeAlarmSourceOverridesCommand(Id, + outcome.Rows.Select(row => new NativeAlarmSourceOverrideEntry(row.SourceName, row.Connection, row.SourceReference, row.Filter)).ToList())); + await OnInitializedAsync(); _nasCsvImportSucceeded = true; _nasCsvImportResult = $"Imported {outcome.Rows.Count} native alarm source override(s)."; @@ -1957,7 +1931,7 @@ try { var user = await GetCurrentUserAsync(); - var result = await InstanceService.AssignToAreaAsync(Id, _reassignAreaId == 0 ? null : _reassignAreaId, user); + var result = await MutationServices.GetRequiredService().ExecuteInstanceAsync(Id, null, (instanceService, currentUser) => instanceService.AssignToAreaAsync(Id, _reassignAreaId == 0 ? null : _reassignAreaId, currentUser)); if (result.IsSuccess) { _areaName = _siteAreas.FirstOrDefault(a => a.Id == _reassignAreaId)?.Name ?? ""; diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceCreate.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceCreate.razor index 93f56781..d7bf9769 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceCreate.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceCreate.razor @@ -1,3 +1,5 @@ +@using Microsoft.Extensions.DependencyInjection +@inject IServiceProvider MutationServices @page "/deployment/instances/create" @using ZB.MOM.WW.ScadaBridge.Security @using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances @@ -324,8 +326,8 @@ try { user = await GetCurrentUserAsync(); - var result = await InstanceService.CreateInstanceAsync( - _createName.Trim(), _createTemplateId, _createSiteId, _createAreaId == 0 ? null : _createAreaId, user); + var result = await MutationServices.GetRequiredService().ExecuteInstanceAsync(null, _createSiteId, (instanceService, currentUser) => instanceService.CreateInstanceAsync( + _createName.Trim(), _createTemplateId, _createSiteId, _createAreaId == 0 ? null : _createAreaId, currentUser)); if (result.IsFailure) { _formError = result.Error; @@ -355,7 +357,7 @@ string? bindError = null; try { - var bind = await InstanceService.SetConnectionBindingsAsync(id, bindings, user); + var bind = await MutationServices.GetRequiredService().ExecuteInstanceAsync(id, null, (instanceService, currentUser) => instanceService.SetConnectionBindingsAsync(id, bindings, currentUser)); if (bind.IsFailure) bindError = bind.Error; } catch (Exception ex) @@ -378,7 +380,7 @@ // CentralUI-024: delegates to the shared helper so the claim type stays // resolved through JwtTokenService rather than a duplicated magic string. private Task GetCurrentUserAsync() - => AuthStateProvider.GetCurrentUsernameAsync(); + => AuthStateProvider.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Deployer); /// /// Opens the folder-tree template picker. A pick clears any dangling-id hint and diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/Topology.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/Topology.razor index 1eae3e83..9dc0e47f 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/Topology.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/Topology.razor @@ -1,3 +1,6 @@ +@using ZB.MOM.WW.ScadaBridge.CentralUI.Services +@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management +@inject IServiceProvider MutationServices @page "/deployment/topology" @page "/deployment/instances" @using ZB.MOM.WW.ScadaBridge.Security @@ -705,8 +708,7 @@ return; } - var user = await GetCurrentUserAsync(); - var result = await AreaService.UpdateAreaAsync(node.EntityId, _renameBuffer.Trim(), user); + var result = await ExecuteConfigurationAsync(new UpdateAreaCommand(node.EntityId, _renameBuffer.Trim())); if (result.IsSuccess) { CancelRename(); @@ -765,8 +767,7 @@ if (req.SiteId == 0) { _createAreaError = "Select a site."; return; } if (string.IsNullOrWhiteSpace(req.Name)) { _createAreaError = "Area name is required."; return; } - var user = await GetCurrentUserAsync(); - var result = await AreaService.CreateAreaAsync(req.Name, req.SiteId, req.ParentAreaId, user); + var result = await ExecuteConfigurationAsync(new CreateAreaCommand(req.SiteId, req.Name, req.ParentAreaId)); if (result.IsSuccess) { _showCreateAreaDialog = false; @@ -800,8 +801,7 @@ private async Task SubmitMoveArea((int AreaId, int? NewParentId) req) { _moveAreaError = null; - var user = await GetCurrentUserAsync(); - var result = await AreaService.MoveAreaAsync(req.AreaId, req.NewParentId, user); + var result = await ExecuteConfigurationAsync(new MoveAreaCommand(req.AreaId, req.NewParentId)); if (result.IsSuccess) { _showMoveAreaDialog = false; @@ -836,8 +836,7 @@ private async Task SubmitMoveInstance((int InstanceId, int? NewAreaId) req) { _moveInstanceError = null; - var user = await GetCurrentUserAsync(); - var result = await InstanceService.AssignToAreaAsync(req.InstanceId, req.NewAreaId, user); + var result = await ExecuteConfigurationAsync(new SetInstanceAreaCommand(req.InstanceId, req.NewAreaId)); if (result.IsSuccess) { _showMoveInstanceDialog = false; @@ -860,8 +859,7 @@ if (!confirmed) return; _actionInProgress = true; - var user = await GetCurrentUserAsync(); - var result = await AreaService.DeleteAreaAsync(node.EntityId, user); + var result = await ExecuteConfigurationAsync(new DeleteAreaCommand(node.EntityId)); if (result.IsSuccess) { _toast.ShowSuccess($"Area '{node.Label}' deleted."); @@ -885,7 +883,8 @@ _actionInProgress = true; try { - var user = await GetCurrentUserAsync(); + var user = await GetCurrentUserAsync(inst.Id); + if (user is null) return; var result = await DeploymentService.DeleteInstanceAsync(inst.Id, user); if (result.IsSuccess) { @@ -910,7 +909,8 @@ _actionInProgress = true; try { - var user = await GetCurrentUserAsync(); + var user = await GetCurrentUserAsync(inst.Id); + if (user is null) return; var result = await DeploymentService.EnableInstanceAsync(inst.Id, user); if (result.IsSuccess) { @@ -941,7 +941,8 @@ _actionInProgress = true; try { - var user = await GetCurrentUserAsync(); + var user = await GetCurrentUserAsync(inst.Id); + if (user is null) return; var result = await DeploymentService.DisableInstanceAsync(inst.Id, user); if (result.IsSuccess) { @@ -965,7 +966,8 @@ _actionInProgress = true; try { - var user = await GetCurrentUserAsync(); + var user = await GetCurrentUserAsync(inst.Id); + if (user is null) return; var result = await DeploymentService.DeployInstanceAsync(inst.Id, user); if (result.IsSuccess) { @@ -1239,6 +1241,35 @@ // CentralUI-024: delegates to the shared helper so the claim type stays // resolved through JwtTokenService rather than a duplicated magic string. - private Task GetCurrentUserAsync() - => AuthStateProvider.GetCurrentUsernameAsync(); + private async Task GetCurrentUserAsync(int instanceId) + { + var user = await AuthStateProvider.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Deployer); + // Re-fetch outside the circuit tracker: the instance may have moved since rendering. + using var scope = ScopeFactory.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var instance = await repository.GetInstanceByIdAsync(instanceId); + if (instance is null || !await SiteScope.IsSiteAllowedAsync(instance.SiteId)) + { + _toast.ShowError("This instance is no longer available within your permitted sites."); + _actionInProgress = false; + return null; + } + return user; + } + + private async Task> ExecuteConfigurationAsync(object command) + { + try + { + var value = await MutationServices.ExecuteConfigurationAsync(command); + return ZB.MOM.WW.ScadaBridge.Commons.Types.Result.Success((T)value!); + } + catch (Exception ex) + { + var message = ex is UnauthorizedAccessException + or ZB.MOM.WW.ScadaBridge.ManagementService.ManagementCommandException + ? ex.Message : "The configuration change failed. Refresh the page and try again."; + return ZB.MOM.WW.ScadaBridge.Commons.Types.Result.Failure(message); + } + } } diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/ApiMethodForm.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/ApiMethodForm.razor index d1e3b893..c63ee4c9 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/ApiMethodForm.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/ApiMethodForm.razor @@ -1,10 +1,14 @@ +@using ZB.MOM.WW.ScadaBridge.CentralUI.Auth +@inject Microsoft.AspNetCore.Components.Authorization.AuthenticationStateProvider MutationAuth +@using ZB.MOM.WW.ScadaBridge.CentralUI.Services +@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management +@inject IServiceProvider MutationServices @page "/design/api-methods/create" @page "/design/api-methods/{Id:int}/edit" @using ZB.MOM.WW.ScadaBridge.Security @using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi @using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories @using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Security -@using ZB.MOM.WW.ScadaBridge.CentralUI.Services @using ScriptAnalysis = ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis @attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)] @inject IInboundApiRepository InboundApiRepository @@ -297,33 +301,10 @@ try { - // Save the ApiMethod entity FIRST so the method Name exists before we reconcile - // key scopes against it. The method entity stays in SQL Server; we leave the - // (now-legacy) ApprovedApiKeyIds column untouched — it is dropped in C5. - string methodName; - if (_existing != null) - { - _existing.Script = _script; - _existing.TimeoutSeconds = _timeoutSeconds; - _existing.TraceAllCalls = _traceAllCalls; - _existing.ParameterDefinitions = _params?.Trim(); - _existing.ReturnDefinition = _returns?.Trim(); - await InboundApiRepository.UpdateApiMethodAsync(_existing); - methodName = _existing.Name; - } - else - { - var m = new ApiMethod(_name.Trim(), _script) - { - TimeoutSeconds = _timeoutSeconds, - TraceAllCalls = _traceAllCalls, - ParameterDefinitions = _params?.Trim(), - ReturnDefinition = _returns?.Trim() - }; - await InboundApiRepository.AddApiMethodAsync(m); - methodName = m.Name; - } - await InboundApiRepository.SaveChangesAsync(); + var methodName = _existing?.Name ?? _name.Trim(); + await MutationServices.ExecuteConfigurationAsync(_existing is not null + ? new UpdateApiMethodCommand(_existing.Id, _script, _timeoutSeconds, _params?.Trim(), _returns?.Trim(), _traceAllCalls) + : new CreateApiMethodCommand(methodName, _script, _timeoutSeconds, _params?.Trim(), _returns?.Trim(), _traceAllCalls)); // Reconcile per-key method-scopes for the affected keys (added/removed vs. load time). if (!await ReconcileKeyScopesAsync(methodName)) return; @@ -371,6 +352,7 @@ { foreach (var update in plan.Updates) { + await MutationAuth.RequireRoleAsync(Roles.Designer); var ok = await ApiKeyAdmin.SetMethodsAsync(update.KeyId, update.NewMethods); if (!ok) throw new InvalidOperationException( diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/DataConnectionForm.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/DataConnectionForm.razor index 34caac5c..e9fb772e 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/DataConnectionForm.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/DataConnectionForm.razor @@ -1,3 +1,6 @@ +@using ZB.MOM.WW.ScadaBridge.CentralUI.Services +@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management +@inject IServiceProvider MutationServices @page "/design/connections/create" @page "/design/connections/{Id:int}/edit" @page "/design/data-connections/create" @@ -327,26 +330,9 @@ try { - if (_editingConnection != null) - { - _editingConnection.Name = _formName.Trim(); - _editingConnection.Protocol = _protocol; - _editingConnection.PrimaryConfiguration = primaryJson; - _editingConnection.BackupConfiguration = backupJson; - _editingConnection.FailoverRetryCount = _showBackup ? _formFailoverRetryCount : 3; - await SiteRepository.UpdateDataConnectionAsync(_editingConnection); - } - else - { - var conn = new DataConnection(_formName.Trim(), _protocol, _formSiteId) - { - PrimaryConfiguration = primaryJson, - BackupConfiguration = backupJson, - FailoverRetryCount = _showBackup ? _formFailoverRetryCount : 3 - }; - await SiteRepository.AddDataConnectionAsync(conn); - } - await SiteRepository.SaveChangesAsync(); + await MutationServices.ExecuteConfigurationAsync(_editingConnection is not null + ? new UpdateDataConnectionCommand(_editingConnection.Id, _formName.Trim(), _protocol, primaryJson, backupJson, _showBackup ? _formFailoverRetryCount : 3) + : new CreateDataConnectionCommand(_formSiteId, _formName.Trim(), _protocol, primaryJson, backupJson, _showBackup ? _formFailoverRetryCount : 3)); NavigationManager.NavigateTo("/design/connections"); } catch (Exception ex) diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/DataConnections.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/DataConnections.razor index a2134739..5ee5e307 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/DataConnections.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/DataConnections.razor @@ -1,7 +1,9 @@ +@using ZB.MOM.WW.ScadaBridge.CentralUI.Services +@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management +@inject IServiceProvider MutationServices @page "/design/connections" @page "/design/data-connections" @using ZB.MOM.WW.ScadaBridge.Security -@using ZB.MOM.WW.ScadaBridge.CentralUI.Services @using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites @using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories @using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums @@ -417,8 +419,7 @@ try { - await SiteRepository.DeleteDataConnectionAsync(conn.Id); - await SiteRepository.SaveChangesAsync(); + await MutationServices.ExecuteConfigurationAsync(new DeleteDataConnectionCommand(conn.Id)); _toast.ShowSuccess($"Connection '{conn.Name}' deleted."); await LoadDataAsync(); } diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/DbConnectionForm.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/DbConnectionForm.razor index 367dd2c6..bf26d490 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/DbConnectionForm.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/DbConnectionForm.razor @@ -1,3 +1,6 @@ +@using ZB.MOM.WW.ScadaBridge.CentralUI.Services +@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management +@inject IServiceProvider MutationServices @page "/design/db-connections/create" @page "/design/db-connections/{Id:int}/edit" @using ZB.MOM.WW.ScadaBridge.Security @@ -94,24 +97,9 @@ try { - if (_existing != null) - { - _existing.Name = _name.Trim(); - _existing.ConnectionString = _connectionString.Trim(); - _existing.MaxRetries = _maxRetries; - _existing.RetryDelay = TimeSpan.FromSeconds(_retryDelaySeconds); - await ExternalSystemRepository.UpdateDatabaseConnectionAsync(_existing); - } - else - { - var dc = new DatabaseConnectionDefinition(_name.Trim(), _connectionString.Trim()) - { - MaxRetries = _maxRetries, - RetryDelay = TimeSpan.FromSeconds(_retryDelaySeconds) - }; - await ExternalSystemRepository.AddDatabaseConnectionAsync(dc); - } - await ExternalSystemRepository.SaveChangesAsync(); + await MutationServices.ExecuteConfigurationAsync(_existing is not null + ? new UpdateDatabaseConnectionDefCommand(_existing.Id, _name.Trim(), _connectionString.Trim(), _maxRetries, TimeSpan.FromSeconds(_retryDelaySeconds)) + : new CreateDatabaseConnectionDefCommand(_name.Trim(), _connectionString.Trim(), _maxRetries, TimeSpan.FromSeconds(_retryDelaySeconds))); NavigationManager.NavigateTo("/design/external-systems"); } catch (Exception ex) { _formError = ex.Message; } diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/ExternalSystemForm.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/ExternalSystemForm.razor index 4be52782..0a77742c 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/ExternalSystemForm.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/ExternalSystemForm.razor @@ -1,3 +1,6 @@ +@using ZB.MOM.WW.ScadaBridge.CentralUI.Services +@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management +@inject IServiceProvider MutationServices @page "/design/external-systems/create" @page "/design/external-systems/{Id:int}/edit" @using ZB.MOM.WW.ScadaBridge.Security @@ -137,29 +140,9 @@ try { - if (_existing != null) - { - _existing.Name = _name.Trim(); - _existing.EndpointUrl = _endpointUrl.Trim(); - _existing.AuthType = _authType; - _existing.AuthConfiguration = _authConfig?.Trim(); - _existing.MaxRetries = _maxRetries; - _existing.RetryDelay = TimeSpan.FromSeconds(_retryDelaySeconds); - _existing.TimeoutSeconds = _timeoutSeconds; - await ExternalSystemRepository.UpdateExternalSystemAsync(_existing); - } - else - { - var es = new ExternalSystemDefinition(_name.Trim(), _endpointUrl.Trim(), _authType) - { - AuthConfiguration = _authConfig?.Trim(), - MaxRetries = _maxRetries, - RetryDelay = TimeSpan.FromSeconds(_retryDelaySeconds), - TimeoutSeconds = _timeoutSeconds - }; - await ExternalSystemRepository.AddExternalSystemAsync(es); - } - await ExternalSystemRepository.SaveChangesAsync(); + await MutationServices.ExecuteConfigurationAsync(_existing is not null + ? new UpdateExternalSystemCommand(_existing.Id, _name.Trim(), _endpointUrl.Trim(), _authType, _authConfig?.Trim() ?? "", _timeoutSeconds, _maxRetries, TimeSpan.FromSeconds(_retryDelaySeconds)) + : new CreateExternalSystemCommand(_name.Trim(), _endpointUrl.Trim(), _authType, _authConfig?.Trim(), _timeoutSeconds, _maxRetries, TimeSpan.FromSeconds(_retryDelaySeconds))); NavigationManager.NavigateTo("/design/external-systems"); } catch (Exception ex) { _formError = ex.Message; } diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/ExternalSystems.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/ExternalSystems.razor index 3aff15b4..38b1dd6b 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/ExternalSystems.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/ExternalSystems.razor @@ -1,3 +1,6 @@ +@using ZB.MOM.WW.ScadaBridge.CentralUI.Services +@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management +@inject IServiceProvider MutationServices @page "/design/external-systems" @using ZB.MOM.WW.ScadaBridge.Security @using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems @@ -189,7 +192,7 @@ private async Task DeleteExtSys(ExternalSystemDefinition es) { if (!await Dialog.ConfirmAsync("Delete External System", $"Delete '{es.Name}'?", danger: true)) return; - try { await ExternalSystemRepository.DeleteExternalSystemAsync(es.Id); await ExternalSystemRepository.SaveChangesAsync(); _toast.ShowSuccess("Deleted."); await LoadAllAsync(); } + try { await MutationServices.ExecuteConfigurationAsync(new DeleteExternalSystemCommand(es.Id)); _toast.ShowSuccess("Deleted."); await LoadAllAsync(); } catch (Exception ex) { _toast.ShowError(ex.Message); } } @@ -262,7 +265,7 @@ private async Task DeleteDbConn(DatabaseConnectionDefinition dc) { if (!await Dialog.ConfirmAsync("Delete DB Connection", $"Delete '{dc.Name}'?", danger: true)) return; - try { await ExternalSystemRepository.DeleteDatabaseConnectionAsync(dc.Id); await ExternalSystemRepository.SaveChangesAsync(); _toast.ShowSuccess("Deleted."); await LoadAllAsync(); } + try { await MutationServices.ExecuteConfigurationAsync(new DeleteDatabaseConnectionDefCommand(dc.Id)); _toast.ShowSuccess("Deleted."); await LoadAllAsync(); } catch (Exception ex) { _toast.ShowError(ex.Message); } } @@ -337,7 +340,7 @@ private async Task DeleteApiMethod(ApiMethod m) { if (!await Dialog.ConfirmAsync("Delete", $"Delete API method '{m.Name}'?", danger: true)) return; - try { await InboundApiRepository.DeleteApiMethodAsync(m.Id); await InboundApiRepository.SaveChangesAsync(); _toast.ShowSuccess("Deleted."); await LoadAllAsync(); } + try { await MutationServices.ExecuteConfigurationAsync(new DeleteApiMethodCommand(m.Id)); _toast.ShowSuccess("Deleted."); await LoadAllAsync(); } catch (Exception ex) { _toast.ShowError(ex.Message); } } diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/SharedScriptForm.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/SharedScriptForm.razor index 0c4826cf..1e9da0c3 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/SharedScriptForm.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/SharedScriptForm.razor @@ -207,7 +207,7 @@ // CentralUI-024: delegates to the shared helper so the claim type stays // resolved through JwtTokenService rather than a duplicated magic string. private Task GetCurrentUserAsync() - => AuthStateProvider.GetCurrentUsernameAsync(); + => AuthStateProvider.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Designer); protected override async Task OnInitializedAsync() { diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/SharedScripts.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/SharedScripts.razor index 88aab9be..e0a7fbeb 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/SharedScripts.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/SharedScripts.razor @@ -104,7 +104,7 @@ // CentralUI-024: delegates to the shared helper so the claim type stays // resolved through JwtTokenService rather than a duplicated magic string. private Task GetCurrentUserAsync() - => AuthStateProvider.GetCurrentUsernameAsync(); + => AuthStateProvider.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Designer); private List _scripts = new(); private bool _loading = true; diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TemplateCreate.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TemplateCreate.razor index ddaab1c9..b04a9283 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TemplateCreate.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TemplateCreate.razor @@ -122,5 +122,5 @@ // CentralUI-024: delegates to the shared helper so the claim type stays // resolved through JwtTokenService rather than a duplicated magic string. private Task GetCurrentUserAsync() - => AuthStateProvider.GetCurrentUsernameAsync(); + => AuthStateProvider.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Designer); } diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TemplateEdit.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TemplateEdit.razor index 8d3ff32f..ed0d21c6 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TemplateEdit.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TemplateEdit.razor @@ -1,3 +1,6 @@ +@using ZB.MOM.WW.ScadaBridge.CentralUI.Services +@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management +@inject IServiceProvider MutationServices @page "/design/templates/{Id:int}" @using ZB.MOM.WW.ScadaBridge.Security @using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances @@ -217,7 +220,7 @@ // CentralUI-024: delegates to the shared helper so the claim type stays // resolved through JwtTokenService rather than a duplicated magic string. private Task GetCurrentUserAsync() - => AuthStateProvider.GetCurrentUsernameAsync(); + => AuthStateProvider.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Designer); private RenderFragment RenderTemplateDetail() => __builder => { @@ -1080,13 +1083,10 @@ { var existing = await TemplateEngineRepository.GetTemplateNativeAlarmSourceByIdAsync(id); if (existing == null) return "Source no longer exists."; - existing.ConnectionName = draft.ConnectionName.Trim(); - existing.SourceReference = draft.SourceReference.Trim(); - existing.ConditionFilter = string.IsNullOrWhiteSpace(draft.ConditionFilter) ? null : draft.ConditionFilter.Trim(); - existing.Description = string.IsNullOrWhiteSpace(draft.Description) ? null : draft.Description.Trim(); - existing.IsLocked = draft.IsLocked; - await TemplateEngineRepository.UpdateTemplateNativeAlarmSourceAsync(existing); - await TemplateEngineRepository.SaveChangesAsync(); + await MutationServices.ExecuteConfigurationAsync(new UpdateTemplateNativeAlarmSourceCommand(id, existing.Name, + draft.ConnectionName.Trim(), draft.SourceReference.Trim(), + string.IsNullOrWhiteSpace(draft.ConditionFilter) ? null : draft.ConditionFilter.Trim(), + string.IsNullOrWhiteSpace(draft.Description) ? null : draft.Description.Trim(), draft.IsLocked)); _toast.ShowSuccess($"Native alarm source '{existing.Name}' updated."); return null; } @@ -1105,8 +1105,8 @@ Description = string.IsNullOrWhiteSpace(draft.Description) ? null : draft.Description.Trim(), IsLocked = draft.IsLocked }; - await TemplateEngineRepository.AddTemplateNativeAlarmSourceAsync(source); - await TemplateEngineRepository.SaveChangesAsync(); + await MutationServices.ExecuteConfigurationAsync(new AddTemplateNativeAlarmSourceCommand(source.TemplateId, source.Name, + source.ConnectionName, source.SourceReference, source.ConditionFilter, source.Description, source.IsLocked)); _toast.ShowSuccess($"Native alarm source '{source.Name}' added."); return null; } @@ -1115,8 +1115,7 @@ { var confirmed = await Dialog.ConfirmAsync("Delete Native Alarm Source", $"Delete native alarm source '{src.Name}'?", danger: true); if (!confirmed) return; - await TemplateEngineRepository.DeleteTemplateNativeAlarmSourceAsync(src.Id); - await TemplateEngineRepository.SaveChangesAsync(); + await MutationServices.ExecuteConfigurationAsync(new DeleteTemplateNativeAlarmSourceCommand(src.Id)); _toast.ShowSuccess($"Native alarm source '{src.Name}' deleted."); await LoadAsync(); } diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/Templates.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/Templates.razor index 8c9c4591..c964772e 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/Templates.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/Templates.razor @@ -118,7 +118,7 @@ // CentralUI-024: delegates to the shared helper so the claim type stays // resolved through JwtTokenService rather than a duplicated magic string. private Task GetCurrentUserAsync() - => AuthStateProvider.GetCurrentUsernameAsync(); + => AuthStateProvider.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Designer); private List